Compare commits
136 Commits
v0.0.42
...
feat/entre
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b81de1248 | |||
| 7d69860edc | |||
| 209b14eb56 | |||
| 2166fe2685 | |||
| 92c8c6a704 | |||
| 9af05ff449 | |||
| fff6fa7e17 | |||
| cae04ed489 | |||
| 6134fc3107 | |||
| 4513dcdc5c | |||
| 3e1760ca98 | |||
|
|
961fa63f3d | ||
| bebfabcacc | |||
| 8f88abab46 | |||
| 476502c91c | |||
| c64e0c7100 | |||
| 348d7fc8f9 | |||
| 38cedfccdf | |||
| 69844bfebc | |||
| d71bd147d5 | |||
| ab1f9a3308 | |||
| 07d174398d | |||
| 1328dcfdd7 | |||
| 9d3dbf98c1 | |||
| 486247bf86 | |||
| 43d7a2514b | |||
| 6579bb72dd | |||
| 7ecc5b6d2f | |||
| 4f6b6ff3c3 | |||
|
|
e208bcd893 | ||
| 3fe0bbf71e | |||
|
|
d566e5d9f7 | ||
| 5bb0aad620 | |||
|
|
19a29f854e | ||
| c21dcd1869 | |||
|
|
86cb3c276a | ||
| 08a17f91b3 | |||
|
|
7b722bdd17 | ||
| 9038d1726a | |||
| bde59bf9ee | |||
| 097eb39cb0 | |||
| 4cdff1200f | |||
| d5b372e243 | |||
| 2e72f93f29 | |||
|
|
79077c7bbd | ||
| f05fcc5c15 | |||
|
|
023d71381e | ||
| e2695335e7 | |||
|
|
91152c0ed8 | ||
| 1b4764878e | |||
|
|
b94c3a95be | ||
| 394c69e84a | |||
|
|
c2074df562 | ||
| 29bfeeb4ee | |||
|
|
5ac03e359f | ||
|
|
340aa2a3c0 | ||
|
|
6eb2ee2578 | ||
| 34c1d162d8 | |||
|
|
bbd05cea3e | ||
| 7f78454553 | |||
|
|
696100a622 | ||
| 97f21ab35c | |||
|
|
fa7b44fb02 | ||
| 9be2e0c379 | |||
|
|
fee7bbb2ec | ||
| b707aae0e8 | |||
|
|
d0beb80199 | ||
| c378b402c4 | |||
|
|
6e707484a0 | ||
| 0067e51e6e | |||
|
|
1c0cdeb085 | ||
| 465339cdd6 | |||
|
|
2bc484574f | ||
| ea1e3b074c | |||
|
|
4944611088 | ||
| fbfc7acfe4 | |||
|
|
92f54f600f | ||
| a905c6a1de | |||
|
|
995e7de2cc | ||
| 2408ccab67 | |||
| 82af4d4c1e | |||
|
|
11491b02c5 | ||
| 024af5887e | |||
|
|
91c0125876 | ||
| b510cdcc42 | |||
|
|
d0213c3212 | ||
| 3ac676689d | |||
|
|
9f47e81efd | ||
| 257b93e691 | |||
|
|
dc5320b324 | ||
| 09a641e5cf | |||
|
|
a0557b077b | ||
| 2d2b38eae4 | |||
|
|
d3581b8ce6 | ||
| 9e53be8ac3 | |||
|
|
2aafa2082a | ||
| 2b64f024b6 | |||
|
|
47cac04257 | ||
| 59d76c5f14 | |||
|
|
c48cc477da | ||
| 5967665e9f | |||
|
|
393c420983 | ||
| 456623b403 | |||
|
|
e2a8e89e55 | ||
| 92a5c48e5e | |||
|
|
6766985713 | ||
| c0d05264df | |||
|
|
9505201499 | ||
| 624591c096 | |||
|
|
e31bdce713 | ||
| 5d72beaf8d | |||
| 43f34015c6 | |||
|
|
ac5ce07e61 | ||
| e9fb36cc24 | |||
|
|
06a41c5f85 | ||
| f263a11fe8 | |||
|
|
c52f22472d | ||
| e7421e985e | |||
|
|
0d258ae9c6 | ||
| 7dd615ea34 | |||
|
|
6eee0745a7 | ||
| 845f94db8c | |||
|
|
86c0e74074 | ||
| be29daf4d1 | |||
|
|
08e7c1508c | ||
| 358da6a8ad | |||
|
|
67428186f6 | ||
| 09d108a1d5 | |||
|
|
f58dc36a0d | ||
| 15c0f414af | |||
|
|
9ed0ba702e | ||
| 93edd0a563 | |||
|
|
c361ef9bb9 | ||
| 7f3d9ef9c6 | |||
|
|
22b959de85 | ||
| d3bc2e11f1 |
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)",
|
||||
"WebFetch(domain:geo.api.gouv.fr)",
|
||||
"Bash(pip3 install:*)",
|
||||
"Bash(python3 -c \":*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
|
||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ NUXT_PUBLIC_GEO_API_BASE=https://geo.api.gouv.fr npm run generate
|
||||
test -f .output/public/index.html
|
||||
|
||||
- name: Build artefact
|
||||
|
||||
19
.idea/dataSources.xml
generated
19
.idea/dataSources.xml
generated
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="ferme" uuid="f407a514-c6b4-4b26-9555-445a85892502">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://localhost:5432/ferme</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="Ferme recette" uuid="ae622167-c834-4e7b-87a5-c1721036f5dc">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://localhost:5432/postgres</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/data_source_mapping.xml
generated
10
.idea/data_source_mapping.xml
generated
@@ -1,10 +0,0 @@
|
||||
<?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>
|
||||
2
.idea/db-forest-config.xml
generated
2
.idea/db-forest-config.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="db-tree-configuration">
|
||||
<option name="data" value="---------------------------------------- 1:0:f407a514-c6b4-4b26-9555-445a85892502 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc " />
|
||||
<option name="data" value="---------------------------------------- 1:0:f407a514-c6b4-4b26-9555-445a85892502 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc 3:0:9cad43df-2147-4989-b7a4-443067034884 4:0:09e221b8-067a-488b-9c1d-4e155a333079 " />
|
||||
</component>
|
||||
</project>
|
||||
706
.idea/workspace.xml
generated
706
.idea/workspace.xml
generated
@@ -4,28 +4,12 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<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" />
|
||||
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/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/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" />
|
||||
<change beforePath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -48,19 +32,23 @@
|
||||
<component name="FileTemplateManagerImpl">
|
||||
<option name="RECENT_TEMPLATES">
|
||||
<list>
|
||||
<option value="Vue Composition API Component" />
|
||||
<option value="TypeScript File" />
|
||||
<option value="PHP File" />
|
||||
<option value="Vue Composition API Component" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||
<map>
|
||||
<entry key="$PROJECT_DIR$" value="fix/makefile" />
|
||||
<entry key="$PROJECT_DIR$" value="feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes" />
|
||||
</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 />
|
||||
@@ -244,14 +232,14 @@
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"git-widget-placeholder": "feat/271-expedition-etape-1",
|
||||
"last_opened_file_path": "/home/sroy/Documents/test/Ferme",
|
||||
"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",
|
||||
"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": "configurable.tailwindcss",
|
||||
"settings.editor.selected.configurable": "advanced.settings",
|
||||
"ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
},
|
||||
@@ -268,21 +256,29 @@
|
||||
}
|
||||
}]]></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.30387.85" />
|
||||
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.32098.40" />
|
||||
</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="" />
|
||||
@@ -313,294 +309,24 @@
|
||||
<workItem from="1770195718952" duration="215000" />
|
||||
<workItem from="1770195959162" duration="18915000" />
|
||||
<workItem from="1770274844804" duration="3940000" />
|
||||
</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>
|
||||
<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-00037" summary="feat : finalisation de l'étape 1 "Réception" (formulaire)">
|
||||
<option name="closed" value="true" />
|
||||
@@ -706,7 +432,295 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1770217875423</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="50" />
|
||||
<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" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -756,32 +770,32 @@
|
||||
</option>
|
||||
</component>
|
||||
<component name="VcsManagerConfiguration">
|
||||
<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 "Réception" (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 : 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="feat : update CHANGELOG.md" />
|
||||
<MESSAGE value="feat : ajout de commentaire" />
|
||||
<MESSAGE value="fix : correction de l'affichage de l'immatriculation sur une réception en cours + correction css étape 3 d'une réception" />
|
||||
<MESSAGE value="feat : ajout de colonne pour les Supplier, Address et modification du numéro de réception" />
|
||||
<MESSAGE value="feat : ajout de colonne pour les Supplier, Address. Modification du numéro de réception et ajout de fixtures" />
|
||||
<MESSAGE value="feat : mise à jour du bon de réception" />
|
||||
<MESSAGE value="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)" />
|
||||
<MESSAGE value="feat : la page de scanner est accessible que pour les admins" />
|
||||
<MESSAGE value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
|
||||
</component>
|
||||
<component name="XDebuggerManager">
|
||||
<breakpoint-manager>
|
||||
@@ -791,10 +805,14 @@
|
||||
<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/shipment.ts</url>
|
||||
<properties lambdaOrdinal="-1" />
|
||||
<option name="timeStamp" value="37" />
|
||||
<url>file://$PROJECT_DIR$/frontend/services/dto/shipment-data.ts</url>
|
||||
<option name="timeStamp" value="43" />
|
||||
</line-breakpoint>
|
||||
</breakpoints>
|
||||
</breakpoint-manager>
|
||||
|
||||
59
AGENTS.md
59
AGENTS.md
@@ -1,59 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
Project overview
|
||||
- Symfony 8 + API Platform 4 backend, Nuxt 3 frontend in `frontend/`.
|
||||
- Apache vhost serves API under `/api` and frontend from `frontend/dist`.
|
||||
- API base URL on frontend uses `NUXT_PUBLIC_API_BASE` (see `frontend/.env`).
|
||||
|
||||
Backend conventions
|
||||
- Use English for code identifiers/messages; keep “pont-bascule” as domain term.
|
||||
- API Platform operations are defined on Doctrine entities.
|
||||
- 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 1–N 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`.
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -44,7 +44,29 @@ Ajouter dans le fichier .env du frontend
|
||||
* [#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
|
||||
* [#FER-18] Mise à jour du tableau d'arrivage
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
169
CLAUDE.md
Normal file
169
CLAUDE.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend:** Symfony 8 + API Platform 4 (PHP 8.4)
|
||||
- **Frontend:** Nuxt 4 (Vue 3, Pinia, Tailwind, Zod) in `frontend/`
|
||||
- **Infra:** Docker (PHP-FPM + Nginx), Apache vhost serves API sous `/api` et frontend depuis `frontend/dist`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
make start # Démarrer les containers
|
||||
make stop # Arrêter les containers
|
||||
make restart # Redémarrer les containers
|
||||
make shell # Shell dans le container PHP
|
||||
|
||||
# Install complet
|
||||
make install # composer install + migrations + build frontend
|
||||
|
||||
# Backend
|
||||
make composer-install # Installer dépendances PHP
|
||||
make migration-migrate # Lancer les migrations
|
||||
make fixtures # Charger les fixtures
|
||||
make cache-clear # Vider le cache Symfony
|
||||
make test # Lancer les tests PHPUnit
|
||||
make test FILES=tests/path/to/TestFile.php # Test spécifique
|
||||
make php-cs-fixer-allow-risky FILES=src/... # Fixer le style
|
||||
|
||||
# Frontend
|
||||
make build-nuxtJS # npm install + build:dist (dans le container)
|
||||
make dev-nuxt # Serveur dev Nuxt (dans le container)
|
||||
# Ou directement dans frontend/ :
|
||||
cd frontend && npm run dev # Dev server (port 3000)
|
||||
cd frontend && npm run build:dist # Build production
|
||||
|
||||
# Base de données
|
||||
make db-reset # ⚠️ Supprime et recrée la BDD + migrations + fixtures
|
||||
```
|
||||
|
||||
## Architecture backend
|
||||
|
||||
```
|
||||
src/
|
||||
├── ApiResource/ # Ressources API Platform custom
|
||||
├── Command/ # Commandes Symfony (dont app:seed)
|
||||
├── DataFixtures/ # Fixtures Doctrine
|
||||
├── Dto/ # DTOs (ex: PontBasculeReading)
|
||||
├── Entity/ # Entités Doctrine (= ressources API Platform)
|
||||
├── Exception/ # Exceptions custom (PontBasculeException)
|
||||
├── Kernel.php
|
||||
├── Service/ # Services métier (PontBasculePayloadDecoder…)
|
||||
└── State/ # State providers/processors API Platform
|
||||
```
|
||||
|
||||
## Architecture frontend
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── components/
|
||||
│ ├── ui/ # Composants réutilisables, auto-importés avec préfixe Ui (ex: UiLoadingDots)
|
||||
│ ├── reception/ # Composants métier réception
|
||||
│ ├── shipment/ # Composants métier expédition
|
||||
│ ├── workflow/ # Composants partagés réception/expédition (workflow-weight, workflow-waiting-list, workflow-liot-fields)
|
||||
│ └── commun/ # Composants communs (update-weight)
|
||||
├── composables/ # useApi, useWeighing, usePdfPrinter, useAppVersion, useLiotHandling, useFormDataLoading, useAddressSync, useWorkflowSteps
|
||||
│ └── steps/ # useWeighingStep (logique étape pesée)
|
||||
├── config/ # reception.config.ts, shipment.config.ts (WorkflowConfig)
|
||||
├── types/ # workflow.ts (interfaces partagées WorkflowEntity, WorkflowConfig, StepDefinition)
|
||||
├── services/ # Couche service avec DTOs typés dans services/dto/
|
||||
│ └── workflow-service.ts # Factory service API (createWorkflowService)
|
||||
├── stores/ # Pinia stores (reception, shipment, auth)
|
||||
│ └── workflow-store.ts # Factory store (useWorkflowStoreLogic)
|
||||
├── pages/ # Pages Nuxt (file-based routing)
|
||||
├── layouts/ # Layout default : max-width 1050px
|
||||
├── i18n/locales/ # Traductions (défaut: fr)
|
||||
├── utils/ # Constants, zod-errors helpers
|
||||
└── assets/css/ # Tailwind config, main.css (font Helvetica)
|
||||
```
|
||||
|
||||
## Conventions backend
|
||||
|
||||
- Code en anglais ; "pont-bascule" est un terme métier conservé tel quel.
|
||||
- Les opérations API Platform sont définies directement sur les entités Doctrine.
|
||||
- Repository custom autorisé dès qu'on a une requête métier non-triviale (agrégations, jointures spécifiques, filtres multiples). Toujours via DQL/QueryBuilder, **jamais de SQL brut** (pas de `Connection::executeQuery`, `fetchAssociative`, etc.). Les CRUD basiques restent sur le repo Doctrine par défaut via `EntityManagerInterface`.
|
||||
- `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`
|
||||
156
README.md
156
README.md
@@ -1,68 +1,87 @@
|
||||
# Projet Ferme
|
||||
# Projet Ferme t
|
||||
|
||||
## 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:
|
||||
@@ -72,63 +91,164 @@ 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
|
||||
|
||||
## Feed des prix bovins
|
||||
|
||||
Une commande Symfony permet de mettre à jour le **poids à l'arrivée**, le **prix au kilo** et le **fournisseur** des bovins existants à partir d'un fichier XLSX. La commande **ne crée jamais de nouveau bovin** : elle complète seulement ceux déjà présents en BDD.
|
||||
|
||||
### Format du fichier XLSX attendu
|
||||
|
||||
Pas de ligne d'en-tête, 4 colonnes dans cet ordre :
|
||||
|
||||
| Colonne | Champ | Format | Exemple |
|
||||
|---------|-------|--------|---------|
|
||||
| A | Numéro national | Avec ou sans préfixe `FR ` (insensible casse) | `FR 7979580026` ou `7979580026` |
|
||||
| B | Fournisseur | Texte libre, casse ignorée | `TERRENA` |
|
||||
| C | Poids à l'arrivée (kg) | Entier | `368` |
|
||||
| D | Prix au kilo | Décimal | `5.7` |
|
||||
| E | Code bâtiment (optionnel) | `B1`, `B2`, `B3`, `ZT` (casse ignorée) | `B2` |
|
||||
|
||||
### Comportement
|
||||
|
||||
- **Numéro national** : le préfixe `FR` (avec ou sans espace) est retiré s'il est présent. Sinon la valeur est utilisée telle quelle.
|
||||
- **Bovin introuvable** en BDD → ligne ignorée, log warning à la fin avec aperçu.
|
||||
- **Fournisseur introuvable** en BDD → le bovin est mis à jour quand même avec `supplier = null`, log warning.
|
||||
- **Bâtiment** (colonne E) : recherché par `code` (insensible casse). Set uniquement si le bovin n'a pas déjà une `buildingCase` assignée (la case prime sur le bâtiment direct côté affichage). Si code introuvable → log warning, champ non set.
|
||||
- **Cellules `weight` / `price` vides ou non numériques** → champ non modifié.
|
||||
- La commande est **idempotente** : peut être relancée sans effet de bord.
|
||||
|
||||
### Lancement en dev
|
||||
|
||||
Copie le fichier dans la racine du projet (mappée dans le container sous `/var/www/html`), puis :
|
||||
|
||||
```bash
|
||||
# Simulation sans écriture en BDD
|
||||
docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bovin.xlsx --dry-run
|
||||
|
||||
# Persistance effective
|
||||
docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bovin.xlsx
|
||||
```
|
||||
|
||||
### Lancement en prod
|
||||
|
||||
Le user SSH n'a généralement pas les droits d'écriture sur `/var/www/ferme/` ; on passe donc le fichier par `/tmp` et on pointe la commande dessus (le chemin du XLSX est juste un argument).
|
||||
|
||||
```bash
|
||||
# 1. Copier le fichier sur le serveur dans /tmp (accessible en écriture)
|
||||
scp feed_bovin.xlsx <user>@<host>:/tmp/
|
||||
|
||||
# 2. SSH sur le serveur
|
||||
ssh <user>@<host>
|
||||
|
||||
# 3. Se placer dans le dossier de l'app (pour bin/console)
|
||||
cd /var/www/ferme
|
||||
|
||||
# 4. Dry-run pour vérifier sans rien écrire
|
||||
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx --dry-run
|
||||
|
||||
# 5. Persistance effective
|
||||
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx
|
||||
|
||||
# 6. Cleanup
|
||||
rm /tmp/feed_bovin.xlsx
|
||||
```
|
||||
|
||||
> Si à l'étape 4 le user PHP (souvent `www-data`) n'arrive pas à lire le fichier (`Permission denied`), donne-lui les droits de lecture avant : `chmod 644 /tmp/feed_bovin.xlsx`.
|
||||
|
||||
### Sortie attendue
|
||||
|
||||
À la fin, un tableau récapitule :
|
||||
|
||||
- Lignes totales lues
|
||||
- Bovins mis à jour
|
||||
- Bovins introuvables (avec aperçu des 10 premiers numéros)
|
||||
- Lignes invalides (numéro national vide)
|
||||
- Fournisseurs introuvables (avec liste et compte par nom)
|
||||
- Bâtiments introuvables (avec liste des codes inconnus)
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
"doctrine/orm": "^3.6",
|
||||
"dompdf/dompdf": "^3.1",
|
||||
"lexik/jwt-authentication-bundle": "*",
|
||||
"malio/ednotif-bundle": ">=0.0.4",
|
||||
"malio/ednotif-bundle": ">=0.0.6",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpoffice/phpspreadsheet": "^5.7",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
|
||||
679
composer.lock
generated
679
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ api_platform:
|
||||
stateless: true
|
||||
cache_headers:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
pagination_client_items_per_page: true
|
||||
pagination_maximum_items_per_page: 100
|
||||
formats:
|
||||
json: ['application/json']
|
||||
jsonld: ['application/ld+json']
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
security:
|
||||
# Hiérarchie des rôles : ADMIN inclut BUREAU qui inclut USER.
|
||||
# Ajouter un nouveau rôle = ajouter une ligne ici (et son équivalent côté
|
||||
# front dans utils/roles.ts).
|
||||
role_hierarchy:
|
||||
ROLE_BUREAU: ROLE_USER
|
||||
ROLE_ADMIN: ROLE_BUREAU
|
||||
|
||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||
password_hashers:
|
||||
App\Entity\User: 'auto'
|
||||
@@ -20,6 +27,7 @@ security:
|
||||
pattern: ^/login_check
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Security\UserChecker
|
||||
json_login:
|
||||
check_path: /login_check
|
||||
username_path: username
|
||||
@@ -30,6 +38,7 @@ security:
|
||||
pattern: ^/
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Security\UserChecker
|
||||
jwt: ~
|
||||
logout:
|
||||
path: /api/logout
|
||||
|
||||
@@ -210,18 +210,18 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* initial_marking?: list<scalar|Param|null>,
|
||||
* events_to_dispatch?: list<string|Param>|null,
|
||||
* places?: list<array{ // Default: []
|
||||
* name: scalar|Param|null,
|
||||
* name?: scalar|Param|null,
|
||||
* metadata?: list<mixed>,
|
||||
* }>,
|
||||
* transitions: list<array{ // Default: []
|
||||
* name: string|Param,
|
||||
* transitions?: list<array{ // Default: []
|
||||
* name?: string|Param,
|
||||
* guard?: string|Param, // An expression to block the transition.
|
||||
* from?: list<array{ // Default: []
|
||||
* place: string|Param,
|
||||
* place?: string|Param,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* }>,
|
||||
* to?: list<array{ // Default: []
|
||||
* place: string|Param,
|
||||
* place?: string|Param,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* }>,
|
||||
* weight?: int|Param, // Default: 1
|
||||
@@ -232,7 +232,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* router?: bool|array{ // Router configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* resource: scalar|Param|null,
|
||||
* resource?: scalar|Param|null,
|
||||
* type?: scalar|Param|null,
|
||||
* default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
|
||||
* http_port?: scalar|Param|null, // Default: 80
|
||||
@@ -457,7 +457,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* allow_no_senders?: bool|Param, // Default: true
|
||||
* },
|
||||
* middleware?: list<string|array{ // Default: []
|
||||
* id: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* arguments?: list<mixed>,
|
||||
* }>,
|
||||
* }>,
|
||||
@@ -629,7 +629,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||
* storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
|
||||
* policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
||||
* policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
||||
* limiters?: list<scalar|Param|null>,
|
||||
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
|
||||
* interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||
@@ -674,7 +674,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
|
||||
* routing?: array<string, array{ // Default: []
|
||||
* service: scalar|Param|null,
|
||||
* service?: scalar|Param|null,
|
||||
* secret?: scalar|Param|null, // Default: ""
|
||||
* }>,
|
||||
* },
|
||||
@@ -754,8 +754,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* },
|
||||
* ldap?: array{
|
||||
* service: scalar|Param|null,
|
||||
* base_dn: scalar|Param|null,
|
||||
* service?: scalar|Param|null,
|
||||
* base_dn?: scalar|Param|null,
|
||||
* search_dn?: scalar|Param|null, // Default: null
|
||||
* search_password?: scalar|Param|null, // Default: null
|
||||
* extra_fields?: list<scalar|Param|null>,
|
||||
@@ -766,7 +766,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* password_attribute?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* entity?: array{
|
||||
* class: scalar|Param|null, // The full entity class name of your user class.
|
||||
* class?: scalar|Param|null, // The full entity class name of your user class.
|
||||
* property?: scalar|Param|null, // Default: null
|
||||
* manager_name?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
@@ -774,7 +774,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* class?: scalar|Param|null, // Default: "Lexik\\Bundle\\JWTAuthenticationBundle\\Security\\User\\JWTUser"
|
||||
* },
|
||||
* }>,
|
||||
* firewalls: array<string, array{ // Default: []
|
||||
* firewalls?: array<string, array{ // Default: []
|
||||
* pattern?: scalar|Param|null,
|
||||
* host?: scalar|Param|null,
|
||||
* methods?: list<scalar|Param|null>,
|
||||
@@ -836,9 +836,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* authenticator?: scalar|Param|null, // Default: "lexik_jwt_authentication.security.jwt_authenticator"
|
||||
* },
|
||||
* login_link?: array{
|
||||
* check_route: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
|
||||
* check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
|
||||
* check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
|
||||
* signature_properties: list<scalar|Param|null>,
|
||||
* signature_properties?: list<scalar|Param|null>,
|
||||
* lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600
|
||||
* max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null
|
||||
* used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set.
|
||||
@@ -940,13 +940,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* failure_handler?: scalar|Param|null,
|
||||
* realm?: scalar|Param|null, // Default: null
|
||||
* token_extractors?: list<scalar|Param|null>,
|
||||
* token_handler: string|array{
|
||||
* token_handler?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* oidc_user_info?: string|array{
|
||||
* base_uri: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
|
||||
* base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
|
||||
* discovery?: array{ // Enable the OIDC discovery.
|
||||
* cache?: array{
|
||||
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* },
|
||||
* },
|
||||
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub"
|
||||
@@ -954,25 +954,25 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* oidc?: array{
|
||||
* discovery?: array{ // Enable the OIDC discovery.
|
||||
* base_uri: list<scalar|Param|null>,
|
||||
* base_uri?: list<scalar|Param|null>,
|
||||
* cache?: array{
|
||||
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* },
|
||||
* },
|
||||
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub"
|
||||
* audience: scalar|Param|null, // Audience set in the token, for validation purpose.
|
||||
* issuers: list<scalar|Param|null>,
|
||||
* algorithms: list<scalar|Param|null>,
|
||||
* audience?: scalar|Param|null, // Audience set in the token, for validation purpose.
|
||||
* issuers?: list<scalar|Param|null>,
|
||||
* algorithms?: list<scalar|Param|null>,
|
||||
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).
|
||||
* encryption?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false
|
||||
* algorithms: list<scalar|Param|null>,
|
||||
* keyset: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
|
||||
* algorithms?: list<scalar|Param|null>,
|
||||
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
|
||||
* },
|
||||
* },
|
||||
* cas?: array{
|
||||
* validation_url: scalar|Param|null, // CAS server validation URL
|
||||
* validation_url?: scalar|Param|null, // CAS server validation URL
|
||||
* prefix?: scalar|Param|null, // CAS prefix // Default: "cas"
|
||||
* http_client?: scalar|Param|null, // HTTP Client service // Default: null
|
||||
* },
|
||||
@@ -1036,7 +1036,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* dbal?: array{
|
||||
* default_connection?: scalar|Param|null,
|
||||
* types?: array<string, string|array{ // Default: []
|
||||
* class: scalar|Param|null,
|
||||
* class?: scalar|Param|null,
|
||||
* }>,
|
||||
* driver_schemes?: array<string, scalar|Param|null>,
|
||||
* connections?: array<string, array{ // Default: []
|
||||
@@ -1207,7 +1207,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* datetime_functions?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* filters?: array<string, string|array{ // Default: []
|
||||
* class: scalar|Param|null,
|
||||
* class?: scalar|Param|null,
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* parameters?: array<string, mixed>,
|
||||
* }>,
|
||||
@@ -1320,14 +1320,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* access_token_issuance?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* signature?: array{
|
||||
* algorithm: scalar|Param|null, // The algorithm use to sign the access tokens.
|
||||
* key: scalar|Param|null, // The signature key. It shall be JWK encoded.
|
||||
* algorithm?: scalar|Param|null, // The algorithm use to sign the access tokens.
|
||||
* key?: scalar|Param|null, // The signature key. It shall be JWK encoded.
|
||||
* },
|
||||
* encryption?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* key_encryption_algorithm: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
||||
* content_encryption_algorithm: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
||||
* key: scalar|Param|null, // The encryption key. It shall be JWK encoded.
|
||||
* key_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
||||
* content_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
||||
* key?: scalar|Param|null, // The encryption key. It shall be JWK encoded.
|
||||
* },
|
||||
* },
|
||||
* access_token_verification?: bool|array{
|
||||
@@ -1337,7 +1337,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* claim_checkers?: list<scalar|Param|null>,
|
||||
* mandatory_claims?: list<scalar|Param|null>,
|
||||
* allowed_algorithms?: list<scalar|Param|null>,
|
||||
* keyset: scalar|Param|null, // The signature keyset. It shall be JWKSet encoded.
|
||||
* keyset?: scalar|Param|null, // The signature keyset. It shall be JWKSet encoded.
|
||||
* },
|
||||
* encryption?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
@@ -1345,7 +1345,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* header_checkers?: list<scalar|Param|null>,
|
||||
* allowed_key_encryption_algorithms?: list<scalar|Param|null>,
|
||||
* allowed_content_encryption_algorithms?: list<scalar|Param|null>,
|
||||
* keyset: scalar|Param|null, // The encryption keyset. It shall be JWKSet encoded.
|
||||
* keyset?: scalar|Param|null, // The encryption keyset. It shall be JWKSet encoded.
|
||||
* },
|
||||
* },
|
||||
* blocklist_token?: bool|array{
|
||||
@@ -1489,7 +1489,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* termsOfService?: scalar|Param|null, // A URL to the Terms of Service for the API. MUST be in the format of a URL. // Default: null
|
||||
* tags?: list<array{ // Default: []
|
||||
* name: scalar|Param|null,
|
||||
* name?: scalar|Param|null,
|
||||
* description?: scalar|Param|null, // Default: null
|
||||
* }>,
|
||||
* license?: array{
|
||||
@@ -1605,14 +1605,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* name?: mixed,
|
||||
* allow_create?: mixed,
|
||||
* item_uri_template?: mixed,
|
||||
* ...<mixed>
|
||||
* ...<string, mixed>
|
||||
* },
|
||||
* }
|
||||
* @psalm-type MonologConfig = array{
|
||||
* use_microseconds?: scalar|Param|null, // Default: true
|
||||
* channels?: list<scalar|Param|null>,
|
||||
* handlers?: array<string, array{ // Default: []
|
||||
* type: scalar|Param|null,
|
||||
* type?: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* priority?: scalar|Param|null, // Default: 0
|
||||
@@ -1735,7 +1735,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* headers?: list<scalar|Param|null>,
|
||||
* mailer?: scalar|Param|null, // Default: null
|
||||
* email_prototype?: string|array{
|
||||
* id: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* method?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* verbosity_levels?: array{
|
||||
@@ -1752,14 +1752,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* }
|
||||
* @psalm-type EdnotifConfig = array{
|
||||
* guichet_wsdl: scalar|Param|null,
|
||||
* metier_wsdl: scalar|Param|null,
|
||||
* exploitation_code: scalar|Param|null,
|
||||
* guichet_wsdl?: scalar|Param|null, // Default: "/var/www/html/vendor/malio/ednotif-bundle/resources/ednotif-ws/WsGuichet.wsdl"
|
||||
* metier_wsdl?: scalar|Param|null, // Default: "/var/www/html/vendor/malio/ednotif-bundle/resources/ednotif-ws/wsIpBNotif.wsdl"
|
||||
* exploitation_code?: scalar|Param|null,
|
||||
* zone?: scalar|Param|null, // Default: null
|
||||
* application?: scalar|Param|null, // Default: null
|
||||
* login: scalar|Param|null,
|
||||
* password: scalar|Param|null,
|
||||
* exploitation_number: scalar|Param|null,
|
||||
* login?: scalar|Param|null,
|
||||
* password?: scalar|Param|null,
|
||||
* exploitation_number?: scalar|Param|null,
|
||||
* exploitation_country_code?: scalar|Param|null, // Default: "FR"
|
||||
* token_ttl_seconds?: int|Param, // Default: 900
|
||||
* soap_options?: array{
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.0.42'
|
||||
app.version: '0.0.94'
|
||||
|
||||
1340
docs/superpowers/plans/2026-04-29-bovine-entry-exit.md
Normal file
1340
docs/superpowers/plans/2026-04-29-bovine-entry-exit.md
Normal file
File diff suppressed because it is too large
Load Diff
598
docs/superpowers/plans/2026-05-04-bovine-info-saisie.md
Normal file
598
docs/superpowers/plans/2026-05-04-bovine-info-saisie.md
Normal file
@@ -0,0 +1,598 @@
|
||||
# Saisie information bovin (post-EDNOTIF) — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
>
|
||||
> **Mode utilisateur :** L'utilisateur souhaite valider chaque étape avant exécution (cf. memory `feedback_step_by_step_validation`). Avant chaque task, présenter ce qui va être fait et attendre OK explicite.
|
||||
|
||||
**Goal:** Ajouter un écran de saisie post-EDNOTIF (poids, prix/kg, bâtiment, case) accessible depuis le tableau "Entrées validées", structuré en accordéons-par-bovin.
|
||||
|
||||
**Architecture:** Un nouveau composant `UiAccordion` réutilisable. Une nouvelle page Nuxt `entry-exit/bovine-info/[id].vue` qui charge la réception et ses bovins, instancie un accordéon par bovin et délègue la saisie à un sous-composant `bovine-info-form.vue`. Pas de nouvel endpoint, pas de migration : on PATCH les `Bovine` existants (`receivedWeight`, `pricePerKg`, `buildingCase`). Mini ajustement backend : exposer les ids de `BuildingCase` et `Building` dans le groupe de sérialisation `bovine:read`, sinon on n'a pas de quoi pré-remplir les selectors.
|
||||
|
||||
**Tech Stack:** Symfony 8 + API Platform 4 (annotations Groups) ; Nuxt 4 + Vue 3 + Tailwind ; pas de tests automatisés (cohérent avec le reste de la feature entry-exit, cf. spec).
|
||||
|
||||
**Spec source:** `docs/superpowers/specs/2026-05-04-bovine-info-saisie-design.md`
|
||||
|
||||
**Branche de travail:** `feat/entree-sortie` (déjà créée).
|
||||
|
||||
---
|
||||
|
||||
## Synthèse du file-mapping
|
||||
|
||||
| Fichier | Type | Responsabilité |
|
||||
| --- | --- | --- |
|
||||
| `src/Entity/BuildingCase.php` | Modify | Ajouter `bovine:read` au groupe de `id` |
|
||||
| `src/Entity/Building.php` | Modify | Ajouter `bovine:read` au groupe de `id` |
|
||||
| `frontend/services/dto/bovine-data.ts` | Modify | Ajouter `id` à `BovineBuildingRef` et `BovineBuildingCaseRef` |
|
||||
| `frontend/components/ui/UiAccordion.vue` | Create | Composant réutilisable, header en slot, body en slot, v-model boolean |
|
||||
| `frontend/components/entry-exit/bovine-info-form.vue` | Create | Sous-composant : 4 champs + bouton Valider, émet `saved` |
|
||||
| `frontend/pages/entry-exit/bovine-info/[id].vue` | Create | Page : header, fetch, tri, état d'ouverture, rendu liste d'accordéons |
|
||||
| `frontend/pages/entry-exit/index.vue` | Modify | Ajouter `row-clickable` + `@row-click` au tableau "Entrées validées" |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Exposer les ids `BuildingCase` et `Building` dans `bovine:read`
|
||||
|
||||
**Contexte :** Quand l'API normalise un `Bovine` avec le groupe `bovine:read`, l'embedded `buildingCase` ne contient que `caseNumber` et `building.label`. Pas d'ids → pas de pré-remplissage possible. On ajoute le groupe `bovine:read` aux deux propriétés `id` concernées (zéro changement de schéma, juste un attribut PHP).
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/BuildingCase.php:42`
|
||||
- Modify: `src/Entity/Building.php:36`
|
||||
|
||||
- [ ] **Step 1 : Patch `BuildingCase.id`**
|
||||
|
||||
```php
|
||||
// src/Entity/BuildingCase.php — remplacer
|
||||
#[Groups(['building:read', 'building_case:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
// par
|
||||
#[Groups(['building:read', 'building_case:read', 'bovine:read'])]
|
||||
private ?int $id = null;
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Patch `Building.id`**
|
||||
|
||||
```php
|
||||
// src/Entity/Building.php — remplacer
|
||||
#[Groups(['building:read', 'building:summary', 'reception:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
// par
|
||||
#[Groups(['building:read', 'building:summary', 'reception:read', 'bovine:read'])]
|
||||
private ?int $id = null;
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Vider le cache (les groupes sont compilés)**
|
||||
|
||||
```bash
|
||||
make cache-clear
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Vérifier que les tests existants passent**
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Attendu : 9/9 tests OK (aucun changement de comportement, juste une exposition supplémentaire).
|
||||
|
||||
- [ ] **Step 5 : Vérification manuelle rapide**
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
'http://localhost:8080/api/bovines/1' | jq '.buildingCase'
|
||||
```
|
||||
|
||||
Attendu : la réponse contient `id` (numérique) en plus de `caseNumber`, et `buildingCase.building` contient `id` en plus de `label`. Si le bovin n'a pas de buildingCase, ce sera `null` — prendre un id de bovin qui en a un (sinon ignorer cette étape).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/BuildingCase.php src/Entity/Building.php
|
||||
git commit -m "feat(api) : exposer BuildingCase.id et Building.id dans bovine:read"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Compléter le DTO frontend `BovineData`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/services/dto/bovine-data.ts`
|
||||
|
||||
- [ ] **Step 1 : Ajouter `id` aux deux interfaces de référence**
|
||||
|
||||
Remplacer le bloc en haut du fichier :
|
||||
|
||||
```ts
|
||||
export interface BovineBuildingRef {
|
||||
id: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface BovineBuildingCaseRef {
|
||||
id: number
|
||||
caseNumber: number | null
|
||||
building: BovineBuildingRef | null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier que TypeScript ne casse pas**
|
||||
|
||||
```bash
|
||||
cd frontend && npx vue-tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
Attendu : pas d'erreur (les autres pages qui consomment `BovineData` ne lisaient pas l'`id` depuis ces sous-objets ; ajouter un champ ne casse rien).
|
||||
|
||||
Si erreurs inattendues, les corriger en touchant seulement les call-sites pointés par tsc.
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/services/dto/bovine-data.ts
|
||||
git commit -m "feat(front) : id dans BovineBuildingRef et BovineBuildingCaseRef"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Créer `UiAccordion`
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/ui/UiAccordion.vue`
|
||||
|
||||
- [ ] **Step 1 : Écrire le composant**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide text-left"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="flex-1">
|
||||
<slot name="header" />
|
||||
</span>
|
||||
<Icon
|
||||
name="mdi:chevron-down"
|
||||
size="24"
|
||||
class="shrink-0 transition-transform"
|
||||
:class="{ 'rotate-180': modelValue }"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="modelValue" class="border border-t-0 border-slate-200 px-6 py-6">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const toggle = () => emit('update:modelValue', !props.modelValue)
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier l'auto-import**
|
||||
|
||||
Nuxt auto-importe les composants de `components/ui/` avec le préfixe `Ui` (cf. CLAUDE.md). Donc `<UiAccordion />` sera utilisable sans import explicite. Pas d'action ici, juste validation mentale.
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/ui/UiAccordion.vue
|
||||
git commit -m "feat(front) : composant UiAccordion réutilisable"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Créer `bovine-info-form.vue` (sous-composant)
|
||||
|
||||
**Contexte :** Encapsule l'état local et le formulaire d'un bovin. Reçoit le bovin et la liste de bâtiments, émet `saved` avec le bovin mis à jour. Permet à la page parent de rester lisible.
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/entry-exit/bovine-info-form.vue`
|
||||
|
||||
- [ ] **Step 1 : Écrire le composant**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<form class="space-y-6" :class="{ submitted }" @submit.prevent="submit">
|
||||
<div class="grid grid-cols-2 gap-x-12 gap-y-6">
|
||||
<UiNumberInput
|
||||
v-model="form.receivedWeight"
|
||||
label="Poids d'arrivée (kg)"
|
||||
:min="0"
|
||||
:step="1"
|
||||
required
|
||||
/>
|
||||
<UiNumberInput
|
||||
v-model="form.pricePerKg"
|
||||
label="Prix d'achat (kg)"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.buildingId"
|
||||
label="Bâtiment"
|
||||
:options="buildingOptions"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.buildingCaseId"
|
||||
label="Case"
|
||||
:options="caseOptions"
|
||||
:disabled="form.buildingId === null"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<UiButton
|
||||
type="submit"
|
||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
||||
:disabled="isSaving"
|
||||
:loading="isSaving"
|
||||
>
|
||||
Valider
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import type { BuildingData } from '~/services/dto/building-data'
|
||||
|
||||
const props = defineProps<{
|
||||
bovine: BovineData
|
||||
buildings: BuildingData[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [bovine: BovineData]
|
||||
}>()
|
||||
|
||||
const api = useApi()
|
||||
|
||||
interface FormState {
|
||||
receivedWeight: number | null
|
||||
pricePerKg: number | null
|
||||
buildingId: number | null
|
||||
buildingCaseId: number | null
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
receivedWeight: props.bovine.receivedWeight,
|
||||
pricePerKg: props.bovine.pricePerKg,
|
||||
buildingId: props.bovine.buildingCase?.building?.id
|
||||
?? props.bovine.effectiveBuilding?.id
|
||||
?? null,
|
||||
buildingCaseId: props.bovine.buildingCase?.id ?? null
|
||||
})
|
||||
|
||||
const submitted = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const buildingOptions = computed(() =>
|
||||
props.buildings.map(b => ({ value: b.id, label: b.label }))
|
||||
)
|
||||
|
||||
const caseOptions = computed(() => {
|
||||
if (form.buildingId === null) return []
|
||||
const building = props.buildings.find(b => b.id === form.buildingId)
|
||||
if (!building?.buildingCases) return []
|
||||
return building.buildingCases.map(c => ({
|
||||
value: c.id,
|
||||
label: c.caseNumber !== null ? `Case ${c.caseNumber}` : (c.code ?? `#${c.id}`)
|
||||
}))
|
||||
})
|
||||
|
||||
watch(() => form.buildingId, (newId) => {
|
||||
if (form.buildingCaseId === null) return
|
||||
const building = props.buildings.find(b => b.id === newId)
|
||||
const caseStillValid = building?.buildingCases?.some(c => c.id === form.buildingCaseId)
|
||||
if (!caseStillValid) {
|
||||
form.buildingCaseId = null
|
||||
}
|
||||
})
|
||||
|
||||
const submit = async () => {
|
||||
submitted.value = true
|
||||
if (
|
||||
form.receivedWeight === null
|
||||
|| form.pricePerKg === null
|
||||
|| form.buildingId === null
|
||||
|| form.buildingCaseId === null
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const updated = await api.patch<BovineData>(
|
||||
`bovines/${props.bovine.id}`,
|
||||
{
|
||||
receivedWeight: form.receivedWeight,
|
||||
pricePerKg: form.pricePerKg,
|
||||
buildingCase: `/api/building_cases/${form.buildingCaseId}`
|
||||
},
|
||||
{ headers: { 'Content-Type': 'application/merge-patch+json' } }
|
||||
)
|
||||
emit('saved', updated)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
> Note : on utilise `application/merge-patch+json` comme content-type côté API Platform pour les PATCH (la convention par défaut). `useApi.patch` a déjà ce content-type par défaut — la ligne `headers` est ici **à supprimer** si `useApi.patch` le pose déjà. Vérifier dans `composables/useApi.ts` à l'étape suivante.
|
||||
|
||||
- [ ] **Step 2 : Vérifier le content-type par défaut de `useApi.patch`**
|
||||
|
||||
```bash
|
||||
grep -n "patch" frontend/composables/useApi.ts | head -10
|
||||
```
|
||||
|
||||
- Si `useApi.patch` injecte déjà `application/merge-patch+json`, **retirer** le bloc `headers` du composant ci-dessus.
|
||||
- Sinon, le garder.
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/entry-exit/bovine-info-form.vue
|
||||
git commit -m "feat(front) : sous-composant bovine-info-form (4 champs + valider)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Créer la page `bovine-info/[id].vue`
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/pages/entry-exit/bovine-info/[id].vue`
|
||||
|
||||
- [ ] **Step 1 : Écrire la page**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-start gap-6 relative mb-8">
|
||||
<Icon
|
||||
@click="router.push('/entry-exit')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500 absolute -left-[60px]"
|
||||
/>
|
||||
<h1 class="font-bold text-3xl uppercase text-primary-500">
|
||||
Saisie information bovin {{ reception?.identificationNumber ?? '' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center text-slate-500">Chargement…</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<UiAccordion
|
||||
v-for="bovine in sortedBovines"
|
||||
:key="bovine.id"
|
||||
:model-value="openId === bovine.id"
|
||||
@update:model-value="onToggle(bovine.id, $event)"
|
||||
>
|
||||
<template #header>
|
||||
<span class="flex items-center gap-3 normal-case">
|
||||
<span class="font-bold text-base">{{ bovine.nationalNumber }}</span>
|
||||
<span
|
||||
v-if="isSaisi(bovine)"
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
||||
>
|
||||
Saisie
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
|
||||
>
|
||||
Attente saisie
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<BovineInfoForm
|
||||
:bovine="bovine"
|
||||
:buildings="buildings"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</UiAccordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import type { BuildingData } from '~/services/dto/building-data'
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import { getBuildingList } from '~/services/building'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
|
||||
const receptionId = computed(() => Number(route.params.id))
|
||||
|
||||
const reception = ref<ReceptionData | null>(null)
|
||||
const bovines = ref<BovineData[]>([])
|
||||
const buildings = ref<BuildingData[]>([])
|
||||
const loading = ref(true)
|
||||
const openId = ref<number | null>(null)
|
||||
|
||||
useHead({
|
||||
title: () => `Saisie information bovin ${reception.value?.identificationNumber ?? ''}`.trim()
|
||||
})
|
||||
|
||||
const isSaisi = (bovine: BovineData) =>
|
||||
bovine.receivedWeight !== null
|
||||
&& bovine.pricePerKg !== null
|
||||
&& bovine.buildingCase !== null
|
||||
|
||||
const sortedBovines = computed(() => {
|
||||
const pending = bovines.value.filter(b => !isSaisi(b))
|
||||
const done = bovines.value.filter(b => isSaisi(b))
|
||||
return [...pending, ...done]
|
||||
})
|
||||
|
||||
const onToggle = (bovineId: number, value: boolean) => {
|
||||
openId.value = value ? bovineId : null
|
||||
}
|
||||
|
||||
const onSaved = (updated: BovineData) => {
|
||||
const idx = bovines.value.findIndex(b => b.id === updated.id)
|
||||
if (idx === -1) return
|
||||
bovines.value[idx] = updated
|
||||
|
||||
// Ouvrir le prochain non-saisi dans la nouvelle liste triée
|
||||
const next = sortedBovines.value.find(b => !isSaisi(b) && b.id !== updated.id)
|
||||
openId.value = next?.id ?? null
|
||||
}
|
||||
|
||||
const loadBovines = async () => {
|
||||
type Hydra = { 'hydra:member'?: BovineData[] }
|
||||
const response = await api.get<BovineData[] | Hydra>(
|
||||
'bovines',
|
||||
{ reception: receptionId.value, itemsPerPage: 200 }
|
||||
)
|
||||
if (Array.isArray(response)) {
|
||||
bovines.value = response
|
||||
} else if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
|
||||
bovines.value = response['hydra:member']
|
||||
} else {
|
||||
bovines.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [r, , b] = await Promise.all([
|
||||
api.get<ReceptionData>(`receptions/${receptionId.value}`),
|
||||
loadBovines(),
|
||||
getBuildingList()
|
||||
])
|
||||
reception.value = r
|
||||
buildings.value = b
|
||||
|
||||
const firstPending = sortedBovines.value.find(bv => !isSaisi(bv))
|
||||
openId.value = firstPending?.id ?? null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
> Note de style : `BovineInfoForm` est référencé sans import — Nuxt auto-importe les composants `components/entry-exit/*.vue` avec un PascalCase basé sur le nom de fichier (à confirmer ; sinon, ajouter `import BovineInfoForm from '~/components/entry-exit/bovine-info-form.vue'`).
|
||||
|
||||
- [ ] **Step 2 : Vérifier l'auto-import**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
Aller sur `http://localhost:3000/entry-exit/bovine-info/<id>` (id d'une réception validée). Si erreur "BovineInfoForm is not defined", ajouter l'import explicite. Si rendu OK, continuer.
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/pages/entry-exit/bovine-info/'[id].vue'
|
||||
git commit -m "feat(front) : page saisie information bovin (accordéons)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Câbler la navigation depuis le tableau "Entrées validées"
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/pages/entry-exit/index.vue`
|
||||
|
||||
- [ ] **Step 1 : Ajouter la fonction de navigation**
|
||||
|
||||
Dans le `<script setup>`, sous le `goToEntry` existant, ajouter :
|
||||
|
||||
```ts
|
||||
const goToBovineInfo = (reception: ReceptionData) => {
|
||||
router.push(`/entry-exit/bovine-info/${reception.id}`)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Activer le clic sur la table validée**
|
||||
|
||||
Dans le `<template>`, sur le `<UiDataTable>` du bloc "Entrées validées" (celui avec `v-model:page="validatedPage"`), ajouter les deux props :
|
||||
|
||||
```vue
|
||||
<UiDataTable
|
||||
v-model:page="validatedPage"
|
||||
v-model:per-page="validatedPerPage"
|
||||
:columns="validatedColumns"
|
||||
:items="validated"
|
||||
:total-items="totalValidated"
|
||||
:loading="validatedLoading"
|
||||
row-clickable
|
||||
@row-click="goToBovineInfo"
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Smoke test manuel**
|
||||
|
||||
Dev server toujours en marche. Sur `/entry-exit`, cliquer sur une ligne dans "Entrées validées" → la page `/entry-exit/bovine-info/{id}` s'ouvre, titre correct, premier accordéon non-saisi ouvert.
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/pages/entry-exit/index.vue
|
||||
git commit -m "feat(front) : clic sur entrée validée → page saisie info bovin"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7 : Vérification fonctionnelle complète
|
||||
|
||||
Pas de code. Juste un parcours manuel en mode admin.
|
||||
|
||||
- [ ] **Step 1 : Cas non-saisi → saisi**
|
||||
- Aller sur `/entry-exit`, cliquer sur une entrée validée avec au moins un bovin non saisi.
|
||||
- Vérifier : premier non-saisi ouvert, badge jaune pour les non-saisis, badge vert pour les déjà-saisis.
|
||||
- Saisir les 4 champs, cliquer Valider.
|
||||
- Vérifier : accordéon se ferme, badge passe vert, l'accordéon suivant non-saisi s'ouvre.
|
||||
|
||||
- [ ] **Step 2 : Champs invalides**
|
||||
- Ouvrir un accordéon vide, cliquer Valider sans rien remplir.
|
||||
- Vérifier : bordures rouges, pas de requête réseau (DevTools).
|
||||
|
||||
- [ ] **Step 3 : Bâtiment change → case reset**
|
||||
- Choisir un bâtiment, choisir une case, changer de bâtiment.
|
||||
- Vérifier : la case repasse à vide.
|
||||
|
||||
- [ ] **Step 4 : Reload sur saisie partielle**
|
||||
- Saisir les 4 champs d'un bovin, valider (badge vert).
|
||||
- Recharger la page (F5).
|
||||
- Vérifier : ce bovin a un badge vert au chargement, il est positionné en bas, fermé. Le premier non-saisi suivant est ouvert.
|
||||
|
||||
- [ ] **Step 5 : Tout saisi**
|
||||
- Saisir tous les bovins.
|
||||
- Vérifier : tous fermés, tous verts, pas d'accordéon ouvert. Pas de toast / pas de redirection.
|
||||
|
||||
- [ ] **Step 6 : Bouton retour**
|
||||
- Cliquer la flèche retour : retour à `/entry-exit`.
|
||||
|
||||
Si un point échoue, debug puis corriger. Une fois OK, le boulot est fini — pas de commit supplémentaire (chaque task a déjà été commitée).
|
||||
|
||||
---
|
||||
|
||||
## Hors plan (à faire si bug remonté)
|
||||
|
||||
- Pagination des bovins si une réception en a > 200 (pour l'instant `itemsPerPage=200` couvre largement le besoin métier).
|
||||
- Animation d'ouverture/fermeture de l'accordéon (pour l'instant `v-if` brut, sans transition).
|
||||
- Tests unitaires Vitest (cohérent avec l'absence de tests frontend dans le repo).
|
||||
@@ -0,0 +1,123 @@
|
||||
# Export Excel de l'inventaire bovin — Design Spec
|
||||
|
||||
Bouton sur la page `/inventory` qui télécharge un XLSX listant tous les bovins actuellement présents sur l'exploitation.
|
||||
|
||||
## Contexte
|
||||
|
||||
Le métier veut un Excel exportable depuis l'écran inventaire. Ferme n'a aujourd'hui aucun outil d'export Excel (uniquement PDF via dompdf). On choisit `phpoffice/phpspreadsheet` côté serveur, en suivant le même pattern que la génération PDF actuelle (endpoint qui streame le fichier, front qui télécharge via blob).
|
||||
|
||||
Périmètre : tous les bovins actifs (`exitedAt IS NULL`), ordre `birthDate ASC`, ignore les filtres UI. Pas de modale de sélection (à voir si le métier en demande une plus tard).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
**Dépendance** : `composer require phpoffice/phpspreadsheet`
|
||||
|
||||
**Nouveau resource** : `src/ApiResource/BovineInventoryExport.php`
|
||||
- `#[ApiResource]` avec une seule opération `Get` :
|
||||
- `uriTemplate: '/bovines/inventory-export'`
|
||||
- `output: false`
|
||||
- `provider: BovineInventoryExportProvider::class`
|
||||
- `security: "is_granted('ROLE_USER')"` (cohérent avec la page `/inventory`)
|
||||
- OpenApi tag `Bovines`
|
||||
|
||||
**Nouveau provider** : `src/State/Bovin/BovineInventoryExportProvider.php`
|
||||
- Injecte `EntityManagerInterface`
|
||||
- Query Doctrine : `WHERE exitedAt IS NULL ORDER BY birthDate ASC`
|
||||
- Construit le `Spreadsheet` avec PhpSpreadsheet
|
||||
- Retourne une `Symfony\Component\HttpFoundation\Response` avec :
|
||||
- `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
|
||||
- `Content-Disposition: attachment; filename="inventaire_bovins_YYYY-MM-DD.xlsx"`
|
||||
- Body = `IOFactory::createWriter($spreadsheet, 'Xlsx')->save('php://output')` capturé via `ob_*`
|
||||
|
||||
### Frontend
|
||||
|
||||
**Page** : `frontend/pages/inventory.vue`
|
||||
- Nouveau bouton "Exporter Excel" à droite du titre, à côté de "Rafraîchir"
|
||||
- Style : même que "Rafraîchir" (bg-primary-500, h-[50px], icône `mdi:file-excel-outline`)
|
||||
- Visible pour tout user authentifié (pas de gate admin)
|
||||
- Au clic : appelle `useApi().getBlob('bovines/inventory-export')`, crée un blob URL, déclenche un `<a download>` synthétique avec le filename retourné par le backend (lu depuis le header `Content-Disposition`)
|
||||
|
||||
## Génération XLSX — détails
|
||||
|
||||
**Fichier** :
|
||||
- 1 seule feuille `Inventaire`
|
||||
- Filename : `inventaire_bovins_YYYY-MM-DD.xlsx` (date du jour serveur)
|
||||
|
||||
**En-têtes (ligne 1)** :
|
||||
- 9 colonnes dans l'ordre : `N° National`, `N° Travail`, `Sexe`, `Né le`, `Age (mois)`, `Race`, `Bâtiment`, `Case`, `Entrée le`
|
||||
- Style : gras, fond `#f1f5f9` (slate-100), bordure noire fine, alignement centré
|
||||
- Auto-filter activé sur la plage des en-têtes (Excel ajoute les boutons de filtre natifs)
|
||||
- Freeze pane : ligne 2 figée
|
||||
|
||||
**Lignes de données (à partir de la ligne 2)** :
|
||||
- Ordre `birthDate ASC` (plus vieux en haut, NULL à la fin via `NULLS LAST` natif Postgres)
|
||||
- Largeurs de colonnes :
|
||||
- N° National : 18
|
||||
- N° Travail : 12
|
||||
- Sexe : 10
|
||||
- Né le : 12
|
||||
- Age : 12
|
||||
- Race : 12
|
||||
- Bâtiment : 30
|
||||
- Case : 8
|
||||
- Entrée le : 12
|
||||
|
||||
**Mapping des valeurs** :
|
||||
- Sexe : `M` → `Mâle`, `F` → `Femelle`, autre / null → vide
|
||||
- Né le, Entrée le : format `JJ/MM/AAAA`, vide si null
|
||||
- Age : entier (mois), vide si null
|
||||
- Bâtiment, Case : valeurs nestées via `bovine.buildingCase.building.label` et `bovine.buildingCase.caseNumber`, vide si null
|
||||
|
||||
**Couleurs des lignes** (basées sur `ageMonths`, mêmes seuils que l'UI) :
|
||||
| Tranche | Hex | Tailwind |
|
||||
|--------|-----|----------|
|
||||
| 24+ mois | `#ddd6fe` | violet-200 |
|
||||
| 22-24 mois | `#fecaca` | red-200 |
|
||||
| 20-22 mois | `#fed7aa` | orange-200 |
|
||||
| < 20 mois ou NULL | `#ffffff` | blanc |
|
||||
|
||||
Le fond est appliqué sur toute la ligne (9 cellules).
|
||||
|
||||
## Flux d'erreur
|
||||
|
||||
- Exception PhpSpreadsheet (création buffer) → propage en 500 standard API Platform
|
||||
- Pas d'utilisateur (token expiré) → 401 standard via la sécurité
|
||||
|
||||
## Performance
|
||||
|
||||
- 936 lignes × 9 colonnes : génération en mémoire < 1s, fichier < 100 KB
|
||||
- Pas de pagination, pas de streaming row-by-row (overkill pour ce volume)
|
||||
|
||||
## Tests
|
||||
|
||||
Optionnel ce lot : test PHPUnit du provider qui vérifie que :
|
||||
- Status 200
|
||||
- Content-Type XLSX
|
||||
- Header `Content-Disposition: attachment; filename=...xlsx`
|
||||
- Body non vide
|
||||
Mock simple de l'`EntityManagerInterface` pour retourner 2 bovins fictifs.
|
||||
|
||||
À faire en follow-up si on veut couvrir.
|
||||
|
||||
## Verification manuelle
|
||||
|
||||
1. `make composer-install` (après avoir ajouté la dep)
|
||||
2. Recharger `/inventory`
|
||||
3. Clic sur le bouton "Exporter Excel"
|
||||
4. Vérifier le téléchargement : nom de fichier = `inventaire_bovins_2026-04-24.xlsx`
|
||||
5. Ouvrir dans Excel/LibreOffice :
|
||||
- 9 colonnes attendues
|
||||
- En-tête figé en scrollant
|
||||
- Auto-filter natif Excel
|
||||
- Lignes colorées selon âge (violet/rouge/orange)
|
||||
- Tri par date de naissance croissante
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- [ ] L'export contient 100 % des bovins actifs (count = `SELECT COUNT(*) FROM bovine WHERE exited_at IS NULL`)
|
||||
- [ ] Le filename inclut la date du jour
|
||||
- [ ] Les couleurs correspondent aux seuils d'âge
|
||||
- [ ] L'ordre matche l'UI (`birthDate ASC`)
|
||||
- [ ] Pas de régression sur les autres endpoints `/api/bovines`
|
||||
199
docs/superpowers/specs/2026-04-29-bovine-entry-exit-design.md
Normal file
199
docs/superpowers/specs/2026-04-29-bovine-entry-exit-design.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Entrée / Sortie des bovins — Design
|
||||
|
||||
## Contexte
|
||||
|
||||
Aujourd'hui, l'application gère les **réceptions** (arrivée d'un camion) qui déclarent un nombre de bovins par race (ex : 5 charolais + 3 limousine + 2 autres). Une fois la réception terminée, ces déclarations sont des indicateurs imprécis et il manque l'étape de saisie individuelle des bovins (numéro national, poids, prix…).
|
||||
|
||||
L'objectif est d'introduire un **workflow d'entrée** qui transforme une réception bovins finie en saisies individuelles enrichies via EDNOTIF, et de poser les fondations pour un futur workflow de sortie symétrique.
|
||||
|
||||
Pour ce lot, **les sorties sont hors scope** mais l'écran liste prévoit déjà leur emplacement.
|
||||
|
||||
## Décisions structurantes
|
||||
|
||||
| Décision | Choix |
|
||||
| --- | --- |
|
||||
| Distinction "en attente" vs "terminée" | Flag explicite `entryCompleted: bool` sur `Reception` |
|
||||
| Lien Bovine → Reception | FK 1-N, `Bovine.reception` ManyToOne **nullable** |
|
||||
| Rendu de l'écran de saisie | UN formulaire (2 lignes) + tableau récap dessous |
|
||||
| Bâtiment + Case | Choisis **par bovin** dans le formulaire |
|
||||
| Persistance | Save individuel à chaque "Ajouter" (POST /bovines) |
|
||||
| Enrichissement EDNOTIF | Au backend via le `BovineProcessor` existant (pas de lookup live) |
|
||||
|
||||
## Modèle de données
|
||||
|
||||
### `Reception` — modification
|
||||
|
||||
Nouveau champ :
|
||||
- `entryCompleted: bool`, default `false`, non nullable.
|
||||
- Pertinent uniquement quand `receptionType.code === 'BOVINS'`. Pour les autres types, reste `false` et ignoré côté UI.
|
||||
- Inclus dans les groupes `reception:read` et `reception:write`.
|
||||
|
||||
Migration : `ALTER TABLE reception ADD COLUMN entry_completed BOOLEAN NOT NULL DEFAULT false`.
|
||||
|
||||
Ajout d'un `BooleanFilter` sur `entryCompleted` dans `#[ApiFilter]`.
|
||||
|
||||
### `Bovine` — modification
|
||||
|
||||
Nouveau champ :
|
||||
- `reception: Reception` (ManyToOne, **nullable**).
|
||||
- Inclus dans `bovine:read` et `bovine:write`.
|
||||
|
||||
Migration : `ALTER TABLE bovine ADD COLUMN reception_id INTEGER NULL` + index + FK contrainte. Bovins existants restent à `NULL` — aucune migration de données.
|
||||
|
||||
Ajout d'un `SearchFilter` exact sur `reception` dans `#[ApiFilter]` pour permettre `GET /bovines?reception={id}`.
|
||||
|
||||
### `Reception` — relation inverse pour le compteur
|
||||
|
||||
Pour permettre l'affichage du compteur "bovins saisis" dans la liste sans N+1 :
|
||||
|
||||
- Ajouter `bovines: Collection<Bovine>` côté `Reception` (OneToMany inverse, `mappedBy: 'reception'`, fetch lazy).
|
||||
- Exposer un getter calculé `getRegisteredBovineCount(): int` dans le groupe `reception:read`.
|
||||
- L'implémentation côté provider/list peut utiliser un `addSelect('COUNT(b.id) AS bovineCount')` via un `QueryExtension` API Platform si le N+1 devient un problème (à mesurer).
|
||||
|
||||
### Aucune autre entité
|
||||
|
||||
Pas de table de jointure (un bovin entre une seule fois via une réception unique). Pas de nouvelle entité `Entry` (la `Reception` joue ce rôle). Pas d'entité `Exit` pour ce lot — la symétrie sera traitée plus tard.
|
||||
|
||||
## Endpoints API
|
||||
|
||||
Tous les endpoints réutilisent les ressources existantes ; **aucun endpoint custom n'est créé**.
|
||||
|
||||
### Liste des entrées en attente
|
||||
|
||||
`GET /api/receptions?receptionType.code=BOVINS&isValid=true&entryCompleted=false`
|
||||
|
||||
### Validation finale d'une entrée
|
||||
|
||||
`PATCH /api/receptions/{id}` avec `{ entryCompleted: true }`.
|
||||
|
||||
### Création d'un bovin lié
|
||||
|
||||
`POST /api/bovines` (Content-Type `application/ld+json`) avec :
|
||||
```json
|
||||
{
|
||||
"nationalNumber": "FR1234567890",
|
||||
"receivedWeight": 368,
|
||||
"pricePerKg": 5.7,
|
||||
"arrivalDate": "2026-04-29",
|
||||
"supplier": "/api/suppliers/12",
|
||||
"reception": "/api/receptions/45",
|
||||
"buildingCase": "/api/building_cases/8"
|
||||
}
|
||||
```
|
||||
|
||||
Le `BovineProcessor` enrichit automatiquement (workNumber, birthDate, race auto-créée via `BovineType`).
|
||||
|
||||
**Nettoyage en passant** : le `BovineProcessor` actuel appelle `setBreedCode()` qui n'existe plus (héritage avant la migration vers `BovineType` FK). À corriger pour qu'il fasse `setBovineType()` avec auto-create d'un `BovineType` si la race retournée par EDNOTIF n'existe pas en base.
|
||||
|
||||
### Suppression d'un bovin
|
||||
|
||||
`DELETE /api/bovines/{id}` — sécurité actuelle `ROLE_ADMIN` à abaisser à `ROLE_USER` pour permettre la correction immédiate depuis le tableau.
|
||||
|
||||
## Front-end
|
||||
|
||||
### Home (`pages/index.vue`)
|
||||
|
||||
- Card "CASES" → renommée "ENTRÉE / SORTIE" (multi-ligne `Entrée<br>Sortie`).
|
||||
- Lien : `/entry-exit`.
|
||||
- Icône : `mdi:swap-horizontal-bold` (à finaliser à l'implémentation).
|
||||
|
||||
### Page liste — `pages/entry-exit/index.vue`
|
||||
|
||||
Deux sections empilées :
|
||||
|
||||
**Entrées en attente**
|
||||
- Composant : `UiDataTable`.
|
||||
- Filtres serveur : `receptionType.code=BOVINS`, `isValid=true`, `entryCompleted=false`.
|
||||
- Colonnes :
|
||||
- Date réception
|
||||
- Fournisseur (`supplier.name`)
|
||||
- Total déclaré (calculé côté front : `sum(bovines_types.quantity) + parseInt(bovineDetail ?? '0')`)
|
||||
- Bovins saisis (depuis `getRegisteredBovineCount` exposé sur Reception)
|
||||
- Action (rangée cliquable)
|
||||
- Click row → `/entry-exit/entry/{receptionId}`.
|
||||
|
||||
**Sorties en attente**
|
||||
- Tableau placeholder vide avec message "À venir".
|
||||
|
||||
### Écran de saisie — `pages/entry-exit/entry/[id].vue`
|
||||
|
||||
**Header**
|
||||
- Titre : "Entrée bovins #N-BR-XXXX — Fournisseur YYY"
|
||||
- Sous-titre : "Bovins déclarés : 8 · Bovins saisis : 3"
|
||||
- Icône retour à gauche.
|
||||
|
||||
**Formulaire (2 lignes)**
|
||||
|
||||
Ligne 1 : Numéro national · Poids à l'arrivée · Date d'arrivée · Vendeur (Supplier select)
|
||||
Ligne 2 : Prix au kilo · Bâtiment (Building select) · Case (BuildingCase select dépendant du bâtiment) · Bouton **Ajouter**
|
||||
|
||||
**Pré-remplissage** (au chargement et après chaque add) :
|
||||
- Date d'arrivée = `reception.receptionDate` (date seule, modifiable)
|
||||
- Vendeur = `reception.supplier` (modifiable)
|
||||
- Bâtiment = premier de `reception.buildings` si dispo, sinon vide
|
||||
- Case = vide (à choisir explicitement)
|
||||
- Numéro national, poids, prix : vides
|
||||
|
||||
**Comportement bouton "Ajouter"**
|
||||
- Disabled si form invalide (n° national vide, poids ≤ 0, prix ≤ 0, building/case manquants).
|
||||
- Click → `POST /api/bovines` avec `application/ld+json`.
|
||||
- Succès → reload du tableau, reset form (en gardant les pré-remplissages), focus sur Numéro national.
|
||||
- Erreur 409 (doublon n° national) → toast "Ce bovin existe déjà".
|
||||
- Erreur EDNOTIF → bovin créé sans enrichissement (race/naissance vides), toast warning.
|
||||
|
||||
**Tableau récap (dessous)**
|
||||
|
||||
Colonnes : N° national · N° travail · Race · Sexe · Date naissance · Poids arrivée · Date arrivée · Prix/kg · Prix total · Bâtiment · Case · Action (icône poubelle).
|
||||
|
||||
Source : `GET /api/bovines?reception={id}` au mount + après chaque add/delete.
|
||||
|
||||
Suppression : `DELETE /api/bovines/{id}` avec `window.confirm`.
|
||||
|
||||
**Footer**
|
||||
- Bouton **Valider l'entrée** (à droite).
|
||||
- Si `bovins saisis < bovins déclarés` → `window.confirm("Vous n'avez saisi que X/Y bovins. Confirmer la fermeture ?")`.
|
||||
- Disabled si 0 bovin saisi.
|
||||
- Click → `PATCH /api/receptions/{id}` avec `{ entryCompleted: true }` → toast succès → redirection `/entry-exit`.
|
||||
|
||||
## Sécurité (rôles)
|
||||
|
||||
| Action | Rôle requis |
|
||||
| --- | --- |
|
||||
| Voir la page entrée/sortie | `ROLE_USER` |
|
||||
| Ajouter un bovin (POST /bovines) | `ROLE_USER` (actuellement `ROLE_ADMIN` — à abaisser, ce flux est métier opérationnel) |
|
||||
| Supprimer un bovin (DELETE /bovines) | `ROLE_USER` (idem, à abaisser) |
|
||||
| Valider l'entrée (PATCH receptions) | `ROLE_USER` |
|
||||
|
||||
L'abaissement à `ROLE_USER` sur `Bovine::Post`, `Bovine::Patch` et `Bovine::Delete` est **délibéré** : ce flux fait partie des opérations métier quotidiennes, pas de l'administration. À confirmer pendant l'implémentation.
|
||||
|
||||
## Cas limites
|
||||
|
||||
- **Total saisi > déclaré** : autorisé (les déclarations en réception sont des indicateurs imprécis).
|
||||
- **Doublon n° national** : la `UniqueConstraint` BDD le rejette → toast.
|
||||
- **EDNOTIF indisponible** : bovin créé sans enrich, comportement actuel du processor.
|
||||
- **Réception supprimée pendant la saisie** : impossible côté UI tant qu'on est dans l'écran. Si ça arrive (autre user), les `POST /bovines` suivants échoueront en 404 sur l'IRI reception → toast.
|
||||
- **Sortie d'un bovin** : non géré dans ce lot. Le futur workflow de sortie viendra basculer `Bovine.exitedAt`.
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- [ ] Migration `entry_completed` sur Reception passe sans erreur.
|
||||
- [ ] Migration `reception_id` sur Bovine passe sans erreur, bovins existants intacts.
|
||||
- [ ] Card "CASES" sur home remplacée par "ENTRÉE / SORTIE".
|
||||
- [ ] `/entry-exit` affiche les entrées en attente et un placeholder sorties.
|
||||
- [ ] Click sur une entrée → écran saisie avec form pré-rempli.
|
||||
- [ ] "Ajouter" → bovin créé, ligne au tableau, form reset (pré-remplissages restaurés).
|
||||
- [ ] Suppression d'une ligne fonctionne avec confirmation.
|
||||
- [ ] "Valider l'entrée" bascule `entryCompleted` et redirige.
|
||||
- [ ] Une réception fermée disparaît de la liste.
|
||||
- [ ] `BovineProcessor` corrigé pour utiliser `setBovineType()` avec auto-create.
|
||||
- [ ] `make test` passe sans régression.
|
||||
|
||||
## Mode d'implémentation
|
||||
|
||||
Sur ce projet, l'utilisateur souhaite **valider chaque étape du plan** avant exécution. À chaque étape du plan d'implémentation, l'agent doit :
|
||||
|
||||
1. Présenter ce qu'il s'apprête à faire (fichiers, changements).
|
||||
2. Attendre la validation explicite de l'utilisateur.
|
||||
3. Exécuter, puis présenter l'étape suivante.
|
||||
|
||||
Cette discipline permet des retours en direct et des ajustements fins en cours de route.
|
||||
187
docs/superpowers/specs/2026-05-04-bovine-info-saisie-design.md
Normal file
187
docs/superpowers/specs/2026-05-04-bovine-info-saisie-design.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Saisie information bovin (post-EDNOTIF)
|
||||
|
||||
## Contexte
|
||||
|
||||
Sur la page `/entry-exit`, le tableau "Entrées validées" liste les receptions
|
||||
dont les bovins sont tous confirmés EDNOTIF (`reception.validatedAt` non null).
|
||||
Une fois cette validation acquise, l'utilisateur doit encore renseigner pour
|
||||
chaque bovin quatre informations métier qui ne viennent pas d'EDNOTIF :
|
||||
|
||||
- poids d'arrivée
|
||||
- prix d'achat au kg
|
||||
- bâtiment
|
||||
- case
|
||||
|
||||
Cette spec décrit l'écran de saisie et le composant accordéon qu'il introduit.
|
||||
|
||||
## Périmètre
|
||||
|
||||
- Nouvelle page accessible uniquement via clic sur une ligne d'"Entrées
|
||||
validées" — pas d'entrée dans la nav globale.
|
||||
- Aucun changement d'entité Doctrine, aucune migration : les quatre champs
|
||||
existent déjà sur `Bovine` (`receivedWeight`, `pricePerKg`, `buildingCase`).
|
||||
- Le champ `building` (legacy XLSX) n'est pas écrit. Côté affichage,
|
||||
`getEffectiveBuilding()` continue de dériver le bâtiment effectif depuis
|
||||
`buildingCase`.
|
||||
- `pricePerKg` reste protégé par `ROLE_BUREAU` côté API. La page exige
|
||||
`ROLE_ADMIN` ; la hiérarchie Symfony fait que `ROLE_ADMIN` hérite
|
||||
`ROLE_BUREAU`, donc pas de cas particulier.
|
||||
- Pas de gestion de "session interrompue" : chaque accordéon validé
|
||||
individuellement est persisté immédiatement.
|
||||
|
||||
## Routing & navigation
|
||||
|
||||
- Page : `frontend/pages/entry-exit/bovine-info/[id].vue` où `[id]` est le
|
||||
`receptionId`.
|
||||
- Sur `frontend/pages/entry-exit/index.vue`, le tableau "Entrées validées"
|
||||
reçoit `row-clickable` et `@row-click="goToBovineInfo"`. Le handler pousse
|
||||
vers `/entry-exit/bovine-info/{reception.id}`.
|
||||
- Le bouton flèche-retour de la page renvoie vers `/entry-exit`.
|
||||
|
||||
## Composant `UiAccordion`
|
||||
|
||||
Fichier : `frontend/components/ui/UiAccordion.vue`. Réutilisable, sans logique
|
||||
métier.
|
||||
|
||||
**Props**
|
||||
- `modelValue: boolean` — état ouvert/fermé, supporte `v-model`.
|
||||
|
||||
**Slots**
|
||||
- `#header` — contenu libre du header (badge, titre, etc., aligné à gauche).
|
||||
- default — corps de l'accordéon, rendu uniquement quand ouvert.
|
||||
|
||||
**Comportement**
|
||||
- Click sur le header → emit `update:modelValue` avec la valeur inversée.
|
||||
- Header : `bg-slate-100`, padding identique aux headers `UiDataTable`
|
||||
(`px-4 py-3`), texte semi-bold uppercase.
|
||||
- Chevron à droite (`mdi:chevron-down`), rotation 180° quand ouvert,
|
||||
transition CSS courte.
|
||||
- Pas d'animation de hauteur au déploiement (pour rester simple) — on rend ou
|
||||
pas via `v-if`.
|
||||
|
||||
## Page `bovine-info/[id].vue`
|
||||
|
||||
**Layout** — copie du pattern `entry-exit/entry/[id].vue` :
|
||||
`<div class="px-[86px]">` + bandeau titre avec flèche retour absolue, titre
|
||||
`<h1>Saisie information bovin {{ reception.identificationNumber }}</h1>`.
|
||||
Pas de sous-titre.
|
||||
|
||||
**Chargement (`onMounted`)**
|
||||
|
||||
1. `GET receptions/{id}` → alimente le titre.
|
||||
2. `GET bovines?reception={id}&itemsPerPage=200` (pas de pagination — on
|
||||
suppose qu'une réception a au plus quelques dizaines de bovins).
|
||||
3. `GET buildings` — la réponse contient `buildingCases` imbriqués
|
||||
(`BuildingData.buildingCases`). On dérive de là à la fois la liste de
|
||||
bâtiments (selector "Bâtiment") et l'index des cases par bâtiment.
|
||||
|
||||
**État local par bovin** (`Map<bovineId, FormState>`) :
|
||||
|
||||
```ts
|
||||
type FormState = {
|
||||
receivedWeight: number | null
|
||||
pricePerKg: number | null
|
||||
buildingId: number | null // UI-only, drive le filtre Case
|
||||
buildingCaseId: number | null
|
||||
submitted: boolean // pour les borders rouges au submit
|
||||
isSaving: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Initialisé depuis l'API : `receivedWeight`, `pricePerKg` directement,
|
||||
`buildingCaseId = bovine.buildingCase?.id`,
|
||||
`buildingId = bovine.effectiveBuilding?.id`.
|
||||
|
||||
**Source de vérité du badge** : `bovine.receivedWeight != null
|
||||
&& bovine.pricePerKg != null && bovine.buildingCase != null` — donc calculé
|
||||
sur les valeurs *persistées*, pas sur le `FormState` en cours d'édition. Vert
|
||||
"Saisie" si les trois sont non-null (le bâtiment est dérivé de la case),
|
||||
sinon jaune "Attente saisie".
|
||||
|
||||
> Note : on garde 4 champs côté UI mais 3 conditions backend, parce que
|
||||
> `building` n'est pas persisté indépendamment.
|
||||
|
||||
**Tri** — non-saisis (badge jaune) en haut puis saisis (badge vert) en bas,
|
||||
ordre d'API préservé à l'intérieur de chaque groupe. Le tri est calculé
|
||||
- au chargement initial,
|
||||
- après chaque PATCH OK (le bovin qui vient d'être saisi descend dans le
|
||||
groupe vert).
|
||||
|
||||
Il ne se recompute pas pendant qu'un accordéon est ouvert et en cours
|
||||
d'édition — sinon les bovins sauteraient de position au moindre changement
|
||||
de l'état "saisi/non-saisi", ce qui ne se produit ici que sur un PATCH
|
||||
réussi.
|
||||
|
||||
**Open state** — `ref<number | null>` qui contient l'id du bovin
|
||||
actuellement ouvert. Un seul accordéon ouvert à la fois.
|
||||
|
||||
- Initialisation : id du premier bovin non-saisi de la liste triée, ou
|
||||
`null` si tout est déjà saisi.
|
||||
- Click sur un header autre que celui ouvert → ferme l'ouvert, ouvre le
|
||||
cliqué.
|
||||
- Click sur le header ouvert → ferme (open = `null`).
|
||||
- Validation OK d'un accordéon → ferme l'actuel, ouvre l'id du prochain
|
||||
non-saisi de la liste (recalculée). Si plus de non-saisi → `null`.
|
||||
|
||||
## Formulaire par accordéon
|
||||
|
||||
Tous les champs `required`, validation au submit (pattern `submitted` flag +
|
||||
`.submitted :invalid` du CSS global).
|
||||
|
||||
| Champ | Composant | Type / format |
|
||||
| ------------------ | -------------------- | ------------------------ |
|
||||
| Poids d'arrivée | `UiNumberInput` | entier kg |
|
||||
| Prix d'achat (kg) | `UiNumberInput` | float, step 0.01 |
|
||||
| Bâtiment | `UiSelect` | options = liste building |
|
||||
| Case | `UiSelect` | options = cases du building sélectionné |
|
||||
|
||||
- Watch sur `buildingId` : si l'utilisateur change le bâtiment et que la case
|
||||
actuellement sélectionnée n'appartient pas au nouveau, on remet
|
||||
`buildingCaseId = null`.
|
||||
- Bouton `Valider` centré, `bg-primary-500`, désactivé pendant `isSaving`.
|
||||
|
||||
**Soumission**
|
||||
|
||||
```
|
||||
PATCH /bovines/{id}
|
||||
{
|
||||
receivedWeight,
|
||||
pricePerKg,
|
||||
buildingCase: `/api/building_cases/${buildingCaseId}`
|
||||
}
|
||||
Content-Type: application/ld+json
|
||||
```
|
||||
|
||||
À la réponse OK, on remplace le bovin dans la liste locale par la version
|
||||
retournée par l'API (qui contient `buildingCase` hydraté pour recomputer le
|
||||
badge), puis on déclenche la transition d'état (resort + open suivant).
|
||||
|
||||
En cas d'erreur HTTP, le toast par défaut de `useApi` suffit ; on garde
|
||||
l'accordéon ouvert et le `FormState` intact.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Pas de bulk-save (pas de "Tout valider").
|
||||
- Pas de tracking "modifié non sauvé" / warning au unload — chaque accordéon
|
||||
est validé explicitement, pas d'autosave.
|
||||
- Pas de tests automatisés ajoutés dans ce lot (cohérent avec le reste de la
|
||||
feature entry-exit).
|
||||
- Pas d'exposition de cet écran ailleurs que via le tableau "Entrées
|
||||
validées".
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- Cliquer sur une ligne du tableau "Entrées validées" ouvre la page
|
||||
`/entry-exit/bovine-info/{id}`.
|
||||
- La page liste tous les bovins de la réception, non-saisis en haut.
|
||||
- Au chargement, un seul accordéon est ouvert : le premier non-saisi (ou
|
||||
aucun si tout est déjà saisi).
|
||||
- Cliquer sur un autre header ferme l'ouvert et ouvre le cliqué.
|
||||
- Soumettre un accordéon avec un champ vide affiche les borders rouges
|
||||
(`submitted` flag) et bloque la requête.
|
||||
- Soumettre un accordéon valide PATCH le bovin et, après réponse OK, ferme
|
||||
l'accordéon, met le badge en vert et ouvre le suivant non-saisi.
|
||||
- Recharger la page après une saisie partielle réaffiche les valeurs
|
||||
pré-remplies et le bon badge pour chaque bovin.
|
||||
- `php-cs-fixer` et `make test` restent verts (pas de code backend modifié,
|
||||
donc rien à régresser).
|
||||
@@ -7,3 +7,17 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,45 @@
|
||||
<template>
|
||||
<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>
|
||||
<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"/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
{{ props.address? "Sauvegarder" : "Ajouter" }}
|
||||
</button>
|
||||
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
||||
{{ props.address ? "Modification d'une adresse" : "Ajout d'une adresse" }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<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 />
|
||||
<UiTextInput id="address-street2" v-model="form.street2" label="Complément" />
|
||||
<UiTextInput id="address-postalCode" v-model="form.postalCode" label="Code postal" />
|
||||
<UiTextInput id="address-city" v-model="form.city" label="Ville" />
|
||||
<UiTextInput id="address-country" v-model="form.countryCode" label="Pays" />
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AddressPayload } from "~/services/address"
|
||||
|
||||
import type { AddressPayload } from "~/services/address"
|
||||
import { getCommunesByPostalCode, type CommuneData } from "~/services/geo"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -40,26 +50,45 @@ 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: "",
|
||||
countryCode: "FR",
|
||||
})
|
||||
|
||||
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 ?? ""
|
||||
form.countryCode = data.countryCode || "FR"
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -70,11 +99,50 @@ 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
|
||||
}>()
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="link">
|
||||
<div class="w-[324px] h-[228px] border border-black rounded-md p-6 flex flex-col justify-between">
|
||||
<div class="w-[300px] h-[216px] border border-primary-700 rounded-lg p-6 flex flex-col justify-between gap-4">
|
||||
<div class="flex justify-between">
|
||||
<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 class="rounded-full w-[80px] h-[80px] bg-[#D9D9D9] flex justify-center items-center">
|
||||
<Icon :name="iconName" class="!text-primary-700" size="44" />
|
||||
</div>
|
||||
<div>
|
||||
<Icon name="mdi:plus" style="color: black" size="44" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="uppercase font-bold">
|
||||
<p class="text-3xl"> {{ label }} </p>
|
||||
<p class="text-3xl text-primary-700">
|
||||
<slot name="label">{{ label }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -27,4 +27,3 @@ const props = defineProps<{
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
||||
65
frontend/components/commun/update-weight.vue
Normal file
65
frontend/components/commun/update-weight.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<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>
|
||||
127
frontend/components/entry-exit/bovine-info-form.vue
Normal file
127
frontend/components/entry-exit/bovine-info-form.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<form class="space-y-6" @submit.prevent="submit">
|
||||
<div class="grid grid-cols-4 gap-x-12 gap-y-6">
|
||||
<UiNumberInput
|
||||
v-model="form.receivedWeight"
|
||||
label="Poids d'arrivée (kg)"
|
||||
wrapperClass="flex-col"
|
||||
labelClass="font-bold uppercase text-xl text-primary-700"
|
||||
:min="0"
|
||||
:step="1"
|
||||
/>
|
||||
<UiNumberInput
|
||||
v-model="form.pricePerKg"
|
||||
label="Prix au kg"
|
||||
wrapperClass="flex-col"
|
||||
labelClass="font-bold uppercase text-xl text-primary-700"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.buildingId"
|
||||
label="Bâtiment"
|
||||
:options="buildingOptions"
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.buildingCaseId"
|
||||
label="Case"
|
||||
:options="caseOptions"
|
||||
:disabled="form.buildingId === null"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<UiButton
|
||||
type="submit"
|
||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
||||
:disabled="isSaving"
|
||||
:loading="isSaving"
|
||||
>
|
||||
Valider
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import type { BuildingData } from '~/services/dto/building-data'
|
||||
|
||||
const props = defineProps<{
|
||||
bovine: BovineData
|
||||
buildings: BuildingData[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [bovine: BovineData]
|
||||
}>()
|
||||
|
||||
const api = useApi()
|
||||
|
||||
interface FormState {
|
||||
receivedWeight: number | null
|
||||
pricePerKg: number | null
|
||||
buildingId: number | null
|
||||
buildingCaseId: number | null
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
receivedWeight: props.bovine.receivedWeight ?? null,
|
||||
pricePerKg: props.bovine.pricePerKg ?? null,
|
||||
buildingId: props.bovine.buildingCase?.building?.id
|
||||
?? props.bovine.effectiveBuilding?.id
|
||||
?? null,
|
||||
buildingCaseId: props.bovine.buildingCase?.id ?? null
|
||||
})
|
||||
|
||||
const isSaving = ref(false)
|
||||
|
||||
const buildingOptions = computed(() =>
|
||||
props.buildings.map(b => ({ value: b.id, label: b.label }))
|
||||
)
|
||||
|
||||
const caseOptions = computed(() => {
|
||||
if (form.buildingId === null) return []
|
||||
const building = props.buildings.find(b => b.id === form.buildingId)
|
||||
if (!building?.buildingCases) return []
|
||||
return building.buildingCases.map(c => ({
|
||||
value: c.id,
|
||||
label: c.caseNumber !== null ? `Case ${c.caseNumber}` : (c.code ?? `#${c.id}`)
|
||||
}))
|
||||
})
|
||||
|
||||
watch(() => form.buildingId, (newId) => {
|
||||
if (form.buildingCaseId === null) return
|
||||
const building = props.buildings.find(b => b.id === newId)
|
||||
const caseStillValid = building?.buildingCases?.some(c => c.id === form.buildingCaseId)
|
||||
if (!caseStillValid) {
|
||||
form.buildingCaseId = null
|
||||
}
|
||||
})
|
||||
|
||||
const submit = async () => {
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (form.receivedWeight != null) payload.receivedWeight = form.receivedWeight
|
||||
if (form.pricePerKg != null) payload.pricePerKg = form.pricePerKg
|
||||
if (form.buildingCaseId != null) {
|
||||
payload.buildingCase = `/api/building_cases/${form.buildingCaseId}`
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
emit('saved', props.bovine)
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const updated = await api.patch<BovineData>(
|
||||
`bovines/${props.bovine.id}`,
|
||||
payload,
|
||||
{ toastSuccessMessage: `Bovin ${props.bovine.nationalNumber} enregistré.` }
|
||||
)
|
||||
emit('saved', updated)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
96
frontend/components/inventory/inventory-export-modal.vue
Normal file
96
frontend/components/inventory/inventory-export-modal.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<UiModal v-model="open" title="Exporter l'inventaire bovin" max-width="max-w-2xl">
|
||||
<p class="mb-5 text-sm text-slate-600">
|
||||
Aucun filtre coché : export complet (tous les bovins actifs).
|
||||
</p>
|
||||
|
||||
<div class="mb-5">
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-600">
|
||||
Tranches d'âge
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
v-for="bucket in ageBuckets"
|
||||
:key="bucket.value"
|
||||
class="flex items-center gap-3 cursor-pointer text-primary-700"
|
||||
>
|
||||
<input
|
||||
v-model="filters.ageRanges"
|
||||
type="checkbox"
|
||||
:value="bucket.value"
|
||||
class="h-4 w-4 cursor-pointer accent-primary-500"
|
||||
/>
|
||||
<span :class="['inline-block rounded px-2 py-0.5 text-xs font-semibold text-white', bucket.colorClass]">
|
||||
{{ bucket.badge }}
|
||||
</span>
|
||||
<span>{{ bucket.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="inline-flex h-[50px] items-center justify-center gap-2 rounded bg-primary-500 px-6 text-base text-white uppercase hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@click="onSubmit"
|
||||
>
|
||||
<Icon
|
||||
v-if="loading"
|
||||
name="mdi:loading"
|
||||
size="20"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<Icon v-else name="mdi:file-excel-outline" size="20" />
|
||||
Exporter
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
export interface InventoryExportFilters {
|
||||
ageRanges: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'submit', filters: InventoryExportFilters): void
|
||||
}>()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const ageBuckets = [
|
||||
{ value: 'over24', label: '≥ 24 mois', badge: '24+', colorClass: 'bg-red-500' },
|
||||
{ value: 'between22And24', label: '22 à 24 mois', badge: '22-24', colorClass: 'bg-orange-500' },
|
||||
{ value: 'between20And22', label: '20 à 22 mois', badge: '20-22', colorClass: 'bg-yellow-500' }
|
||||
]
|
||||
|
||||
const filters = reactive<InventoryExportFilters>({
|
||||
ageRanges: []
|
||||
})
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
filters.ageRanges = []
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = () => {
|
||||
emit('submit', { ageRanges: [...filters.ageRanges] })
|
||||
}
|
||||
</script>
|
||||
@@ -1,21 +1,26 @@
|
||||
<template>
|
||||
<div
|
||||
<form
|
||||
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"
|
||||
class="flex flex-col items-center gap-16">
|
||||
<h1 class="text-4xl uppercase font-bold">Sélection des marchandises réceptionnnées</h1>
|
||||
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>
|
||||
<div
|
||||
class="flex flex-row gap-8 items-center">
|
||||
class="flex flex-row gap-8 items-center w-full">
|
||||
<div
|
||||
v-for="type in bovineType"
|
||||
:key="type.id"
|
||||
class="mt-8 flex flex-row mb-2 gap-6">
|
||||
class="mt-8 flex flex-row mb-2 w-full">
|
||||
<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
|
||||
@@ -23,15 +28,22 @@
|
||||
<UiNumberInput
|
||||
label="Autres"
|
||||
v-model="otherQuantity"
|
||||
class="max-w-[80px]"
|
||||
wrapper-class="gap-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
||||
@click="goNext"
|
||||
>Peser
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
|
||||
@@ -50,6 +62,7 @@ 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)
|
||||
@@ -77,14 +90,14 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => receptionId.value,
|
||||
async (id) => {
|
||||
if (!id || !receptionIri.value) {
|
||||
[() => receptionId.value, () => bovineType.value],
|
||||
async ([id, types]) => {
|
||||
if (!id || !receptionIri.value || types.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectionMap: Record<string, number | null> = {}
|
||||
for (const type of bovineType.value) {
|
||||
for (const type of types) {
|
||||
selectionMap[String(type.id)] = 0
|
||||
}
|
||||
|
||||
@@ -161,7 +174,13 @@ async function goNext() {
|
||||
return
|
||||
}
|
||||
|
||||
// @TODO Ajouter un composable pour le toaster qui gère les key i18n
|
||||
showBovineError.value = false
|
||||
|
||||
if (totalBovines.value === 0) {
|
||||
showBovineError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (totalBovines.value > 52) {
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<form @submit.prevent="validate">
|
||||
<form ref="formRef" :class="{ submitted }" @submit.prevent="validate">
|
||||
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
|
||||
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1">Réception</h1>
|
||||
<!-- Nom de l'utilisateur -->
|
||||
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Réception</h1>
|
||||
<UiSelect
|
||||
id="reception-user"
|
||||
v-model="form.userId"
|
||||
@@ -13,15 +12,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"
|
||||
@@ -31,8 +30,8 @@
|
||||
label: type.label
|
||||
}))"
|
||||
wrapper-class="col-start-1 row-start-4"
|
||||
required
|
||||
/>
|
||||
<!-- Fournisseur -->
|
||||
<UiSelect
|
||||
id="reception-supplier"
|
||||
v-model="form.supplierId"
|
||||
@@ -43,20 +42,17 @@
|
||||
}))"
|
||||
:loading="isLoadingSuppliers"
|
||||
wrapper-class="col-start-1 row-start-5"
|
||||
required
|
||||
/>
|
||||
<!-- Adresse fournisseur -->
|
||||
<UiSelect
|
||||
id="reception-address"
|
||||
v-model="form.addressId"
|
||||
label="Adresse"
|
||||
:options="supplierAddresses.map((address) => ({
|
||||
value: String(address.id),
|
||||
label: address.fullAddress
|
||||
}))"
|
||||
:disabled="isLoadingSuppliers || supplierAddresses.length === 0"
|
||||
:options="addressOptions"
|
||||
:disabled="isLoadingSuppliers || ownerAddresses.length === 0"
|
||||
wrapper-class="col-start-2 row-start-1"
|
||||
required
|
||||
/>
|
||||
<!-- Camion -->
|
||||
<UiSelect
|
||||
id="reception-truck"
|
||||
v-model="form.truckId"
|
||||
@@ -67,8 +63,8 @@
|
||||
}))"
|
||||
:loading="isLoadingTrucks"
|
||||
wrapper-class="col-start-2 row-start-2"
|
||||
required
|
||||
/>
|
||||
<!-- Transporteur -->
|
||||
<UiSelect
|
||||
id="reception-carrier"
|
||||
v-model="form.carrierId"
|
||||
@@ -80,27 +76,15 @@
|
||||
:loading="isLoadingCarriers"
|
||||
select-class="h-[34px]"
|
||||
wrapper-class="col-start-2 row-start-3"
|
||||
required
|
||||
/>
|
||||
<!-- 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">
|
||||
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
|
||||
<UiLicensePlateInput
|
||||
v-model="form.licensePlate"
|
||||
v-model:allowAny="allowAnyLicensePlate"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<!-- Immatriculation (LIOT) -->
|
||||
<UiSelect
|
||||
v-if="isLiotCarrier"
|
||||
id="reception-vehicle"
|
||||
@@ -112,43 +96,53 @@
|
||||
}))"
|
||||
: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">
|
||||
<button
|
||||
<UiButton
|
||||
type="submit"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||
>Peser
|
||||
</button>
|
||||
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 {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";
|
||||
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'
|
||||
|
||||
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),
|
||||
@@ -161,62 +155,27 @@ 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)
|
||||
|
||||
// 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 { 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)
|
||||
|
||||
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) {
|
||||
@@ -224,50 +183,6 @@ const clearReceptionBovines = async (receptionIri: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate le formulaire depuis la réception en cours
|
||||
watch(
|
||||
() => receptionStore.current,
|
||||
(reception) => {
|
||||
isHydrating.value = true
|
||||
form.licensePlate = reception?.licensePlate ?? ''
|
||||
form.receptionDate = reception?.receptionDate ?? new Date().toISOString().slice(0, 10)
|
||||
form.receptionTypeId = reception?.receptionType?.id
|
||||
? String(reception.receptionType.id)
|
||||
: ''
|
||||
form.userId = reception?.user?.id
|
||||
? String(reception.user.id)
|
||||
: form.userId
|
||||
form.supplierId = reception?.supplier?.id
|
||||
? String(reception.supplier.id)
|
||||
: ''
|
||||
form.addressId = reception?.address?.id
|
||||
? String(reception.address.id)
|
||||
: ''
|
||||
form.truckId = reception?.truck?.id
|
||||
? String(reception.truck.id)
|
||||
: ''
|
||||
form.carrierId = reception?.carrier?.id
|
||||
? String(reception.carrier.id)
|
||||
: ''
|
||||
form.driverId = reception?.driver?.id
|
||||
? String(reception.driver.id)
|
||||
: ''
|
||||
isHydrating.value = false
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
|
||||
// Charge la liste des users pour le select
|
||||
const loadUsers = async () => {
|
||||
isLoadingUsers.value = true
|
||||
try {
|
||||
users.value = await getUsers()
|
||||
} finally {
|
||||
isLoadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Charge la liste des fournisseurs pour le select
|
||||
const loadSuppliers = async () => {
|
||||
isLoadingSuppliers.value = true
|
||||
try {
|
||||
@@ -277,182 +192,33 @@ const loadSuppliers = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Charge la liste des camions pour le select
|
||||
const loadTrucks = async () => {
|
||||
isLoadingTrucks.value = true
|
||||
try {
|
||||
trucks.value = await getTruckList()
|
||||
} finally {
|
||||
isLoadingTrucks.value = false
|
||||
}
|
||||
}
|
||||
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 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 loadTrucks()
|
||||
await loadCarriers()
|
||||
await loadCommonData()
|
||||
await loadDrivers()
|
||||
await loadVehicles()
|
||||
await authStore.ensureSession()
|
||||
setDefaultUser()
|
||||
})
|
||||
|
||||
// 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 buildPayload = () => {
|
||||
const normalizedLicensePlate = form.licensePlate.trim()
|
||||
const normalizedReceptionDate = form.receptionDate.trim()
|
||||
const normalizedReceptionTypeId = form.receptionTypeId.trim()
|
||||
@@ -462,29 +228,16 @@ async function validate() {
|
||||
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 basePayload = {
|
||||
const receptionTypeIri = normalizedReceptionTypeId ? `/api/reception_types/${normalizedReceptionTypeId}` : null
|
||||
const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
|
||||
const supplierIri = normalizedSupplierId ? `/api/suppliers/${normalizedSupplierId}` : null
|
||||
const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
|
||||
const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
|
||||
const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
|
||||
const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
|
||||
|
||||
return {
|
||||
licensePlate: normalizedLicensePlate,
|
||||
receptionDate: normalizedReceptionDate,
|
||||
receptionType: receptionTypeIri,
|
||||
@@ -492,13 +245,35 @@ async function validate() {
|
||||
supplier: supplierIri,
|
||||
address: addressIri,
|
||||
truck: truckIri,
|
||||
carrier: carrierIri
|
||||
carrier: carrierIri,
|
||||
...(isLiotCarrier.value && driverIri ? { driver: driverIri } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...basePayload,
|
||||
...(isLiotCarrier.value && driverIri ? {driver: driverIri} : {})
|
||||
const saveDraft = async () => {
|
||||
const payload = buildPayload()
|
||||
if (!receptionStore.current) {
|
||||
await receptionStore.createReception({
|
||||
currentStep: 0,
|
||||
...payload
|
||||
})
|
||||
return
|
||||
}
|
||||
await receptionStore.updateReception(receptionStore.current.id, {
|
||||
currentStep: receptionStore.current.currentStep,
|
||||
...payload
|
||||
})
|
||||
}
|
||||
|
||||
const validateFields = () => {
|
||||
submitted.value = true
|
||||
return formRef.value?.reportValidity() ?? false
|
||||
}
|
||||
|
||||
defineExpose({ saveDraft, validateFields })
|
||||
|
||||
async function validate() {
|
||||
const payload = buildPayload()
|
||||
|
||||
if (!receptionStore.current) {
|
||||
const created = await receptionStore.createReception({
|
||||
@@ -527,5 +302,4 @@ async function validate() {
|
||||
...payload
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-16">
|
||||
<form :class="['flex flex-col items-center gap-16', { submitted }]" @submit.prevent="goNext">
|
||||
<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">Sélection des marchandises réceptionnnées</h1>
|
||||
<h1 class="text-4xl uppercase font-bold text-primary-500">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"
|
||||
@@ -21,24 +22,30 @@
|
||||
label="Préciser"
|
||||
placeholder="Précisions complémentaires"
|
||||
:maxlength="255"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedMerchandiseTypeId && !isGranule"
|
||||
class="flex gap-4 w-[550px] justify-evenly"
|
||||
class="flex flex-col gap-4 w-[550px]"
|
||||
>
|
||||
<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 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>
|
||||
<p class="text-red-500 text-sm" :class="showBuildingError ? '' : 'invisible'">
|
||||
Veuillez sélectionner au moins un bâtiment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -47,29 +54,35 @@
|
||||
>
|
||||
<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">{{ type.label }}</p>
|
||||
<p class="font-bold uppercase text-primary-500">{{ type.label }}</p>
|
||||
<div
|
||||
v-for="building in buildings"
|
||||
:key="building.id"
|
||||
class="flex items-center gap-2 text-lg"
|
||||
class="flex items-center gap-2 text-lg pl-[2px]"
|
||||
>
|
||||
<UiCheckbox
|
||||
v-model="selectedPelletBuildingIds[String(type.id)]"
|
||||
:value="String(building.id)"
|
||||
:label="building.label"
|
||||
label-class="text-lg"
|
||||
label-class="text-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-red-500 text-sm" :class="showBuildingError ? '' : 'invisible'">
|
||||
Veuillez sélectionner au moins un bâtiment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
||||
@click="goNext"
|
||||
>Peser
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -97,6 +110,9 @@ 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 => {
|
||||
@@ -179,6 +195,23 @@ 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}`
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<div class="flex flex-col items-center w-[660px]">
|
||||
<h1 class="font-bold text-5xl uppercase">{{ 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>
|
||||
@@ -1,183 +1,161 @@
|
||||
<template>
|
||||
<form @submit.prevent="validate">
|
||||
<div
|
||||
class="flex flex-col items-center gap-16">
|
||||
<div
|
||||
class="flex flex-row gap-6 items-center">
|
||||
<form>
|
||||
<div class="flex flex-row justify-between gap-x-12 font-bold uppercase mb-8">
|
||||
<div
|
||||
v-for="type in bovineType"
|
||||
v-for="type in bovineTypes"
|
||||
:key="type.id"
|
||||
class="flex flex-row mb-2 gap-6 ">
|
||||
>
|
||||
<UiNumberInput
|
||||
:label="type.label"
|
||||
:code="type.code"
|
||||
v-model="bovineQuantities[String(type.id)]"
|
||||
:disabled="!auth.isAdmin"
|
||||
v-model="localQuantities[String(type.id)]"
|
||||
:disabled="!isAdmin"
|
||||
:placeholder="0"
|
||||
:min="0"
|
||||
:max="10"
|
||||
wrapperClass="w-44 flex-col"
|
||||
inputClass="font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class=" flex flex-row mb-2 gap-6">
|
||||
<UiNumberInput
|
||||
label="Autres"
|
||||
v-model="otherQuantity"
|
||||
:disabled="!auth.isAdmin"
|
||||
/>
|
||||
</div>
|
||||
<UiNumberInput
|
||||
label="Autres"
|
||||
v-model="localOtherQuantity"
|
||||
:disabled="!isAdmin"
|
||||
wrapperClass="w-44 flex-col"
|
||||
inputClass="font-medium"
|
||||
/>
|
||||
</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 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()
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
idReception: number
|
||||
modelValue: ReceptionBovineTypeData[]
|
||||
otherQuantity: number | null
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
const receptionId = props.idReception
|
||||
const reception = await getReception(receptionId)
|
||||
|
||||
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 emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: ReceptionBovineTypeData[]): void
|
||||
(event: 'update:otherQuantity', value: number | null): void
|
||||
}>()
|
||||
|
||||
const loadBovineType = async () => {
|
||||
isLoadingBovineType.value = true
|
||||
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
|
||||
try {
|
||||
bovineType.value = await getBovineTypeList()
|
||||
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
|
||||
}
|
||||
} finally {
|
||||
isLoadingBovineType.value = false
|
||||
isSyncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBovineType()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => receptionId,
|
||||
async (id) => {
|
||||
if (!id || !receptionIri.value) {
|
||||
() => props.otherQuantity,
|
||||
(value) => {
|
||||
if (isSyncing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
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}
|
||||
const next = value ?? 0
|
||||
isSyncing.value = true
|
||||
localOtherQuantity.value = next
|
||||
isSyncing.value = false
|
||||
}
|
||||
)
|
||||
|
||||
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.')
|
||||
})
|
||||
watch(localOtherQuantity, (value) => {
|
||||
if (isSyncing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await syncBovineSelections(receptionIri.value)
|
||||
const next = value ?? 0
|
||||
emit('update:otherQuantity', next)
|
||||
})
|
||||
|
||||
await updateReception(receptionId, {
|
||||
merchandiseType: null,
|
||||
merchandiseDetail: null,
|
||||
bovineDetail: otherQuantity.value ? String(otherQuantity.value) : null,
|
||||
})
|
||||
}
|
||||
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()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,65 +1,72 @@
|
||||
<template>
|
||||
<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]"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<UiTextInput
|
||||
v-if="isAutres"
|
||||
id="merchandise-detail"
|
||||
:disabled="!auth.isAdmin"
|
||||
:disabled="!isAdmin"
|
||||
v-model="merchandiseDetail"
|
||||
label="Préciser"
|
||||
placeholder="Précisions complémentaires"
|
||||
placeholder="Préciser"
|
||||
:maxlength="255"
|
||||
wrapper-class="w-[200px] mt-12 mb-7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="merchandiseTypeId && !isGranule"
|
||||
class="flex gap-4 w-[550px] justify-evenly"
|
||||
v-if="selectedMerchandiseTypeId && !isGranule"
|
||||
class="w-full grid grid-cols-[1fr_200px]"
|
||||
>
|
||||
<div
|
||||
v-for="building in buildings"
|
||||
:key="building.id"
|
||||
<div class="grid grid-cols-4 gap-6"
|
||||
>
|
||||
<UiCheckbox
|
||||
v-model="selectedBuildingIds"
|
||||
:value="String(building.id)"
|
||||
:label="building.label"
|
||||
:disabled="!auth.isAdmin"
|
||||
label-class="text-xl"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="merchandiseTypeId && isGranule"
|
||||
class="flex flex-col gap-10 w-full max-w-[1100px]"
|
||||
v-if="selectedMerchandiseTypeId && isGranule"
|
||||
class="grid grid-cols-[1fr_200px] w-full col-start-2 row-start-1"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-10 md:grid-cols-4">
|
||||
<div class="grid grid-cols-4 gap-6 justify-between">
|
||||
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4">
|
||||
<p class="font-bold uppercase">{{ type.label }}</p>
|
||||
<p class="mb-1 font-medium uppercase">{{ type.label }}</p>
|
||||
<div
|
||||
v-for="building in buildings"
|
||||
:key="building.id"
|
||||
class="flex items-center gap-2 text-lg"
|
||||
class="flex text-lg"
|
||||
>
|
||||
<UiCheckbox
|
||||
v-model="selectedPelletBuildingIds[String(type.id)]"
|
||||
:value="String(building.id)"
|
||||
:label="building.label"
|
||||
:disabled="!auth.isAdmin"
|
||||
:disabled="!isAdmin"
|
||||
input-class="accent-primary-700 focus:ring-primary-700"
|
||||
label-class="text-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -67,81 +74,181 @@
|
||||
</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} 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";
|
||||
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
|
||||
}>()
|
||||
|
||||
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('')
|
||||
const auth = useAuthStore()
|
||||
const props = defineProps<{
|
||||
idReception: number
|
||||
}>()
|
||||
const receptionId = props.idReception
|
||||
const reception = await getReception(receptionId)
|
||||
const merchandiseTypeId = await reception.receptionType?.id
|
||||
// Verrou de synchro pour empêcher les aller-retours infinis entre parent et composant.
|
||||
const isSyncing = ref(false)
|
||||
const isReady = ref(false)
|
||||
|
||||
// Extrait l'ID d'une relation depuis un IRI ou un objet complet.
|
||||
const getRelationId = (value: unknown): string | null => {
|
||||
if (!value) {
|
||||
return null
|
||||
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]
|
||||
}
|
||||
|
||||
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
|
||||
return clone
|
||||
}
|
||||
|
||||
// Type de marchandise sélectionné dans le select
|
||||
const selectedMerchandiseType = computed(() =>
|
||||
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)
|
||||
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 }
|
||||
)
|
||||
|
||||
// Charge les référentiels et hydrate le formulaire depuis la réception
|
||||
onMounted(async () => {
|
||||
const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([
|
||||
getMerchandiseTypeList(),
|
||||
@@ -152,106 +259,7 @@ onMounted(async () => {
|
||||
buildings.value = buildingList
|
||||
pelletTypes.value = pelletTypeList
|
||||
|
||||
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
|
||||
hydrateFromModelValue(props.modelValue)
|
||||
isReady.value = true
|
||||
})
|
||||
// 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>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<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>
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<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 -->
|
||||
<form ref="formRef" :class="{ submitted }" @submit.prevent="validate">
|
||||
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
|
||||
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Expédition</h1>
|
||||
<UiSelect
|
||||
id="shipment-user"
|
||||
v-model="form.userId"
|
||||
@@ -13,57 +12,62 @@
|
||||
}))"
|
||||
: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
|
||||
/>
|
||||
<!-- 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 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Client -->
|
||||
<UiSelect
|
||||
id="shipment-customer"
|
||||
v-model="form.customerId"
|
||||
label="Client"
|
||||
:options="customers.map((customer) => ({
|
||||
value: String(customer.id),
|
||||
label: customer.label
|
||||
label: customer.name || `Client #${customer.id}`
|
||||
}))"
|
||||
:loading="isLoadingCustomers"
|
||||
wrapper-class="col-start-1 row-start-5"
|
||||
required
|
||||
/>
|
||||
<!-- Adresse du client -->
|
||||
<UiSelect
|
||||
id="shipment-address"
|
||||
v-model="form.addressId"
|
||||
:options="customerAddressOptions"
|
||||
:disabled="isLoadingCustomers || customerAddresses.length === 0"
|
||||
:options="addressOptions"
|
||||
:disabled="isLoadingCustomers || ownerAddresses.length === 0"
|
||||
label="Adresse"
|
||||
wrapper-class="col-start-2 row-start-1"
|
||||
required
|
||||
/>
|
||||
<!-- Camion -->
|
||||
<UiSelect
|
||||
id="shipment-truck"
|
||||
v-model="form.truckId"
|
||||
@@ -74,8 +78,8 @@
|
||||
}))"
|
||||
:loading="isLoadingTrucks"
|
||||
wrapper-class="col-start-2 row-start-2"
|
||||
required
|
||||
/>
|
||||
<!-- Transporteur -->
|
||||
<UiSelect
|
||||
id="shipment-carrier"
|
||||
v-model="form.carrierId"
|
||||
@@ -85,27 +89,15 @@
|
||||
label: carrier.name
|
||||
}))"
|
||||
wrapper-class="col-start-2 row-start-3"
|
||||
required
|
||||
/>
|
||||
<!-- 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">
|
||||
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
|
||||
<UiLicensePlateInput
|
||||
v-model="form.licencePlate"
|
||||
v-model="form.licensePlate"
|
||||
v-model:allowAny="allowAnyLicensePlate"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<!-- Immatriculation (LIOT) -->
|
||||
<UiSelect
|
||||
v-if="isLiotCarrier"
|
||||
id="shipment-vehicle"
|
||||
@@ -117,73 +109,50 @@
|
||||
}))"
|
||||
: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">
|
||||
<button
|
||||
<UiButton
|
||||
type="submit"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||
@click="submitted = true"
|
||||
>Valider
|
||||
</button>
|
||||
</UiButton>
|
||||
</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'
|
||||
|
||||
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 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 shipmentStore = useShipmentStore()
|
||||
const isHydrating = ref(false)
|
||||
const submitted = ref(false)
|
||||
const formRef = ref<HTMLFormElement | null>(null)
|
||||
|
||||
const form = reactive<ShipmentFormData>({
|
||||
userId: '',
|
||||
@@ -194,60 +163,26 @@ const form = reactive<ShipmentFormData>({
|
||||
carrierId: '',
|
||||
driverId: '',
|
||||
vehicleId: '',
|
||||
licencePlate: '',
|
||||
licensePlate: '',
|
||||
})
|
||||
// Adresses liées au client sélectionné
|
||||
const customerAddresses = computed<AddressData[]>(() => {
|
||||
const customerId = Number(form.customerId)
|
||||
if (!Number.isFinite(customerId)) {
|
||||
return []
|
||||
}
|
||||
return customers.value.find((customer) => customer.id === customerId)?.addresses ?? []
|
||||
})
|
||||
// Options pour le select des adresses du client
|
||||
const customerAddressOptions = computed(() =>
|
||||
customerAddresses.value
|
||||
.map((address) => ({
|
||||
value: String(address.id),
|
||||
label: address.fullAddress
|
||||
}))
|
||||
)
|
||||
// Chauffeurs liés au transporteur sélectionné (LIOT)
|
||||
const filteredDrivers = computed<DriverData[]>(() => {
|
||||
if (!form.carrierId) {
|
||||
return []
|
||||
}
|
||||
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
|
||||
})
|
||||
// Véhicules liés au transporteur + camion sélectionnés (LIOT)
|
||||
const filteredVehicles = computed<VehicleData[]>(() => {
|
||||
if (!form.carrierId) {
|
||||
return []
|
||||
}
|
||||
return vehicles.value.filter(
|
||||
(vehicle) =>
|
||||
String(vehicle.carrier?.id) === form.carrierId &&
|
||||
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
|
||||
)
|
||||
})
|
||||
// Chargement des données pour les selects
|
||||
const loadUsers = async () => {
|
||||
isLoadingUsers.value = true
|
||||
try {
|
||||
users.value = await getUsers()
|
||||
} finally {
|
||||
isLoadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadShipmentType = async () => {
|
||||
isLoadingShipmentTypes.value = true
|
||||
try {
|
||||
bovineShipment.value = await getShipmentTypeList()
|
||||
} finally {
|
||||
isLoadingShipmentTypes.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 loadCustomers = async () => {
|
||||
isLoadingCustomers.value = true
|
||||
@@ -256,267 +191,53 @@ 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.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.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) : ''
|
||||
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
|
||||
}
|
||||
selectedShipmentTypeId.value = shipment?.shipmentType?.id ? String(shipment.shipmentType.id) : ''
|
||||
shipmentQuantity.value = shipment?.nbBovinSend ?? 0
|
||||
isHydrating.value = false
|
||||
},
|
||||
{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}
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Extra watcher for LIOT defaults after hydration
|
||||
watch(
|
||||
() => isHydrating.value,
|
||||
(value) => {
|
||||
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
|
||||
if (!value && isLiotCarrier.value) {
|
||||
if (filteredDrivers.value.length === 1 && !form.driverId) {
|
||||
form.driverId = String(filteredDrivers.value[0].id)
|
||||
}
|
||||
})
|
||||
.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)
|
||||
if (filteredVehicles.value.length === 1 && !form.vehicleId) {
|
||||
form.vehicleId = String(filteredVehicles.value[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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.licencePlate.trim()
|
||||
const normalizedLicensePlate = form.licensePlate.trim()
|
||||
const normalizedShipmentDate = form.shipmentDate.trim()
|
||||
const normalizedCustomerId = form.customerId.trim()
|
||||
const normalizedTruckId = form.truckId.trim()
|
||||
@@ -524,62 +245,55 @@ 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 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
|
||||
|
||||
return {
|
||||
licencePlate: normalizedLicensePlate,
|
||||
licensePlate: normalizedLicensePlate,
|
||||
shipmentDate: normalizedShipmentDate,
|
||||
customer: customerIri,
|
||||
truck: truckIri,
|
||||
carrier: carrierIri,
|
||||
driver: driverIri,
|
||||
user: userIri,
|
||||
address: addressIri
|
||||
address: addressIri,
|
||||
shipmentType: shipmentTypeIri,
|
||||
nbBovinSend: normalizedQuantity,
|
||||
}
|
||||
}
|
||||
|
||||
const saveDraft = async () => {
|
||||
const payload = buildPayload()
|
||||
if (!shipmentStore.current) {
|
||||
const created = await shipmentStore.createShipment({
|
||||
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 ?? []
|
||||
)
|
||||
}
|
||||
|
||||
defineExpose({saveDraft})
|
||||
// Valide le formulaire et crée/met à jour l'expédition
|
||||
const validateFields = () => {
|
||||
submitted.value = true
|
||||
return formRef.value?.reportValidity() ?? false
|
||||
}
|
||||
|
||||
defineExpose({ saveDraft, validateFields })
|
||||
|
||||
const validate = async () => {
|
||||
const payload = buildPayload()
|
||||
if (!shipmentStore.current) {
|
||||
@@ -589,7 +303,6 @@ const validate = async () => {
|
||||
})
|
||||
if (created) {
|
||||
await shipmentStore.loadShipment(created.id)
|
||||
await syncBovinShipments(created.id, shipmentStore.current?.bovinShipments ?? [])
|
||||
await router.push(`/shipment/${created.id}`)
|
||||
}
|
||||
return
|
||||
@@ -600,6 +313,5 @@ const validate = async () => {
|
||||
...payload
|
||||
})
|
||||
await shipmentStore.loadShipment(shipmentStore.current.id)
|
||||
await syncBovinShipments(shipmentStore.current.id, shipmentStore.current?.bovinShipments ?? [])
|
||||
}
|
||||
</script>
|
||||
|
||||
26
frontend/components/shipment/shipment-loading.vue
Normal file
26
frontend/components/shipment/shipment-loading.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<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>
|
||||
@@ -1,101 +0,0 @@
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<div class="flex flex-col items-center w-[660px]">
|
||||
<h1 class="font-bold text-5xl uppercase">{{ 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>
|
||||
34
frontend/components/ui/UiAccordion.vue
Normal file
34
frontend/components/ui/UiAccordion.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide text-left"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="flex-1">
|
||||
<slot name="header" />
|
||||
</span>
|
||||
<Icon
|
||||
name="mdi:chevron-down"
|
||||
size="24"
|
||||
class="shrink-0 transition-transform"
|
||||
:class="{ 'rotate-180': modelValue }"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="modelValue" class="border border-t-0 border-slate-200 px-6 py-6">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const toggle = () => emit('update:modelValue', !props.modelValue)
|
||||
</script>
|
||||
39
frontend/components/ui/UiButton.vue
Normal file
39
frontend/components/ui/UiButton.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<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>
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<label
|
||||
class="flex items-center gap-2"
|
||||
class="flex items-center gap-2 cursor-pointer text-primary-700"
|
||||
:class="labelClass"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="checked"
|
||||
:disabled="disabled"
|
||||
:class="inputClass"
|
||||
:class="['h-4 w-4 cursor-pointer text-primary-500', inputClass]"
|
||||
@change="onChange"
|
||||
>
|
||||
<span v-if="label">{{ label }}</span>
|
||||
|
||||
238
frontend/components/ui/UiDataTable.vue
Normal file
238
frontend/components/ui/UiDataTable.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="relative border border-slate-200">
|
||||
<div
|
||||
class="grid items-center gap-6 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" class="min-w-0">
|
||||
<slot :name="`header-${col.key}`" :column="col">{{ col.label }}</slot>
|
||||
</div>
|
||||
<div v-if="showActions" class="min-w-0">
|
||||
<slot name="header-actions">Actions</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="dimRows ? 'opacity-50 transition-opacity' : ''" :aria-busy="loading || undefined">
|
||||
<template v-if="paginatedItems.length">
|
||||
<div
|
||||
v-for="(item, index) in paginatedItems"
|
||||
:key="item.id ?? index"
|
||||
class="grid gap-6 px-4 py-3 text-sm border-t border-slate-200"
|
||||
:class="[
|
||||
rowClickable ? 'hover:bg-slate-50 cursor-pointer' : '',
|
||||
rowClass ? rowClass(item) : ''
|
||||
]"
|
||||
:style="{ gridTemplateColumns: gridCols }"
|
||||
:role="rowClickable ? 'button' : undefined"
|
||||
:tabindex="rowClickable ? 0 : undefined"
|
||||
@click="onRowClick(item)"
|
||||
@keydown.enter="onRowClick(item)"
|
||||
@keydown.space.prevent="onRowClick(item)"
|
||||
>
|
||||
<div v-for="col in columns" :key="col.key" class="min-w-0 truncate">
|
||||
<slot :name="`cell-${col.key}`" :item="item" :column="col">
|
||||
{{ getNestedValue(item, col.key) }}
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="showActions" @click.stop>
|
||||
<slot name="actions" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="loading"
|
||||
class="flex items-center justify-center border-t border-slate-200 px-4 py-8 text-primary-500"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<UiLoadingDots />
|
||||
<span class="sr-only">Chargement…</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="border-t border-slate-200 px-4 py-8 text-center text-sm text-slate-500"
|
||||
>
|
||||
<slot name="empty">{{ emptyMessage }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="dimRows"
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="rounded bg-white/80 px-4 py-2 text-primary-500 shadow">
|
||||
<UiLoadingDots />
|
||||
<span class="sr-only">Chargement…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > 0" class="flex justify-between pt-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label :for="perPageId" class="whitespace-nowrap text-sm text-slate-700">Lignes :</label>
|
||||
<select
|
||||
:id="perPageId"
|
||||
:value="currentPerPage"
|
||||
class="h-10 rounded border border-slate-300 bg-white px-2 text-sm text-primary-700"
|
||||
@change="onPerPageChange(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="n in perPageOptions" :key="n" :value="n">{{ n }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Pagination" class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
|
||||
:disabled="currentPage <= 1"
|
||||
aria-label="Page précédente"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
|
||||
<template v-for="(entry, i) in visiblePages" :key="`${typeof entry}-${entry}-${i}`">
|
||||
<span
|
||||
v-if="entry === '...'"
|
||||
class="px-1 text-sm text-slate-400"
|
||||
aria-hidden="true"
|
||||
>…</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
|
||||
:class="entry === currentPage
|
||||
? 'bg-primary-500 font-semibold text-white'
|
||||
: 'text-slate-700 hover:bg-slate-100'"
|
||||
:aria-current="entry === currentPage ? 'page' : undefined"
|
||||
@click="goToPage(entry)"
|
||||
>
|
||||
{{ entry }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
|
||||
:disabled="currentPage >= totalPages"
|
||||
aria-label="Page suivante"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import { computed, useId } from 'vue'
|
||||
|
||||
interface Column {
|
||||
key: string
|
||||
label: string
|
||||
width?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
columns: Column[]
|
||||
items: T[]
|
||||
totalItems?: number
|
||||
page?: number
|
||||
perPage?: number
|
||||
perPageOptions?: number[]
|
||||
rowClickable?: boolean
|
||||
showActions?: boolean
|
||||
emptyMessage?: string
|
||||
loading?: boolean
|
||||
rowClass?: (item: T) => string | undefined
|
||||
}>(), {
|
||||
totalItems: undefined,
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
perPageOptions: () => [5, 10, 25, 50],
|
||||
rowClickable: false,
|
||||
showActions: false,
|
||||
emptyMessage: 'Aucune donnée',
|
||||
loading: false,
|
||||
rowClass: undefined
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:page', value: number): void
|
||||
(e: 'update:perPage', value: number): void
|
||||
(e: 'row-click', item: T): void
|
||||
}>()
|
||||
|
||||
const perPageId = useId()
|
||||
|
||||
const currentPage = computed(() => props.page)
|
||||
const currentPerPage = computed(() => props.perPage)
|
||||
|
||||
const isServerSide = computed(() => props.totalItems !== undefined)
|
||||
const total = computed(() => props.totalItems ?? props.items.length)
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(total.value / currentPerPage.value))
|
||||
)
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
if (isServerSide.value) return props.items
|
||||
const start = (currentPage.value - 1) * currentPerPage.value
|
||||
return props.items.slice(start, start + currentPerPage.value)
|
||||
})
|
||||
|
||||
const gridCols = computed(() => {
|
||||
const dataCols = props.columns.map(c => c.width ?? '1fr').join(' ')
|
||||
return props.showActions ? `${dataCols} 60px` : dataCols
|
||||
})
|
||||
|
||||
const dimRows = computed(() => props.loading && paginatedItems.value.length > 0)
|
||||
|
||||
const visiblePages = computed<(number | '...')[]>(() => {
|
||||
const tp = totalPages.value
|
||||
const cp = currentPage.value
|
||||
|
||||
if (tp <= 5) {
|
||||
return Array.from({ length: tp }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
const pages: (number | '...')[] = []
|
||||
pages.push(1)
|
||||
|
||||
if (cp > 3) pages.push('...')
|
||||
|
||||
const start = Math.max(2, cp - 1)
|
||||
const end = Math.min(tp - 1, cp + 1)
|
||||
for (let i = start; i <= end; i++) pages.push(i)
|
||||
|
||||
if (cp < tp - 2) pages.push('...')
|
||||
|
||||
if (tp > 1) pages.push(tp)
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const goToPage = (n: number) => {
|
||||
if (n < 1 || n > totalPages.value || n === currentPage.value) return
|
||||
emit('update:page', n)
|
||||
}
|
||||
|
||||
const onPerPageChange = (value: string) => {
|
||||
emit('update:perPage', Number(value))
|
||||
emit('update:page', 1)
|
||||
}
|
||||
|
||||
const onRowClick = (item: T) => {
|
||||
if (!props.rowClickable) return
|
||||
emit('row-click', item)
|
||||
}
|
||||
|
||||
const getNestedValue = (obj: any, path: string): string => {
|
||||
const value = path.split('.').reduce((acc, key) => acc?.[key], obj)
|
||||
return value ?? '—'
|
||||
}
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
class="font-bold uppercase text-xl mb-2"
|
||||
class="font-bold uppercase text-xl text-primary-700"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ label }}
|
||||
@@ -14,9 +14,10 @@
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="disabled"
|
||||
v-bind="attrs"
|
||||
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase bg-transparent appearance-none h-[34px]"
|
||||
class="w-full min-w-0 border-b border-primary-700 justify-self-start text-primary-700 bg-transparent appearance-none"
|
||||
:class="[
|
||||
isEmpty ? 'text-neutral-400' : 'text-black',
|
||||
sizeClass,
|
||||
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
inputClass
|
||||
]"
|
||||
@@ -36,12 +37,14 @@ const props = withDefaults(
|
||||
label?: string
|
||||
modelValue: string | null | undefined
|
||||
disabled?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
inputClass?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
inputClass: ''
|
||||
@@ -54,6 +57,11 @@ const emit = defineEmits<{
|
||||
|
||||
const attrs = useAttrs()
|
||||
const isEmpty = computed(() => !props.modelValue)
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'compact'
|
||||
? 'text-sm h-8 font-normal normal-case tracking-normal'
|
||||
: 'text-xl py-[6px] uppercase h-[34px]'
|
||||
)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
|
||||
108
frontend/components/ui/UiDateMaskedInput.vue
Normal file
108
frontend/components/ui/UiDateMaskedInput.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div :class="['flex flex-col', wrapperClass]">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
class="font-bold uppercase text-xl text-primary-700"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<input
|
||||
:id="id"
|
||||
v-maska="'##/##/####'"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:value="displayValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
v-bind="attrs"
|
||||
class="w-full min-w-0 border-b border-primary-700 bg-transparent"
|
||||
:class="[
|
||||
sizeClass,
|
||||
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-text',
|
||||
inputClass
|
||||
]"
|
||||
@input="onInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { vMaska } from 'maska/vue'
|
||||
import { computed, ref, useAttrs, watch } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
label?: string
|
||||
modelValue: string | null | undefined
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
inputClass?: string
|
||||
}>(),
|
||||
{
|
||||
placeholder: 'JJ/MM/AAAA',
|
||||
disabled: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
inputClass: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const toDisplay = (iso: string | null | undefined): string => {
|
||||
if (!iso) return ''
|
||||
const parts = iso.split('-')
|
||||
if (parts.length !== 3) return ''
|
||||
const [year, month, day] = parts
|
||||
if (year.length !== 4 || month.length !== 2 || day.length !== 2) return ''
|
||||
return `${day}/${month}/${year}`
|
||||
}
|
||||
|
||||
const toIso = (display: string): string | null => {
|
||||
const match = display.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
|
||||
if (!match) return null
|
||||
const [, day, month, year] = match
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const displayValue = ref(toDisplay(props.modelValue))
|
||||
|
||||
watch(() => props.modelValue, (newIso) => {
|
||||
const expected = toDisplay(newIso)
|
||||
if (expected !== displayValue.value) {
|
||||
displayValue.value = expected
|
||||
}
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => !displayValue.value)
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'compact'
|
||||
? 'text-sm h-8 font-normal normal-case tracking-normal'
|
||||
: 'text-xl py-[6px]'
|
||||
)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
displayValue.value = target.value
|
||||
if (target.value === '') {
|
||||
emit('update:modelValue', '')
|
||||
return
|
||||
}
|
||||
const iso = toIso(target.value)
|
||||
emit('update:modelValue', iso ?? '')
|
||||
}
|
||||
</script>
|
||||
96
frontend/components/ui/UiModal.vue
Normal file
96
frontend/components/ui/UiModal.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@mousedown.self="closeOnBackdrop"
|
||||
>
|
||||
<div
|
||||
class="w-full rounded-md bg-white shadow-2xl"
|
||||
:class="maxWidth"
|
||||
@mousedown.stop
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500">{{ title }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-slate-500 hover:text-primary-500 flex items-center"
|
||||
aria-label="Fermer"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="24" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="border-t border-slate-200 px-6 py-4"
|
||||
>
|
||||
<slot name="footer" :close="close" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
closeOnBackdropClick?: boolean
|
||||
maxWidth?: string
|
||||
}>(), {
|
||||
title: '',
|
||||
closeOnBackdropClick: true,
|
||||
maxWidth: 'max-w-lg'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const close = () => emit('update:modelValue', false)
|
||||
|
||||
const closeOnBackdrop = () => {
|
||||
if (props.closeOnBackdropClick) close()
|
||||
}
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && props.modelValue) close()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,9 +1,10 @@
|
||||
// flex row passer en class wraper class flex col ainsi que le wfull 34
|
||||
<template>
|
||||
<div :class="['flex flex-row items-center gap-2', wrapperClass]">
|
||||
<div :class="['flex', wrapperClass]">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
class="text-xl text-bold flex items-center"
|
||||
class="text-xl flex items-center gap-2 text-primary-700"
|
||||
:class="labelClass"
|
||||
>
|
||||
<span
|
||||
@@ -25,7 +26,7 @@
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
v-bind="attrs"
|
||||
class="border-b border-black text-xl bg-transparent w-12"
|
||||
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
|
||||
:class="[
|
||||
isEmpty ? 'text-neutral-400' : 'text-black',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-text',
|
||||
@@ -74,14 +75,43 @@ 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 numeric = Math.max(0, Number(target.value))
|
||||
emit('update:modelValue', Number.isNaN(numeric) ? null : numeric)
|
||||
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)
|
||||
}
|
||||
|
||||
if (numeric !== parsed) {
|
||||
target.value = String(numeric)
|
||||
}
|
||||
emit('update:modelValue', numeric)
|
||||
}
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
|
||||
93
frontend/components/ui/UiRadioGroup.vue
Normal file
93
frontend/components/ui/UiRadioGroup.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<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>
|
||||
@@ -3,7 +3,7 @@
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
class="font-bold uppercase text-xl mb-2"
|
||||
class="font-bold uppercase text-xl text-primary-700"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ label }}
|
||||
@@ -13,22 +13,23 @@
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="disabled || loading"
|
||||
v-bind="attrs"
|
||||
class="border-b border-black justify-self-start text-xl pb-[6px] bg-transparent"
|
||||
class="w-full min-w-0 border-b border-primary-700 justify-self-start text-primary-700 bg-transparent"
|
||||
:class="[
|
||||
isEmpty ? 'text-neutral-400' : 'text-black',
|
||||
sizeClass,
|
||||
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||
disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
selectClass
|
||||
]"
|
||||
@change="onChange"
|
||||
>
|
||||
<option value="" disabled class="text-neutral-400">
|
||||
<option value="" class="text-neutral-400">
|
||||
{{ placeholderText }}
|
||||
</option>
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="text-black"
|
||||
class="text-primary-700"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
@@ -55,6 +56,7 @@ const props = withDefaults(
|
||||
options: SelectOption[]
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
selectClass?: string
|
||||
@@ -63,6 +65,7 @@ const props = withDefaults(
|
||||
placeholder: 'Sélectionner',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
selectClass: ''
|
||||
@@ -77,6 +80,11 @@ const attrs = useAttrs()
|
||||
|
||||
const isEmpty = computed(() => props.modelValue === '' || props.modelValue === null || props.modelValue === undefined)
|
||||
const placeholderText = computed(() => props.placeholder || 'Sélectionner')
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'compact'
|
||||
? 'text-sm h-8 font-normal normal-case tracking-normal'
|
||||
: 'text-xl py-[6px]'
|
||||
)
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
class="font-bold uppercase text-xl mb-2"
|
||||
class="font-bold uppercase text-xl text-primary-700"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ label }}
|
||||
@@ -16,9 +16,10 @@
|
||||
:maxlength="maxlength"
|
||||
:disabled="disabled"
|
||||
v-bind="attrs"
|
||||
class="border-b border-black text-xl pb-[6px] bg-transparent"
|
||||
class="w-full min-w-0 border-b border-primary-700 bg-transparent"
|
||||
:class="[
|
||||
isEmpty ? 'text-neutral-400' : 'text-black',
|
||||
sizeClass,
|
||||
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-text',
|
||||
inputClass
|
||||
]"
|
||||
@@ -40,6 +41,7 @@ const props = withDefaults(
|
||||
placeholder?: string
|
||||
maxlength?: number | string
|
||||
disabled?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
inputClass?: string
|
||||
@@ -48,6 +50,7 @@ const props = withDefaults(
|
||||
placeholder: '',
|
||||
maxlength: undefined,
|
||||
disabled: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
inputClass: ''
|
||||
@@ -60,6 +63,11 @@ const emit = defineEmits<{
|
||||
|
||||
const attrs = useAttrs()
|
||||
const isEmpty = computed(() => !props.modelValue)
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'compact'
|
||||
? 'text-sm h-8 font-normal normal-case tracking-normal'
|
||||
: 'text-xl py-[6px]'
|
||||
)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<label :for="inputId" class="font-bold uppercase text-xl mb-2">{{ label }}</label>
|
||||
<label :for="inputId" class="font-bold uppercase text-xl text-primary-500">{{ label }}</label>
|
||||
<div class="flex items-end gap-8">
|
||||
<input
|
||||
:id="inputId"
|
||||
@@ -9,7 +9,8 @@
|
||||
type="text"
|
||||
:maxlength="maxLength"
|
||||
:placeholder="placeholderText"
|
||||
class="border-b border-black flex-1 min-w-0 text-xl uppercase h-[30px]"
|
||||
:required="required"
|
||||
class="border-b border-primary-700 flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<UiCheckbox
|
||||
@@ -32,12 +33,14 @@ type Props = {
|
||||
allowAny?: boolean
|
||||
label?: string
|
||||
id?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
allowAny: false,
|
||||
label: 'Immatriculation',
|
||||
id: 'license-plate'
|
||||
id: 'license-plate',
|
||||
required: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div
|
||||
v-for="(label, index) in labels"
|
||||
:key="label"
|
||||
class="absolute top-0 whitespace-nowrap"
|
||||
class="absolute top-0 whitespace-nowrap text-primary-500"
|
||||
:class="labelClass(index)"
|
||||
:style="positionStyle(index)"
|
||||
>
|
||||
|
||||
57
frontend/components/workflow/workflow-liot-fields.vue
Normal file
57
frontend/components/workflow/workflow-liot-fields.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<template v-if="!isLiotCarrier">
|
||||
<div :class="wrapperClass">
|
||||
<UiLicensePlateInput
|
||||
v-model="form.licensePlate"
|
||||
v-model:allowAny="allowAnyLicensePlate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="isLiotCarrier">
|
||||
<UiSelect
|
||||
:id="`${idPrefix}-vehicle`"
|
||||
v-model="form.vehicleId"
|
||||
label="Immatriculation"
|
||||
:options="filteredVehicles.map((vehicle) => ({
|
||||
value: String(vehicle.id),
|
||||
label: vehicle.plate
|
||||
}))"
|
||||
:loading="isLoadingVehicles"
|
||||
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
|
||||
:wrapper-class="wrapperClass"
|
||||
/>
|
||||
<UiSelect
|
||||
:id="`${idPrefix}-driver`"
|
||||
v-model="form.driverId"
|
||||
label="Nom du chauffeur si LIOT"
|
||||
:options="filteredDrivers.map((driver) => ({
|
||||
value: String(driver.id),
|
||||
label: driver.name
|
||||
}))"
|
||||
:loading="isLoadingDrivers"
|
||||
:wrapper-class="driverWrapperClass"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DriverData } from '~/services/dto/driver-data'
|
||||
import type { VehicleData } from '~/services/dto/vehicle-data'
|
||||
|
||||
defineProps<{
|
||||
idPrefix: string
|
||||
form: { licensePlate: string; vehicleId: string; driverId: string }
|
||||
isLiotCarrier: boolean
|
||||
allowAnyLicensePlate: boolean
|
||||
filteredVehicles: VehicleData[]
|
||||
filteredDrivers: DriverData[]
|
||||
isLoadingVehicles: boolean
|
||||
isLoadingDrivers: boolean
|
||||
wrapperClass?: string
|
||||
driverWrapperClass?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:allowAnyLicensePlate': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
72
frontend/components/workflow/workflow-waiting-list.vue
Normal file
72
frontend/components/workflow/workflow-waiting-list.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-10">
|
||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">{{ title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
<div class="mt-6 border border-slate-200 mb-16">
|
||||
<div
|
||||
class="grid gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
|
||||
:style="{ gridTemplateColumns: gridCols }"
|
||||
>
|
||||
<div v-for="col in columns" :key="col.key">{{ col.label }}</div>
|
||||
<div v-if="showActions">Actions</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="grid gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
|
||||
:style="{ gridTemplateColumns: gridCols }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goToItem(item.id)"
|
||||
@keydown.enter="goToItem(item.id)"
|
||||
>
|
||||
<div v-for="col in columns" :key="col.key">
|
||||
<slot :name="`cell-${col.key}`" :item="item">
|
||||
{{ getNestedValue(item, col.key) }}
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="showActions" @click.stop>
|
||||
<slot name="actions" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Column {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
columns: Column[]
|
||||
items: any[]
|
||||
routePrefix: string
|
||||
showActions?: boolean
|
||||
}>(), {
|
||||
showActions: false
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const gridCols = computed(() => {
|
||||
const dataCols = props.columns.map(() => '1fr').join(' ')
|
||||
return props.showActions ? `${dataCols} 60px` : dataCols
|
||||
})
|
||||
|
||||
const goToItem = (id: number) => {
|
||||
router.push(`${props.routePrefix}/${id}`)
|
||||
}
|
||||
|
||||
const getNestedValue = (obj: any, path: string): string => {
|
||||
const value = path.split('.').reduce((acc, key) => acc?.[key], obj)
|
||||
return value || '—'
|
||||
}
|
||||
</script>
|
||||
81
frontend/components/workflow/workflow-weight.vue
Normal file
81
frontend/components/workflow/workflow-weight.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<div class="flex flex-col items-center w-[660px]">
|
||||
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
|
||||
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
|
||||
<div
|
||||
v-if="!displayWeight"
|
||||
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
|
||||
<UiLoadingDots />
|
||||
</div>
|
||||
<div v-else class="w-full">
|
||||
<div
|
||||
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl text-primary-500">
|
||||
{{ displayWeight }} kg
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center mt-[54px]">
|
||||
<UiButton
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
||||
@click="fetchWeight"
|
||||
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
|
||||
<UiButton
|
||||
v-if="displayWeight !== null && !showGenerateReceipt"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
|
||||
@click="saveWeight"
|
||||
>Valider la pesée</UiButton>
|
||||
<UiButton
|
||||
v-if="showGenerateReceipt"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
|
||||
@click="printReceipt"
|
||||
>Générer le bon</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue'
|
||||
import { useWeighingStep } from '~/composables/steps/useWeighingStep'
|
||||
import type { WeightData } from '~/services/dto/weight-data'
|
||||
|
||||
const props = defineProps<{
|
||||
mode: 'gross' | 'tare'
|
||||
entityName: 'reception' | 'shipment'
|
||||
apiResource: string
|
||||
titleLabel: string
|
||||
isFinal: boolean
|
||||
entity: any
|
||||
getWeightFromScale: () => Promise<WeightData>
|
||||
updateEntity: (id: number, payload: any) => Promise<any>
|
||||
loadEntity: (id: number) => Promise<any>
|
||||
clearEntity: () => void
|
||||
buildReceiptFilename: (entity: any) => string
|
||||
}>()
|
||||
|
||||
const entityRef = toRef(props, 'entity')
|
||||
|
||||
const {
|
||||
displayWeight,
|
||||
title,
|
||||
fetchWeight,
|
||||
saveWeight,
|
||||
saveWeightDraft,
|
||||
showGenerateReceipt,
|
||||
printReceipt
|
||||
} = useWeighingStep({
|
||||
mode: props.mode,
|
||||
entity: entityRef,
|
||||
entityName: props.entityName,
|
||||
apiResource: props.apiResource,
|
||||
titleLabel: props.titleLabel,
|
||||
isFinal: props.isFinal,
|
||||
getWeightFromScale: props.getWeightFromScale,
|
||||
updateEntity: props.updateEntity,
|
||||
loadEntity: props.loadEntity,
|
||||
clearEntity: props.clearEntity,
|
||||
buildReceiptFilename: props.buildReceiptFilename
|
||||
})
|
||||
|
||||
defineExpose({ saveWeightDraft })
|
||||
</script>
|
||||
80
frontend/composables/steps/useWeighingStep.ts
Normal file
80
frontend/composables/steps/useWeighingStep.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { useWeighing } from '~/composables/useWeighing'
|
||||
import { usePdfPrinter } from '~/composables/usePdfPrinter'
|
||||
import type { WeightData } from '~/services/dto/weight-data'
|
||||
|
||||
interface UseWeighingStepOptions {
|
||||
mode: 'gross' | 'tare'
|
||||
entity: Ref<any>
|
||||
entityName: 'reception' | 'shipment'
|
||||
apiResource: string
|
||||
titleLabel: string
|
||||
isFinal: boolean
|
||||
getWeightFromScale: () => Promise<WeightData>
|
||||
updateEntity: (id: number, payload: any) => Promise<any>
|
||||
loadEntity: (id: number) => Promise<any>
|
||||
clearEntity: () => void
|
||||
buildReceiptFilename: (entity: any) => string
|
||||
}
|
||||
|
||||
export const useWeighingStep = (options: UseWeighingStepOptions) => {
|
||||
const router = useRouter()
|
||||
const { printPdf } = usePdfPrinter()
|
||||
|
||||
const {
|
||||
weightData,
|
||||
currentWeightEntry,
|
||||
displayWeight,
|
||||
displayDsd,
|
||||
title,
|
||||
showLoadingBox,
|
||||
fetchWeight,
|
||||
saveWeight,
|
||||
saveWeightDraft
|
||||
} = useWeighing({
|
||||
mode: options.mode,
|
||||
entity: options.entity,
|
||||
entityName: options.entityName,
|
||||
apiResource: options.apiResource,
|
||||
titleLabel: options.titleLabel,
|
||||
isFinal: options.isFinal,
|
||||
getWeightFromScale: options.getWeightFromScale,
|
||||
updateEntity: options.updateEntity,
|
||||
loadEntity: options.loadEntity
|
||||
})
|
||||
|
||||
const showGenerateReceipt = computed(
|
||||
() => options.isFinal && displayWeight.value !== null
|
||||
)
|
||||
|
||||
const printReceipt = async () => {
|
||||
if (!import.meta.client || !options.entity.value) return
|
||||
|
||||
await saveWeight()
|
||||
const entity = options.entity.value
|
||||
const filename = options.buildReceiptFilename(entity)
|
||||
await printPdf(`/${options.apiResource}/${entity.id}/receipt`, filename)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
|
||||
const result = await options.updateEntity(entity.id, { isValid: true })
|
||||
if (!result) return
|
||||
|
||||
options.clearEntity()
|
||||
await router.push('/')
|
||||
}
|
||||
|
||||
return {
|
||||
weightData,
|
||||
currentWeightEntry,
|
||||
displayWeight,
|
||||
displayDsd,
|
||||
title,
|
||||
showLoadingBox,
|
||||
fetchWeight,
|
||||
saveWeight,
|
||||
saveWeightDraft,
|
||||
showGenerateReceipt,
|
||||
printReceipt
|
||||
}
|
||||
}
|
||||
54
frontend/composables/useAddressSync.ts
Normal file
54
frontend/composables/useAddressSync.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { AddressData } from '~/services/dto/address-data'
|
||||
|
||||
interface AddressOwner {
|
||||
id: number
|
||||
addresses?: AddressData[]
|
||||
}
|
||||
|
||||
export const useAddressSync = (
|
||||
form: { addressId: string },
|
||||
ownerId: Ref<string>,
|
||||
owners: Ref<AddressOwner[]>
|
||||
) => {
|
||||
const ownerAddresses = computed<AddressData[]>(() => {
|
||||
const id = Number(ownerId.value)
|
||||
if (!Number.isFinite(id)) return []
|
||||
return owners.value.find((owner) => owner.id === id)?.addresses ?? []
|
||||
})
|
||||
|
||||
const addressOptions = computed(() =>
|
||||
ownerAddresses.value.map((address) => ({
|
||||
value: String(address.id),
|
||||
label: address.fullAddress
|
||||
}))
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [ownerId.value, form.addressId, owners.value],
|
||||
() => {
|
||||
if (!ownerId.value) {
|
||||
form.addressId = ''
|
||||
return
|
||||
}
|
||||
if (!form.addressId && ownerAddresses.value.length === 1) {
|
||||
form.addressId = String(ownerAddresses.value[0].id)
|
||||
return
|
||||
}
|
||||
if (!form.addressId) return
|
||||
const matches = ownerAddresses.value.some(
|
||||
(address) => String(address.id) === form.addressId
|
||||
)
|
||||
if (!matches) {
|
||||
if (ownerAddresses.value.length === 1) {
|
||||
form.addressId = String(ownerAddresses.value[0].id)
|
||||
} else {
|
||||
form.addressId = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return { ownerAddresses, addressOptions }
|
||||
}
|
||||
113
frontend/composables/useBarcodeScanner.ts
Normal file
113
frontend/composables/useBarcodeScanner.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
BarcodeDetector: new (options?: { formats: string[] }) => {
|
||||
detect(source: HTMLVideoElement | ImageBitmapSource): Promise<{ rawValue: string }[]>
|
||||
}
|
||||
}
|
||||
const BarcodeDetector: Window['BarcodeDetector'] | undefined
|
||||
}
|
||||
|
||||
export function useBarcodeScanner(onDetected: (code: string) => void) {
|
||||
const isSupported = ref('BarcodeDetector' in globalThis)
|
||||
const isScanning = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let detector: InstanceType<Window['BarcodeDetector']> | null = null
|
||||
let stream: MediaStream | null = null
|
||||
let animationFrameId: number | null = null
|
||||
let lastDetectedCode = ''
|
||||
let lastDetectedTime = 0
|
||||
|
||||
const COOLDOWN_MS = 2000
|
||||
|
||||
async function start(videoElement: HTMLVideoElement) {
|
||||
if (!isSupported.value) {
|
||||
error.value = 'BarcodeDetector non supporté. Utilisez Chrome sur Android.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
detector = new BarcodeDetector({ formats: ['code_39', 'code_128'] })
|
||||
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
}
|
||||
})
|
||||
|
||||
videoElement.srcObject = stream
|
||||
await videoElement.play()
|
||||
isScanning.value = true
|
||||
error.value = null
|
||||
|
||||
scanLoop(videoElement)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur lors du démarrage de la caméra'
|
||||
isScanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scanLoop(videoElement: HTMLVideoElement) {
|
||||
if (!isScanning.value || !detector) return
|
||||
|
||||
animationFrameId = requestAnimationFrame(async () => {
|
||||
try {
|
||||
if (videoElement.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) {
|
||||
const barcodes = await detector!.detect(videoElement)
|
||||
|
||||
if (barcodes.length > 0) {
|
||||
const code = barcodes[0].rawValue.slice(4)
|
||||
const now = Date.now()
|
||||
|
||||
if (code !== lastDetectedCode || now - lastDetectedTime > COOLDOWN_MS) {
|
||||
lastDetectedCode = code
|
||||
lastDetectedTime = now
|
||||
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(100)
|
||||
}
|
||||
|
||||
onDetected(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Detection error on single frame, continue
|
||||
}
|
||||
|
||||
scanLoop(videoElement)
|
||||
})
|
||||
}
|
||||
|
||||
function stop() {
|
||||
isScanning.value = false
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
stream = null
|
||||
}
|
||||
|
||||
detector = null
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isScanning,
|
||||
error,
|
||||
start,
|
||||
stop
|
||||
}
|
||||
}
|
||||
88
frontend/composables/useBovineColumns.ts
Normal file
88
frontend/composables/useBovineColumns.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export interface BovineColumn {
|
||||
key: string
|
||||
label: string
|
||||
width?: string
|
||||
}
|
||||
|
||||
export interface UseBovineColumnsOptions {
|
||||
/**
|
||||
* 'inventory' (par défaut) : colonnes complètes incluant Bâtiment + Case.
|
||||
* 'case' : pas de Bâtiment ni Case (déjà dans le titre de la page),
|
||||
* largeurs élargies pour combler l'espace.
|
||||
*/
|
||||
variant?: 'inventory' | 'case'
|
||||
}
|
||||
|
||||
/**
|
||||
* Définition partagée des colonnes des tableaux bovins (inventory + case).
|
||||
* 4 variants : avec/sans colonnes prix × inventory/case.
|
||||
*
|
||||
* Les colonnes Prix/kg et Prix total sont visibles pour les rôles BUREAU
|
||||
* et ADMIN (BUREAU hérite ses droits price-visibility, ADMIN hérite de BUREAU).
|
||||
*/
|
||||
export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
const withPricesInventory: BovineColumn[] = [
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
|
||||
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '72px' },
|
||||
{ key: 'age', label: 'Age', width: '110px' },
|
||||
{ key: 'bovineType.label', label: 'Race', width: '90px' },
|
||||
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
|
||||
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' },
|
||||
{ key: 'pricePerKg', label: 'Prix/kg', width: '65px' },
|
||||
{ key: 'finalPrice', label: 'Prix total', width: '80px' }
|
||||
]
|
||||
|
||||
const withoutPricesInventory: BovineColumn[] = [
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
|
||||
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '72px' },
|
||||
{ key: 'age', label: 'Age', width: '110px' },
|
||||
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '120px' },
|
||||
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }
|
||||
]
|
||||
|
||||
const withPricesCase: BovineColumn[] = [
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
|
||||
{ key: 'sex', label: 'Sexe', width: '90px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '100px' },
|
||||
{ key: 'age', label: 'Age', width: '90px' },
|
||||
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '110px' },
|
||||
{ key: 'pricePerKg', label: 'Prix/kg', width: '85px' },
|
||||
{ key: 'finalPrice', label: 'Prix total', width: '105px' }
|
||||
]
|
||||
|
||||
const withoutPricesCase: BovineColumn[] = [
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '130px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '100px' },
|
||||
{ key: 'sex', label: 'Sexe', width: '110px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '140px' },
|
||||
{ key: 'age', label: 'Age', width: '130px' },
|
||||
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '170px' }
|
||||
]
|
||||
|
||||
const columns = computed<BovineColumn[]>(() => {
|
||||
const isCase = options.variant === 'case'
|
||||
const seePrice = auth.isBureau
|
||||
|
||||
if (isCase) {
|
||||
return seePrice ? withPricesCase : withoutPricesCase
|
||||
}
|
||||
return seePrice ? withPricesInventory : withoutPricesInventory
|
||||
})
|
||||
|
||||
return { columns }
|
||||
}
|
||||
102
frontend/composables/useDataTableServerState.ts
Normal file
102
frontend/composables/useDataTableServerState.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
type FilterValue = string | number | boolean | null
|
||||
|
||||
export interface UseDataTableServerStateOptions {
|
||||
initialPerPage?: number
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export function useDataTableServerState<T = Record<string, unknown>>(
|
||||
endpoint: string,
|
||||
initialFilters: Record<string, FilterValue> = {},
|
||||
options: UseDataTableServerStateOptions = {}
|
||||
) {
|
||||
const api = useApi()
|
||||
|
||||
const debounceMs = options.debounceMs ?? 300
|
||||
const initialPerPage = options.initialPerPage ?? 10
|
||||
|
||||
const items = ref<T[]>([]) as { value: T[] }
|
||||
const totalItems = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = ref(initialPerPage)
|
||||
const filters = ref<Record<string, FilterValue>>({ ...initialFilters })
|
||||
const loading = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requestToken = 0
|
||||
|
||||
const buildQueryParams = (): Record<string, string | number | boolean> => {
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
page: page.value,
|
||||
itemsPerPage: perPage.value
|
||||
}
|
||||
for (const [key, value] of Object.entries(filters.value)) {
|
||||
if (value === '' || value === null) continue
|
||||
params[key] = value as string | number | boolean
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
const fetchItems = async (): Promise<void> => {
|
||||
const currentToken = ++requestToken
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: T[]; totalItems: number }>(
|
||||
endpoint,
|
||||
buildQueryParams(),
|
||||
{
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' }
|
||||
}
|
||||
)
|
||||
if (currentToken !== requestToken) return
|
||||
items.value = data?.member ?? []
|
||||
totalItems.value = data?.totalItems ?? 0
|
||||
} finally {
|
||||
if (currentToken === requestToken) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reload = (): void => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
void fetchItems()
|
||||
}
|
||||
|
||||
const scheduleReload = (): void => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
void fetchItems()
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
watch([page, perPage], () => {
|
||||
reload()
|
||||
})
|
||||
|
||||
watch(filters, () => {
|
||||
if (page.value !== 1) {
|
||||
page.value = 1
|
||||
return
|
||||
}
|
||||
scheduleReload()
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
items,
|
||||
totalItems,
|
||||
page,
|
||||
perPage,
|
||||
filters,
|
||||
loading,
|
||||
reload
|
||||
}
|
||||
}
|
||||
73
frontend/composables/useFormDataLoading.ts
Normal file
73
frontend/composables/useFormDataLoading.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { TruckData } from '~/services/dto/truck-data'
|
||||
import type { CarrierData } from '~/services/dto/carrier-data'
|
||||
import { getUsers } from '~/services/auth'
|
||||
import { getTruckList } from '~/services/truck'
|
||||
import { getCarrierList } from '~/services/carrier'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export const useFormDataLoading = (form: { userId: string }) => {
|
||||
const users = ref<UserData[]>([])
|
||||
const trucks = ref<TruckData[]>([])
|
||||
const carriers = ref<CarrierData[]>([])
|
||||
const isLoadingUsers = ref(false)
|
||||
const isLoadingTrucks = ref(false)
|
||||
const isLoadingCarriers = ref(false)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loadUsers = async () => {
|
||||
isLoadingUsers.value = true
|
||||
try {
|
||||
users.value = await getUsers()
|
||||
} finally {
|
||||
isLoadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadTrucks = async () => {
|
||||
isLoadingTrucks.value = true
|
||||
try {
|
||||
trucks.value = await getTruckList()
|
||||
} finally {
|
||||
isLoadingTrucks.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadCarriers = async () => {
|
||||
isLoadingCarriers.value = true
|
||||
try {
|
||||
carriers.value = await getCarrierList()
|
||||
} finally {
|
||||
isLoadingCarriers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDefaultUser = () => {
|
||||
if (form.userId) return
|
||||
if (authStore.user?.id) {
|
||||
form.userId = String(authStore.user.id)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCommonData = async () => {
|
||||
await loadUsers()
|
||||
await loadTrucks()
|
||||
await loadCarriers()
|
||||
await authStore.ensureSession()
|
||||
setDefaultUser()
|
||||
}
|
||||
|
||||
return {
|
||||
users,
|
||||
trucks,
|
||||
carriers,
|
||||
isLoadingUsers,
|
||||
isLoadingTrucks,
|
||||
isLoadingCarriers,
|
||||
loadUsers,
|
||||
loadTrucks,
|
||||
loadCarriers,
|
||||
setDefaultUser,
|
||||
loadCommonData
|
||||
}
|
||||
}
|
||||
153
frontend/composables/useLiotHandling.ts
Normal file
153
frontend/composables/useLiotHandling.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { CarrierData } from '~/services/dto/carrier-data'
|
||||
import type { DriverData } from '~/services/dto/driver-data'
|
||||
import type { VehicleData } from '~/services/dto/vehicle-data'
|
||||
import { getDriverList } from '~/services/driver'
|
||||
import { getVehicleList } from '~/services/vehicle'
|
||||
import { SUPPLIER_CODE } from '~/utils/constants'
|
||||
|
||||
interface LiotForm {
|
||||
carrierId: string
|
||||
truckId: string
|
||||
driverId: string
|
||||
vehicleId: string
|
||||
licensePlate: string
|
||||
}
|
||||
|
||||
export const useLiotHandling = (
|
||||
form: LiotForm,
|
||||
carriers: Ref<CarrierData[]>,
|
||||
isHydrating: Ref<boolean>
|
||||
) => {
|
||||
const drivers = ref<DriverData[]>([])
|
||||
const vehicles = ref<VehicleData[]>([])
|
||||
const isLoadingDrivers = ref(false)
|
||||
const isLoadingVehicles = ref(false)
|
||||
const allowAnyLicensePlate = ref(false)
|
||||
|
||||
const selectedCarrier = computed(() =>
|
||||
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
|
||||
)
|
||||
|
||||
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
|
||||
|
||||
const filteredDrivers = computed<DriverData[]>(() => {
|
||||
if (!form.carrierId) return []
|
||||
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
|
||||
})
|
||||
|
||||
const filteredVehicles = computed<VehicleData[]>(() => {
|
||||
if (!form.carrierId) return []
|
||||
return vehicles.value.filter(
|
||||
(vehicle) =>
|
||||
String(vehicle.carrier?.id) === form.carrierId &&
|
||||
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
|
||||
)
|
||||
})
|
||||
|
||||
const loadDrivers = async () => {
|
||||
isLoadingDrivers.value = true
|
||||
try {
|
||||
drivers.value = await getDriverList()
|
||||
} finally {
|
||||
isLoadingDrivers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadVehicles = async () => {
|
||||
isLoadingVehicles.value = true
|
||||
try {
|
||||
vehicles.value = await getVehicleList()
|
||||
} finally {
|
||||
isLoadingVehicles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select driver/vehicle when carrier changes
|
||||
watch(
|
||||
() => form.carrierId,
|
||||
() => {
|
||||
if (isHydrating.value) return
|
||||
if (!form.carrierId) {
|
||||
form.driverId = ''
|
||||
form.vehicleId = ''
|
||||
return
|
||||
}
|
||||
if (!isLiotCarrier.value) {
|
||||
form.driverId = ''
|
||||
form.vehicleId = ''
|
||||
return
|
||||
}
|
||||
if (filteredDrivers.value.length === 1) {
|
||||
form.driverId = String(filteredDrivers.value[0].id)
|
||||
}
|
||||
if (filteredVehicles.value.length === 1) {
|
||||
form.vehicleId = String(filteredVehicles.value[0].id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Validate/auto-select vehicle when truck/carrier changes
|
||||
watch(
|
||||
() => [form.truckId, form.carrierId, vehicles.value],
|
||||
() => {
|
||||
if (!isLiotCarrier.value) return
|
||||
if (filteredVehicles.value.length === 1) {
|
||||
form.vehicleId = String(filteredVehicles.value[0].id)
|
||||
return
|
||||
}
|
||||
if (!form.vehicleId) return
|
||||
const matches = filteredVehicles.value.some(
|
||||
(vehicle) => String(vehicle.id) === form.vehicleId
|
||||
)
|
||||
if (!matches) {
|
||||
form.vehicleId = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Sync license plate from selected vehicle
|
||||
watch(
|
||||
() => [form.vehicleId, form.carrierId, vehicles.value],
|
||||
() => {
|
||||
if (!isLiotCarrier.value) return
|
||||
if (isHydrating.value) return
|
||||
const selected = filteredVehicles.value.find(
|
||||
(vehicle) => String(vehicle.id) === form.vehicleId
|
||||
)
|
||||
if (selected) {
|
||||
form.licensePlate = selected.plate
|
||||
allowAnyLicensePlate.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Auto-select vehicle from license plate
|
||||
watch(
|
||||
() => [form.licensePlate, form.carrierId, vehicles.value],
|
||||
() => {
|
||||
if (!isLiotCarrier.value || form.vehicleId) return
|
||||
const match = filteredVehicles.value.find(
|
||||
(vehicle) => vehicle.plate === form.licensePlate
|
||||
)
|
||||
if (match) {
|
||||
form.vehicleId = String(match.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
drivers,
|
||||
vehicles,
|
||||
isLoadingDrivers,
|
||||
isLoadingVehicles,
|
||||
allowAnyLicensePlate,
|
||||
isLiotCarrier,
|
||||
filteredDrivers,
|
||||
filteredVehicles,
|
||||
loadDrivers,
|
||||
loadVehicles
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,66 @@
|
||||
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";
|
||||
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'
|
||||
|
||||
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,
|
||||
reception,
|
||||
updateReception,
|
||||
loadReception
|
||||
}: UseWeighingOptions) => {
|
||||
mode,
|
||||
entity,
|
||||
entityName,
|
||||
apiResource,
|
||||
titleLabel,
|
||||
isFinal = false,
|
||||
getWeightFromScale,
|
||||
updateEntity,
|
||||
loadEntity
|
||||
}: UseWeighingOptions) => {
|
||||
const weightData = ref<WeightData | null>(null)
|
||||
const isFetching = ref(false)
|
||||
|
||||
const currentWeightEntry = computed<WeightEntryData | null>(() => {
|
||||
const weights = reception.value?.weights ?? []
|
||||
const weights = entity.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(() => (mode === 'gross' ? 'Pesée à plein' : 'Pesée à vide'))
|
||||
const showLoadingBox = computed(
|
||||
() => isFetching.value || (displayWeight.value === null && currentWeightEntry.value === null)
|
||||
)
|
||||
const title = computed(() => titleLabel)
|
||||
const showLoadingBox = computed(() => isFetching.value)
|
||||
|
||||
const fetchWeight = async () => {
|
||||
isFetching.value = true
|
||||
weightData.value = await getWeight().finally(() => {
|
||||
weightData.value = await getWeightFromScale().finally(() => {
|
||||
isFetching.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const saveWeight = async () => {
|
||||
if (!reception.value) {
|
||||
return
|
||||
}
|
||||
if (!entity.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 (baseWeight === null) return
|
||||
|
||||
const relationPayload: Record<string, string> = {}
|
||||
relationPayload[entityName] = `/api/${apiResource}/${entity.value.id}`
|
||||
|
||||
if (existingEntry?.id) {
|
||||
await updateWeight(existingEntry.id, {
|
||||
@@ -62,7 +71,7 @@ export const useWeighing = ({
|
||||
})
|
||||
} else {
|
||||
await createWeight({
|
||||
reception: `api/receptions/${reception.value.id}`,
|
||||
...relationPayload,
|
||||
type: mode,
|
||||
dsd: baseDsd,
|
||||
weight: baseWeight,
|
||||
@@ -70,16 +79,48 @@ export const useWeighing = ({
|
||||
})
|
||||
}
|
||||
|
||||
const nextStep = mode === 'tare'
|
||||
? reception.value.currentStep
|
||||
: reception.value.currentStep + 1
|
||||
await updateReception(reception.value.id, {
|
||||
const nextStep = isFinal
|
||||
? entity.value.currentStep
|
||||
: entity.value.currentStep + 1
|
||||
await updateEntity(entity.value.id, {
|
||||
currentStep: nextStep,
|
||||
isValid: reception.value.isValid
|
||||
isValid: entity.value.isValid
|
||||
})
|
||||
|
||||
if (loadReception) {
|
||||
await loadReception(reception.value.id)
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,90 +132,34 @@ export const useWeighing = ({
|
||||
title,
|
||||
showLoadingBox,
|
||||
fetchWeight,
|
||||
saveWeight
|
||||
saveWeight,
|
||||
saveWeightDraft
|
||||
}
|
||||
}
|
||||
|
||||
// Backward-compatible aliases
|
||||
export const useWeighingShipment = ({
|
||||
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
|
||||
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
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
84
frontend/composables/useWorkflowSteps.ts
Normal file
84
frontend/composables/useWorkflowSteps.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { WorkflowConfig } from '~/types/workflow'
|
||||
|
||||
interface WorkflowStore {
|
||||
current: any
|
||||
isLoading: boolean
|
||||
clearCurrent: () => void
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const useWorkflowSteps = (config: WorkflowConfig, store: WorkflowStore) => {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const stepLabels = config.steps.map(s => s.label)
|
||||
|
||||
const currentStep = computed(() => store.current?.currentStep ?? 0)
|
||||
const entity = computed(() => store.current)
|
||||
|
||||
const loadMethod = `load${config.entityName.charAt(0).toUpperCase() + config.entityName.slice(1)}`
|
||||
const updateMethod = `update${config.entityName.charAt(0).toUpperCase() + config.entityName.slice(1)}`
|
||||
|
||||
const resolveId = (param: unknown) => {
|
||||
const idStr = Array.isArray(param) ? param[0] : param
|
||||
if (!idStr) return null
|
||||
const id = Number(idStr)
|
||||
return Number.isFinite(id) ? id : null
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (param) => {
|
||||
const id = resolveId(param)
|
||||
if (id === null) {
|
||||
store.clearCurrent()
|
||||
return
|
||||
}
|
||||
await store[loadMethod](id)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
|
||||
const saveAndHold = async () => {
|
||||
if (!store.current) {
|
||||
await router.push('/')
|
||||
return
|
||||
}
|
||||
const datePayload: Record<string, any> = {}
|
||||
const rawDate = store.current[config.dateField]
|
||||
datePayload[config.dateField] = rawDate ? rawDate.slice(0, 10) : rawDate
|
||||
await store[updateMethod](store.current.id, {
|
||||
currentStep: store.current.currentStep,
|
||||
licensePlate: store.current.licensePlate,
|
||||
...datePayload
|
||||
})
|
||||
await router.push('/')
|
||||
}
|
||||
|
||||
const handleStepSelect = async (step: number) => {
|
||||
if (!store.current) return
|
||||
if (step === store.current.currentStep) return
|
||||
await store[updateMethod](store.current.id, { currentStep: step })
|
||||
await store[loadMethod](store.current.id)
|
||||
}
|
||||
|
||||
const advanceStep = async () => {
|
||||
if (!store.current) return
|
||||
const nextStep = store.current.currentStep + 1
|
||||
await store[updateMethod](store.current.id, { currentStep: nextStep })
|
||||
await store[loadMethod](store.current.id)
|
||||
}
|
||||
|
||||
return {
|
||||
stepLabels,
|
||||
currentStep,
|
||||
entity,
|
||||
init,
|
||||
saveAndHold,
|
||||
handleStepSelect,
|
||||
advanceStep
|
||||
}
|
||||
}
|
||||
25
frontend/config/reception.config.ts
Normal file
25
frontend/config/reception.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { WorkflowConfig, WorkflowEntity } from '~/types/workflow'
|
||||
|
||||
export const receptionConfig: WorkflowConfig = {
|
||||
entityName: 'reception',
|
||||
apiResource: 'receptions',
|
||||
steps: [
|
||||
{ label: 'Réception' },
|
||||
{ label: 'Pesée à plein', weighingMode: 'gross' },
|
||||
{ label: 'Sélection réception' },
|
||||
{ label: 'Pesée à vide', weighingMode: 'tare', isFinal: true }
|
||||
],
|
||||
weighingLabels: {
|
||||
gross: 'Pesée à plein',
|
||||
tare: 'Pesée à vide'
|
||||
},
|
||||
buildReceiptFilename: (entity: WorkflowEntity) => {
|
||||
const rec = entity as any
|
||||
return `${rec.identificationNumber ?? rec.id}_${rec.supplier?.name ?? 'fournisseur'}_${rec.licensePlate ?? 'immat'}.pdf`
|
||||
},
|
||||
routePrefix: '/reception',
|
||||
toastPrefix: 'reception',
|
||||
dateField: 'receptionDate'
|
||||
}
|
||||
|
||||
export const RECEPTION_STEP_LABELS = receptionConfig.steps.map(s => s.label)
|
||||
25
frontend/config/shipment.config.ts
Normal file
25
frontend/config/shipment.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { WorkflowConfig, WorkflowEntity } from '~/types/workflow'
|
||||
|
||||
export const shipmentConfig: WorkflowConfig = {
|
||||
entityName: 'shipment',
|
||||
apiResource: 'shipments',
|
||||
steps: [
|
||||
{ label: 'Expédition' },
|
||||
{ label: 'Pesée à vide', weighingMode: 'tare' },
|
||||
{ label: 'Chargement' },
|
||||
{ label: 'Pesée à plein', weighingMode: 'gross', isFinal: true }
|
||||
],
|
||||
weighingLabels: {
|
||||
gross: 'Pesée à plein',
|
||||
tare: 'Pesée à vide'
|
||||
},
|
||||
buildReceiptFilename: (entity: WorkflowEntity) => {
|
||||
const ship = entity as any
|
||||
return `${ship.identificationNumber ?? ship.id}_${ship.customer?.label ?? 'client'}_${ship.licensePlate ?? 'immat'}.pdf`
|
||||
},
|
||||
routePrefix: '/shipment',
|
||||
toastPrefix: 'shipment',
|
||||
dateField: 'shipmentDate'
|
||||
}
|
||||
|
||||
export const SHIPMENT_STEP_LABELS = shipmentConfig.steps.map(s => s.label)
|
||||
@@ -1,20 +0,0 @@
|
||||
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,
|
||||
]
|
||||
@@ -12,6 +12,7 @@
|
||||
"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": {
|
||||
@@ -22,6 +23,7 @@
|
||||
"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": {
|
||||
@@ -82,7 +84,13 @@
|
||||
"list": "Impossible de récupérer la liste des camions."
|
||||
},
|
||||
"bovin": {
|
||||
"list": "Impossible de récupérer la liste des races de bovins."
|
||||
"list": "Impossible de récupérer la liste des races de bovins.",
|
||||
"fetch": "Impossible de récupérer le type bovin.",
|
||||
"create": "Impossible de créer le type bovin.",
|
||||
"update": "Impossible de mettre à jour le type bovin."
|
||||
},
|
||||
"bovine": {
|
||||
"create": "Impossible d'enregistrer le bovin."
|
||||
},
|
||||
"carrier": {
|
||||
"list": "Impossible de récupérer la liste des transporteurs.",
|
||||
@@ -106,15 +114,23 @@
|
||||
},
|
||||
"success": {
|
||||
"reception": {
|
||||
"update": "Réception mise à jour avec succès."
|
||||
"create": "Réception créée avec succès",
|
||||
"update": "Réception mise à jour avec succès.",
|
||||
"delete": "Réception supprimée avec succès."
|
||||
},
|
||||
"shipment": {
|
||||
"update": "Éxpedition mise à jour avec succès."
|
||||
"create": "Éxpedition créée avec succès",
|
||||
"update": "Éxpedition mise à jour avec succès.",
|
||||
"delete": "Expédition supprimée avec succès."
|
||||
},
|
||||
"supplier": {
|
||||
"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."
|
||||
@@ -129,6 +145,13 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<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>
|
||||
<NuxtLink to="/admin/customer/customer-list">
|
||||
Client
|
||||
</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>
|
||||
@@ -1,53 +1,183 @@
|
||||
<template>
|
||||
<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">
|
||||
<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) -->
|
||||
<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>
|
||||
<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 }">
|
||||
|
||||
<!-- 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 }">
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
:class="isExactActive ? 'opacity-100' : 'opacity-50'"
|
||||
:class="route.path === '/'
|
||||
? 'opacity-100'
|
||||
: 'opacity-65 hover:opacity-100 transition'"
|
||||
>
|
||||
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'"
|
||||
>
|
||||
Admin
|
||||
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
|
||||
</a>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<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>
|
||||
|
||||
<!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
|
||||
<div class="w-[44px] md:hidden"></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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<!-- Overlay (mobile) -->
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
@@ -62,6 +192,8 @@
|
||||
@click="closeMenu"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
<!-- Drawer (mobile) -->
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="-translate-x-full"
|
||||
@@ -72,9 +204,7 @@
|
||||
>
|
||||
<aside
|
||||
v-if="isMenuOpen"
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-2xl font-bold uppercase">Menu</span>
|
||||
@@ -87,12 +217,33 @@
|
||||
<Icon name="mdi:close" size="44"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="mt-8 flex flex-col gap-6 text-xl font-bold uppercase">
|
||||
<NuxtLink to="/" class="opacity-100" @click="closeMenu">Accueil</NuxtLink>
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
v-if="auth.isAuthenticated"
|
||||
type="button"
|
||||
class="mt-5 text-xl font-bold uppercase"
|
||||
class="mt-6 text-xl font-bold uppercase"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Déconnexion
|
||||
@@ -100,10 +251,10 @@
|
||||
</aside>
|
||||
</transition>
|
||||
</header>
|
||||
<main class="mx-auto w-full max-w-[1280px] pb-0">
|
||||
<main class="md:mx-auto w-full md:max-w-[1280px] mt-4 md:mt-16">
|
||||
<slot/>
|
||||
</main>
|
||||
<footer class="w-full mt-8 bg-primary-500 p-6">
|
||||
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">
|
||||
<p class="font-bold text-white text-right">v{{ version }}</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -114,9 +265,12 @@ import {useAuthStore} from '~/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const isMenuOpen = ref(false)
|
||||
const {version} = useAppVersion()
|
||||
|
||||
const isMenuOpen = ref(false)
|
||||
|
||||
const userDisplayName = computed(() => auth.user?.username ?? 'Utilisateur')
|
||||
|
||||
const closeMenu = () => {
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
|
||||
27
frontend/middleware/admin-guard.global.ts
Normal file
27
frontend/middleware/admin-guard.global.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
/**
|
||||
* Garde-fou global : empêche les utilisateurs non-admin d'accéder aux pages
|
||||
* sous /admin/*. Renvoie vers la home pour les utilisateurs authentifiés
|
||||
* non-admin, et vers /login pour les non authentifiés.
|
||||
*
|
||||
* L'API back rejette de toute façon les actions admin avec un 403, mais ce
|
||||
* middleware évite l'affichage des pages vides / en erreur quand un user
|
||||
* tape directement l'URL /admin/...
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
if (!to.path.startsWith('/admin')) {
|
||||
return
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
await auth.ensureSession()
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (!auth.isAdmin) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
@@ -15,7 +15,8 @@ export default defineNuxtConfig({
|
||||
css: ['~/assets/css/main.css', '~/assets/css/toast.css'],
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE,
|
||||
geoApiBase: ''
|
||||
}
|
||||
},
|
||||
toast: {
|
||||
|
||||
114
frontend/pages/admin/bovin/[[id]].vue
Normal file
114
frontend/pages/admin/bovin/[[id]].vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<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">
|
||||
useHead({ title: 'Type de bovin' })
|
||||
|
||||
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>
|
||||
72
frontend/pages/admin/bovin/bovin-list.vue
Normal file
72
frontend/pages/admin/bovin/bovin-list.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des types bovins</h1>
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/bovin"
|
||||
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"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToBovin"
|
||||
>
|
||||
<template #header-label>
|
||||
<UiTextInput v-model="filters.label" placeholder="Nom" size="compact" />
|
||||
</template>
|
||||
<template #header-code>
|
||||
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Types de bovins' })
|
||||
|
||||
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<BovineTypeData>(
|
||||
'bovine_types',
|
||||
{
|
||||
label: '',
|
||||
code: ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'label', label: 'Nom' },
|
||||
{ key: 'code', label: 'Code' }
|
||||
]
|
||||
|
||||
const goToBovin = (bovin: BovineTypeData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/bovin/${bovin.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
@@ -1,53 +1,73 @@
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||
>Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
<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
|
||||
/>
|
||||
|
||||
<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"
|
||||
<UiTextInput
|
||||
label="Code transporteur"
|
||||
id="code-id"
|
||||
v-model="form.code"
|
||||
/>
|
||||
</div>
|
||||
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>
|
||||
</form>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Transporteur' })
|
||||
|
||||
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 = Number(route.params.id)
|
||||
const idCarrier = computed(() => resolveId(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
|
||||
@@ -59,7 +79,7 @@ const hydrateFromUser = (carrier: CarrierData | null) => {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => idCarrier,
|
||||
() => idCarrier.value,
|
||||
async (id) => {
|
||||
if (id === null) {
|
||||
return
|
||||
@@ -85,13 +105,12 @@ async function validate() {
|
||||
|
||||
}
|
||||
|
||||
if(idCarrier){
|
||||
await updateCarrier(idCarrier, basePayload)
|
||||
navigate()
|
||||
if(idCarrier.value){
|
||||
await updateCarrier(idCarrier.value, basePayload)
|
||||
return
|
||||
}else{
|
||||
await createCarrier(basePayload)
|
||||
}
|
||||
await createCarrier(basePayload)
|
||||
navigate()
|
||||
}
|
||||
|
||||
function navigate(){
|
||||
|
||||
@@ -1,51 +1,63 @@
|
||||
<template>
|
||||
|
||||
<div class="flex items-center justify-between ">
|
||||
<h1 class="text-3xl font-bold uppercase">listes des transporteurs</h1>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">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-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 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)"
|
||||
@keydown.enter="goToCarrier(carrier.id)"
|
||||
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"
|
||||
>
|
||||
<div>{{ carrier.name}}</div>
|
||||
<div>{{ carrier.code }}</div>
|
||||
</div>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToCarrier"
|
||||
>
|
||||
<template #header-name>
|
||||
<UiTextInput v-model="filters.name" placeholder="Label" size="compact" />
|
||||
</template>
|
||||
<template #header-code>
|
||||
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {CarrierData} from "~/services/dto/carrier-data";
|
||||
import {getCarrierList} from "~/services/carrier";
|
||||
useHead({ title: 'Transporteurs' })
|
||||
|
||||
import type { CarrierData } from '~/services/dto/carrier-data'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const carrierList = ref<CarrierData[]>()
|
||||
const router = useRouter()
|
||||
|
||||
const goToCarrier = (id: number) => {
|
||||
router.push(`/admin/carrier/${id}`)
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<CarrierData>(
|
||||
'carriers',
|
||||
{
|
||||
name: '',
|
||||
code: ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Label' },
|
||||
{ key: 'code', label: 'Code' }
|
||||
]
|
||||
|
||||
const goToCarrier = (carrier: CarrierData) => {
|
||||
router.push(`/admin/carrier/${carrier.id}`)
|
||||
}
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
carrierList.value = await getCarrierList(false)
|
||||
})
|
||||
onMounted(reload)
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,251 @@
|
||||
<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">
|
||||
definePageMeta({layout: "admin"})
|
||||
useHead({ title: 'Client' })
|
||||
|
||||
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>
|
||||
|
||||
46
frontend/pages/admin/customer/address.vue
Normal file
46
frontend/pages/admin/customer/address.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<Address type="customer" :address="address" @validate="validate"/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Adresse client' })
|
||||
|
||||
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>
|
||||
@@ -1,112 +1,82 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-3xl font-bold uppercase">Client</h1>
|
||||
<NuxtLink
|
||||
to="/admin/customer"
|
||||
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-6 border border-slate-200 mb-16">
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div
|
||||
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 class="px-[86px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1>
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/customer"
|
||||
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"
|
||||
>
|
||||
<div>Nom</div>
|
||||
<div>Code</div>
|
||||
<div>Rue</div>
|
||||
<div>Complément</div>
|
||||
<div>Code Postal</div>
|
||||
<div>Ville</div>
|
||||
<div>Pays</div>
|
||||
</div>
|
||||
|
||||
<div v-if="customerList.length === 0" class="px-4 py-6 text-slate-400">
|
||||
Aucun fournisseur.
|
||||
</div>
|
||||
|
||||
<div v-for="customer in customerList" :key="customer.id">
|
||||
<div
|
||||
v-if="!customer.addresses || customer.addresses.length === 0"
|
||||
class="grid grid-cols-7 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer"
|
||||
@click="goToCustomer(customer.id)"
|
||||
>
|
||||
<div class="truncate">{{ customer.label }}</div>
|
||||
<div class="truncate">{{ customer.code }}</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="customer.addresses.length > 0">
|
||||
<div
|
||||
v-for="(address, idx) in customer.addresses"
|
||||
:key="address.id ?? `${customer.id}-${idx}-${address.street}-${address.postalCode}`"
|
||||
class="grid grid-cols-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="goToCustomer(customer.id)"
|
||||
>
|
||||
<div class="truncate">
|
||||
{{ idx === 0 ? customer.label : "↳" }}
|
||||
</div>
|
||||
<div class="truncate">{{ idx === 0 ? customer.code : "" }}</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="goToCustomer(customer.id)"
|
||||
>
|
||||
<div class="truncate">{{ customer.label }}</div>
|
||||
<div class="truncate">{{ customer.code }}</div>
|
||||
<div class="col-span-5 text-slate-400">
|
||||
Adresses non chargées
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToCustomer"
|
||||
>
|
||||
<template #header-name>
|
||||
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
|
||||
</template>
|
||||
<template #header-phone>
|
||||
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
|
||||
</template>
|
||||
<template #header-email>
|
||||
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
|
||||
</template>
|
||||
<template #header-createdBy.username>
|
||||
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getCustomerList } from "~/services/customer"
|
||||
import type { CustomerData } from "~/services/dto/customer-data"
|
||||
import { useAuthStore } from "~/stores/auth"
|
||||
useHead({ title: 'Clients' })
|
||||
|
||||
definePageMeta({ layout: "admin" })
|
||||
import type { CustomerData } from '~/services/dto/customer-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const customerList = ref<CustomerData[]>([])
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const goToCustomer = (id: number) => {
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<CustomerData>(
|
||||
'customers',
|
||||
{
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
'createdBy.username': ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom' },
|
||||
{ key: 'phone', label: 'Téléphone' },
|
||||
{ key: 'email', label: 'Mail' },
|
||||
{ key: 'createdBy.username', label: 'Créé par' }
|
||||
]
|
||||
|
||||
const goToCustomer = (customer: CustomerData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/customer/${id}`)
|
||||
router.push(`/admin/customer/${customer.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isAdmin) return
|
||||
customerList.value = await getCustomerList()
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
</script>
|
||||
@@ -1,53 +1,64 @@
|
||||
<template>
|
||||
<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>
|
||||
<form :class="{ submitted }" @submit.prevent="validate">
|
||||
|
||||
<button
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
||||
<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" }}
|
||||
</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"
|
||||
type="submit"
|
||||
:disabled="isLoading || !auth.isAdmin"
|
||||
@click="submitted = true"
|
||||
>
|
||||
{{ supplierId ? "Sauvegarder" : "Ajouter" }}
|
||||
</button>
|
||||
<Icon :name="supplierId ? '' : 'mdi:plus'" size="28" />
|
||||
{{ supplierId ? "Valider" : "Ajouter" }}
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<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"/>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<div class="overflow-x-auto mb-11 text-primary-700">
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<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 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="6" class="py-4 text-slate-400">
|
||||
<td colspan="5" class="py-4 text-slate-400">
|
||||
Aucune adresse.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -56,32 +67,45 @@
|
||||
<tr
|
||||
v-for="(address, index) in form.addresses"
|
||||
:key="address.id ?? index"
|
||||
class="border-b border-gray-100 hover:bg-slate-50"
|
||||
class="border border-gray-100 hover:bg-slate-50"
|
||||
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
@click="goToEditAddress(address.id ?? null)"
|
||||
>
|
||||
<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>
|
||||
<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="supplierId === null || !auth.isAdmin"
|
||||
@click="goToAddAddress"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Fournisseur' })
|
||||
|
||||
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()
|
||||
@@ -94,6 +118,7 @@ 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: "",
|
||||
@@ -101,6 +126,30 @@ const form = reactive<SupplierFormData>({
|
||||
addresses: [],
|
||||
})
|
||||
|
||||
// Address form (creation mode only)
|
||||
const addressForm = reactive<AddressPayload>({
|
||||
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
|
||||
})
|
||||
const communes = ref<CommuneData[]>([])
|
||||
const isLoadingCities = ref(false)
|
||||
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(() => addressForm.postalCode, (cp) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
|
||||
if (cp.length === 5) {
|
||||
debounceTimer = setTimeout(async () => {
|
||||
isLoadingCities.value = true
|
||||
try {
|
||||
communes.value = await getCommunesByPostalCode(cp)
|
||||
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
|
||||
else addressForm.city = ''
|
||||
} finally { isLoadingCities.value = false }
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
|
||||
const goToAddAddress = () => {
|
||||
if (supplierId.value === null || !auth.isAdmin) return
|
||||
router.push({
|
||||
@@ -140,7 +189,6 @@ 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 ?? "",
|
||||
@@ -179,14 +227,24 @@ async function validate() {
|
||||
email,
|
||||
phone,
|
||||
}
|
||||
let targetId: number | null = null
|
||||
|
||||
if (supplierId.value !== null) {
|
||||
await updateSupplier(supplierId.value, supplierPayload)
|
||||
targetId = supplierId.value
|
||||
} else {
|
||||
await createSupplier(supplierPayload)
|
||||
const addressData = await createAddress({ ...addressForm })
|
||||
const addressIRI = `/api/addresses/${addressData.id}`
|
||||
const creationPayload = {
|
||||
...supplierPayload,
|
||||
addresses: [addressIRI],
|
||||
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
|
||||
}
|
||||
const created = await createSupplier(creationPayload)
|
||||
targetId = created.id
|
||||
}
|
||||
|
||||
await router.push("/admin/supplier/supplier-list")
|
||||
await router.push(`/admin/supplier/${targetId}`)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Adresse fournisseur' })
|
||||
|
||||
import type {AddressData, AddressPayload} from "~/services/address";
|
||||
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) })
|
||||
@@ -18,15 +18,12 @@ 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) => {
|
||||
|
||||
@@ -1,112 +1,82 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<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-6 border border-slate-200 mb-16">
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div
|
||||
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 class="px-[86px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1>
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/supplier"
|
||||
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"
|
||||
>
|
||||
<div>Nom</div>
|
||||
<div>Mail</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">
|
||||
<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>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToSupplier"
|
||||
>
|
||||
<template #header-name>
|
||||
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
|
||||
</template>
|
||||
<template #header-phone>
|
||||
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
|
||||
</template>
|
||||
<template #header-email>
|
||||
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
|
||||
</template>
|
||||
<template #header-createdBy.username>
|
||||
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getSupplierList } from "~/services/supplier"
|
||||
import type { SupplierData } from "~/services/dto/supplier-data"
|
||||
import { useAuthStore } from "~/stores/auth"
|
||||
useHead({ title: 'Fournisseurs' })
|
||||
|
||||
definePageMeta({ layout: "admin" })
|
||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const supplierList = ref<SupplierData[]>([])
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const goToSupplier = (id: number) => {
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<SupplierData>(
|
||||
'suppliers',
|
||||
{
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
'createdBy.username': ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom' },
|
||||
{ key: 'phone', label: 'Téléphone' },
|
||||
{ key: 'email', label: 'Mail' },
|
||||
{ key: 'createdBy.username', label: 'Créé par' }
|
||||
]
|
||||
|
||||
const goToSupplier = (supplier: SupplierData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/supplier/${id}`)
|
||||
router.push(`/admin/supplier/${supplier.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isAdmin) return
|
||||
supplierList.value = await getSupplierList()
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,57 +1,94 @@
|
||||
<template>
|
||||
<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" }}
|
||||
<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" }}
|
||||
</h1>
|
||||
<button
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
||||
type="submit"
|
||||
>
|
||||
{{ userId ? 'Sauvegarder' : 'Ajouter' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-y-16 gap-x-40 mb-16">
|
||||
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
|
||||
<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="Rôle de l'utilisateur"
|
||||
label="Role 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">
|
||||
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'
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Utilisateur' })
|
||||
|
||||
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'
|
||||
|
||||
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
|
||||
@@ -62,11 +99,11 @@ const resolveUserId = (param: unknown) => {
|
||||
return Number.isFinite(id) ? id : null
|
||||
}
|
||||
|
||||
|
||||
const form = reactive<UserFormData>({
|
||||
username: '',
|
||||
password: '',
|
||||
role: ''
|
||||
role: '',
|
||||
isLocked: false
|
||||
})
|
||||
|
||||
const hydrateFromUser = (user: UserData | null) => {
|
||||
@@ -76,9 +113,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -96,30 +134,33 @@ 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 = {
|
||||
const basePayload: UserPayload = {
|
||||
username: normalizedUsername,
|
||||
roles: normalizedRole ? [normalizedRole] : undefined,
|
||||
password: normalizedPassword || undefined
|
||||
isLocked: form.isLocked,
|
||||
}
|
||||
if (normalizedPassword) {
|
||||
basePayload.password = normalizedPassword
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,57 +1,110 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
<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 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)"
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/user"
|
||||
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"
|
||||
>
|
||||
<div>
|
||||
{{ user.username }}
|
||||
</div>
|
||||
<div>
|
||||
{{ user.roles?.join(', ') || ' ---' }}
|
||||
</div>
|
||||
</div>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToUser"
|
||||
>
|
||||
<template #header-username>
|
||||
<UiTextInput
|
||||
v-model="filters.username"
|
||||
placeholder="Utilisateur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-roles>
|
||||
<UiTextInput :model-value="''" placeholder="Role" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-isLocked>
|
||||
<UiSelect
|
||||
v-model="filters.isLocked"
|
||||
placeholder="Statut"
|
||||
:options="statusOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-roles="{ item }">
|
||||
{{ getRoleLabels(item.roles) }}
|
||||
</template>
|
||||
<template #cell-isLocked="{ item }">
|
||||
<span
|
||||
v-if="item.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>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
useHead({ title: 'Utilisateurs' })
|
||||
|
||||
import type {UserData} from "~/services/dto/user-data";
|
||||
import {getAdminUsers, getUsers} from "~/services/auth";
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { ROLE } from '~/utils/constants'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
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) => {
|
||||
router.push(`/admin/user/${id}`)
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<UserData>(
|
||||
'admin/users',
|
||||
{
|
||||
username: '',
|
||||
isLocked: ''
|
||||
}
|
||||
)
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'false', label: 'Actif' },
|
||||
{ value: 'true', label: 'Verrouillé' }
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ key: 'username', label: 'Utilisateur' },
|
||||
{ key: 'roles', label: 'Role' },
|
||||
{ key: 'isLocked', label: 'Statut', width: '160px' }
|
||||
]
|
||||
|
||||
const getRoleLabels = (roles?: string[]) => {
|
||||
if (!roles || roles.length === 0) return '---'
|
||||
return roles.map(role => roleLabelByValue.get(role) ?? role).join(', ')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
userList.value = await getAdminUsers()
|
||||
const goToUser = (user: UserData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/user/${user.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
157
frontend/pages/entry-exit/bovine-info/[id].vue
Normal file
157
frontend/pages/entry-exit/bovine-info/[id].vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-start gap-6 relative mb-8">
|
||||
<Icon
|
||||
@click="router.push('/entry-exit')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500 absolute -left-[60px]"
|
||||
/>
|
||||
<h1 class="font-bold text-3xl uppercase text-primary-500">
|
||||
Saisie information bovin {{ reception?.identificationNumber ?? '' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center text-slate-500">Chargement…</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="mb-4 max-w-[200px]">
|
||||
<UiTextInput
|
||||
v-model="searchQuery"
|
||||
placeholder="N° national"
|
||||
size="compact"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
inputClass="text-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<UiAccordion
|
||||
v-for="bovine in filteredBovines"
|
||||
:key="bovine.id"
|
||||
:model-value="openId === bovine.id"
|
||||
@update:model-value="onToggle(bovine.id, $event)"
|
||||
>
|
||||
<template #header>
|
||||
<span class="flex items-center gap-3 normal-case">
|
||||
<span class="font-bold text-base">{{ bovine.nationalNumber }}</span>
|
||||
<span
|
||||
v-if="isSaisi(bovine)"
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
||||
>
|
||||
Saisie
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
|
||||
>
|
||||
Attente saisie
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<BovineInfoForm
|
||||
:bovine="bovine"
|
||||
:buildings="buildings"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</UiAccordion>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import type { BuildingData } from '~/services/dto/building-data'
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import { getBuildingList } from '~/services/building'
|
||||
import BovineInfoForm from '~/components/entry-exit/bovine-info-form.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
|
||||
const receptionId = computed(() => Number(route.params.id))
|
||||
|
||||
const reception = ref<ReceptionData | null>(null)
|
||||
const bovines = ref<BovineData[]>([])
|
||||
const buildings = ref<BuildingData[]>([])
|
||||
const loading = ref(true)
|
||||
const openId = ref<number | null>(null)
|
||||
const searchQueryRaw = ref('')
|
||||
const searchQuery = computed<string>({
|
||||
get: () => searchQueryRaw.value,
|
||||
set: (value) => {
|
||||
searchQueryRaw.value = value.replace(/\D/g, '')
|
||||
}
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: () => `Saisie information bovin ${reception.value?.identificationNumber ?? ''}`.trim()
|
||||
})
|
||||
|
||||
const isSaisi = (bovine: BovineData) =>
|
||||
bovine.receivedWeight != null
|
||||
&& bovine.pricePerKg != null
|
||||
&& bovine.buildingCase != null
|
||||
|
||||
const sortedBovines = computed(() => {
|
||||
const pending = bovines.value.filter(b => !isSaisi(b))
|
||||
const done = bovines.value.filter(b => isSaisi(b))
|
||||
return [...pending, ...done]
|
||||
})
|
||||
|
||||
const filteredBovines = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
if (!query) return sortedBovines.value
|
||||
return sortedBovines.value.filter(b =>
|
||||
b.nationalNumber.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
const onToggle = (bovineId: number, value: boolean) => {
|
||||
openId.value = value ? bovineId : null
|
||||
}
|
||||
|
||||
const onSaved = (updated: BovineData) => {
|
||||
const idx = bovines.value.findIndex(b => b.id === updated.id)
|
||||
if (idx === -1) return
|
||||
bovines.value[idx] = updated
|
||||
|
||||
const next = sortedBovines.value.find(b => !isSaisi(b) && b.id !== updated.id)
|
||||
openId.value = next?.id ?? null
|
||||
}
|
||||
|
||||
const loadBovines = async () => {
|
||||
type Hydra = { 'hydra:member'?: BovineData[] }
|
||||
const response = await api.get<BovineData[] | Hydra>(
|
||||
'bovines',
|
||||
{ reception: receptionId.value, itemsPerPage: 200 }
|
||||
)
|
||||
if (Array.isArray(response)) {
|
||||
bovines.value = response
|
||||
} else if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
|
||||
bovines.value = response['hydra:member']
|
||||
} else {
|
||||
bovines.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [r, , b] = await Promise.all([
|
||||
api.get<ReceptionData>(`receptions/${receptionId.value}`),
|
||||
loadBovines(),
|
||||
getBuildingList()
|
||||
])
|
||||
reception.value = r
|
||||
buildings.value = b
|
||||
|
||||
const firstPending = sortedBovines.value.find(bv => !isSaisi(bv))
|
||||
openId.value = firstPending?.id ?? null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
377
frontend/pages/entry-exit/entry/[id].vue
Normal file
377
frontend/pages/entry-exit/entry/[id].vue
Normal file
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-start gap-6 relative" :class="{ 'mb-8': isConsultationMode }">
|
||||
<Icon
|
||||
@click="router.push('/entry-exit')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500 absolute -left-[60px]"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="font-bold text-3xl uppercase text-primary-500">
|
||||
Entrée bovins {{ reception?.identificationNumber ?? '' }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!isConsultationMode" class="text-sm text-slate-600 mt-1 mb-8">
|
||||
{{ reception?.supplier?.name ?? '—' }} · Bovins déclarés : {{ declaredCount }} · Bovins saisis : {{ savedBovinesTotal }}
|
||||
</p>
|
||||
|
||||
<template v-if="!isConsultationMode">
|
||||
<form
|
||||
class="grid grid-cols-4 gap-x-16 gap-y-8 mb-12 items-end"
|
||||
:class="{ submitted }"
|
||||
@submit.prevent="addBovine"
|
||||
>
|
||||
<UiTextInput
|
||||
v-model="form.nationalNumber"
|
||||
label="Numéro national"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.entryCause"
|
||||
label="Cause d'entrée"
|
||||
:options="entryCauseOptions"
|
||||
required
|
||||
/>
|
||||
<UiDateMaskedInput
|
||||
v-model="form.arrivalDate"
|
||||
label="Date d'entrée"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.supplierId"
|
||||
label="Vendeur"
|
||||
:options="supplierOptions"
|
||||
required
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div class="flex justify-center mb-12">
|
||||
<UiButton
|
||||
type="button"
|
||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
||||
:disabled="isAdding"
|
||||
:loading="isAdding"
|
||||
@click="addBovine"
|
||||
>
|
||||
Ajouter
|
||||
</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UiDataTable
|
||||
v-model:page="recapPage"
|
||||
v-model:per-page="recapPerPage"
|
||||
:columns="recapColumns"
|
||||
:items="savedBovines"
|
||||
:total-items="savedBovinesTotal"
|
||||
:loading="savedBovinesLoading"
|
||||
:show-actions="!isConsultationMode"
|
||||
>
|
||||
<template #header-nationalNumber>
|
||||
<UiTextInput v-model="recapFilters.nationalNumber" placeholder="N° National" size="compact" />
|
||||
</template>
|
||||
<template #header-workNumber>
|
||||
<UiTextInput v-model="recapFilters.workNumber" placeholder="N° Travail" size="compact" />
|
||||
</template>
|
||||
<template #header-bovineType.label>
|
||||
<UiTextInput v-model="recapFilters['bovineType.label']" placeholder="Race" size="compact" />
|
||||
</template>
|
||||
<template #header-sex>
|
||||
<UiTextInput v-model="recapFilters.sex" placeholder="Sexe" size="compact" />
|
||||
</template>
|
||||
<template #header-birthDate>
|
||||
<UiDateMaskedInput v-model="birthDateFilter" placeholder="Né le" size="compact" />
|
||||
</template>
|
||||
<template #header-arrivalDate>
|
||||
<UiDateMaskedInput v-model="arrivalDateFilter" placeholder="Entrée le" size="compact" />
|
||||
</template>
|
||||
<template #header-supplier.name>
|
||||
<UiTextInput :model-value="''" placeholder="Vendeur" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-entryCause>
|
||||
<UiTextInput :model-value="''" placeholder="Cause" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-ednotifConfirmedAt>
|
||||
<UiTextInput :model-value="''" placeholder="EDNOTIF" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<UiTextInput :model-value="''" placeholder="Action" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-birthDate="{ item }">
|
||||
{{ formatDate(item.birthDate) }}
|
||||
</template>
|
||||
<template #cell-arrivalDate="{ item }">
|
||||
{{ formatDate(item.arrivalDate) }}
|
||||
</template>
|
||||
<template #cell-bovineType.label="{ item }">
|
||||
{{ item.bovineType?.label ?? '—' }}
|
||||
</template>
|
||||
<template #cell-supplier.name="{ item }">
|
||||
{{ supplierName(item.supplier) }}
|
||||
</template>
|
||||
<template #cell-entryCause="{ item }">
|
||||
{{ entryCauseLabel(item.entryCause) }}
|
||||
</template>
|
||||
<template #cell-ednotifConfirmedAt="{ item }">
|
||||
<span
|
||||
v-if="item.ednotifConfirmedAt"
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
||||
>
|
||||
Validé
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700"
|
||||
>
|
||||
En attente
|
||||
</span>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<Icon
|
||||
name="mdi:delete-outline"
|
||||
size="24"
|
||||
class="cursor-pointer text-red-500 hover:text-red-700"
|
||||
@click="confirmDeleteBovine(item)"
|
||||
/>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
|
||||
<div v-if="!isConsultationMode" class="flex justify-center mt-8">
|
||||
<UiButton
|
||||
type="button"
|
||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
||||
:disabled="savedBovinesTotal === 0 || isValidating"
|
||||
:loading="isValidating"
|
||||
@click="validateEntry"
|
||||
>
|
||||
Valider l'entrée
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||
import { getSupplierList } from '~/services/supplier'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
|
||||
const receptionId = computed(() => Number(route.params.id))
|
||||
|
||||
const reception = ref<ReceptionData | null>(null)
|
||||
const suppliers = ref<SupplierData[]>([])
|
||||
|
||||
const {
|
||||
items: savedBovines,
|
||||
totalItems: savedBovinesTotal,
|
||||
page: recapPage,
|
||||
perPage: recapPerPage,
|
||||
filters: recapFilters,
|
||||
loading: savedBovinesLoading,
|
||||
reload: reloadSavedBovines
|
||||
} = useDataTableServerState<BovineData>(
|
||||
'bovines',
|
||||
{
|
||||
reception: receptionId.value,
|
||||
nationalNumber: '',
|
||||
workNumber: '',
|
||||
'bovineType.label': '',
|
||||
sex: '',
|
||||
'birthDate[after]': '',
|
||||
'birthDate[strictly_before]': '',
|
||||
'arrivalDate[after]': '',
|
||||
'arrivalDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const birthDateFilter = computed<string>({
|
||||
get: () => (recapFilters.value['birthDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
recapFilters.value['birthDate[after]'] = ''
|
||||
recapFilters.value['birthDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
recapFilters.value['birthDate[after]'] = value
|
||||
recapFilters.value['birthDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const arrivalDateFilter = computed<string>({
|
||||
get: () => (recapFilters.value['arrivalDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
recapFilters.value['arrivalDate[after]'] = ''
|
||||
recapFilters.value['arrivalDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
recapFilters.value['arrivalDate[after]'] = value
|
||||
recapFilters.value['arrivalDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const isAdding = ref(false)
|
||||
const isValidating = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
const isConsultationMode = computed(() => reception.value?.entryCompleted === true)
|
||||
|
||||
const recapColumns = computed(() => {
|
||||
const cols: Array<{ key: string; label: string; width: string }> = [
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '100px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '110px' },
|
||||
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '75px' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '75px' },
|
||||
{ key: 'supplier.name', label: 'Vendeur', width: '150px' },
|
||||
{ key: 'entryCause', label: 'Cause', width: '100px' }
|
||||
]
|
||||
if (isConsultationMode.value) {
|
||||
cols.push({ key: 'ednotifConfirmedAt', label: 'EDNOTIF', width: '110px' })
|
||||
}
|
||||
return cols
|
||||
})
|
||||
|
||||
const entryCauseLabel = (code: string | null | undefined) => {
|
||||
if (!code) return '—'
|
||||
return entryCauseOptions.find(o => o.value === code)?.label ?? code
|
||||
}
|
||||
|
||||
const supplierName = (supplier: BovineData['supplier']) => {
|
||||
if (supplier && typeof supplier === 'object') return supplier.name
|
||||
return '—'
|
||||
}
|
||||
|
||||
const formatDate = (date: string | null | undefined) => {
|
||||
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' })
|
||||
}
|
||||
|
||||
const confirmDeleteBovine = async (bovine: BovineData) => {
|
||||
const confirmed = window.confirm(`Supprimer le bovin ${bovine.nationalNumber} ?`)
|
||||
if (!confirmed) return
|
||||
|
||||
await api.delete(`bovines/${bovine.id}`)
|
||||
reloadSavedBovines()
|
||||
}
|
||||
|
||||
const validateEntry = async () => {
|
||||
if (savedBovinesTotal.value === 0 || isValidating.value) return
|
||||
|
||||
const message = savedBovinesTotal.value !== declaredCount.value
|
||||
? `Attention : ${savedBovinesTotal.value} bovins saisis sur ${declaredCount.value} déclarés. Êtes-vous sûr de vouloir valider l'entrée ?`
|
||||
: `Êtes-vous sûr de vouloir valider l'entrée ?`
|
||||
|
||||
if (!window.confirm(message)) return
|
||||
|
||||
isValidating.value = true
|
||||
try {
|
||||
await api.patch(`receptions/${receptionId.value}`, { entryCompleted: true })
|
||||
router.push('/entry-exit')
|
||||
} finally {
|
||||
isValidating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
type EntryCause = 'A' | 'N' | 'P'
|
||||
|
||||
interface FormState {
|
||||
nationalNumber: string
|
||||
entryCause: EntryCause
|
||||
arrivalDate: string
|
||||
supplierId: string | number | null
|
||||
}
|
||||
|
||||
const entryCauseOptions = [
|
||||
{ value: 'A', label: 'Achat' },
|
||||
{ value: 'N', label: 'Naissance' },
|
||||
{ value: 'P', label: 'Prêt ou pension' }
|
||||
]
|
||||
|
||||
const initialForm = (): FormState => ({
|
||||
nationalNumber: '',
|
||||
entryCause: 'A',
|
||||
arrivalDate: reception.value?.receptionDate?.slice(0, 10) ?? '',
|
||||
supplierId: reception.value?.supplier?.id ?? null
|
||||
})
|
||||
|
||||
const form = reactive<FormState>(initialForm())
|
||||
|
||||
const supplierOptions = computed(() =>
|
||||
suppliers.value.map(s => ({ value: s.id, label: s.name }))
|
||||
)
|
||||
|
||||
const declaredCount = computed(() => reception.value?.declaredBovineCount ?? 0)
|
||||
|
||||
const isFormValid = computed(() =>
|
||||
form.nationalNumber.trim() !== ''
|
||||
&& !!form.entryCause
|
||||
&& !!form.arrivalDate
|
||||
&& form.supplierId !== null
|
||||
)
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, initialForm())
|
||||
}
|
||||
|
||||
const loadReception = async () => {
|
||||
reception.value = await api.get<ReceptionData>(`receptions/${receptionId.value}`)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const focusFirstField = () => {
|
||||
const el = document.querySelector<HTMLInputElement>('form input[type="text"]')
|
||||
el?.focus()
|
||||
}
|
||||
|
||||
const addBovine = async () => {
|
||||
submitted.value = true
|
||||
if (!isFormValid.value || isAdding.value) return
|
||||
|
||||
isAdding.value = true
|
||||
try {
|
||||
const payload = {
|
||||
nationalNumber: form.nationalNumber.trim(),
|
||||
entryCause: form.entryCause,
|
||||
arrivalDate: form.arrivalDate,
|
||||
supplier: `/api/suppliers/${form.supplierId}`,
|
||||
reception: `/api/receptions/${receptionId.value}`
|
||||
}
|
||||
|
||||
await api.post<BovineData>('bovines', payload, {
|
||||
headers: { 'Content-Type': 'application/ld+json' }
|
||||
})
|
||||
|
||||
reloadSavedBovines()
|
||||
resetForm()
|
||||
submitted.value = false
|
||||
await nextTick()
|
||||
focusFirstField()
|
||||
} finally {
|
||||
isAdding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
suppliers.value = await getSupplierList()
|
||||
await loadReception()
|
||||
reloadSavedBovines()
|
||||
})
|
||||
</script>
|
||||
265
frontend/pages/entry-exit/index.vue
Normal file
265
frontend/pages/entry-exit/index.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-start gap-10 relative">
|
||||
<Icon
|
||||
@click="router.push('/')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500 absolute -left-[60px]"
|
||||
/>
|
||||
<h1 class="font-bold text-3xl uppercase text-primary-500">Entrée / Sortie</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid grid-cols-2 gap-8">
|
||||
<section>
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées en attente</h2>
|
||||
<UiDataTable
|
||||
v-model:page="entryPage"
|
||||
v-model:per-page="entryPerPage"
|
||||
:columns="entryColumns"
|
||||
:items="entries"
|
||||
:total-items="totalEntries"
|
||||
:loading="entriesLoading"
|
||||
row-clickable
|
||||
@row-click="goToEntry"
|
||||
>
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="entryFilters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionDate>
|
||||
<UiDateMaskedInput v-model="entryDateFilter" placeholder="Date" size="compact" />
|
||||
</template>
|
||||
<template #header-declaredCount>
|
||||
<UiTextInput :model-value="''" placeholder="Déclarés" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-registeredBovineCount>
|
||||
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-status>
|
||||
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-identificationNumber="{ item }">
|
||||
{{ item.identificationNumber }}
|
||||
</template>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #cell-declaredCount="{ item }">
|
||||
{{ item.declaredBovineCount ?? 0 }}
|
||||
</template>
|
||||
<template #cell-registeredBovineCount="{ item }">
|
||||
{{ item.registeredBovineCount ?? 0 }}
|
||||
</template>
|
||||
<template #cell-status="{ item }">
|
||||
<span
|
||||
v-if="!item.entryCompleted"
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
|
||||
>
|
||||
Attente saisie
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700"
|
||||
>
|
||||
Attente EDNOTIF
|
||||
</span>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées validées</h2>
|
||||
<UiDataTable
|
||||
v-model:page="validatedPage"
|
||||
v-model:per-page="validatedPerPage"
|
||||
:columns="validatedColumns"
|
||||
:items="validated"
|
||||
:total-items="totalValidated"
|
||||
:loading="validatedLoading"
|
||||
row-clickable
|
||||
@row-click="goToBovineInfo"
|
||||
>
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="validatedFilters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionDate>
|
||||
<UiDateMaskedInput v-model="validatedDateFilter" placeholder="Date" size="compact" />
|
||||
</template>
|
||||
<template #header-registeredBovineCount>
|
||||
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-validatedAt>
|
||||
<UiTextInput :model-value="''" placeholder="Validée le" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-status>
|
||||
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-identificationNumber="{ item }">
|
||||
{{ item.identificationNumber }}
|
||||
</template>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #cell-registeredBovineCount="{ item }">
|
||||
{{ item.registeredBovineCount ?? 0 }}
|
||||
</template>
|
||||
<template #cell-validatedAt="{ item }">
|
||||
{{ formatDate(item.validatedAt) }}
|
||||
</template>
|
||||
<template #cell-status>
|
||||
<span
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
||||
>
|
||||
Validée
|
||||
</span>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 mb-16 grid grid-cols-2 gap-8">
|
||||
<section>
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties en attente</h2>
|
||||
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
|
||||
À venir
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties validées</h2>
|
||||
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
|
||||
À venir
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
items: entries,
|
||||
totalItems: totalEntries,
|
||||
page: entryPage,
|
||||
perPage: entryPerPage,
|
||||
filters: entryFilters,
|
||||
loading: entriesLoading,
|
||||
reload
|
||||
} = useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
'isValid': 'true',
|
||||
'exists[validatedAt]': 'false',
|
||||
'receptionType.code': 'BOVINS',
|
||||
'identificationNumber': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 5 }
|
||||
)
|
||||
|
||||
const {
|
||||
items: validated,
|
||||
totalItems: totalValidated,
|
||||
page: validatedPage,
|
||||
perPage: validatedPerPage,
|
||||
filters: validatedFilters,
|
||||
loading: validatedLoading,
|
||||
reload: reloadValidated
|
||||
} = useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
'isValid': 'true',
|
||||
'exists[validatedAt]': 'true',
|
||||
'receptionType.code': 'BOVINS',
|
||||
'identificationNumber': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 5 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const entryDateFilter = computed<string>({
|
||||
get: () => (entryFilters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
entryFilters.value['receptionDate[after]'] = ''
|
||||
entryFilters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
entryFilters.value['receptionDate[after]'] = value
|
||||
entryFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const validatedDateFilter = computed<string>({
|
||||
get: () => (validatedFilters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
validatedFilters.value['receptionDate[after]'] = ''
|
||||
validatedFilters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
validatedFilters.value['receptionDate[after]'] = value
|
||||
validatedFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const entryColumns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'receptionDate', label: 'Date', width: '75px' },
|
||||
{ key: 'declaredCount', label: 'Déclarés', width: '75px' },
|
||||
{ key: 'registeredBovineCount', label: 'Saisis', width: '70px' },
|
||||
{ key: 'status', label: 'Statut', width: '1fr' }
|
||||
]
|
||||
|
||||
const validatedColumns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'receptionDate', label: 'Date', width: '75px' },
|
||||
{ key: 'registeredBovineCount', label: 'Saisis', width: '50px' },
|
||||
{ key: 'validatedAt', label: 'Validée le', width: '75px' },
|
||||
{ key: 'status', label: 'Statut', width: '1fr' }
|
||||
]
|
||||
|
||||
const formatDate = (date: string | null | undefined) => {
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
const goToEntry = (reception: ReceptionData) => {
|
||||
router.push(`/entry-exit/entry/${reception.id}`)
|
||||
}
|
||||
|
||||
const goToBovineInfo = (reception: ReceptionData) => {
|
||||
router.push(`/entry-exit/bovine-info/${reception.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reload()
|
||||
reloadValidated()
|
||||
})
|
||||
</script>
|
||||
@@ -1,15 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Accueil' })
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-center mt-8 gap-8 mb-8 md:mb-0">
|
||||
<div class="flex flex-wrap justify-center pb-16 gap-12">
|
||||
<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="/" 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="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 link="/entry-exit" iconName="mdi:swap-horizontal-circle-outline">
|
||||
<template #label>
|
||||
Entrée<br>Sortie
|
||||
</template>
|
||||
</card-link>
|
||||
<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 label="PASSEPORT DU BOVIN" link="/" iconName="mdi:cow" />
|
||||
<card-link link="/inventory" iconName="mdi:cow">
|
||||
<template #label>
|
||||
INVENTAIRE<br>BOVINS
|
||||
</template>
|
||||
</card-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
182
frontend/pages/infrastructure/bovine.vue
Normal file
182
frontend/pages/infrastructure/bovine.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<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">
|
||||
useHead({ title: 'Bovins' })
|
||||
|
||||
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>
|
||||
231
frontend/pages/infrastructure/building.vue
Normal file
231
frontend/pages/infrastructure/building.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<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('/')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 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">
|
||||
useHead({ title: 'Bâtiments' })
|
||||
|
||||
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>
|
||||
291
frontend/pages/infrastructure/case.vue
Normal file
291
frontend/pages/infrastructure/case.vue
Normal file
@@ -0,0 +1,291 @@
|
||||
<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>
|
||||
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
|
||||
<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 && auth.isAdmin"
|
||||
: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"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
<div class="flex items-center gap-3 rounded-md bg-red-500 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-white">{{ stats.over24 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-white">≥ 24 mois</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-md bg-orange-500 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-white">{{ stats.between22And24 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-white">22 – 24 mois</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-md bg-yellow-500 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-white">{{ stats.between20And22 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-white">20 – 22 mois</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
:row-clickable="auth.isAdmin"
|
||||
empty-message="Aucun bovin dans cette case."
|
||||
@row-click="goToBovine"
|
||||
>
|
||||
<template #header-nationalNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.nationalNumber"
|
||||
placeholder="N° National"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-workNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.workNumber"
|
||||
placeholder="N° Travail"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-sex>
|
||||
<UiSelect
|
||||
v-model="filters.sex"
|
||||
placeholder="Sexe"
|
||||
:options="sexOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-birthDate>
|
||||
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
|
||||
</template>
|
||||
<template #header-age>
|
||||
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-pricePerKg>
|
||||
<UiTextInput :model-value="''" placeholder="Prix/kg" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-finalPrice>
|
||||
<UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-bovineType.label>
|
||||
<UiTextInput
|
||||
v-model="filters['bovineType.label']"
|
||||
placeholder="Race"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-arrivalDate>
|
||||
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
|
||||
</template>
|
||||
<template #cell-birthDate="{ item }">
|
||||
{{ formatDate(item.birthDate) }}
|
||||
</template>
|
||||
<template #cell-age="{ item }">
|
||||
<span
|
||||
class="inline-block rounded px-2 py-0.5 font-semibold"
|
||||
:class="ageBadgeClass(item.ageMonths)"
|
||||
>
|
||||
{{ formatAgeLabel(item.ageMonths) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-arrivalDate="{ item }">
|
||||
{{ formatDate(item.arrivalDate) }}
|
||||
</template>
|
||||
<template #cell-pricePerKg="{ item }">
|
||||
{{ formatPrice(item.pricePerKg) }}
|
||||
</template>
|
||||
<template #cell-finalPrice="{ item }">
|
||||
{{ formatPrice(item.finalPrice) }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Cases' })
|
||||
|
||||
import type { BuildingCaseData } from '~/services/dto/building-case-data'
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
import { useBovineColumns } from '~/composables/useBovineColumns'
|
||||
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
|
||||
|
||||
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)
|
||||
|
||||
interface InventoryStats {
|
||||
total: number
|
||||
over24: number
|
||||
between22And24: number
|
||||
between20And22: number
|
||||
}
|
||||
|
||||
const stats = ref<InventoryStats>({
|
||||
total: 0,
|
||||
over24: 0,
|
||||
between22And24: 0,
|
||||
between20And22: 0
|
||||
})
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!hasCaseId.value) {
|
||||
stats.value = { total: 0, over24: 0, between22And24: 0, between20And22: 0 }
|
||||
return
|
||||
}
|
||||
try {
|
||||
stats.value = await api.get<InventoryStats>('bovines/inventory-stats', {
|
||||
buildingCaseId: caseId.value
|
||||
}, { toast: false })
|
||||
} catch {
|
||||
// silencieux
|
||||
}
|
||||
}
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<BovineData>(
|
||||
'bovines',
|
||||
{
|
||||
'exists[exitedAt]': 'false',
|
||||
buildingCase: '',
|
||||
nationalNumber: '',
|
||||
workNumber: '',
|
||||
'bovineType.label': '',
|
||||
sex: '',
|
||||
'arrivalDate[after]': '',
|
||||
'arrivalDate[strictly_before]': '',
|
||||
'birthDate[after]': '',
|
||||
'birthDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const sexOptions = [
|
||||
{ value: 'M', label: 'Mâle' },
|
||||
{ value: 'F', label: 'Femelle' }
|
||||
]
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const singleDateFilter = (afterKey: string, beforeKey: string) =>
|
||||
computed<string>({
|
||||
get: () => (filters.value[afterKey] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value[afterKey] = ''
|
||||
filters.value[beforeKey] = ''
|
||||
return
|
||||
}
|
||||
filters.value[afterKey] = value
|
||||
filters.value[beforeKey] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
|
||||
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
|
||||
|
||||
const { columns } = useBovineColumns({ variant: 'case' })
|
||||
|
||||
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 formatPrice = (price: number | null) => {
|
||||
if (price === null || price === undefined) return '—'
|
||||
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||
}
|
||||
|
||||
|
||||
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 = (bovine: BovineData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push({
|
||||
path: '/infrastructure/bovine',
|
||||
query: { id: String(bovine.id), caseId: String(caseId.value) }
|
||||
})
|
||||
}
|
||||
|
||||
watch(caseId, (id) => {
|
||||
if (!hasCaseId.value) {
|
||||
filters.value.buildingCase = ''
|
||||
buildingCase.value = null
|
||||
return
|
||||
}
|
||||
filters.value.buildingCase = `/api/building_cases/${id}`
|
||||
loadCase()
|
||||
loadStats()
|
||||
reload()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
315
frontend/pages/inventory.vue
Normal file
315
frontend/pages/inventory.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<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('/')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="font-bold text-3xl uppercase text-primary-500">Inventaire bovins</h1>
|
||||
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
|
||||
<div
|
||||
v-if="auth.isBureau"
|
||||
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
|
||||
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
|
||||
title="Exporter en Excel"
|
||||
@click="showExportModal = true"
|
||||
>
|
||||
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="auth.isBureau"
|
||||
type="button"
|
||||
:disabled="syncing"
|
||||
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 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@click="syncInventory"
|
||||
>
|
||||
<Icon name="mdi:sync" size="28" :class="syncing ? 'animate-spin' : ''" />
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
<div class="flex items-center gap-3 rounded-md bg-red-500 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-white">{{ stats.over24 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-white">≥ 24 mois</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-md bg-orange-500 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-white">{{ stats.between22And24 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-white">22 – 24 mois</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-md bg-yellow-500 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-white">{{ stats.between20And22 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-white">20 – 22 mois</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 mb-8">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
>
|
||||
<template #header-nationalNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.nationalNumber"
|
||||
placeholder="N° National"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-workNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.workNumber"
|
||||
placeholder="N° Travail"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-sex>
|
||||
<UiSelect
|
||||
v-model="filters.sex"
|
||||
placeholder="Sexe"
|
||||
:options="sexOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-birthDate>
|
||||
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
|
||||
</template>
|
||||
<template #header-bovineType.label>
|
||||
<UiTextInput
|
||||
v-model="filters['bovineType.label']"
|
||||
placeholder="Race"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-arrivalDate>
|
||||
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
|
||||
</template>
|
||||
<template #header-buildingCase.building.label>
|
||||
<UiTextInput :model-value="''" placeholder="Bâtiment" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-buildingCase.caseNumber>
|
||||
<UiTextInput :model-value="''" placeholder="Case" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-age>
|
||||
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-pricePerKg>
|
||||
<UiTextInput :model-value="''" placeholder="Prix/kg" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-finalPrice>
|
||||
<UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-birthDate="{ item }">
|
||||
{{ formatDate(item.birthDate) }}
|
||||
</template>
|
||||
<template #cell-age="{ item }">
|
||||
<span
|
||||
class="inline-block rounded px-2 py-0.5 font-semibold"
|
||||
:class="ageBadgeClass(item.ageMonths)"
|
||||
>
|
||||
{{ formatAgeLabel(item.ageMonths) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-arrivalDate="{ item }">
|
||||
{{ formatDate(item.arrivalDate) }}
|
||||
</template>
|
||||
<template #cell-buildingCase.building.label="{ item }">
|
||||
{{ item.effectiveBuilding?.label ?? '—' }}
|
||||
</template>
|
||||
<template #cell-buildingCase.caseNumber="{ item }">
|
||||
{{ item.buildingCase?.caseNumber ?? '—' }}
|
||||
</template>
|
||||
<template #cell-pricePerKg="{ item }">
|
||||
{{ formatPrice(item.pricePerKg) }}
|
||||
</template>
|
||||
<template #cell-finalPrice="{ item }">
|
||||
{{ formatPrice(item.finalPrice) }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
|
||||
<InventoryExportModal
|
||||
v-model="showExportModal"
|
||||
:loading="exporting"
|
||||
@submit="exportInventory"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Inventaire' })
|
||||
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import type { InventoryExportFilters } from '~/components/inventory/inventory-export-modal.vue'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
import { useBovineColumns } from '~/composables/useBovineColumns'
|
||||
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const api = useApi()
|
||||
const toast = useToast()
|
||||
|
||||
interface SyncResult {
|
||||
created: number
|
||||
updated: number
|
||||
exited: number
|
||||
total: number
|
||||
}
|
||||
|
||||
interface InventoryStats {
|
||||
total: number
|
||||
over24: number
|
||||
between22And24: number
|
||||
between20And22: number
|
||||
}
|
||||
|
||||
const stats = ref<InventoryStats>({
|
||||
total: 0,
|
||||
over24: 0,
|
||||
between22And24: 0,
|
||||
between20And22: 0
|
||||
})
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
stats.value = await api.get<InventoryStats>('bovines/inventory-stats', {}, { toast: false })
|
||||
} catch {
|
||||
// silencieux : l'écran reste utilisable sans la légende
|
||||
}
|
||||
}
|
||||
|
||||
const syncing = ref(false)
|
||||
const exporting = ref(false)
|
||||
const showExportModal = ref(false)
|
||||
|
||||
const exportInventory = async (filters: InventoryExportFilters) => {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (filters.ageRanges.length > 0) {
|
||||
query.ageRanges = filters.ageRanges.join(',')
|
||||
}
|
||||
const blob = await api.getBlob('bovines/inventory-export', query)
|
||||
const filename = `inventaire_bovins_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
showExportModal.value = false
|
||||
} catch {
|
||||
// toast déjà géré par useApi onResponseError
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const syncInventory = async () => {
|
||||
if (syncing.value) return
|
||||
const confirmed = window.confirm(
|
||||
"Lancer la synchronisation avec EDNOTIF ?\n\nLes bovins absents de la réponse seront marqués comme sortis."
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
syncing.value = true
|
||||
try {
|
||||
const result = await api.post<SyncResult>('bovines/sync-inventory')
|
||||
toast.success({
|
||||
title: 'Inventaire synchronisé',
|
||||
message: `Créés : ${result.created} · Mis à jour : ${result.updated} · Sortis : ${result.exited} · Total EDNOTIF : ${result.total}`
|
||||
})
|
||||
reload()
|
||||
loadStats()
|
||||
} catch {
|
||||
// error toast already handled by useApi onResponseError
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<BovineData>(
|
||||
'bovines',
|
||||
{
|
||||
'exists[exitedAt]': 'false',
|
||||
'exists[ednotifConfirmedAt]': 'true',
|
||||
nationalNumber: '',
|
||||
workNumber: '',
|
||||
'bovineType.label': '',
|
||||
sex: '',
|
||||
'arrivalDate[after]': '',
|
||||
'arrivalDate[strictly_before]': '',
|
||||
'birthDate[after]': '',
|
||||
'birthDate[strictly_before]': ''
|
||||
}
|
||||
)
|
||||
|
||||
const sexOptions = [
|
||||
{ value: 'M', label: 'Mâle' },
|
||||
{ value: 'F', label: 'Femelle' }
|
||||
]
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const singleDateFilter = (afterKey: string, beforeKey: string) =>
|
||||
computed<string>({
|
||||
get: () => (filters.value[afterKey] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value[afterKey] = ''
|
||||
filters.value[beforeKey] = ''
|
||||
return
|
||||
}
|
||||
filters.value[afterKey] = value
|
||||
filters.value[beforeKey] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
|
||||
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
|
||||
|
||||
const { columns } = useBovineColumns()
|
||||
|
||||
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 formatPrice = (price: number | null) => {
|
||||
if (price === null || price === undefined) return '—'
|
||||
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
reload()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
@@ -16,6 +16,7 @@
|
||||
<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"
|
||||
>
|
||||
@@ -39,19 +40,21 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<UiButton
|
||||
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
|
||||
</button>
|
||||
</UiButton>
|
||||
<p class="font-bold">v{{ version }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Connexion' })
|
||||
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { getUsers } from '~/services/auth'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
@@ -1,91 +1,104 @@
|
||||
<template>
|
||||
<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 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>
|
||||
<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 &&
|
||||
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/>
|
||||
<ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/>
|
||||
<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 &&
|
||||
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"/>
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useReceptionStore} from '~/stores/reception'
|
||||
import {storeToRefs} from 'pinia'
|
||||
import {RECEPTION_STEP_LABELS} from '~/constants/steps'
|
||||
import {RECEPTION_TYPE_CODES} from "~/utils/constants";
|
||||
useHead({ title: 'Réception' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
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 { current: storeReception } = storeToRefs(receptionStore)
|
||||
const receptionFormRef = ref<{ saveDraft: () => Promise<void>, validateFields: () => boolean } | null>(null)
|
||||
const grossWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
|
||||
const tareWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
|
||||
|
||||
const resolveReceptionId = (param: unknown) => {
|
||||
const idStr = Array.isArray(param) ? param[0] : param
|
||||
if (!idStr) {
|
||||
return null
|
||||
}
|
||||
const id = Number(idStr)
|
||||
return Number.isFinite(id) ? id : null
|
||||
}
|
||||
const { stepLabels, handleStepSelect } = useWorkflowSteps(receptionConfig, receptionStore)
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (param) => {
|
||||
const id = resolveReceptionId(param)
|
||||
if (id === null) {
|
||||
receptionStore.clearCurrent()
|
||||
return
|
||||
}
|
||||
await receptionStore.loadReception(id)
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
const router = useRouter()
|
||||
|
||||
const saveAndHold = async () => {
|
||||
if (!receptionStore.current) {
|
||||
await router.push('/')
|
||||
return
|
||||
if (receptionFormRef.value) {
|
||||
if (!receptionFormRef.value.validateFields()) return
|
||||
await receptionFormRef.value.saveDraft()
|
||||
} else {
|
||||
if (grossWeightRef.value) await grossWeightRef.value.saveWeightDraft()
|
||||
if (tareWeightRef.value) await tareWeightRef.value.saveWeightDraft()
|
||||
}
|
||||
|
||||
await 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)
|
||||
}
|
||||
// Init route watcher
|
||||
const route = useRoute()
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (param) => {
|
||||
const idStr = Array.isArray(param) ? param[0] : param
|
||||
if (!idStr) {
|
||||
receptionStore.clearCurrent()
|
||||
return
|
||||
}
|
||||
const id = Number(idStr)
|
||||
if (Number.isFinite(id)) {
|
||||
await receptionStore.loadReception(id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,58 +1,164 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-start 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 finie</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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</div>
|
||||
<div>Fournisseur</div>
|
||||
<div>Adresse</div>
|
||||
<div>Type réception</div>
|
||||
<div>Poids</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="reception in receptionList"
|
||||
:key="reception.id"
|
||||
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="goToReception(reception.id)"
|
||||
<div class="px-[86px]">
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToReception"
|
||||
>
|
||||
<div>{{ reception.identificationNumber}}</div>
|
||||
<div>{{ reception.receptionDate}}</div>
|
||||
<div>{{ reception.supplier?.name }}</div>
|
||||
<div>{{ reception.address?.fullAddress }}</div>
|
||||
<div>{{ reception.receptionType?.label }}</div>
|
||||
<div>{{ formatWeighing(reception, 'gross') }} | {{ formatWeighing(reception, 'tare') }}</div>
|
||||
</div>
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionDate>
|
||||
<UiDateMaskedInput
|
||||
v-model="receptionDateFilter"
|
||||
placeholder="Date"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-supplier.name>
|
||||
<UiTextInput
|
||||
v-model="filters['supplier.name']"
|
||||
placeholder="Fournisseur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-address.fullAddress>
|
||||
<UiTextInput
|
||||
:model-value="''"
|
||||
placeholder="Adresse"
|
||||
size="compact"
|
||||
disabled
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionType.label>
|
||||
<UiSelect
|
||||
v-model="filters['receptionType.id']"
|
||||
placeholder="Type réception"
|
||||
:options="receptionTypeOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-weighing>
|
||||
<UiTextInput
|
||||
:model-value="''"
|
||||
placeholder="Poids"
|
||||
size="compact"
|
||||
disabled
|
||||
/>
|
||||
</template>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #cell-weighing="{ item }">
|
||||
{{ formatWeighing(item) }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {ReceptionData} from "~/services/dto/reception-data";
|
||||
import {getReceptionList} from "~/services/reception";
|
||||
useHead({ title: 'Validation réception' })
|
||||
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||
import { getReceptionTypeList } from '~/services/reception-type'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const receptionList = ref<ReceptionData[]>()
|
||||
const router = useRouter()
|
||||
const receptionTypes = ref<ReceptionTypeData[]>([])
|
||||
|
||||
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 `${entry.weight} kg`
|
||||
const receptionTypeOptions = computed(() =>
|
||||
receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label }))
|
||||
)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
isValid: true,
|
||||
'identificationNumber': '',
|
||||
'supplier.name': '',
|
||||
'receptionType.id': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const goToReception = (id: number) => {
|
||||
router.push(`/reception/update/${id}`)
|
||||
const receptionDateFilter = computed<string>({
|
||||
get: () => (filters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['receptionDate[after]'] = ''
|
||||
filters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['receptionDate[after]'] = value
|
||||
filters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'receptionDate', label: 'Date', width: '120px' },
|
||||
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||
{ key: 'receptionType.label', label: 'Type réception', width: '0.9fr' },
|
||||
{ key: 'weighing', label: 'Poids', width: '82px' }
|
||||
]
|
||||
|
||||
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) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return `${gross - tare} kg`
|
||||
}
|
||||
|
||||
const goToReception = (reception: ReceptionData) => {
|
||||
router.push(`/reception/update/${reception.id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
receptionList.value = await getReceptionList(true)
|
||||
receptionTypes.value = await getReceptionTypeList()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,166 @@
|
||||
<template>
|
||||
<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 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 en attente</h1>
|
||||
</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 class="px-[86px]">
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
:show-actions="auth.isAdmin"
|
||||
row-clickable
|
||||
@row-click="goToReception"
|
||||
>
|
||||
<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>
|
||||
<template #header-receptionDate>
|
||||
<UiDateMaskedInput v-model="receptionDateFilter" placeholder="Date" size="compact" />
|
||||
</template>
|
||||
<template #header-supplier.name>
|
||||
<UiTextInput
|
||||
v-model="filters['supplier.name']"
|
||||
placeholder="Fournisseur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-address.fullAddress>
|
||||
<UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-receptionType.label>
|
||||
<UiSelect
|
||||
v-model="filters['receptionType.id']"
|
||||
placeholder="Type réception"
|
||||
:options="receptionTypeOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-carrier.name>
|
||||
<UiTextInput
|
||||
v-model="filters['carrier.name']"
|
||||
placeholder="Transporteur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-licensePlate>
|
||||
<UiTextInput
|
||||
v-model="filters['licensePlate']"
|
||||
placeholder="Immatriculation"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<UiTextInput :model-value="''" placeholder="Actions" size="compact" disabled />
|
||||
</template>
|
||||
<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>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {ReceptionData} from "~/services/dto/reception-data";
|
||||
import {getReceptionList} from "~/services/reception";
|
||||
useHead({ title: 'Réceptions en attente' })
|
||||
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||
import { deleteReception } from '~/services/reception'
|
||||
import { getReceptionTypeList } from '~/services/reception-type'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const receptionList = ref<ReceptionData[]>()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const receptionTypes = ref<ReceptionTypeData[]>([])
|
||||
|
||||
const goToReception = (id: number) => {
|
||||
router.push(`/reception/${id}`)
|
||||
const receptionTypeOptions = computed(() =>
|
||||
receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label }))
|
||||
)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
isValid: false,
|
||||
'supplier.name': '',
|
||||
'carrier.name': '',
|
||||
'licensePlate': '',
|
||||
'receptionType.id': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const receptionDateFilter = computed<string>({
|
||||
get: () => (filters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['receptionDate[after]'] = ''
|
||||
filters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['receptionDate[after]'] = value
|
||||
filters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'receptionDate', label: 'Date', width: '120px' },
|
||||
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||
{ key: 'receptionType.label', label: 'Type réception', width: '1.1fr' },
|
||||
{ key: 'carrier.name', label: 'Transporteur' },
|
||||
{ key: 'licensePlate', label: 'Immatriculation', width: '110px' }
|
||||
]
|
||||
|
||||
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 goToReception = (reception: ReceptionData) => {
|
||||
router.push(`/reception/${reception.id}`)
|
||||
}
|
||||
|
||||
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)
|
||||
reload()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
receptionList.value = await getReceptionList(false)
|
||||
receptionTypes.value = await getReceptionTypeList()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
231
frontend/pages/scan.vue
Normal file
231
frontend/pages/scan.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<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">
|
||||
useHead({ title: 'Scanner' })
|
||||
|
||||
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>
|
||||
@@ -3,80 +3,98 @@
|
||||
<div class="flex justify-between h-[52px] mb-[80px]">
|
||||
<div class="flex flex-1 mr-16">
|
||||
<UiStepper
|
||||
:labels="SHIPMENT_STEP_LABELS"
|
||||
:labels="stepLabels"
|
||||
:current-step="storeShipment?.currentStep ?? 0"
|
||||
@select="handleStepSelect"
|
||||
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
<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
|
||||
</button>
|
||||
</UiButton>
|
||||
</div>
|
||||
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
|
||||
<ShipmentWeight v-if="storeShipment?.currentStep === 1" mode="gross"/>
|
||||
<ShipmentWeight v-if="storeShipment?.currentStep >= 2" mode="tare"/>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
import {SHIPMENT_STEP_LABELS} from "~/constants/steps";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useShipmentStore} from "~/stores/shipment";
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Expédition' })
|
||||
|
||||
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 { ref, watch } from 'vue'
|
||||
|
||||
const shipmentStore = useShipmentStore()
|
||||
const {current: storeShipment} = storeToRefs(shipmentStore)
|
||||
const shipmentFormRef = ref<{ saveDraft: () => Promise<void> } | null>(null)
|
||||
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 route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
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 (
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (param) => {
|
||||
const id = resolveShipmentId(param)
|
||||
if (id === null) {
|
||||
const idStr = Array.isArray(param) ? param[0] : param
|
||||
if (!idStr) {
|
||||
shipmentStore.clearCurrent()
|
||||
return
|
||||
}
|
||||
await shipmentStore.loadShipment(id)
|
||||
const id = Number(idStr)
|
||||
if (Number.isFinite(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>
|
||||
|
||||
@@ -1,81 +1,172 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-start 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 finie</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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</div>
|
||||
<div>Client</div>
|
||||
<div>Adresse</div>
|
||||
<div>Type d'expéditon</div>
|
||||
<div>Poids</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="shipment in shipmentList"
|
||||
:key="shipment
|
||||
.id"
|
||||
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="goToshipment(shipment.id)"
|
||||
<div class="px-[86px]">
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToShipment"
|
||||
>
|
||||
<div>{{ shipment.identificationNumber }}</div>
|
||||
<div>{{ shipment.shipmentDate }}</div>
|
||||
<div>{{ shipment.customer?.label }}</div>
|
||||
<div>{{ shipment.address?.fullAddress }}</div>
|
||||
<div>
|
||||
<template v-if="formatBovinShipmentLines(shipment).length">
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-shipmentDate>
|
||||
<UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
|
||||
</template>
|
||||
<template #header-customer.name>
|
||||
<UiTextInput
|
||||
v-model="filters['customer.name']"
|
||||
placeholder="Client"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-address.fullAddress>
|
||||
<UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-shipmentType.label>
|
||||
<UiSelect
|
||||
v-model="filters['shipmentType.id']"
|
||||
placeholder="Type d'expédition"
|
||||
:options="shipmentTypeOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-weighing>
|
||||
<UiTextInput :model-value="''" placeholder="Poids" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-shipmentDate="{ item }">
|
||||
{{ formatDate(item.shipmentDate) }}
|
||||
</template>
|
||||
<template #cell-shipmentType.label="{ item }">
|
||||
<template v-if="formatShipmentLines(item).length">
|
||||
<div
|
||||
v-for="(line, index) in formatBovinShipmentLines(shipment)"
|
||||
v-for="(line, index) in formatShipmentLines(item)"
|
||||
:key="index"
|
||||
class="leading-5"
|
||||
>
|
||||
{{ line }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div>{{ formatWeighing(shipment, 'gross') }} | {{ formatWeighing(shipment, 'tare') }}</div>
|
||||
</div>
|
||||
<template v-else>—</template>
|
||||
</template>
|
||||
<template #cell-weighing="{ item }">
|
||||
{{ formatWeighing(item) }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {ShipmentData} from "~/services/dto/shipment-data";
|
||||
import {getShipmentList} from "~/services/shipment";
|
||||
useHead({ title: 'Validation expédition' })
|
||||
|
||||
import type { ShipmentData } from '~/services/dto/shipment-data'
|
||||
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||
import { getShipmentTypeList } from '~/services/shipment-type'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const shipmentList = ref<ShipmentData[]>()
|
||||
const router = useRouter()
|
||||
const shipmentTypes = ref<ShipmentTypeData[]>([])
|
||||
|
||||
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 `${entry.weight} kg`
|
||||
const shipmentTypeOptions = computed(() =>
|
||||
shipmentTypes.value.map(st => ({ value: st.id, label: st.label }))
|
||||
)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<ShipmentData>(
|
||||
'shipments',
|
||||
{
|
||||
isValid: true,
|
||||
'identificationNumber': '',
|
||||
'customer.name': '',
|
||||
'shipmentType.id': '',
|
||||
'shipmentDate[after]': '',
|
||||
'shipmentDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const formatBovinShipmentLines = (shipment: ShipmentData) => {
|
||||
if (!shipment.bovinShipments?.length) {
|
||||
return []
|
||||
const shipmentDateFilter = computed<string>({
|
||||
get: () => (filters.value['shipmentDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['shipmentDate[after]'] = ''
|
||||
filters.value['shipmentDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['shipmentDate[after]'] = value
|
||||
filters.value['shipmentDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
return shipment.bovinShipments.map((entry) => {
|
||||
const label = typeof entry.shipmentType === 'string'
|
||||
? entry.shipmentType
|
||||
: entry.shipmentType?.label
|
||||
return `${label ?? '—'} : ${entry.nbBovinSend ?? '—'}`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'shipmentDate', label: 'Date', width: '120px' },
|
||||
{ key: 'customer.name', label: 'Client', width: '1.5fr' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||
{ key: 'shipmentType.label', label: "Type d'expédition", width: '1.1fr' },
|
||||
{ key: 'weighing', label: 'Poids', width: '82px' }
|
||||
]
|
||||
|
||||
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/update/${id}`)
|
||||
const formatShipmentLines = (shipment: ShipmentData) => {
|
||||
if (!shipment.shipmentType && shipment.nbBovinSend == null) {
|
||||
return []
|
||||
}
|
||||
const label = typeof shipment.shipmentType === 'string'
|
||||
? shipment.shipmentType
|
||||
: shipment.shipmentType?.label
|
||||
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
|
||||
}
|
||||
|
||||
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) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return `${gross - tare} kg`
|
||||
}
|
||||
|
||||
const goToShipment = (shipment: ShipmentData) => {
|
||||
router.push(`/shipment/update/${shipment.id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
shipmentList.value = await getShipmentList(true)
|
||||
shipmentTypes.value = await getShipmentTypeList()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
677
frontend/pages/shipment/update/[[id]].vue
Normal file
677
frontend/pages/shipment/update/[[id]].vue
Normal file
@@ -0,0 +1,677 @@
|
||||
<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">
|
||||
useHead({ title: 'Modifier expédition' })
|
||||
|
||||
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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user