Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc9b7855f | ||
| b61321c7b7 | |||
|
|
b130d44054 | ||
| dfa29ffc7a | |||
|
|
cde2c4fbb7 | ||
| 5552d98935 | |||
|
|
9e67a5e289 | ||
| 92289f9cb2 | |||
|
|
59418f2c66 | ||
| e1c9e25187 | |||
| 0b22574932 | |||
|
|
9115699f96 | ||
| 178b4e4eee | |||
|
|
fbc8365405 | ||
| 4561467532 | |||
| c441420522 | |||
| ba9ea2de71 | |||
|
|
961fa63f3d | ||
| bebfabcacc | |||
|
|
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 |
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"WebFetch(domain:geo.api.gouv.fr)",
|
||||||
|
"Bash(pip3 install:*)",
|
||||||
|
"Bash(python3 -c \":*)",
|
||||||
|
"Bash(make cache-clear *)",
|
||||||
|
"Bash(make test *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd frontend
|
||||||
npm ci
|
npm ci
|
||||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
|
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ NUXT_PUBLIC_GEO_API_BASE=https://geo.api.gouv.fr npm run generate
|
||||||
test -f .output/public/index.html
|
test -f .output/public/index.html
|
||||||
|
|
||||||
- name: Build artefact
|
- name: Build artefact
|
||||||
|
|||||||
19
.idea/dataSources.xml
generated
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>
|
|
||||||
12
.idea/db-forest-config.xml
generated
12
.idea/db-forest-config.xml
generated
@@ -1,6 +1,16 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="db-forest-configuration">
|
||||||
|
<data version="2">.
|
||||||
|
----------------------------------------
|
||||||
|
1:0:9cad43df-2147-4989-b7a4-443067034884
|
||||||
|
2:0:ae622167-c834-4e7b-87a5-c1721036f5dc
|
||||||
|
3:0:f407a514-c6b4-4b26-9555-445a85892502
|
||||||
|
4:0:09e221b8-067a-488b-9c1d-4e155a333079
|
||||||
|
5:0:9d8c1ad3-2491-4642-964a-666003c14128
|
||||||
|
.</data>
|
||||||
|
</component>
|
||||||
<component name="db-tree-configuration">
|
<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>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
5
.idea/ferme.iml
generated
5
.idea/ferme.iml
generated
@@ -155,6 +155,11 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/maennchen/zipstream-php" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/markbaker/complex" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/markbaker/matrix" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/phpoffice/phpspreadsheet" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/simple-cache" />
|
||||||
<excludePattern pattern="reference.php" />
|
<excludePattern pattern="reference.php" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
|
|||||||
5
.idea/php.xml
generated
5
.idea/php.xml
generated
@@ -174,6 +174,11 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
|
||||||
</include_path>
|
</include_path>
|
||||||
</component>
|
</component>
|
||||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||||
|
|||||||
689
.idea/workspace.xml
generated
689
.idea/workspace.xml
generated
@@ -4,11 +4,16 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : corrections diverses">
|
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : label age bovin">
|
||||||
|
<change beforePath="$PROJECT_DIR$/.claude/settings.local.json" beforeDir="false" afterPath="$PROJECT_DIR$/.claude/settings.local.json" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/db-forest-config.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/db-forest-config.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/ferme.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/ferme.iml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/php.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/php.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/frontend/pages/infrastructure/case.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/infrastructure/case.vue" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/frontend/pages/bovine/[id].vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/bovine/[id].vue" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/Entity/BuildingCase.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/BuildingCase.php" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/Entity/BovineMovement.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/BovineMovement.php" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/State/Bovin/BovineMovementProcessor.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/State/Bovin/BovineMovementProcessor.php" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@@ -31,16 +36,16 @@
|
|||||||
<component name="FileTemplateManagerImpl">
|
<component name="FileTemplateManagerImpl">
|
||||||
<option name="RECENT_TEMPLATES">
|
<option name="RECENT_TEMPLATES">
|
||||||
<list>
|
<list>
|
||||||
<option value="Vue Composition API Component" />
|
|
||||||
<option value="TypeScript File" />
|
<option value="TypeScript File" />
|
||||||
<option value="PHP File" />
|
<option value="PHP File" />
|
||||||
|
<option value="Vue Composition API Component" />
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
<component name="Git.Settings">
|
<component name="Git.Settings">
|
||||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||||
<map>
|
<map>
|
||||||
<entry key="$PROJECT_DIR$" value="fit/332-refonte-reception-terminee" />
|
<entry key="$PROJECT_DIR$" value="feat/entree-sortie" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
@@ -212,6 +217,11 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
|
||||||
</include_path>
|
</include_path>
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectColorInfo">{
|
<component name="ProjectColorInfo">{
|
||||||
@@ -231,14 +241,16 @@
|
|||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"git-widget-placeholder": "feat/278-plan-du-site",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
|
"git-widget-placeholder": "feat/vie-du-bovin",
|
||||||
|
"git.auto.fetch.suggestion.counter": "3",
|
||||||
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
|
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.detected.package.tslint": "true",
|
"node.js.detected.package.tslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"nodejs_package_manager_path": "npm",
|
"nodejs_package_manager_path": "npm",
|
||||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
"settings.editor.selected.configurable": "advanced.settings",
|
||||||
"ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external",
|
"ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external",
|
||||||
"vue.rearranger.settings.migration": "true"
|
"vue.rearranger.settings.migration": "true"
|
||||||
},
|
},
|
||||||
@@ -257,25 +269,27 @@
|
|||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="CopyFile.RECENT_KEYS">
|
<key name="CopyFile.RECENT_KEYS">
|
||||||
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
|
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
|
||||||
|
<recent name="$PROJECT_DIR$/frontend/components/commun" />
|
||||||
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\pages\shipment" />
|
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\pages\shipment" />
|
||||||
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\composables" />
|
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\composables" />
|
||||||
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\components\shipment" />
|
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\components\shipment" />
|
||||||
</key>
|
</key>
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
<key name="MoveFile.RECENT_KEYS">
|
||||||
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
|
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
|
||||||
|
<recent name="C:\Users\m-tristan\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
|
||||||
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\templates" />
|
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\templates" />
|
||||||
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
|
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
|
||||||
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches\Ferme_MCD\MCD_DOC" />
|
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches\Ferme_MCD\MCD_DOC" />
|
||||||
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\frontend\pages\reception" />
|
|
||||||
</key>
|
</key>
|
||||||
</component>
|
</component>
|
||||||
<component name="SharedIndexes">
|
<component name="SharedIndexes">
|
||||||
<attachedChunks>
|
<attachedChunks>
|
||||||
<set>
|
<set>
|
||||||
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.30387.85" />
|
<option value="bundled-php-predefined-a98d8de5180a-022fa7b8ab75-com.jetbrains.php.sharedIndexes-PS-261.23567.149" />
|
||||||
</set>
|
</set>
|
||||||
</attachedChunks>
|
</attachedChunks>
|
||||||
</component>
|
</component>
|
||||||
|
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
|
||||||
<component name="TaskManager">
|
<component name="TaskManager">
|
||||||
<task active="true" id="Default" summary="Default task">
|
<task active="true" id="Default" summary="Default task">
|
||||||
<changelist id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="" />
|
<changelist id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="" />
|
||||||
@@ -310,294 +324,30 @@
|
|||||||
<workItem from="1770879701502" duration="25805000" />
|
<workItem from="1770879701502" duration="25805000" />
|
||||||
<workItem from="1770966186589" duration="914000" />
|
<workItem from="1770966186589" duration="914000" />
|
||||||
<workItem from="1770967274060" duration="2388000" />
|
<workItem from="1770967274060" duration="2388000" />
|
||||||
</task>
|
<workItem from="1772466451823" duration="598000" />
|
||||||
<task id="LOCAL-00007" summary="test : ajout de TU sur les services et providers">
|
<workItem from="1772626984813" duration="969000" />
|
||||||
<option name="closed" value="true" />
|
<workItem from="1772786360430" duration="21000" />
|
||||||
<created>1768318921478</created>
|
<workItem from="1772786475316" duration="3016000" />
|
||||||
<option name="number" value="00007" />
|
<workItem from="1773049125640" duration="406000" />
|
||||||
<option name="presentableId" value="LOCAL-00007" />
|
<workItem from="1773049540928" duration="539000" />
|
||||||
<option name="project" value="LOCAL" />
|
<workItem from="1773050154207" duration="1879000" />
|
||||||
<updated>1768318921478</updated>
|
<workItem from="1773212999001" duration="652000" />
|
||||||
</task>
|
<workItem from="1773215356754" duration="5754000" />
|
||||||
<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">
|
<workItem from="1773756072697" duration="5450000" />
|
||||||
<option name="closed" value="true" />
|
<workItem from="1773766075191" duration="6202000" />
|
||||||
<created>1768498751836</created>
|
<workItem from="1773824491213" duration="24805000" />
|
||||||
<option name="number" value="00008" />
|
<workItem from="1774275549972" duration="51000" />
|
||||||
<option name="presentableId" value="LOCAL-00008" />
|
<workItem from="1774276665015" duration="33750000" />
|
||||||
<option name="project" value="LOCAL" />
|
<workItem from="1776755742205" duration="88521000" />
|
||||||
<updated>1768498751836</updated>
|
<workItem from="1777453284124" duration="86000" />
|
||||||
</task>
|
<workItem from="1777453433907" duration="337000" />
|
||||||
<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">
|
<workItem from="1777454070632" duration="17254000" />
|
||||||
<option name="closed" value="true" />
|
<workItem from="1777540415843" duration="13205000" />
|
||||||
<created>1768555180530</created>
|
<workItem from="1777877316149" duration="29389000" />
|
||||||
<option name="number" value="00009" />
|
<workItem from="1777982616362" duration="23909000" />
|
||||||
<option name="presentableId" value="LOCAL-00009" />
|
<workItem from="1778482021120" duration="1280000" />
|
||||||
<option name="project" value="LOCAL" />
|
<workItem from="1778656317630" duration="279000" />
|
||||||
<updated>1768555180530</updated>
|
<workItem from="1778664396844" duration="2576000" />
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00010" summary="feat : ajout de l'authentification avec lexik">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1768832208350</created>
|
|
||||||
<option name="number" value="00010" />
|
|
||||||
<option name="presentableId" value="LOCAL-00010" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1768832208350</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00011" summary="feat : update du CHANGELOG.md">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1768832516587</created>
|
|
||||||
<option name="number" value="00011" />
|
|
||||||
<option name="presentableId" value="LOCAL-00011" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1768832516587</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00012" summary="fix : correction de l'accès au swagger en mode dev qui n'était plus accessible">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1768940104944</created>
|
|
||||||
<option name="number" value="00012" />
|
|
||||||
<option name="presentableId" value="LOCAL-00012" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1768940104944</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00013" summary="feat : ajout de la conf pour le déploiement en recette">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769005220331</created>
|
|
||||||
<option name="number" value="00013" />
|
|
||||||
<option name="presentableId" value="LOCAL-00013" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769005220331</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00014" summary="fix : fix de la conf pour le déploiement en recette">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769008700008</created>
|
|
||||||
<option name="number" value="00014" />
|
|
||||||
<option name="presentableId" value="LOCAL-00014" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769008700008</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00015" summary="fix : fix de la conf pour le déploiement en recette">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769014602062</created>
|
|
||||||
<option name="number" value="00015" />
|
|
||||||
<option name="presentableId" value="LOCAL-00015" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769014602062</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00016" summary="fix : migration apache vers nginx pour un déploiement plus simple">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769019284586</created>
|
|
||||||
<option name="number" value="00016" />
|
|
||||||
<option name="presentableId" value="LOCAL-00016" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769019284586</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00017" summary="fix : dernière modification pour le déploiement en recette et le changement de conf vers nginx">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769021756823</created>
|
|
||||||
<option name="number" value="00017" />
|
|
||||||
<option name="presentableId" value="LOCAL-00017" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769021756823</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00018" summary="ci : auto tag + release artefact">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769021818384</created>
|
|
||||||
<option name="number" value="00018" />
|
|
||||||
<option name="presentableId" value="LOCAL-00018" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769021818384</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00019" summary="ci : fix release artefact">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769022071620</created>
|
|
||||||
<option name="number" value="00019" />
|
|
||||||
<option name="presentableId" value="LOCAL-00019" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769022071620</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00020" summary="ci : fix release artefact">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769024603812</created>
|
|
||||||
<option name="number" value="00020" />
|
|
||||||
<option name="presentableId" value="LOCAL-00020" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769024603812</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00021" summary="ci : ajout du script et de la doc déploiement">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769026716634</created>
|
|
||||||
<option name="number" value="00021" />
|
|
||||||
<option name="presentableId" value="LOCAL-00021" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769026716634</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00022" summary="fix : correction du path URI pour la création d'un poids dans une réception">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769073690382</created>
|
|
||||||
<option name="number" value="00022" />
|
|
||||||
<option name="presentableId" value="LOCAL-00022" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769073690382</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00023" summary="feat : Ajout du bundle Monolog pour la gestion des logs">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769075990984</created>
|
|
||||||
<option name="number" value="00023" />
|
|
||||||
<option name="presentableId" value="LOCAL-00023" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769075990984</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00024" summary="fix : affiche plus détail dans les logs en recette/prod">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769077633390</created>
|
|
||||||
<option name="number" value="00024" />
|
|
||||||
<option name="presentableId" value="LOCAL-00024" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769077633390</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00025" summary="fix : modification du script de déploiement pour corriger le problème d'écriture des logs de prod">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769079030808</created>
|
|
||||||
<option name="number" value="00025" />
|
|
||||||
<option name="presentableId" value="LOCAL-00025" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769079030808</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00026" summary="fix : doc de déploiement">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769094376813</created>
|
|
||||||
<option name="number" value="00026" />
|
|
||||||
<option name="presentableId" value="LOCAL-00026" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769094376813</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00027" summary="fix : doc et script de déploiement">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769096187792</created>
|
|
||||||
<option name="number" value="00027" />
|
|
||||||
<option name="presentableId" value="LOCAL-00027" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769096187792</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00028" summary="fix : doc et script de déploiement">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769097091268</created>
|
|
||||||
<option name="number" value="00028" />
|
|
||||||
<option name="presentableId" value="LOCAL-00028" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769097091268</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00029" summary="fix : gitea workflow">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769097476629</created>
|
|
||||||
<option name="number" value="00029" />
|
|
||||||
<option name="presentableId" value="LOCAL-00029" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769097476629</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00030" summary="fix : script de déploiement">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769098182184</created>
|
|
||||||
<option name="number" value="00030" />
|
|
||||||
<option name="presentableId" value="LOCAL-00030" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769098182184</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00031" summary="feat : ajout plus d'information sur la liste des réceptions côté front sur la page d'accueil">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769098861988</created>
|
|
||||||
<option name="number" value="00031" />
|
|
||||||
<option name="presentableId" value="LOCAL-00031" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769098861988</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00032" summary="fix : redirige sur le login sur une 401 et reset du auth state + doc + timeout du toaster">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769100048933</created>
|
|
||||||
<option name="number" value="00032" />
|
|
||||||
<option name="presentableId" value="LOCAL-00032" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769100048933</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00033" summary="feat : ajout de la debug bar en mod dev">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769177611987</created>
|
|
||||||
<option name="number" value="00033" />
|
|
||||||
<option name="presentableId" value="LOCAL-00033" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769177611987</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00034" summary="feat : ajout du bundle Malio ednotif pour l'utilisation des WS">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769184861047</created>
|
|
||||||
<option name="number" value="00034" />
|
|
||||||
<option name="presentableId" value="LOCAL-00034" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769184861047</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00035" summary="fix : modification de la conf du bundle ednotif">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769434793487</created>
|
|
||||||
<option name="number" value="00035" />
|
|
||||||
<option name="presentableId" value="LOCAL-00035" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769434793487</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00036" summary="feat : update du CHANGELOG.md">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769435038236</created>
|
|
||||||
<option name="number" value="00036" />
|
|
||||||
<option name="presentableId" value="LOCAL-00036" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769435038236</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 "Réception" (formulaire)">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769529522614</created>
|
|
||||||
<option name="number" value="00037" />
|
|
||||||
<option name="presentableId" value="LOCAL-00037" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769529522614</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00038" summary="feat : ajout du numéro identification des receptions et ajustement du bon de reception">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769676223697</created>
|
|
||||||
<option name="number" value="00038" />
|
|
||||||
<option name="presentableId" value="LOCAL-00038" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769676223697</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00039" summary="feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769700808988</created>
|
|
||||||
<option name="number" value="00039" />
|
|
||||||
<option name="presentableId" value="LOCAL-00039" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769700808988</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00040" summary="feat : mise en place de composant UI pour les select, checkbox, date, text">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769705141157</created>
|
|
||||||
<option name="number" value="00040" />
|
|
||||||
<option name="presentableId" value="LOCAL-00040" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769705141157</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00041" summary="feat : update CHANGELOG.md">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769705240487</created>
|
|
||||||
<option name="number" value="00041" />
|
|
||||||
<option name="presentableId" value="LOCAL-00041" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769705240487</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00042" summary="feat : ajout de commentaire">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769760766200</created>
|
|
||||||
<option name="number" value="00042" />
|
|
||||||
<option name="presentableId" value="LOCAL-00042" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769760766200</updated>
|
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00043" summary="fix : correction de l'affichage de l'immatriculation sur une réception en cours + correction css étape 3 d'une réception">
|
<task id="LOCAL-00043" summary="fix : correction de l'affichage de l'immatriculation sur une réception en cours + correction css étape 3 d'une réception">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
@@ -703,7 +453,295 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1770969471135</updated>
|
<updated>1770969471135</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="56" />
|
<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>
|
||||||
|
<task id="LOCAL-00086" summary="fix : update icon entrée/sortie">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1777896558092</created>
|
||||||
|
<option name="number" value="00086" />
|
||||||
|
<option name="presentableId" value="LOCAL-00086" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1777896558092</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00087" summary="fix : wording">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1777983048277</created>
|
||||||
|
<option name="number" value="00087" />
|
||||||
|
<option name="presentableId" value="LOCAL-00087" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1777983048278</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00088" summary="fix : wording">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1777983581324</created>
|
||||||
|
<option name="number" value="00088" />
|
||||||
|
<option name="presentableId" value="LOCAL-00088" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1777983581324</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00089" summary="feat : update CHANGELOG.md">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1778073247660</created>
|
||||||
|
<option name="number" value="00089" />
|
||||||
|
<option name="presentableId" value="LOCAL-00089" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1778073247660</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00090" summary="feat : amélioration du tableau bovin">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1778135981350</created>
|
||||||
|
<option name="number" value="00090" />
|
||||||
|
<option name="presentableId" value="LOCAL-00090" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1778135981350</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00091" summary="fix : label age bovin">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1778136373027</created>
|
||||||
|
<option name="number" value="00091" />
|
||||||
|
<option name="presentableId" value="LOCAL-00091" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1778136373027</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="92" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -753,32 +791,32 @@
|
|||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
<component name="VcsManagerConfiguration">
|
<component name="VcsManagerConfiguration">
|
||||||
<MESSAGE value="fix : gitea workflow" />
|
<MESSAGE value="fix : espacement" />
|
||||||
<MESSAGE value="fix : script de déploiement" />
|
<MESSAGE value="fix : text" />
|
||||||
<MESSAGE value="feat : ajout plus d'information sur la liste des réceptions côté front sur la page d'accueil" />
|
<MESSAGE value="feat : front page admin bovin et changelog" />
|
||||||
<MESSAGE value="fix : redirige sur le login sur une 401 et reset du auth state + doc + timeout du toaster" />
|
<MESSAGE value="fix : on ne bloque plus le poids max d'une pesée" />
|
||||||
<MESSAGE value="feat : ajout de la debug bar en mod dev" />
|
<MESSAGE value="feat : ajout de supplier dans la feed et fixtures" />
|
||||||
<MESSAGE value="feat : ajout du bundle Malio ednotif pour l'utilisation des WS" />
|
<MESSAGE value="feat : ajout de bâtiment dans les fixtures et seed + organisation du menu" />
|
||||||
<MESSAGE value="fix : modification de la conf du bundle ednotif" />
|
<MESSAGE value="fix : on ne pèse plus automatiquement + fix message de création réception" />
|
||||||
<MESSAGE value="feat : update du CHANGELOG.md" />
|
<MESSAGE value="fix : correction des retours de la V0" />
|
||||||
<MESSAGE value="feat : finalisation de l'étape 1 "Réception" (formulaire)" />
|
<MESSAGE value="feat : ajout de l'api de l'état pour chercher les villes via le CP" />
|
||||||
<MESSAGE value="feat : ajout du numéro identification des receptions et ajustement du bon de reception" />
|
<MESSAGE value="fix : script de déploiement + CI/CD build de l'app" />
|
||||||
<MESSAGE value="feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception" />
|
<MESSAGE value="fix : order navbar + modification création fournisseur et client" />
|
||||||
<MESSAGE value="feat : mise en place de composant UI pour les select, checkbox, date, text" />
|
<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 : 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" />
|
||||||
|
<MESSAGE value="fix : update icon entrée/sortie" />
|
||||||
|
<MESSAGE value="fix : wording" />
|
||||||
<MESSAGE value="feat : update CHANGELOG.md" />
|
<MESSAGE value="feat : update CHANGELOG.md" />
|
||||||
<MESSAGE value="feat : ajout de commentaire" />
|
<MESSAGE value="feat : amélioration du tableau bovin" />
|
||||||
<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="fix : label age bovin" />
|
||||||
<MESSAGE value="feat : ajout de colonne pour les Supplier, Address et modification du numéro de réception" />
|
<option name="LAST_COMMIT_MESSAGE" value="fix : label age bovin" />
|
||||||
<MESSAGE value="feat : ajout de colonne pour les Supplier, Address. Modification du numéro de réception et ajout de fixtures" />
|
|
||||||
<MESSAGE value="feat : mise à jour du bon de réception" />
|
|
||||||
<MESSAGE value="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)" />
|
|
||||||
<MESSAGE value="feat : creer une nouvelle expedtion (WIP)" />
|
|
||||||
<MESSAGE value="feat : ajout d'une page de creation d'une expedition" />
|
|
||||||
<MESSAGE value="feat : changelog" />
|
|
||||||
<MESSAGE value="feat : lister les expeditions terminees" />
|
|
||||||
<MESSAGE value="fix: corrections diverses" />
|
|
||||||
<MESSAGE value="fix : corrections diverses" />
|
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="fix : corrections diverses" />
|
|
||||||
</component>
|
</component>
|
||||||
<component name="XDebuggerManager">
|
<component name="XDebuggerManager">
|
||||||
<breakpoint-manager>
|
<breakpoint-manager>
|
||||||
@@ -797,11 +835,6 @@
|
|||||||
<url>file://$PROJECT_DIR$/frontend/services/dto/shipment-data.ts</url>
|
<url>file://$PROJECT_DIR$/frontend/services/dto/shipment-data.ts</url>
|
||||||
<option name="timeStamp" value="43" />
|
<option name="timeStamp" value="43" />
|
||||||
</line-breakpoint>
|
</line-breakpoint>
|
||||||
<line-breakpoint enabled="true" type="javascript">
|
|
||||||
<url>file://$PROJECT_DIR$/frontend/layouts/default.vue</url>
|
|
||||||
<line>72</line>
|
|
||||||
<option name="timeStamp" value="48" />
|
|
||||||
</line-breakpoint>
|
|
||||||
</breakpoints>
|
</breakpoints>
|
||||||
</breakpoint-manager>
|
</breakpoint-manager>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
73
AGENTS.md
73
AGENTS.md
@@ -1,73 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
Project overview
|
|
||||||
- Symfony 8 + API Platform 4 backend, Nuxt 3 frontend in `frontend/`.
|
|
||||||
- Apache vhost serves API under `/api` and frontend from `frontend/dist`.
|
|
||||||
- API base URL on frontend uses `NUXT_PUBLIC_API_BASE` (see `frontend/.env`).
|
|
||||||
|
|
||||||
Backend conventions
|
|
||||||
- Use English for code identifiers/messages; keep “pont-bascule” as domain term.
|
|
||||||
- API Platform operations are defined on Doctrine entities.
|
|
||||||
- No custom repository classes are used (`src/Repository` removed); use default Doctrine repositories via `EntityManagerInterface`.
|
|
||||||
- Reception entity is in `src/Entity/Reception.php`, with custom weigh endpoint `/receptions/weigh`.
|
|
||||||
- Reception fields: `date_reception`, `license_plate`, `current_step` (default 0), `is_valid` (default false).
|
|
||||||
- Reception also has `identification_number` (auto `N-BR-####`), `merchandise_type`, `merchandise_detail`, `buildings` (M2M), and `pellet_buildings` (via `reception_pellet_building`).
|
|
||||||
- `date_reception` is set by the UI, stored as `DateTimeImmutable`, serialized as `Y-m-d`.
|
|
||||||
- Weight entity (`src/Entity/Weight.php`) is 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.
|
|
||||||
- Bovine storage:
|
|
||||||
- `src/Entity/Bovine.php` with fields `nationalNumber` (unique), `receivedWeight`, `arrivalDate`, and `buildingCase` (ManyToOne).
|
|
||||||
- `src/Entity/BuildingCase.php` has `bovines` (OneToMany).
|
|
||||||
- Case PDF report:
|
|
||||||
- Endpoint: `GET /building_cases/{id}/weights-report` (provider: `App\State\BuildingCaseWeightsReportProvider`).
|
|
||||||
- Template: `templates/case_weights_report.html.twig`.
|
|
||||||
- Projection logic is done in backend from `arrivalDate`; daily gain is currently fixed at `1.3 kg/day` for all races.
|
|
||||||
|
|
||||||
Frontend conventions
|
|
||||||
- Nuxt SSR disabled; Tailwind used.
|
|
||||||
- Layout in `frontend/layouts/default.vue`: max width `1050px`, header full width.
|
|
||||||
- Tailwind custom color palette is `primary` (e.g. `bg-primary-500`).
|
|
||||||
- Global font stack uses Helvetica via Tailwind (`font-sans`) and `frontend/assets/css/main.css`.
|
|
||||||
- API composable in `frontend/composables/useApi.ts` with `get/post/put/patch/delete` and default JSON/PATCH content types.
|
|
||||||
- API errors/success toasts can be customized via `toastErrorMessage`/`toastSuccessMessage` or i18n keys `toastErrorKey`/`toastSuccessKey`. Global method fallbacks use `errors.http.*` keys.
|
|
||||||
- `useApi` uses `useNuxtApp().$i18n` (not `useI18n`) to avoid setup-only constraint in service calls.
|
|
||||||
- Pinia store: `frontend/stores/reception.ts` is the source of truth for the current reception.
|
|
||||||
- Zod is used for form validation (e.g. `frontend/components/reception/reception-form.vue`); shared helpers live in `frontend/utils/zod-errors.ts`.
|
|
||||||
- Weighing logic is shared via `frontend/composables/useWeighing.ts`.
|
|
||||||
- Reception step UI uses store state (`currentStep`) in `frontend/pages/reception/[[id]].vue`.
|
|
||||||
- Step 2 uses `frontend/components/reception/reception-product-received.vue` for merchandise selection; type codes in `frontend/utils/constants.ts`.
|
|
||||||
- Active nav styles in header use `NuxtLink` with `custom` slot.
|
|
||||||
- Reusable UI components live under `frontend/components/ui/` and are auto-imported with `Ui` prefix (e.g. `UiLoadingDots`).
|
|
||||||
- Service layer lives in `frontend/services/` with typed DTOs in `frontend/services/dto/`.
|
|
||||||
- Reception service uses `receptions`, `receptions/{id}`, `receptions/weigh` and supports success/error toast keys.
|
|
||||||
- Reception receipt endpoint is `receptions/{id}/receipt` (PDF) via `frontend/composables/usePdfPrinter.ts`.
|
|
||||||
- Infrastructure case page prints the case weight report PDF from `frontend/pages/infrastructure/case.vue` using `usePdfPrinter('/building_cases/{id}/weights-report')`.
|
|
||||||
|
|
||||||
Environment & routing
|
|
||||||
- Frontend dev server: `npm run dev` in `frontend/`.
|
|
||||||
- API base for local dev: `http://localhost:8080/api` (set in `frontend/.env` via `NUXT_PUBLIC_API_BASE`).
|
|
||||||
- CORS handled by Nelmio; `.env` includes `CORS_ALLOW_ORIGIN` regex for localhost.
|
|
||||||
- Nuxt i18n locales live in `frontend/i18n/locales` (configured via `langDir: 'locales'`).
|
|
||||||
- Default locale is `fr`; translations in `frontend/i18n/locales/fr.json`.
|
|
||||||
|
|
||||||
Notes
|
|
||||||
- Do not add a GET that creates resources; use POST + PATCH.
|
|
||||||
- Keep endpoints in plural (API Platform convention).
|
|
||||||
- Seed and fixtures conventions:
|
|
||||||
- `app:seed` now seeds infrastructure (`statut`, `building_layout`, `building_case`, `building_case_position`) and bovines.
|
|
||||||
- `app:seed` uses intermediate flushes (after buildings and after infrastructure) so find queries can resolve just-created records.
|
|
||||||
- Bovine seed rows use a legacy case token mapping to building-case code (`B{building}-C{case}`) before fallback to direct id lookup.
|
|
||||||
- Fixtures include `BuildingInfrastructureFixtures` + `BovineFixtures` (via `AppFixtures` dependencies).
|
|
||||||
- New reference data added:
|
|
||||||
- Reception types (`reception_type`, fields: `label`, `code`), selectable on reception form.
|
|
||||||
- Merchandise types (`merchandise_type`, fields: `label`, `code`) and pellet types (`pellet_type`, fields: `label`, `code`).
|
|
||||||
- Buildings (`building`, fields: `label`, `code`) and reception allocations (`reception_building` M2M, `reception_pellet_building` unique on reception/pellet/building).
|
|
||||||
- Suppliers (`supplier`) with addresses (`address`, fields: `label`, `street`, `postal_code`, `city`, `country_code` ISO2), via `supplier_address` join table.
|
|
||||||
- Trucks (`truck`, field: `name`), linked to receptions.
|
|
||||||
- Carriers (`carrier`, fields: `name`, nullable `code`), Drivers (`driver`, fields: `name`, `carrier_id`), Vehicles (`vehicle`, fields: `plate`, `carrier_id`, `truck_id`) used for LIOT logic.
|
|
||||||
- Reception links: `reception_type_id`, `supplier_id`, `address_id`, `truck_id`, `carrier_id`, `driver_id`, `user_id`.
|
|
||||||
- Address exposes `fullAddress` via getter for display.
|
|
||||||
- LIOT behavior in reception form: if carrier code = `LIOT`, show driver + vehicle selects and hide manual license plate input; vehicle list filters by truck type and carrier; selected vehicle sets `license_plate`.
|
|
||||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -51,7 +51,23 @@ Ajouter dans le fichier .env du frontend
|
|||||||
* Creation page admin ajout/modification bovins
|
* Creation page admin ajout/modification bovins
|
||||||
* [#331] Mettre à jour l'entité Shipment et bovin_shipment
|
* [#331] Mettre à jour l'entité Shipment et bovin_shipment
|
||||||
* [#278] Plan du site
|
* [#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
|
||||||
|
* [#FER-26] Passeport du bovin
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
### Fixed
|
### 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`
|
||||||
152
README.md
152
README.md
@@ -1,68 +1,87 @@
|
|||||||
# Projet Ferme
|
# Projet Ferme t
|
||||||
|
|
||||||
## Installation du projet
|
## Installation du projet
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm.
|
Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm.
|
||||||
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows)
|
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
Pour linux, il faut installer docker et nvm.
|
Pour linux, il faut installer docker et nvm.
|
||||||
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux)
|
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux)
|
||||||
|
|
||||||
### Installation du projet
|
### Installation du projet
|
||||||
|
|
||||||
Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes
|
Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make start
|
make start
|
||||||
make install
|
make install
|
||||||
```
|
```
|
||||||
|
|
||||||
Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible.
|
Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible.
|
||||||
|
|
||||||
### Configuration global
|
### Configuration global
|
||||||
|
|
||||||
Pour les variables d'environnement, il faut demander un .env.local pour le backend et un .env pour le frontend à votre collègue.
|
Pour les variables d'environnement, il faut demander un .env.local pour le backend et un .env pour le frontend à votre collègue.
|
||||||
|
|
||||||
Vérifier que dans le .env.local, vous avez :
|
Vérifier que dans le .env.local, vous avez :
|
||||||
* APP_SECRET (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));" et doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens)
|
|
||||||
* DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
- APP_SECRET (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));" et doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens)
|
||||||
* PONT_BASCULE_BYPASS (doit être à true en dev)
|
- DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||||
* PONT_BASCULE_URL
|
- PONT_BASCULE_BYPASS (doit être à true en dev)
|
||||||
* JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
|
- PONT_BASCULE_URL
|
||||||
* JWT_PUBLIC_KEY
|
- JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
|
||||||
* JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
|
- JWT_PUBLIC_KEY
|
||||||
* COOKIE_SECURE=0 (en dev 0 et en prod 1. Si c'est du http, laisser en 0)
|
- JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
|
||||||
|
- COOKIE_SECURE=0 (en dev 0 et en prod 1. Si c'est du http, laisser en 0)
|
||||||
|
|
||||||
Vérifier que dans le .env du dossier frontend, vous avez :
|
Vérifier que dans le .env du dossier frontend, vous avez :
|
||||||
* NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
|
|
||||||
|
- NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
|
||||||
|
|
||||||
### Configuration xdebug
|
### Configuration xdebug
|
||||||
|
|
||||||
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
|
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
|
||||||
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
|
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
|
||||||
* Name : ferme-docker
|
|
||||||
* Host : localhost
|
- Name : ferme-docker
|
||||||
* Port : 8080
|
- Host : localhost
|
||||||
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
|
- Port : 8080
|
||||||
|
- Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
|
||||||
|
|
||||||
Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local
|
Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local
|
||||||
|
|
||||||
## Utilisation du projet
|
## Utilisation du projet
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
L'api est disponible sur http://localhost:8080/api
|
L'api est disponible sur http://localhost:8080/api
|
||||||
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
|
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
|
||||||
Vous pouvez modifier le port si nécessaire.
|
Vous pouvez modifier le port si nécessaire.
|
||||||
|
|
||||||
La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter.
|
La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter.
|
||||||
C'est un bdd local dans le docker.
|
C'est un bdd local dans le docker.
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
|
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dev-nuxt
|
make dev-nuxt
|
||||||
```
|
```
|
||||||
|
|
||||||
Le front sera accessible sur http://localhost:3000
|
Le front sera accessible sur http://localhost:3000
|
||||||
|
|
||||||
### Authentification
|
### Authentification
|
||||||
|
|
||||||
Ce projet utilise l'authentification JWT avec un cookie httpOnly (LexikJWTAuthenticationBundle).
|
Ce projet utilise l'authentification JWT avec un cookie httpOnly (LexikJWTAuthenticationBundle).
|
||||||
Le frontend ne lit jamais directement le token, le navigateur envoie automatiquement le cookie.
|
Le frontend ne lit jamais directement le token, le navigateur envoie automatiquement le cookie.
|
||||||
|
|
||||||
### Login flow
|
### Login flow
|
||||||
|
|
||||||
- Frontend envoie les identifiants à:
|
- Frontend envoie les identifiants à:
|
||||||
- `POST /api/login_check`
|
- `POST /api/login_check`
|
||||||
- Backend returns:
|
- Backend returns:
|
||||||
@@ -72,63 +91,164 @@ Le frontend ne lit jamais directement le token, le navigateur envoie automatique
|
|||||||
- La déconnexion utilise `POST /api/logout` et redirige vers `/login`.
|
- La déconnexion utilise `POST /api/logout` et redirige vers `/login`.
|
||||||
|
|
||||||
### Fixtures
|
### Fixtures
|
||||||
|
|
||||||
Pour lancer les fixtures (Attention sa purge la bdd complètement)
|
Pour lancer les fixtures (Attention sa purge la bdd complètement)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php bin/console doctrine:fixtures:load
|
php bin/console doctrine:fixtures:load
|
||||||
```
|
```
|
||||||
|
|
||||||
Attention cette commande est dangereuse, à utiliser que pour les débuts de la prod ou en recette.
|
Attention cette commande est dangereuse, à utiliser que pour les débuts de la prod ou en recette.
|
||||||
Dans un premier temps pour remplir les listes, vous pouvez lancer la commande symfony
|
Dans un premier temps pour remplir les listes, vous pouvez lancer la commande symfony
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php bin/console app:seed
|
php bin/console app:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
La commande va faire une update ou une création en fonction des data existante.
|
La commande va faire une update ou une création en fonction des data existante.
|
||||||
|
|
||||||
## Livraison en recette
|
## Livraison en recette
|
||||||
|
|
||||||
### Préparatifs
|
### Préparatifs
|
||||||
|
|
||||||
Avant de déployer, il faut penser à ajouter les variables d'env s'il y a des changements/modifications.
|
Avant de déployer, il faut penser à ajouter les variables d'env s'il y a des changements/modifications.
|
||||||
Le .env se trouve /var/www/ferme/.env
|
Le .env se trouve /var/www/ferme/.env
|
||||||
|
|
||||||
Le script de livraison est version dans le repo dans script/deploy-release.sh <br>
|
Le script de livraison est version dans le repo dans script/deploy-release.sh <br>
|
||||||
Sur la machine, il est disponible dans /usr/local/bin/deploy-ferme <br>
|
Sur la machine, il est disponible dans /usr/local/bin/deploy-ferme <br>
|
||||||
Pour le modifier, il faut copier le contenu du deploy-release.sh dans le deploy-ferme
|
Pour le modifier, il faut copier le contenu du deploy-release.sh dans le deploy-ferme
|
||||||
|
|
||||||
### Livraison
|
### Livraison
|
||||||
|
|
||||||
Sur le serveur de recette, il suffit d'utiliser cette commande pour livrer
|
Sur le serveur de recette, il suffit d'utiliser cette commande pour livrer
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/usr/local/bin/deploy-ferme vX.Y.Z
|
/usr/local/bin/deploy-ferme vX.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commandes utiles
|
## Commandes utiles
|
||||||
|
|
||||||
Pour restart le container
|
Pour restart le container
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make restart
|
make restart
|
||||||
```
|
```
|
||||||
|
|
||||||
Pour lancer les TU
|
Pour lancer les TU
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test
|
make test
|
||||||
```
|
```
|
||||||
|
|
||||||
Pour accéder au container et lance des commandes
|
Pour accéder au container et lance des commandes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make shell
|
make shell
|
||||||
```
|
```
|
||||||
|
|
||||||
Pour clear le cache Symfony
|
Pour clear le cache Symfony
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make cache-clear
|
make cache-clear
|
||||||
```
|
```
|
||||||
|
|
||||||
Faire une migration
|
Faire une migration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make migration-migrate
|
make migration-migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
Pour générer un password pour un user
|
Pour générer un password pour un user
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make shell
|
make shell
|
||||||
php bin/console security:hash-password
|
php bin/console security:hash-password
|
||||||
```
|
```
|
||||||
|
|
||||||
Sélectionner entity User, taper sont mdp, le copier et l'ajouter dans l'insert de bdd suivant :
|
Sélectionner entity User, taper sont mdp, le copier et l'ajouter dans l'insert de bdd suivant :
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
INSERT INTO "user" (username, roles, password)
|
INSERT INTO "user" (username, roles, password)
|
||||||
VALUES ('Mon user', '["ROLE_USER"]', 'Mon mdp hashé');
|
VALUES ('Mon user', '["ROLE_USER"]', 'Mon mdp hashé');
|
||||||
```
|
```
|
||||||
|
|
||||||
## Gestion des logs
|
## Gestion des logs
|
||||||
|
|
||||||
Pour suivre les logs en temps réel :
|
Pour suivre les logs en temps réel :
|
||||||
* tail -f var/log/dev.log
|
|
||||||
* tail -f var/log/prod.log
|
- tail -f var/log/dev.log
|
||||||
|
- tail -f var/log/prod.log
|
||||||
|
|
||||||
|
## 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",
|
"doctrine/orm": "^3.6",
|
||||||
"dompdf/dompdf": "^3.1",
|
"dompdf/dompdf": "^3.1",
|
||||||
"lexik/jwt-authentication-bundle": "*",
|
"lexik/jwt-authentication-bundle": "*",
|
||||||
"malio/ednotif-bundle": ">=0.0.4",
|
"malio/ednotif-bundle": ">=0.0.6",
|
||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
|
"phpoffice/phpspreadsheet": "^5.7",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
"symfony/asset": "8.0.*",
|
"symfony/asset": "8.0.*",
|
||||||
"symfony/console": "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
|
stateless: true
|
||||||
cache_headers:
|
cache_headers:
|
||||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||||
|
pagination_client_items_per_page: true
|
||||||
|
pagination_maximum_items_per_page: 100
|
||||||
formats:
|
formats:
|
||||||
json: ['application/json']
|
json: ['application/json']
|
||||||
jsonld: ['application/ld+json']
|
jsonld: ['application/ld+json']
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
security:
|
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
|
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||||
password_hashers:
|
password_hashers:
|
||||||
App\Entity\User: 'auto'
|
App\Entity\User: 'auto'
|
||||||
@@ -20,6 +27,7 @@ security:
|
|||||||
pattern: ^/login_check
|
pattern: ^/login_check
|
||||||
stateless: true
|
stateless: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
user_checker: App\Security\UserChecker
|
||||||
json_login:
|
json_login:
|
||||||
check_path: /login_check
|
check_path: /login_check
|
||||||
username_path: username
|
username_path: username
|
||||||
@@ -30,6 +38,7 @@ security:
|
|||||||
pattern: ^/
|
pattern: ^/
|
||||||
stateless: true
|
stateless: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
user_checker: App\Security\UserChecker
|
||||||
jwt: ~
|
jwt: ~
|
||||||
logout:
|
logout:
|
||||||
path: /api/logout
|
path: /api/logout
|
||||||
|
|||||||
@@ -210,18 +210,18 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* initial_marking?: list<scalar|Param|null>,
|
* initial_marking?: list<scalar|Param|null>,
|
||||||
* events_to_dispatch?: list<string|Param>|null,
|
* events_to_dispatch?: list<string|Param>|null,
|
||||||
* places?: list<array{ // Default: []
|
* places?: list<array{ // Default: []
|
||||||
* name: scalar|Param|null,
|
* name?: scalar|Param|null,
|
||||||
* metadata?: list<mixed>,
|
* metadata?: list<mixed>,
|
||||||
* }>,
|
* }>,
|
||||||
* transitions: list<array{ // Default: []
|
* transitions?: list<array{ // Default: []
|
||||||
* name: string|Param,
|
* name?: string|Param,
|
||||||
* guard?: string|Param, // An expression to block the transition.
|
* guard?: string|Param, // An expression to block the transition.
|
||||||
* from?: list<array{ // Default: []
|
* from?: list<array{ // Default: []
|
||||||
* place: string|Param,
|
* place?: string|Param,
|
||||||
* weight?: int|Param, // Default: 1
|
* weight?: int|Param, // Default: 1
|
||||||
* }>,
|
* }>,
|
||||||
* to?: list<array{ // Default: []
|
* to?: list<array{ // Default: []
|
||||||
* place: string|Param,
|
* place?: string|Param,
|
||||||
* weight?: int|Param, // Default: 1
|
* weight?: int|Param, // Default: 1
|
||||||
* }>,
|
* }>,
|
||||||
* weight?: int|Param, // Default: 1
|
* weight?: int|Param, // Default: 1
|
||||||
@@ -232,7 +232,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* },
|
* },
|
||||||
* router?: bool|array{ // Router configuration
|
* router?: bool|array{ // Router configuration
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: false
|
||||||
* resource: scalar|Param|null,
|
* resource?: scalar|Param|null,
|
||||||
* type?: scalar|Param|null,
|
* type?: scalar|Param|null,
|
||||||
* default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
|
* default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
|
||||||
* http_port?: scalar|Param|null, // Default: 80
|
* http_port?: scalar|Param|null, // Default: 80
|
||||||
@@ -457,7 +457,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* allow_no_senders?: bool|Param, // Default: true
|
* allow_no_senders?: bool|Param, // Default: true
|
||||||
* },
|
* },
|
||||||
* middleware?: list<string|array{ // Default: []
|
* middleware?: list<string|array{ // Default: []
|
||||||
* id: scalar|Param|null,
|
* id?: scalar|Param|null,
|
||||||
* arguments?: list<mixed>,
|
* arguments?: list<mixed>,
|
||||||
* }>,
|
* }>,
|
||||||
* }>,
|
* }>,
|
||||||
@@ -629,7 +629,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||||
* storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
|
* storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
|
||||||
* policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
* policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
||||||
* limiters?: list<scalar|Param|null>,
|
* limiters?: list<scalar|Param|null>,
|
||||||
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
|
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
|
||||||
* interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
* interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||||
@@ -674,7 +674,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: false
|
||||||
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
|
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
|
||||||
* routing?: array<string, array{ // Default: []
|
* routing?: array<string, array{ // Default: []
|
||||||
* service: scalar|Param|null,
|
* service?: scalar|Param|null,
|
||||||
* secret?: scalar|Param|null, // Default: ""
|
* secret?: scalar|Param|null, // Default: ""
|
||||||
* }>,
|
* }>,
|
||||||
* },
|
* },
|
||||||
@@ -754,8 +754,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* }>,
|
* }>,
|
||||||
* },
|
* },
|
||||||
* ldap?: array{
|
* ldap?: array{
|
||||||
* service: scalar|Param|null,
|
* service?: scalar|Param|null,
|
||||||
* base_dn: scalar|Param|null,
|
* base_dn?: scalar|Param|null,
|
||||||
* search_dn?: scalar|Param|null, // Default: null
|
* search_dn?: scalar|Param|null, // Default: null
|
||||||
* search_password?: scalar|Param|null, // Default: null
|
* search_password?: scalar|Param|null, // Default: null
|
||||||
* extra_fields?: list<scalar|Param|null>,
|
* extra_fields?: list<scalar|Param|null>,
|
||||||
@@ -766,7 +766,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* password_attribute?: scalar|Param|null, // Default: null
|
* password_attribute?: scalar|Param|null, // Default: null
|
||||||
* },
|
* },
|
||||||
* entity?: array{
|
* entity?: array{
|
||||||
* class: scalar|Param|null, // The full entity class name of your user class.
|
* class?: scalar|Param|null, // The full entity class name of your user class.
|
||||||
* property?: scalar|Param|null, // Default: null
|
* property?: scalar|Param|null, // Default: null
|
||||||
* manager_name?: scalar|Param|null, // Default: null
|
* manager_name?: scalar|Param|null, // Default: null
|
||||||
* },
|
* },
|
||||||
@@ -774,7 +774,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* class?: scalar|Param|null, // Default: "Lexik\\Bundle\\JWTAuthenticationBundle\\Security\\User\\JWTUser"
|
* class?: scalar|Param|null, // Default: "Lexik\\Bundle\\JWTAuthenticationBundle\\Security\\User\\JWTUser"
|
||||||
* },
|
* },
|
||||||
* }>,
|
* }>,
|
||||||
* firewalls: array<string, array{ // Default: []
|
* firewalls?: array<string, array{ // Default: []
|
||||||
* pattern?: scalar|Param|null,
|
* pattern?: scalar|Param|null,
|
||||||
* host?: scalar|Param|null,
|
* host?: scalar|Param|null,
|
||||||
* methods?: list<scalar|Param|null>,
|
* methods?: list<scalar|Param|null>,
|
||||||
@@ -836,9 +836,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* authenticator?: scalar|Param|null, // Default: "lexik_jwt_authentication.security.jwt_authenticator"
|
* authenticator?: scalar|Param|null, // Default: "lexik_jwt_authentication.security.jwt_authenticator"
|
||||||
* },
|
* },
|
||||||
* login_link?: array{
|
* login_link?: array{
|
||||||
* check_route: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
|
* check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
|
||||||
* check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
|
* check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
|
||||||
* signature_properties: list<scalar|Param|null>,
|
* signature_properties?: list<scalar|Param|null>,
|
||||||
* lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600
|
* lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600
|
||||||
* max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null
|
* max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null
|
||||||
* used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set.
|
* used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set.
|
||||||
@@ -940,13 +940,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* failure_handler?: scalar|Param|null,
|
* failure_handler?: scalar|Param|null,
|
||||||
* realm?: scalar|Param|null, // Default: null
|
* realm?: scalar|Param|null, // Default: null
|
||||||
* token_extractors?: list<scalar|Param|null>,
|
* token_extractors?: list<scalar|Param|null>,
|
||||||
* token_handler: string|array{
|
* token_handler?: string|array{
|
||||||
* id?: scalar|Param|null,
|
* id?: scalar|Param|null,
|
||||||
* oidc_user_info?: string|array{
|
* oidc_user_info?: string|array{
|
||||||
* base_uri: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
|
* base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
|
||||||
* discovery?: array{ // Enable the OIDC discovery.
|
* discovery?: array{ // Enable the OIDC discovery.
|
||||||
* cache?: array{
|
* cache?: array{
|
||||||
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||||
* },
|
* },
|
||||||
* },
|
* },
|
||||||
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub"
|
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub"
|
||||||
@@ -954,25 +954,25 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* },
|
* },
|
||||||
* oidc?: array{
|
* oidc?: array{
|
||||||
* discovery?: array{ // Enable the OIDC discovery.
|
* discovery?: array{ // Enable the OIDC discovery.
|
||||||
* base_uri: list<scalar|Param|null>,
|
* base_uri?: list<scalar|Param|null>,
|
||||||
* cache?: array{
|
* cache?: array{
|
||||||
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||||
* },
|
* },
|
||||||
* },
|
* },
|
||||||
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub"
|
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub"
|
||||||
* audience: scalar|Param|null, // Audience set in the token, for validation purpose.
|
* audience?: scalar|Param|null, // Audience set in the token, for validation purpose.
|
||||||
* issuers: list<scalar|Param|null>,
|
* issuers?: list<scalar|Param|null>,
|
||||||
* algorithms: list<scalar|Param|null>,
|
* algorithms?: list<scalar|Param|null>,
|
||||||
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).
|
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).
|
||||||
* encryption?: bool|array{
|
* encryption?: bool|array{
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: false
|
||||||
* enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false
|
* enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false
|
||||||
* algorithms: list<scalar|Param|null>,
|
* algorithms?: list<scalar|Param|null>,
|
||||||
* keyset: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
|
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
|
||||||
* },
|
* },
|
||||||
* },
|
* },
|
||||||
* cas?: array{
|
* cas?: array{
|
||||||
* validation_url: scalar|Param|null, // CAS server validation URL
|
* validation_url?: scalar|Param|null, // CAS server validation URL
|
||||||
* prefix?: scalar|Param|null, // CAS prefix // Default: "cas"
|
* prefix?: scalar|Param|null, // CAS prefix // Default: "cas"
|
||||||
* http_client?: scalar|Param|null, // HTTP Client service // Default: null
|
* http_client?: scalar|Param|null, // HTTP Client service // Default: null
|
||||||
* },
|
* },
|
||||||
@@ -1036,7 +1036,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* dbal?: array{
|
* dbal?: array{
|
||||||
* default_connection?: scalar|Param|null,
|
* default_connection?: scalar|Param|null,
|
||||||
* types?: array<string, string|array{ // Default: []
|
* types?: array<string, string|array{ // Default: []
|
||||||
* class: scalar|Param|null,
|
* class?: scalar|Param|null,
|
||||||
* }>,
|
* }>,
|
||||||
* driver_schemes?: array<string, scalar|Param|null>,
|
* driver_schemes?: array<string, scalar|Param|null>,
|
||||||
* connections?: array<string, array{ // Default: []
|
* connections?: array<string, array{ // Default: []
|
||||||
@@ -1207,7 +1207,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* datetime_functions?: array<string, scalar|Param|null>,
|
* datetime_functions?: array<string, scalar|Param|null>,
|
||||||
* },
|
* },
|
||||||
* filters?: array<string, string|array{ // Default: []
|
* filters?: array<string, string|array{ // Default: []
|
||||||
* class: scalar|Param|null,
|
* class?: scalar|Param|null,
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: false
|
||||||
* parameters?: array<string, mixed>,
|
* parameters?: array<string, mixed>,
|
||||||
* }>,
|
* }>,
|
||||||
@@ -1320,14 +1320,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* access_token_issuance?: bool|array{
|
* access_token_issuance?: bool|array{
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: false
|
||||||
* signature?: array{
|
* signature?: array{
|
||||||
* algorithm: scalar|Param|null, // The algorithm use to sign the access tokens.
|
* algorithm?: scalar|Param|null, // The algorithm use to sign the access tokens.
|
||||||
* key: scalar|Param|null, // The signature key. It shall be JWK encoded.
|
* key?: scalar|Param|null, // The signature key. It shall be JWK encoded.
|
||||||
* },
|
* },
|
||||||
* encryption?: bool|array{
|
* encryption?: bool|array{
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: false
|
||||||
* key_encryption_algorithm: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
* key_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
||||||
* content_encryption_algorithm: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
* content_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
||||||
* key: scalar|Param|null, // The encryption key. It shall be JWK encoded.
|
* key?: scalar|Param|null, // The encryption key. It shall be JWK encoded.
|
||||||
* },
|
* },
|
||||||
* },
|
* },
|
||||||
* access_token_verification?: bool|array{
|
* access_token_verification?: bool|array{
|
||||||
@@ -1337,7 +1337,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* claim_checkers?: list<scalar|Param|null>,
|
* claim_checkers?: list<scalar|Param|null>,
|
||||||
* mandatory_claims?: list<scalar|Param|null>,
|
* mandatory_claims?: list<scalar|Param|null>,
|
||||||
* allowed_algorithms?: list<scalar|Param|null>,
|
* allowed_algorithms?: list<scalar|Param|null>,
|
||||||
* keyset: scalar|Param|null, // The signature keyset. It shall be JWKSet encoded.
|
* keyset?: scalar|Param|null, // The signature keyset. It shall be JWKSet encoded.
|
||||||
* },
|
* },
|
||||||
* encryption?: bool|array{
|
* encryption?: bool|array{
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: false
|
||||||
@@ -1345,7 +1345,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* header_checkers?: list<scalar|Param|null>,
|
* header_checkers?: list<scalar|Param|null>,
|
||||||
* allowed_key_encryption_algorithms?: list<scalar|Param|null>,
|
* allowed_key_encryption_algorithms?: list<scalar|Param|null>,
|
||||||
* allowed_content_encryption_algorithms?: list<scalar|Param|null>,
|
* allowed_content_encryption_algorithms?: list<scalar|Param|null>,
|
||||||
* keyset: scalar|Param|null, // The encryption keyset. It shall be JWKSet encoded.
|
* keyset?: scalar|Param|null, // The encryption keyset. It shall be JWKSet encoded.
|
||||||
* },
|
* },
|
||||||
* },
|
* },
|
||||||
* blocklist_token?: bool|array{
|
* blocklist_token?: bool|array{
|
||||||
@@ -1489,7 +1489,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* },
|
* },
|
||||||
* termsOfService?: scalar|Param|null, // A URL to the Terms of Service for the API. MUST be in the format of a URL. // Default: null
|
* termsOfService?: scalar|Param|null, // A URL to the Terms of Service for the API. MUST be in the format of a URL. // Default: null
|
||||||
* tags?: list<array{ // Default: []
|
* tags?: list<array{ // Default: []
|
||||||
* name: scalar|Param|null,
|
* name?: scalar|Param|null,
|
||||||
* description?: scalar|Param|null, // Default: null
|
* description?: scalar|Param|null, // Default: null
|
||||||
* }>,
|
* }>,
|
||||||
* license?: array{
|
* license?: array{
|
||||||
@@ -1605,14 +1605,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* name?: mixed,
|
* name?: mixed,
|
||||||
* allow_create?: mixed,
|
* allow_create?: mixed,
|
||||||
* item_uri_template?: mixed,
|
* item_uri_template?: mixed,
|
||||||
* ...<mixed>
|
* ...<string, mixed>
|
||||||
* },
|
* },
|
||||||
* }
|
* }
|
||||||
* @psalm-type MonologConfig = array{
|
* @psalm-type MonologConfig = array{
|
||||||
* use_microseconds?: scalar|Param|null, // Default: true
|
* use_microseconds?: scalar|Param|null, // Default: true
|
||||||
* channels?: list<scalar|Param|null>,
|
* channels?: list<scalar|Param|null>,
|
||||||
* handlers?: array<string, array{ // Default: []
|
* handlers?: array<string, array{ // Default: []
|
||||||
* type: scalar|Param|null,
|
* type?: scalar|Param|null,
|
||||||
* id?: scalar|Param|null,
|
* id?: scalar|Param|null,
|
||||||
* enabled?: bool|Param, // Default: true
|
* enabled?: bool|Param, // Default: true
|
||||||
* priority?: scalar|Param|null, // Default: 0
|
* priority?: scalar|Param|null, // Default: 0
|
||||||
@@ -1735,7 +1735,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* headers?: list<scalar|Param|null>,
|
* headers?: list<scalar|Param|null>,
|
||||||
* mailer?: scalar|Param|null, // Default: null
|
* mailer?: scalar|Param|null, // Default: null
|
||||||
* email_prototype?: string|array{
|
* email_prototype?: string|array{
|
||||||
* id: scalar|Param|null,
|
* id?: scalar|Param|null,
|
||||||
* method?: scalar|Param|null, // Default: null
|
* method?: scalar|Param|null, // Default: null
|
||||||
* },
|
* },
|
||||||
* verbosity_levels?: array{
|
* verbosity_levels?: array{
|
||||||
@@ -1752,14 +1752,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* }>,
|
* }>,
|
||||||
* }
|
* }
|
||||||
* @psalm-type EdnotifConfig = array{
|
* @psalm-type EdnotifConfig = array{
|
||||||
* guichet_wsdl: scalar|Param|null,
|
* guichet_wsdl?: scalar|Param|null, // Default: "/var/www/html/vendor/malio/ednotif-bundle/resources/ednotif-ws/WsGuichet.wsdl"
|
||||||
* metier_wsdl: scalar|Param|null,
|
* metier_wsdl?: scalar|Param|null, // Default: "/var/www/html/vendor/malio/ednotif-bundle/resources/ednotif-ws/wsIpBNotif.wsdl"
|
||||||
* exploitation_code: scalar|Param|null,
|
* exploitation_code?: scalar|Param|null,
|
||||||
* zone?: scalar|Param|null, // Default: null
|
* zone?: scalar|Param|null, // Default: null
|
||||||
* application?: scalar|Param|null, // Default: null
|
* application?: scalar|Param|null, // Default: null
|
||||||
* login: scalar|Param|null,
|
* login?: scalar|Param|null,
|
||||||
* password: scalar|Param|null,
|
* password?: scalar|Param|null,
|
||||||
* exploitation_number: scalar|Param|null,
|
* exploitation_number?: scalar|Param|null,
|
||||||
* exploitation_country_code?: scalar|Param|null, // Default: "FR"
|
* exploitation_country_code?: scalar|Param|null, // Default: "FR"
|
||||||
* token_ttl_seconds?: int|Param, // Default: 900
|
* token_ttl_seconds?: int|Param, // Default: 900
|
||||||
* soap_options?: array{
|
* soap_options?: array{
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.0.56'
|
app.version: '0.0.101'
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -7,3 +7,17 @@
|
|||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.submitted :invalid {
|
||||||
|
@apply border-red-500 text-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitted :has(:invalid) > label {
|
||||||
|
@apply text-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitted label:has(:invalid) {
|
||||||
|
@apply text-red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,45 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="validateForm">
|
<form :class="{ submitted }" @submit.prevent="validateForm">
|
||||||
<div class="flex items-center justify-between gap-10">
|
<div class="flex items-center mb-11 justify-between relative">
|
||||||
<div>
|
<div class="flex flex-row absolute -left-[60px] ">
|
||||||
<h1 class="text-3xl font-bold uppercase">
|
<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">
|
||||||
{{ props.address ? "Modification d'une adresse" : "Ajout d'une adresse" }}
|
{{ props.address ? "Modification d'une adresse" : "Ajout d'une adresse" }}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="grid grid-cols-2 gap-y-16 gap-x-[200px] mb-16">
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
<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" 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"
|
type="submit"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
|
@click="submitted = true"
|
||||||
>
|
>
|
||||||
{{ props.address? "Sauvegarder" : "Ajouter" }}
|
Valider
|
||||||
</button>
|
</UiButton>
|
||||||
</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" />
|
|
||||||
<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" />
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -40,26 +50,45 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
const communes = ref<CommuneData[]>([])
|
||||||
|
const isLoadingCities = ref(false)
|
||||||
|
|
||||||
|
const communeOptions = computed(() =>
|
||||||
|
communes.value.map(c => ({ value: c.nom, label: c.nom }))
|
||||||
|
)
|
||||||
|
|
||||||
const emptyForm = (): AddressPayload => ({
|
const emptyForm = (): AddressPayload => ({
|
||||||
label: "",
|
|
||||||
street: "",
|
street: "",
|
||||||
street2: null,
|
street2: null,
|
||||||
postalCode: "",
|
postalCode: "",
|
||||||
city: "",
|
city: "",
|
||||||
countryCode: "",
|
countryCode: "FR",
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = reactive<AddressPayload>(emptyForm())
|
const form = reactive<AddressPayload>(emptyForm())
|
||||||
|
|
||||||
|
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 hydrateForm = (address?: AddressPayload | null) => {
|
||||||
const data = address ?? emptyForm()
|
const data = address ?? emptyForm()
|
||||||
form.label = data.label ?? ""
|
|
||||||
form.street = data.street ?? ""
|
form.street = data.street ?? ""
|
||||||
form.street2 = data.street2 ?? null
|
form.street2 = data.street2 ?? null
|
||||||
form.postalCode = data.postalCode ?? ""
|
form.postalCode = data.postalCode ?? ""
|
||||||
form.city = data.city ?? ""
|
form.city = data.city ?? ""
|
||||||
form.countryCode = data.countryCode ?? ""
|
form.countryCode = data.countryCode || "FR"
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -70,11 +99,50 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.postalCode,
|
||||||
|
(cp) => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
|
||||||
|
if (!cp || cp.length < 5) {
|
||||||
|
communes.value = []
|
||||||
|
form.city = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cp.length === 5) {
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
isLoadingCities.value = true
|
||||||
|
const previousCity = form.city
|
||||||
|
try {
|
||||||
|
communes.value = await getCommunesByPostalCode(cp)
|
||||||
|
|
||||||
|
if (communes.value.length === 1) {
|
||||||
|
form.city = communes.value[0].nom
|
||||||
|
} else if (communes.value.some(c => c.nom === previousCity)) {
|
||||||
|
form.city = previousCity
|
||||||
|
} else {
|
||||||
|
form.city = ''
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoadingCities.value = false
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
if (isLoading.value) return
|
if (isLoading.value) return
|
||||||
emit("validate", {...form})
|
emit("validate", {...form})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push(backPath.value)
|
||||||
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'validate', form: AddressPayload): void
|
(event: 'validate', form: AddressPayload): void
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NuxtLink :to="link">
|
<NuxtLink :to="link">
|
||||||
<div class="w-[300px] h-[216px] border border-black rounded-lg p-6 flex flex-col justify-between gap-4">
|
<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="flex justify-between">
|
||||||
<div class="rounded-full w-[80px] h-[80px] bg-[#D9D9D9] flex justify-center items-center">
|
<div class="rounded-full w-[80px] h-[80px] bg-[#D9D9D9] flex justify-center items-center">
|
||||||
<Icon :name="iconName" style="color: black" size="44" />
|
<Icon :name="iconName" class="!text-primary-700" size="44" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Icon name="mdi:plus" style="color: black" size="44" />
|
<Icon name="mdi:plus" style="color: black" size="44" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uppercase font-bold">
|
<div class="uppercase font-bold">
|
||||||
<p class="text-3xl text-primary-500">
|
<p class="text-3xl text-primary-700">
|
||||||
<slot name="label">{{ label }}</slot>
|
<slot name="label">{{ label }}</slot>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
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,14 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<form
|
||||||
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"
|
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"
|
||||||
class="flex flex-col items-center gap-16">
|
class="flex flex-col gap-16"
|
||||||
|
@submit.prevent="goNext"
|
||||||
|
>
|
||||||
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1>
|
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1>
|
||||||
<div
|
<div
|
||||||
class="flex flex-row gap-8 items-center">
|
class="flex flex-row gap-8 items-center w-full">
|
||||||
<div
|
<div
|
||||||
v-for="type in bovineType"
|
v-for="type in bovineType"
|
||||||
:key="type.id"
|
:key="type.id"
|
||||||
class="mt-8 flex flex-row mb-2 gap-6">
|
class="mt-8 flex flex-row mb-2 w-full">
|
||||||
<UiNumberInput
|
<UiNumberInput
|
||||||
:id="type.id"
|
:id="type.id"
|
||||||
:label="type.label"
|
:label="type.label"
|
||||||
@@ -17,6 +19,8 @@
|
|||||||
:placeholder="0"
|
:placeholder="0"
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="10"
|
:max="10"
|
||||||
|
class="max-w-[150px]"
|
||||||
|
wrapper-class="gap-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -24,15 +28,22 @@
|
|||||||
<UiNumberInput
|
<UiNumberInput
|
||||||
label="Autres"
|
label="Autres"
|
||||||
v-model="otherQuantity"
|
v-model="otherQuantity"
|
||||||
|
class="max-w-[80px]"
|
||||||
|
wrapper-class="gap-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<p class="text-red-500 text-sm" :class="showBovineError ? '' : 'invisible'">
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
Veuillez saisir au moins une race bovine.
|
||||||
@click="goNext"
|
</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
|
>Valider
|
||||||
</button>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
|
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
|
||||||
@@ -51,6 +62,7 @@ const toast = useToast()
|
|||||||
const isLoadingBovineType = ref(false)
|
const isLoadingBovineType = ref(false)
|
||||||
const bovineType = ref<BovineTypeData[]>([])
|
const bovineType = ref<BovineTypeData[]>([])
|
||||||
const receptionStore = useReceptionStore()
|
const receptionStore = useReceptionStore()
|
||||||
|
const showBovineError = ref(false)
|
||||||
const bovineQuantities = reactive<Record<string, number | null>>({})
|
const bovineQuantities = reactive<Record<string, number | null>>({})
|
||||||
const otherQuantity = ref<number | null>(0)
|
const otherQuantity = ref<number | null>(0)
|
||||||
const receptionId = computed(() => receptionStore.current?.id ?? null)
|
const receptionId = computed(() => receptionStore.current?.id ?? null)
|
||||||
@@ -162,7 +174,13 @@ async function goNext() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// @TODO Ajouter un composable pour le toaster qui gère les key i18n
|
showBovineError.value = false
|
||||||
|
|
||||||
|
if (totalBovines.value === 0) {
|
||||||
|
showBovineError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (totalBovines.value > 52) {
|
if (totalBovines.value > 52) {
|
||||||
toast.error({
|
toast.error({
|
||||||
title: 'Erreur',
|
title: 'Erreur',
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="validate">
|
<form ref="formRef" :class="{ submitted }" @submit.prevent="validate">
|
||||||
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
|
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
|
||||||
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Réception</h1>
|
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Réception</h1>
|
||||||
<!-- Nom de l'utilisateur -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="reception-user"
|
id="reception-user"
|
||||||
v-model="form.userId"
|
v-model="form.userId"
|
||||||
@@ -13,15 +12,15 @@
|
|||||||
}))"
|
}))"
|
||||||
:loading="isLoadingUsers"
|
:loading="isLoadingUsers"
|
||||||
wrapper-class="col-start-1 row-start-2"
|
wrapper-class="col-start-1 row-start-2"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Date de réception -->
|
|
||||||
<UiDateInput
|
<UiDateInput
|
||||||
id="reception-date"
|
id="reception-date"
|
||||||
v-model="form.receptionDate"
|
v-model="form.receptionDate"
|
||||||
label="Date de réception"
|
label="Date de réception"
|
||||||
wrapper-class="col-start-1 row-start-3"
|
wrapper-class="col-start-1 row-start-3"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Type de réception -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="reception-type"
|
id="reception-type"
|
||||||
v-model="form.receptionTypeId"
|
v-model="form.receptionTypeId"
|
||||||
@@ -31,8 +30,8 @@
|
|||||||
label: type.label
|
label: type.label
|
||||||
}))"
|
}))"
|
||||||
wrapper-class="col-start-1 row-start-4"
|
wrapper-class="col-start-1 row-start-4"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Fournisseur -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="reception-supplier"
|
id="reception-supplier"
|
||||||
v-model="form.supplierId"
|
v-model="form.supplierId"
|
||||||
@@ -43,20 +42,17 @@
|
|||||||
}))"
|
}))"
|
||||||
:loading="isLoadingSuppliers"
|
:loading="isLoadingSuppliers"
|
||||||
wrapper-class="col-start-1 row-start-5"
|
wrapper-class="col-start-1 row-start-5"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Adresse fournisseur -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="reception-address"
|
id="reception-address"
|
||||||
v-model="form.addressId"
|
v-model="form.addressId"
|
||||||
label="Adresse"
|
label="Adresse"
|
||||||
:options="supplierAddresses.map((address) => ({
|
:options="addressOptions"
|
||||||
value: String(address.id),
|
:disabled="isLoadingSuppliers || ownerAddresses.length === 0"
|
||||||
label: address.fullAddress
|
|
||||||
}))"
|
|
||||||
:disabled="isLoadingSuppliers || supplierAddresses.length === 0"
|
|
||||||
wrapper-class="col-start-2 row-start-1"
|
wrapper-class="col-start-2 row-start-1"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Camion -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="reception-truck"
|
id="reception-truck"
|
||||||
v-model="form.truckId"
|
v-model="form.truckId"
|
||||||
@@ -67,8 +63,8 @@
|
|||||||
}))"
|
}))"
|
||||||
:loading="isLoadingTrucks"
|
:loading="isLoadingTrucks"
|
||||||
wrapper-class="col-start-2 row-start-2"
|
wrapper-class="col-start-2 row-start-2"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Transporteur -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="reception-carrier"
|
id="reception-carrier"
|
||||||
v-model="form.carrierId"
|
v-model="form.carrierId"
|
||||||
@@ -80,15 +76,15 @@
|
|||||||
:loading="isLoadingCarriers"
|
:loading="isLoadingCarriers"
|
||||||
select-class="h-[34px]"
|
select-class="h-[34px]"
|
||||||
wrapper-class="col-start-2 row-start-3"
|
wrapper-class="col-start-2 row-start-3"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Plaque d'immatriculation -->
|
|
||||||
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
|
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
|
||||||
<UiLicensePlateInput
|
<UiLicensePlateInput
|
||||||
v-model="form.licensePlate"
|
v-model="form.licensePlate"
|
||||||
v-model:allowAny="allowAnyLicensePlate"
|
v-model:allowAny="allowAnyLicensePlate"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Immatriculation (LIOT) -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
v-if="isLiotCarrier"
|
v-if="isLiotCarrier"
|
||||||
id="reception-vehicle"
|
id="reception-vehicle"
|
||||||
@@ -101,8 +97,8 @@
|
|||||||
:loading="isLoadingVehicles"
|
:loading="isLoadingVehicles"
|
||||||
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
|
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
|
||||||
wrapper-class="col-start-2 row-start-4 h-[64px]"
|
wrapper-class="col-start-2 row-start-4 h-[64px]"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Chauffeur (LIOT) -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="reception-driver"
|
id="reception-driver"
|
||||||
v-model="form.driverId"
|
v-model="form.driverId"
|
||||||
@@ -114,42 +110,39 @@
|
|||||||
:loading="isLoadingDrivers"
|
:loading="isLoadingDrivers"
|
||||||
v-if="isLiotCarrier"
|
v-if="isLiotCarrier"
|
||||||
wrapper-class="col-start-2 row-start-5"
|
wrapper-class="col-start-2 row-start-5"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<UiButton
|
<UiButton
|
||||||
type="submit"
|
type="submit"
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||||
|
@click="submitted = true"
|
||||||
>Valider
|
>Valider
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {useReceptionStore} from '~/stores/reception'
|
import { useReceptionStore } from '~/stores/reception'
|
||||||
import type {ReceptionTypeData} from '~/services/dto/reception-type-data'
|
import { useFormDataLoading } from '~/composables/useFormDataLoading'
|
||||||
import {getReceptionTypeList} from '~/services/reception-type'
|
import { useLiotHandling } from '~/composables/useLiotHandling'
|
||||||
import type {UserData} from '~/services/dto/user-data'
|
import { useAddressSync } from '~/composables/useAddressSync'
|
||||||
import {getUsers} from '~/services/auth'
|
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||||
import {useAuthStore} from '~/stores/auth'
|
import { getReceptionTypeList } from '~/services/reception-type'
|
||||||
import type {SupplierData} from '~/services/dto/supplier-data'
|
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||||
import {getSupplierList} from '~/services/supplier'
|
import { getSupplierList } from '~/services/supplier'
|
||||||
import type {TruckData} from '~/services/dto/truck-data'
|
import { RECEPTION_TYPE_CODES } from '~/utils/constants'
|
||||||
import {getTruckList} from '~/services/truck'
|
import { deleteReceptionBovine, getReceptionBovineList } from '~/services/reception-bovine'
|
||||||
import type {CarrierData} from '~/services/dto/carrier-data'
|
import type { ReceptionFormData } from '~/services/dto/reception-data'
|
||||||
import {getCarrierList} from '~/services/carrier'
|
|
||||||
import type {DriverData} from '~/services/dto/driver-data'
|
|
||||||
import {getDriverList} from '~/services/driver'
|
|
||||||
import type {VehicleData} from '~/services/dto/vehicle-data'
|
|
||||||
import {getVehicleList} from '~/services/vehicle'
|
|
||||||
import {RECEPTION_TYPE_CODES, SUPPLIER_CODE} from "~/utils/constants";
|
|
||||||
import {deleteReceptionBovine, getReceptionBovineList} from "~/services/reception-bovine";
|
|
||||||
import type {ReceptionFormData} from "~/services/dto/reception-data";
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const receptionStore = useReceptionStore()
|
const receptionStore = useReceptionStore()
|
||||||
|
const isHydrating = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
const formRef = ref<HTMLFormElement | null>(null)
|
||||||
|
|
||||||
const form = reactive<ReceptionFormData>({
|
const form = reactive<ReceptionFormData>({
|
||||||
licensePlate: '',
|
licensePlate: '',
|
||||||
receptionDate: new Date().toISOString().slice(0, 10),
|
receptionDate: new Date().toISOString().slice(0, 10),
|
||||||
@@ -162,62 +155,27 @@ const form = reactive<ReceptionFormData>({
|
|||||||
driverId: '',
|
driverId: '',
|
||||||
vehicleId: ''
|
vehicleId: ''
|
||||||
})
|
})
|
||||||
const allowAnyLicensePlate = ref(false)
|
|
||||||
const receptionTypes = ref<ReceptionTypeData[]>([])
|
const receptionTypes = ref<ReceptionTypeData[]>([])
|
||||||
const users = ref<UserData[]>([])
|
|
||||||
const isLoadingUsers = ref(false)
|
|
||||||
const suppliers = ref<SupplierData[]>([])
|
const suppliers = ref<SupplierData[]>([])
|
||||||
const isLoadingSuppliers = ref(false)
|
const isLoadingSuppliers = ref(false)
|
||||||
const trucks = ref<TruckData[]>([])
|
|
||||||
const isLoadingTrucks = ref(false)
|
|
||||||
const carriers = ref<CarrierData[]>([])
|
|
||||||
const isLoadingCarriers = ref(false)
|
|
||||||
const drivers = ref<DriverData[]>([])
|
|
||||||
const isLoadingDrivers = ref(false)
|
|
||||||
const vehicles = ref<VehicleData[]>([])
|
|
||||||
const isLoadingVehicles = ref(false)
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
// Empêche les watchers de reset des champs pendant le remplissage initial
|
|
||||||
const isHydrating = ref(false)
|
|
||||||
|
|
||||||
// Transporteur sélectionné dans le formulaire
|
const { users, trucks, carriers, isLoadingUsers, isLoadingTrucks, isLoadingCarriers, loadCommonData } =
|
||||||
const selectedCarrier = computed(() =>
|
useFormDataLoading(form)
|
||||||
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
|
|
||||||
)
|
const {
|
||||||
// Indique si le transporteur est LIOT
|
isLiotCarrier, filteredDrivers, filteredVehicles,
|
||||||
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
|
isLoadingDrivers, isLoadingVehicles, allowAnyLicensePlate,
|
||||||
// Adresses disponibles pour le fournisseur sélectionné
|
loadDrivers, loadVehicles
|
||||||
const supplierAddresses = computed(() => {
|
} = useLiotHandling(form, carriers, isHydrating)
|
||||||
const supplierId = Number(form.supplierId)
|
|
||||||
if (!Number.isFinite(supplierId)) {
|
const supplierIdRef = computed(() => form.supplierId)
|
||||||
return []
|
const { ownerAddresses, addressOptions } = useAddressSync(form, supplierIdRef, suppliers)
|
||||||
}
|
|
||||||
return suppliers.value.find((supplier) => supplier.id === supplierId)?.addresses ?? []
|
|
||||||
})
|
|
||||||
// Chauffeurs filtrés par transporteur (LIOT)
|
|
||||||
const filteredDrivers = computed<DriverData[]>(() => {
|
|
||||||
if (!form.carrierId) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
|
|
||||||
})
|
|
||||||
// Véhicules filtrés par transporteur + type de camion
|
|
||||||
const filteredVehicles = computed<VehicleData[]>(() => {
|
|
||||||
if (!form.carrierId) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return vehicles.value.filter(
|
|
||||||
(vehicle) =>
|
|
||||||
String(vehicle.carrier?.id) === form.carrierId &&
|
|
||||||
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedReceptionType = computed(() =>
|
const selectedReceptionType = computed(() =>
|
||||||
receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null
|
receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null
|
||||||
)
|
)
|
||||||
|
|
||||||
// Supprime les données bovines si on change de type de réception
|
|
||||||
const clearReceptionBovines = async (receptionIri: string) => {
|
const clearReceptionBovines = async (receptionIri: string) => {
|
||||||
const existing = await getReceptionBovineList(receptionIri)
|
const existing = await getReceptionBovineList(receptionIri)
|
||||||
for (const selection of existing) {
|
for (const selection of existing) {
|
||||||
@@ -225,50 +183,6 @@ const clearReceptionBovines = async (receptionIri: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hydrate le formulaire depuis la réception en cours
|
|
||||||
watch(
|
|
||||||
() => receptionStore.current,
|
|
||||||
(reception) => {
|
|
||||||
isHydrating.value = true
|
|
||||||
form.licensePlate = reception?.licensePlate ?? ''
|
|
||||||
form.receptionDate = reception?.receptionDate ?? new Date().toISOString().slice(0, 10)
|
|
||||||
form.receptionTypeId = reception?.receptionType?.id
|
|
||||||
? String(reception.receptionType.id)
|
|
||||||
: ''
|
|
||||||
form.userId = reception?.user?.id
|
|
||||||
? String(reception.user.id)
|
|
||||||
: form.userId
|
|
||||||
form.supplierId = reception?.supplier?.id
|
|
||||||
? String(reception.supplier.id)
|
|
||||||
: ''
|
|
||||||
form.addressId = reception?.address?.id
|
|
||||||
? String(reception.address.id)
|
|
||||||
: ''
|
|
||||||
form.truckId = reception?.truck?.id
|
|
||||||
? String(reception.truck.id)
|
|
||||||
: ''
|
|
||||||
form.carrierId = reception?.carrier?.id
|
|
||||||
? String(reception.carrier.id)
|
|
||||||
: ''
|
|
||||||
form.driverId = reception?.driver?.id
|
|
||||||
? String(reception.driver.id)
|
|
||||||
: ''
|
|
||||||
isHydrating.value = false
|
|
||||||
},
|
|
||||||
{immediate: true}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Charge la liste des users pour le select
|
|
||||||
const loadUsers = async () => {
|
|
||||||
isLoadingUsers.value = true
|
|
||||||
try {
|
|
||||||
users.value = await getUsers()
|
|
||||||
} finally {
|
|
||||||
isLoadingUsers.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Charge la liste des fournisseurs pour le select
|
|
||||||
const loadSuppliers = async () => {
|
const loadSuppliers = async () => {
|
||||||
isLoadingSuppliers.value = true
|
isLoadingSuppliers.value = true
|
||||||
try {
|
try {
|
||||||
@@ -278,186 +192,33 @@ const loadSuppliers = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charge la liste des camions pour le select
|
watch(
|
||||||
const loadTrucks = async () => {
|
() => receptionStore.current,
|
||||||
isLoadingTrucks.value = true
|
(reception) => {
|
||||||
try {
|
isHydrating.value = true
|
||||||
trucks.value = await getTruckList()
|
form.licensePlate = reception?.licensePlate ?? ''
|
||||||
} finally {
|
form.receptionDate = reception?.receptionDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)
|
||||||
isLoadingTrucks.value = false
|
form.receptionTypeId = reception?.receptionType?.id ? String(reception.receptionType.id) : ''
|
||||||
}
|
form.userId = reception?.user?.id ? String(reception.user.id) : form.userId
|
||||||
}
|
form.supplierId = reception?.supplier?.id ? String(reception.supplier.id) : ''
|
||||||
|
form.addressId = reception?.address?.id ? String(reception.address.id) : ''
|
||||||
|
form.truckId = reception?.truck?.id ? String(reception.truck.id) : ''
|
||||||
|
form.carrierId = reception?.carrier?.id ? String(reception.carrier.id) : ''
|
||||||
|
form.driverId = reception?.driver?.id ? String(reception.driver.id) : ''
|
||||||
|
isHydrating.value = false
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
// Charge la liste des transporteurs pour le select
|
|
||||||
const loadCarriers = async () => {
|
|
||||||
isLoadingCarriers.value = true
|
|
||||||
try {
|
|
||||||
carriers.value = await getCarrierList()
|
|
||||||
} finally {
|
|
||||||
isLoadingCarriers.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Charge la liste des chauffeurs pour le select
|
|
||||||
const loadDrivers = async () => {
|
|
||||||
isLoadingDrivers.value = true
|
|
||||||
try {
|
|
||||||
drivers.value = await getDriverList()
|
|
||||||
} finally {
|
|
||||||
isLoadingDrivers.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Charge la liste des véhicules pour le select
|
|
||||||
const loadVehicles = async () => {
|
|
||||||
isLoadingVehicles.value = true
|
|
||||||
try {
|
|
||||||
vehicles.value = await getVehicleList()
|
|
||||||
} finally {
|
|
||||||
isLoadingVehicles.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// On met le user connecté par défaut dans le select
|
|
||||||
const setDefaultUser = () => {
|
|
||||||
if (form.userId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (authStore.user?.id) {
|
|
||||||
form.userId = String(authStore.user.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// On récupère toutes les données des selects au chargement du composant
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
receptionTypes.value = await getReceptionTypeList()
|
receptionTypes.value = await getReceptionTypeList()
|
||||||
await loadUsers()
|
|
||||||
await loadSuppliers()
|
await loadSuppliers()
|
||||||
await loadTrucks()
|
await loadCommonData()
|
||||||
await loadCarriers()
|
|
||||||
await loadDrivers()
|
await loadDrivers()
|
||||||
await loadVehicles()
|
await loadVehicles()
|
||||||
await authStore.ensureSession()
|
|
||||||
setDefaultUser()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
|
const buildPayload = () => {
|
||||||
watch(
|
|
||||||
() => [form.supplierId, form.addressId, suppliers.value],
|
|
||||||
() => {
|
|
||||||
if (!form.supplierId) {
|
|
||||||
form.addressId = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!form.addressId && supplierAddresses.value.length === 1) {
|
|
||||||
form.addressId = String(supplierAddresses.value[0].id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!form.addressId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const matches = supplierAddresses.value.some(
|
|
||||||
(address) => String(address.id) === form.addressId
|
|
||||||
)
|
|
||||||
if (!matches) {
|
|
||||||
if (supplierAddresses.value.length === 1) {
|
|
||||||
form.addressId = String(supplierAddresses.value[0].id)
|
|
||||||
} else {
|
|
||||||
form.addressId = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{immediate: true}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Valide/auto-sélectionne le véhicule selon camion + transporteur (LIOT)
|
|
||||||
watch(
|
|
||||||
() => form.carrierId,
|
|
||||||
() => {
|
|
||||||
if (isHydrating.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!form.carrierId) {
|
|
||||||
form.driverId = ''
|
|
||||||
form.vehicleId = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isLiotCarrier.value) {
|
|
||||||
form.driverId = ''
|
|
||||||
form.vehicleId = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (filteredDrivers.value.length === 1) {
|
|
||||||
form.driverId = String(filteredDrivers.value[0].id)
|
|
||||||
}
|
|
||||||
if (filteredVehicles.value.length === 1) {
|
|
||||||
form.vehicleId = String(filteredVehicles.value[0].id)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{immediate: true}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Récupère la plaque depuis le véhicule choisi (LIOT)
|
|
||||||
watch(
|
|
||||||
() => [form.truckId, form.carrierId, vehicles.value],
|
|
||||||
() => {
|
|
||||||
if (!isLiotCarrier.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (filteredVehicles.value.length === 1) {
|
|
||||||
form.vehicleId = String(filteredVehicles.value[0].id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!form.vehicleId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const matches = filteredVehicles.value.some(
|
|
||||||
(vehicle) => String(vehicle.id) === form.vehicleId
|
|
||||||
)
|
|
||||||
if (!matches) {
|
|
||||||
form.vehicleId = ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{immediate: true}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Auto-renseigne le véhicule si la plaque correspond (LIOT)
|
|
||||||
watch(
|
|
||||||
() => [form.vehicleId, form.carrierId, vehicles.value],
|
|
||||||
() => {
|
|
||||||
if (!isLiotCarrier.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isHydrating.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const selected = filteredVehicles.value.find(
|
|
||||||
(vehicle) => String(vehicle.id) === form.vehicleId
|
|
||||||
)
|
|
||||||
if (selected) {
|
|
||||||
form.licensePlate = selected.plate
|
|
||||||
allowAnyLicensePlate.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [form.licensePlate, form.carrierId, vehicles.value],
|
|
||||||
() => {
|
|
||||||
if (!isLiotCarrier.value || form.vehicleId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const match = filteredVehicles.value.find(
|
|
||||||
(vehicle) => vehicle.plate === form.licensePlate
|
|
||||||
)
|
|
||||||
if (match) {
|
|
||||||
form.vehicleId = String(match.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Valide le formulaire et crée/met à jour la réception
|
|
||||||
async function validate() {
|
|
||||||
const normalizedLicensePlate = form.licensePlate.trim()
|
const normalizedLicensePlate = form.licensePlate.trim()
|
||||||
const normalizedReceptionDate = form.receptionDate.trim()
|
const normalizedReceptionDate = form.receptionDate.trim()
|
||||||
const normalizedReceptionTypeId = form.receptionTypeId.trim()
|
const normalizedReceptionTypeId = form.receptionTypeId.trim()
|
||||||
@@ -467,29 +228,16 @@ async function validate() {
|
|||||||
const normalizedTruckId = form.truckId.trim()
|
const normalizedTruckId = form.truckId.trim()
|
||||||
const normalizedCarrierId = form.carrierId.trim()
|
const normalizedCarrierId = form.carrierId.trim()
|
||||||
const normalizedDriverId = form.driverId.trim()
|
const normalizedDriverId = form.driverId.trim()
|
||||||
const receptionTypeIri = normalizedReceptionTypeId
|
|
||||||
? `/api/reception_types/${normalizedReceptionTypeId}`
|
|
||||||
: null
|
|
||||||
const userIri = normalizedUserId
|
|
||||||
? `/api/users/${normalizedUserId}`
|
|
||||||
: null
|
|
||||||
const supplierIri = normalizedSupplierId
|
|
||||||
? `/api/suppliers/${normalizedSupplierId}`
|
|
||||||
: null
|
|
||||||
const addressIri = normalizedAddressId
|
|
||||||
? `/api/addresses/${normalizedAddressId}`
|
|
||||||
: null
|
|
||||||
const truckIri = normalizedTruckId
|
|
||||||
? `/api/trucks/${normalizedTruckId}`
|
|
||||||
: null
|
|
||||||
const carrierIri = normalizedCarrierId
|
|
||||||
? `/api/carriers/${normalizedCarrierId}`
|
|
||||||
: null
|
|
||||||
const driverIri = normalizedDriverId
|
|
||||||
? `/api/drivers/${normalizedDriverId}`
|
|
||||||
: null
|
|
||||||
|
|
||||||
const basePayload = {
|
const receptionTypeIri = normalizedReceptionTypeId ? `/api/reception_types/${normalizedReceptionTypeId}` : null
|
||||||
|
const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
|
||||||
|
const supplierIri = normalizedSupplierId ? `/api/suppliers/${normalizedSupplierId}` : null
|
||||||
|
const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
|
||||||
|
const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
|
||||||
|
const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
|
||||||
|
const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
|
||||||
|
|
||||||
|
return {
|
||||||
licensePlate: normalizedLicensePlate,
|
licensePlate: normalizedLicensePlate,
|
||||||
receptionDate: normalizedReceptionDate,
|
receptionDate: normalizedReceptionDate,
|
||||||
receptionType: receptionTypeIri,
|
receptionType: receptionTypeIri,
|
||||||
@@ -497,13 +245,35 @@ async function validate() {
|
|||||||
supplier: supplierIri,
|
supplier: supplierIri,
|
||||||
address: addressIri,
|
address: addressIri,
|
||||||
truck: truckIri,
|
truck: truckIri,
|
||||||
carrier: carrierIri
|
carrier: carrierIri,
|
||||||
|
...(isLiotCarrier.value && driverIri ? { driver: driverIri } : {})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const saveDraft = async () => {
|
||||||
...basePayload,
|
const payload = buildPayload()
|
||||||
...(isLiotCarrier.value && driverIri ? {driver: driverIri} : {})
|
if (!receptionStore.current) {
|
||||||
|
await receptionStore.createReception({
|
||||||
|
currentStep: 0,
|
||||||
|
...payload
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
await receptionStore.updateReception(receptionStore.current.id, {
|
||||||
|
currentStep: receptionStore.current.currentStep,
|
||||||
|
...payload
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFields = () => {
|
||||||
|
submitted.value = true
|
||||||
|
return formRef.value?.reportValidity() ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ saveDraft, validateFields })
|
||||||
|
|
||||||
|
async function validate() {
|
||||||
|
const payload = buildPayload()
|
||||||
|
|
||||||
if (!receptionStore.current) {
|
if (!receptionStore.current) {
|
||||||
const created = await receptionStore.createReception({
|
const created = await receptionStore.createReception({
|
||||||
@@ -532,5 +302,4 @@ async function validate() {
|
|||||||
...payload
|
...payload
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center gap-16">
|
<form :class="['flex flex-col items-center gap-16', { submitted }]" @submit.prevent="goNext">
|
||||||
<div
|
<div
|
||||||
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"
|
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"
|
||||||
class="flex flex-col gap-16 items-center w-full">
|
class="flex flex-col gap-16 items-center w-full">
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
label="Type de marchandises"
|
label="Type de marchandises"
|
||||||
:options="merchandiseTypes.map((type) => ({ value: String(type.id), label: type.label }))"
|
:options="merchandiseTypes.map((type) => ({ value: String(type.id), label: type.label }))"
|
||||||
wrapper-class="w-[550px]"
|
wrapper-class="w-[550px]"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="selectedMerchandiseTypeId && isAutres"
|
v-if="selectedMerchandiseTypeId && isAutres"
|
||||||
@@ -21,13 +22,15 @@
|
|||||||
label="Préciser"
|
label="Préciser"
|
||||||
placeholder="Précisions complémentaires"
|
placeholder="Précisions complémentaires"
|
||||||
:maxlength="255"
|
:maxlength="255"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="selectedMerchandiseTypeId && !isGranule"
|
v-if="selectedMerchandiseTypeId && !isGranule"
|
||||||
class="flex gap-4 w-[550px] justify-between"
|
class="flex flex-col gap-4 w-[550px]"
|
||||||
>
|
>
|
||||||
|
<div class="flex gap-4 justify-between">
|
||||||
<div
|
<div
|
||||||
v-for="building in buildings"
|
v-for="building in buildings"
|
||||||
:key="building.id"
|
:key="building.id"
|
||||||
@@ -40,6 +43,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-red-500 text-sm" :class="showBuildingError ? '' : 'invisible'">
|
||||||
|
Veuillez sélectionner au moins un bâtiment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="selectedMerchandiseTypeId && isGranule"
|
v-if="selectedMerchandiseTypeId && isGranule"
|
||||||
@@ -62,14 +69,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-red-500 text-sm" :class="showBuildingError ? '' : 'invisible'">
|
||||||
|
Veuillez sélectionner au moins un bâtiment.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex justify-center">
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
<UiButton
|
||||||
@click="goNext"
|
type="submit"
|
||||||
|
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||||
|
@click="submitted = true"
|
||||||
>Valider
|
>Valider
|
||||||
</button>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -97,6 +110,9 @@ const selectedMerchandiseTypeId = ref('')
|
|||||||
const selectedBuildingIds = ref<string[]>([])
|
const selectedBuildingIds = ref<string[]>([])
|
||||||
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
|
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
|
||||||
const merchandiseDetail = ref('')
|
const merchandiseDetail = ref('')
|
||||||
|
const submitted = ref(false)
|
||||||
|
const showBuildingError = ref(false)
|
||||||
|
const showPelletBuildingError = ref(false)
|
||||||
|
|
||||||
// Extrait l'ID d'une relation depuis un IRI ou un objet complet.
|
// Extrait l'ID d'une relation depuis un IRI ou un objet complet.
|
||||||
const getRelationId = (value: unknown): string | null => {
|
const getRelationId = (value: unknown): string | null => {
|
||||||
@@ -179,6 +195,23 @@ async function goNext() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showBuildingError.value = false
|
||||||
|
showPelletBuildingError.value = false
|
||||||
|
|
||||||
|
if (!isGranule.value && !isAutres.value && selectedBuildingIds.value.length === 0) {
|
||||||
|
showBuildingError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGranule.value) {
|
||||||
|
const hasAnyPelletBuilding = Object.values(selectedPelletBuildingIds.value)
|
||||||
|
.some((ids) => ids.length > 0)
|
||||||
|
if (!hasAnyPelletBuilding) {
|
||||||
|
showPelletBuildingError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nextStep = receptionStore.current.currentStep + 1
|
const nextStep = receptionStore.current.currentStep + 1
|
||||||
const receptionIri = `/api/receptions/${receptionStore.current.id}`
|
const receptionIri = `/api/receptions/${receptionStore.current.id}`
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<div class="flex flex-col items-center w-[660px]">
|
|
||||||
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
|
|
||||||
<!--@TODO Voir comment faire pour savoir si le pont-bascule et bien connecté + ajouter un icon comme sur la maquette-->
|
|
||||||
<p class="text-primary-500 uppercase text-2xl text-primary-500 mt-2">Pont-bascule connecté</p>
|
|
||||||
<div
|
|
||||||
v-if="showLoadingBox"
|
|
||||||
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
|
|
||||||
<UiLoadingDots />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="displayWeight !== null" class="w-full">
|
|
||||||
<div
|
|
||||||
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl text-primary-500">
|
|
||||||
{{ displayWeight }} kg
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center mt-[54px]">
|
|
||||||
<UiButton
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
|
||||||
@click="fetchWeight"
|
|
||||||
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
|
|
||||||
<UiButton
|
|
||||||
v-if="displayWeight !== null && !showGenerateReceipt"
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
|
|
||||||
@click="saveWeight"
|
|
||||||
>Valider la pesée</UiButton>
|
|
||||||
<UiButton
|
|
||||||
v-if="showGenerateReceipt"
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
|
|
||||||
@click="printReceipt"
|
|
||||||
>Générer le bon</UiButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {computed, onMounted} from 'vue'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { useWeighing } from '~/composables/useWeighing'
|
|
||||||
import { usePdfPrinter } from '~/composables/usePdfPrinter'
|
|
||||||
import { useReceptionStore } from '~/stores/reception'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
mode: 'gross' | 'tare'
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const receptionStore = useReceptionStore()
|
|
||||||
const { current: storeReception } = storeToRefs(receptionStore)
|
|
||||||
const { printPdf } = usePdfPrinter()
|
|
||||||
const {
|
|
||||||
displayWeight,
|
|
||||||
title,
|
|
||||||
showLoadingBox,
|
|
||||||
fetchWeight,
|
|
||||||
saveWeight
|
|
||||||
} = useWeighing({
|
|
||||||
mode: props.mode,
|
|
||||||
reception: storeReception,
|
|
||||||
updateReception: receptionStore.updateReception,
|
|
||||||
loadReception: receptionStore.loadReception
|
|
||||||
})
|
|
||||||
// Affiche le bouton de génération du bon à l'étape tare
|
|
||||||
const showGenerateReceipt = computed(
|
|
||||||
() => props.mode === 'tare' && displayWeight.value !== null
|
|
||||||
)
|
|
||||||
|
|
||||||
// Génère le bon de réception, puis clôture la réception
|
|
||||||
const printReceipt = async () => {
|
|
||||||
if (!import.meta.client || !receptionStore.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await saveWeight()
|
|
||||||
const reception = receptionStore.current
|
|
||||||
const filename = `${reception.identificationNumber ?? reception.id}_${reception.supplier?.name ?? 'fournisseur'}_${reception.licensePlate ?? 'immat'}.pdf`
|
|
||||||
await printPdf(`/receptions/${reception.id}/receipt`, filename)
|
|
||||||
|
|
||||||
// Laisse le temps a la boite de dialogue d'impression de s'ouvrir.
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
||||||
|
|
||||||
const result = await receptionStore.updateReception(receptionStore.current.id, {
|
|
||||||
isValid: true
|
|
||||||
})
|
|
||||||
if (!result) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
receptionStore.clearCurrent()
|
|
||||||
await router.push('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Récupère le poids dès l'arrivée sur l'écran
|
|
||||||
onMounted(() => {
|
|
||||||
if (displayWeight.value === null) {
|
|
||||||
fetchWeight()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,183 +1,161 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="validate">
|
<form>
|
||||||
|
<div class="flex flex-row justify-between gap-x-12 font-bold uppercase mb-8">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center gap-16">
|
v-for="type in bovineTypes"
|
||||||
<div
|
|
||||||
class="flex flex-row gap-6 items-center">
|
|
||||||
<div
|
|
||||||
v-for="type in bovineType"
|
|
||||||
:key="type.id"
|
:key="type.id"
|
||||||
class="flex flex-row mb-2 gap-6 ">
|
>
|
||||||
<UiNumberInput
|
<UiNumberInput
|
||||||
:label="type.label"
|
:label="type.label"
|
||||||
:code="type.code"
|
:code="type.code"
|
||||||
v-model="bovineQuantities[String(type.id)]"
|
v-model="localQuantities[String(type.id)]"
|
||||||
:disabled="!auth.isAdmin"
|
:disabled="!isAdmin"
|
||||||
:placeholder="0"
|
:placeholder="0"
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="10"
|
:max="10"
|
||||||
|
wrapperClass="w-44 flex-col"
|
||||||
|
inputClass="font-medium"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class=" flex flex-row mb-2 gap-6">
|
|
||||||
<UiNumberInput
|
<UiNumberInput
|
||||||
label="Autres"
|
label="Autres"
|
||||||
v-model="otherQuantity"
|
v-model="localOtherQuantity"
|
||||||
:disabled="!auth.isAdmin"
|
:disabled="!isAdmin"
|
||||||
|
wrapperClass="w-44 flex-col"
|
||||||
|
inputClass="font-medium"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<UiButton
|
|
||||||
type="submit"
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
|
||||||
:disabled="!auth.isAdmin"
|
|
||||||
>Valider
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
import {getBovineTypeList} from "~/services/bovine-type";
|
import { getBovineTypeList } from '~/services/bovine-type'
|
||||||
import {
|
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
|
||||||
createReceptionBovine,
|
import type { ReceptionBovineTypeData } from '~/services/dto/reception-bovine-data'
|
||||||
deleteReceptionBovine,
|
|
||||||
getReceptionBovineList,
|
|
||||||
updateReceptionBovine
|
|
||||||
} from "~/services/reception-bovine";
|
|
||||||
import {computed, onMounted, reactive, ref, watch} from "vue";
|
|
||||||
import {getReception, updateReception} from "~/services/reception";
|
|
||||||
const toast = useToast()
|
|
||||||
const isLoadingBovineType = ref(false)
|
|
||||||
const bovineType = ref<BovineTypeData[]>([])
|
|
||||||
const bovineQuantities = reactive<Record<string, number | null>>({})
|
|
||||||
const otherQuantity = ref<number | null>(0)
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
idReception: number
|
modelValue: ReceptionBovineTypeData[]
|
||||||
|
otherQuantity: number | null
|
||||||
|
isAdmin: boolean
|
||||||
}>()
|
}>()
|
||||||
const receptionId = props.idReception
|
|
||||||
const reception = await getReception(receptionId)
|
|
||||||
|
|
||||||
const receptionIri = computed(() =>
|
const emit = defineEmits<{
|
||||||
receptionId ? `/api/receptions/${receptionId}` : null
|
(event: 'update:modelValue', value: ReceptionBovineTypeData[]): void
|
||||||
)
|
(event: 'update:otherQuantity', value: number | null): void
|
||||||
const totalBovines = computed(() => {
|
}>()
|
||||||
const base = Object.values(bovineQuantities).reduce((sum, value) => {
|
|
||||||
return sum + (value ?? 0)
|
|
||||||
}, 0)
|
|
||||||
return base + (otherQuantity.value ?? 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadBovineType = async () => {
|
const bovineTypes = ref<BovineTypeData[]>([])
|
||||||
isLoadingBovineType.value = true
|
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 {
|
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 {
|
} finally {
|
||||||
isLoadingBovineType.value = false
|
isSyncing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
watch(
|
||||||
await loadBovineType()
|
() => props.otherQuantity,
|
||||||
|
(value) => {
|
||||||
|
if (isSyncing.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = value ?? 0
|
||||||
|
isSyncing.value = true
|
||||||
|
localOtherQuantity.value = next
|
||||||
|
isSyncing.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(localOtherQuantity, (value) => {
|
||||||
|
if (isSyncing.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = value ?? 0
|
||||||
|
emit('update:otherQuantity', next)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => receptionId, () => bovineType.value],
|
() => props.modelValue,
|
||||||
async ([id, types]) => {
|
() => {
|
||||||
if (!id || !receptionIri.value || types.length === 0) {
|
// Hydratation locale uniquement quand le parent change.
|
||||||
return
|
syncLocalFromProps()
|
||||||
}
|
|
||||||
|
|
||||||
const selectionMap: Record<string, number | null> = {}
|
|
||||||
for (const type of types) {
|
|
||||||
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 = reception.bovineDetail
|
|
||||||
const parsedOther =
|
|
||||||
typeof existingOther === 'string' && existingOther.trim() !== ''
|
|
||||||
? Number(existingOther)
|
|
||||||
: 0
|
|
||||||
otherQuantity.value = Number.isFinite(parsedOther) ? parsedOther : 0
|
|
||||||
},
|
},
|
||||||
{immediate: true}
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
async function syncBovineSelections(receptionIri: string) {
|
watch(
|
||||||
const existing = await getReceptionBovineList(receptionIri)
|
localQuantities,
|
||||||
const existingMap = new Map<string, { id: number; quantity: number | null }>()
|
() => {
|
||||||
|
if (isSyncing.value) {
|
||||||
for (const selection of existing) {
|
|
||||||
const bovineTypeId = String(selection.bovineType.id)
|
|
||||||
existingMap.set(bovineTypeId, {
|
|
||||||
id: selection.id,
|
|
||||||
quantity: selection.quantity ?? 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supprime les entrées supprimées ou modifiées
|
|
||||||
for (const [bovineTypeId, entry] of existingMap.entries()) {
|
|
||||||
const selectedQuantity = bovineQuantities[bovineTypeId] ?? 0
|
|
||||||
if (!selectedQuantity) {
|
|
||||||
await deleteReceptionBovine(entry.id)
|
|
||||||
existingMap.delete(bovineTypeId)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedQuantity !== entry.quantity) {
|
|
||||||
await updateReceptionBovine(entry.id, {quantity: selectedQuantity})
|
|
||||||
existingMap.set(bovineTypeId, {
|
|
||||||
id: entry.id,
|
|
||||||
quantity: selectedQuantity
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crée les entrées manquantes
|
|
||||||
for (const [bovineTypeId, quantity] of Object.entries(bovineQuantities)) {
|
|
||||||
if (!quantity) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (existingMap.has(bovineTypeId)) {
|
|
||||||
// Déjà à jour
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
await createReceptionBovine({
|
|
||||||
reception: receptionIri,
|
|
||||||
bovineType: `/api/bovine_types/${bovineTypeId}`,
|
|
||||||
quantity
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validate() {
|
|
||||||
// @TODO Ajouter un composable pour le toaster qui gère les key i18n
|
|
||||||
if (totalBovines.value > 52) {
|
|
||||||
toast.error({
|
|
||||||
title: 'Erreur',
|
|
||||||
message: ('Le total des bovins ne peut pas dépasser 52.')
|
|
||||||
})
|
|
||||||
return
|
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 }
|
||||||
|
)
|
||||||
|
|
||||||
await syncBovineSelections(receptionIri.value)
|
onMounted(async () => {
|
||||||
|
bovineTypes.value = await getBovineTypeList()
|
||||||
await updateReception(receptionId, {
|
syncLocalFromProps()
|
||||||
merchandiseType: null,
|
})
|
||||||
merchandiseDetail: null,
|
|
||||||
bovineDetail: otherQuantity.value ? String(otherQuantity.value) : null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="validate">
|
<form>
|
||||||
<div class="flex flex-col items-center gap-16">
|
<div class="flex flex-col">
|
||||||
<div
|
<div class="w-full relative grid grid-cols-[1fr_200px]">
|
||||||
class="flex flex-col gap-16 items-center w-full">
|
<UiRadioGroup
|
||||||
<UiTextInput
|
|
||||||
id="merchandise-type"
|
id="merchandise-type"
|
||||||
v-model="selectedMerchandiseTypeId"
|
v-model="selectedMerchandiseTypeId"
|
||||||
label="Type de marchandises"
|
label="Type de marchandises"
|
||||||
:value="reception.merchandiseType?.label"
|
:options="merchandiseTypes.map((type) => ({
|
||||||
wrapper-class="w-[550px]"
|
value: String(type.id),
|
||||||
:disabled="true"
|
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"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
v-if="merchandiseTypeId && isAutres"
|
|
||||||
class="flex flex-col w-full max-w-[550px]"
|
|
||||||
>
|
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
|
v-if="isAutres"
|
||||||
id="merchandise-detail"
|
id="merchandise-detail"
|
||||||
:disabled="!auth.isAdmin"
|
:disabled="!isAdmin"
|
||||||
v-model="merchandiseDetail"
|
v-model="merchandiseDetail"
|
||||||
label="Préciser"
|
placeholder="Préciser"
|
||||||
placeholder="Précisions complémentaires"
|
|
||||||
:maxlength="255"
|
:maxlength="255"
|
||||||
|
wrapper-class="w-[200px] mt-12 mb-7"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="merchandiseTypeId && !isGranule"
|
v-if="selectedMerchandiseTypeId && !isGranule"
|
||||||
class="flex gap-4 w-[550px] justify-evenly"
|
class="w-full grid grid-cols-[1fr_200px]"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-4 gap-6"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="building in buildings"
|
v-for="building in buildings"
|
||||||
@@ -37,29 +41,32 @@
|
|||||||
v-model="selectedBuildingIds"
|
v-model="selectedBuildingIds"
|
||||||
:value="String(building.id)"
|
:value="String(building.id)"
|
||||||
:label="building.label"
|
:label="building.label"
|
||||||
:disabled="!auth.isAdmin"
|
:disabled="!isAdmin"
|
||||||
label-class="text-xl"
|
input-class="accent-primary-700 focus:ring-primary-700"
|
||||||
|
label-class="uppercase"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="merchandiseTypeId && isGranule"
|
v-if="selectedMerchandiseTypeId && isGranule"
|
||||||
class="flex flex-col gap-10 w-full max-w-[1100px]"
|
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">
|
<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
|
<div
|
||||||
v-for="building in buildings"
|
v-for="building in buildings"
|
||||||
:key="building.id"
|
:key="building.id"
|
||||||
class="flex items-center gap-2 text-lg"
|
class="flex text-lg"
|
||||||
>
|
>
|
||||||
<UiCheckbox
|
<UiCheckbox
|
||||||
v-model="selectedPelletBuildingIds[String(type.id)]"
|
v-model="selectedPelletBuildingIds[String(type.id)]"
|
||||||
:value="String(building.id)"
|
:value="String(building.id)"
|
||||||
:label="building.label"
|
:label="building.label"
|
||||||
:disabled="!auth.isAdmin"
|
:disabled="!isAdmin"
|
||||||
|
input-class="accent-primary-700 focus:ring-primary-700"
|
||||||
label-class="text-lg"
|
label-class="text-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,82 +74,181 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UiButton
|
|
||||||
v-if="auth.isAdmin"
|
|
||||||
type="submit"
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
|
||||||
:disabled="!auth.isAdmin"
|
|
||||||
>Valider
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, onMounted, ref} from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import {getBuildingList} from '~/services/building'
|
import type { BuildingData } from '~/services/dto/building-data'
|
||||||
import {getMerchandiseTypeList} from '~/services/merchandise-type'
|
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
|
||||||
import type {MerchandiseTypeData} from '~/services/dto/merchandise-type-data'
|
import type { PelletTypeData } from '~/services/dto/pellet-type-data'
|
||||||
import type {BuildingData} from '~/services/dto/building-data'
|
import type { MerchandiseEntryData } from '~/services/dto/reception-data'
|
||||||
import type {PelletTypeData} from '~/services/dto/pellet-type-data'
|
import { getBuildingList } from '~/services/building'
|
||||||
import {getPelletTypeList} from '~/services/pellet-type'
|
import { getMerchandiseTypeList } from '~/services/merchandise-type'
|
||||||
import {
|
import { getPelletTypeList } from '~/services/pellet-type'
|
||||||
createReceptionPelletBuilding,
|
import { MERCHANDISE_TYPE_CODES } from '~/utils/constants'
|
||||||
deleteReceptionPelletBuilding,
|
|
||||||
getReceptionPelletBuildingList
|
const props = defineProps<{
|
||||||
} from '~/services/reception-pellet-building'
|
modelValue: MerchandiseEntryData
|
||||||
import {MERCHANDISE_TYPE_CODES} from '~/utils/constants'
|
isAdmin: boolean
|
||||||
import {getReception, updateReception} from "~/services/reception";
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: MerchandiseEntryData): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const merchandiseTypes = ref<MerchandiseTypeData[]>([])
|
const merchandiseTypes = ref<MerchandiseTypeData[]>([])
|
||||||
const buildings = ref<BuildingData[]>([])
|
const buildings = ref<BuildingData[]>([])
|
||||||
const pelletTypes = ref<PelletTypeData[]>([])
|
const pelletTypes = ref<PelletTypeData[]>([])
|
||||||
|
|
||||||
const selectedMerchandiseTypeId = ref('')
|
const selectedMerchandiseTypeId = ref('')
|
||||||
const selectedBuildingIds = ref<string[]>([])
|
const selectedBuildingIds = ref<string[]>([])
|
||||||
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
|
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
|
||||||
const merchandiseDetail = ref('')
|
const merchandiseDetail = ref('')
|
||||||
const auth = useAuthStore()
|
// Verrou de synchro pour empêcher les aller-retours infinis entre parent et composant.
|
||||||
const props = defineProps<{
|
const isSyncing = ref(false)
|
||||||
idReception: number
|
const isReady = ref(false)
|
||||||
}>()
|
|
||||||
const receptionId = props.idReception
|
|
||||||
const reception = await getReception(receptionId)
|
|
||||||
const merchandiseTypeId = await reception.receptionType?.id
|
|
||||||
|
|
||||||
// Extrait l'ID d'une relation depuis un IRI ou un objet complet.
|
const selectedMerchandiseType = computed(() =>
|
||||||
const getRelationId = (value: unknown): string | null => {
|
merchandiseTypes.value.find((type) => String(type.id) === selectedMerchandiseTypeId.value) ?? null
|
||||||
if (!value) {
|
)
|
||||||
return null
|
const isGranule = computed(
|
||||||
}
|
() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.GRANULE
|
||||||
|
)
|
||||||
|
const isAutres = computed(
|
||||||
|
() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES
|
||||||
|
)
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
function clonePelletSelections(value: Record<string, string[]>) {
|
||||||
const match = value.match(/\/(\d+)$/)
|
const clone: Record<string, string[]> = {}
|
||||||
return match ? match[1] : null
|
for (const [key, buildingIds] of Object.entries(value)) {
|
||||||
|
clone[key] = [...buildingIds]
|
||||||
}
|
}
|
||||||
|
return clone
|
||||||
if (typeof value === 'object' && 'id' in value) {
|
|
||||||
const record = value as { id?: number | string }
|
|
||||||
if (typeof record.id === 'number') {
|
|
||||||
return String(record.id)
|
|
||||||
}
|
|
||||||
if (typeof record.id === 'string') {
|
|
||||||
return record.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type de marchandise sélectionné dans le select
|
function sorted(values: string[]): string[] {
|
||||||
const selectedMerchandiseType = computed(() =>
|
return [...values].sort()
|
||||||
merchandiseTypes.value.find((type) => String(type.id) === selectedMerchandiseTypeId.value)
|
}
|
||||||
)
|
|
||||||
// Indique si le type est "Granulé"
|
function normalizeModel(value: MerchandiseEntryData): MerchandiseEntryData {
|
||||||
const isGranule = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.GRANULE)
|
// Normalisation stable pour comparer deux modèles sans faux positifs (ordre des tableaux).
|
||||||
// Indique si le type est "Autres"
|
const pellet: Record<string, string[]> = {}
|
||||||
const isAutres = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES)
|
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 () => {
|
onMounted(async () => {
|
||||||
const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([
|
const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([
|
||||||
getMerchandiseTypeList(),
|
getMerchandiseTypeList(),
|
||||||
@@ -153,106 +259,7 @@ onMounted(async () => {
|
|||||||
buildings.value = buildingList
|
buildings.value = buildingList
|
||||||
pelletTypes.value = pelletTypeList
|
pelletTypes.value = pelletTypeList
|
||||||
|
|
||||||
const currentId = reception.merchandiseType?.id
|
hydrateFromModelValue(props.modelValue)
|
||||||
if (currentId) {
|
isReady.value = true
|
||||||
selectedMerchandiseTypeId.value = String(currentId)
|
|
||||||
}
|
|
||||||
merchandiseDetail.value = reception.merchandiseDetail ?? ''
|
|
||||||
|
|
||||||
selectedBuildingIds.value =
|
|
||||||
reception.buildings?.map((building) => String(building.id)) ?? []
|
|
||||||
|
|
||||||
const existingPelletSelections = reception.pelletBuildings ?? []
|
|
||||||
const selectionMap: Record<string, string[]> = {}
|
|
||||||
for (const selection of existingPelletSelections) {
|
|
||||||
// L'API peut renvoyer les relations comme IRI ou comme objets selon le contexte.
|
|
||||||
const pelletTypeId = getRelationId(selection.pelletType)
|
|
||||||
const buildingId = getRelationId(selection.building)
|
|
||||||
if (!pelletTypeId || !buildingId) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!selectionMap[pelletTypeId]) {
|
|
||||||
selectionMap[pelletTypeId] = []
|
|
||||||
}
|
|
||||||
selectionMap[pelletTypeId].push(buildingId)
|
|
||||||
}
|
|
||||||
for (const pelletType of pelletTypes.value) {
|
|
||||||
const key = String(pelletType.id)
|
|
||||||
if (!selectionMap[key]) {
|
|
||||||
selectionMap[key] = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selectedPelletBuildingIds.value = selectionMap
|
|
||||||
})
|
})
|
||||||
// Enregistre les sélections et passe à l'étape suivante
|
|
||||||
async function validate() {
|
|
||||||
|
|
||||||
const receptionIri = `/api/receptions/${reception.id}`
|
|
||||||
|
|
||||||
await updateReception(reception.id, {
|
|
||||||
merchandiseDetail: isAutres.value ? merchandiseDetail.value.trim() : null,
|
|
||||||
buildings: isGranule.value
|
|
||||||
? []
|
|
||||||
: selectedBuildingIds.value.map((id) => `/api/buildings/${id}`),
|
|
||||||
bovineDetail: null,
|
|
||||||
bovinesTypes: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isGranule.value) {
|
|
||||||
await syncPelletSelections(receptionIri)
|
|
||||||
} else {
|
|
||||||
await clearPelletSelections(receptionIri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supprime toutes les associations granulés/bâtiments existantes
|
|
||||||
async function clearPelletSelections(receptionIri: string) {
|
|
||||||
const existing = await getReceptionPelletBuildingList(receptionIri)
|
|
||||||
for (const selection of existing) {
|
|
||||||
await deleteReceptionPelletBuilding(selection.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Synchronise les associations granulés/bâtiments avec l'état du formulaire
|
|
||||||
async function syncPelletSelections(receptionIri: string) {
|
|
||||||
const existing = await getReceptionPelletBuildingList(receptionIri)
|
|
||||||
const existingMap = new Map<string, number>()
|
|
||||||
for (const selection of existing) {
|
|
||||||
// Construit la table de correspondance avec des IDs normalisés pour éviter les doublons.
|
|
||||||
const pelletTypeId = getRelationId(selection.pelletType)
|
|
||||||
const buildingId = getRelationId(selection.building)
|
|
||||||
if (!pelletTypeId || !buildingId) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const key = `${pelletTypeId}:${buildingId}`
|
|
||||||
existingMap.set(key, selection.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const desiredEntries: Array<{ pelletTypeId: string; buildingId: string }> = []
|
|
||||||
for (const [pelletTypeId, buildingIds] of Object.entries(selectedPelletBuildingIds.value)) {
|
|
||||||
for (const buildingId of buildingIds) {
|
|
||||||
desiredEntries.push({pelletTypeId, buildingId})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const desiredKeys = new Set(desiredEntries.map(
|
|
||||||
(entry) => `${entry.pelletTypeId}:${entry.buildingId}`
|
|
||||||
))
|
|
||||||
|
|
||||||
for (const [key, id] of existingMap.entries()) {
|
|
||||||
if (!desiredKeys.has(key)) {
|
|
||||||
await deleteReceptionPelletBuilding(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of desiredEntries) {
|
|
||||||
const key = `${entry.pelletTypeId}:${entry.buildingId}`
|
|
||||||
if (!existingMap.has(key)) {
|
|
||||||
await createReceptionPelletBuilding({
|
|
||||||
reception: receptionIri,
|
|
||||||
pelletType: `/api/pellet_types/${entry.pelletTypeId}`,
|
|
||||||
building: `/api/buildings/${entry.buildingId}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
<template>
|
|
||||||
<form @submit.prevent="validate">
|
|
||||||
<div class="grid grid-cols-2 gap-x-40 gap-y-8 mb-8">
|
|
||||||
<UiNumberInput
|
|
||||||
label="Dsd"
|
|
||||||
class="col-start-2"
|
|
||||||
labelClass="font-bold uppercase"
|
|
||||||
v-model="sharedWeightMeta.dsd"
|
|
||||||
:disabled="!auth.isAdmin"
|
|
||||||
/>
|
|
||||||
<UiDateInput
|
|
||||||
label="Date pesée"
|
|
||||||
v-model="sharedWeightMeta.weighedAt"
|
|
||||||
:disabled="!auth.isAdmin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-x-40 mb-16">
|
|
||||||
<UiNumberInput
|
|
||||||
v-for="weight in form.weights"
|
|
||||||
:key="weight.type"
|
|
||||||
:label="getWeightLabel(weight.type)"
|
|
||||||
labelClass="font-bold uppercase text-xl"
|
|
||||||
inputClass="w-24"
|
|
||||||
v-model="weight.weight"
|
|
||||||
:wrapper-class="weight.type === 'tare' ? 'col-start-1 row-start-1' : 'col-start-2 row-start-1'"
|
|
||||||
:disabled="!auth.isAdmin"
|
|
||||||
:min="0"
|
|
||||||
:max="48000"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<UiButton
|
|
||||||
v-if="auth.isAdmin"
|
|
||||||
type="submit"
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
|
||||||
|
|
||||||
>
|
|
||||||
Valider
|
|
||||||
</UiButton>
|
|
||||||
</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, dsd: null, weighedAt: null},
|
|
||||||
{id: 0, type: 'gross' as const, weight: 0, dsd: null, weighedAt: null}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
// DSD et date de pesée sont partagés entre tare et gross dans l'UI.
|
|
||||||
const sharedWeightMeta = reactive<{
|
|
||||||
dsd: number | string | null
|
|
||||||
weighedAt: string | null
|
|
||||||
}>({
|
|
||||||
dsd: null,
|
|
||||||
weighedAt: null
|
|
||||||
})
|
|
||||||
|
|
||||||
const getWeightLabel = (type: 'tare' | 'gross'): string => {
|
|
||||||
return type === 'tare' ? 'Pesée à vide' : 'Pesée à plein'
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydrateFromReception = (reception: ReceptionFormWeight) => {
|
|
||||||
// On hydrate chaque ligne par son type (tare/gross), sans dépendre d'un index.
|
|
||||||
for (const receptionWeight of reception.weights) {
|
|
||||||
const formWeight = form.weights.find(weight => weight.type === receptionWeight.type)
|
|
||||||
if (formWeight) {
|
|
||||||
Object.assign(formWeight, receptionWeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// On récupère une valeur existante pour préremplir les champs partagés.
|
|
||||||
const weightWithMeta = reception.weights.find(weight =>
|
|
||||||
(weight.dsd !== null && weight.dsd !== undefined)
|
|
||||||
|| (weight.weighedAt !== null && weight.weighedAt !== undefined && weight.weighedAt !== '')
|
|
||||||
)
|
|
||||||
|
|
||||||
if (weightWithMeta) {
|
|
||||||
sharedWeightMeta.dsd = weightWithMeta.dsd ?? null
|
|
||||||
sharedWeightMeta.weighedAt = weightWithMeta.weighedAt ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const reception = await getReception(idReception)
|
|
||||||
hydrateFromReception(reception)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function validate() {
|
|
||||||
const sharedDsd =
|
|
||||||
sharedWeightMeta.dsd === null || sharedWeightMeta.dsd === undefined || sharedWeightMeta.dsd === ''
|
|
||||||
? null
|
|
||||||
: Number(sharedWeightMeta.dsd)
|
|
||||||
const sharedWeighedAt =
|
|
||||||
sharedWeightMeta.weighedAt === null || sharedWeightMeta.weighedAt === undefined || sharedWeightMeta.weighedAt === ''
|
|
||||||
? null
|
|
||||||
: sharedWeightMeta.weighedAt
|
|
||||||
for (const weight of form.weights) {
|
|
||||||
if (weight.id) {
|
|
||||||
await updateWeight(weight.id, {
|
|
||||||
weight: weight.weight,
|
|
||||||
dsd: Number.isFinite(sharedDsd) ? sharedDsd : null,
|
|
||||||
weighedAt: sharedWeighedAt
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="validate">
|
<form ref="formRef" :class="{ submitted }" @submit.prevent="validate">
|
||||||
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
|
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
|
||||||
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Expédition</h1>
|
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Expédition</h1>
|
||||||
<!-- Nom de l'utilisateur -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="shipment-user"
|
id="shipment-user"
|
||||||
v-model="form.userId"
|
v-model="form.userId"
|
||||||
@@ -13,30 +12,33 @@
|
|||||||
}))"
|
}))"
|
||||||
:loading="isLoadingUsers"
|
:loading="isLoadingUsers"
|
||||||
wrapper-class="col-start-1 row-start-2"
|
wrapper-class="col-start-1 row-start-2"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Date de l'éxpedition -->
|
|
||||||
<UiDateInput
|
<UiDateInput
|
||||||
id="shipment-date"
|
id="shipment-date"
|
||||||
v-model="form.shipmentDate"
|
v-model="form.shipmentDate"
|
||||||
label="Date du jour"
|
label="Date du jour"
|
||||||
wrapper-class="col-start-1 row-start-3"
|
wrapper-class="col-start-1 row-start-3"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Type d'expédition -->
|
|
||||||
<div class="col-start-1 row-start-4 h-[64px]">
|
<div class="col-start-1 row-start-4 h-[64px]">
|
||||||
<div class="flex items-end gap-8 justify-between">
|
<div class="flex w-full items-end gap-[104px]">
|
||||||
<UiRadioGroup
|
<UiRadioGroup
|
||||||
id="shipment-type"
|
id="shipment-type"
|
||||||
name="shipment-type"
|
name="shipment-type"
|
||||||
label="Type d'expédition bovine"
|
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"
|
v-model="selectedShipmentTypeId"
|
||||||
:options="bovineShipment.map((type) => ({
|
:options="bovineShipment.map((type) => ({
|
||||||
value: String(type.id),
|
value: String(type.id),
|
||||||
label: type.label
|
label: type.label
|
||||||
}))"
|
}))"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<UiNumberInput
|
<UiNumberInput
|
||||||
id="shipment-type-quantity"
|
id="shipment-type-quantity"
|
||||||
label="Quantité"
|
|
||||||
v-model="shipmentQuantity"
|
v-model="shipmentQuantity"
|
||||||
:placeholder="0"
|
:placeholder="0"
|
||||||
:min="0"
|
:min="0"
|
||||||
@@ -45,7 +47,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Client -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="shipment-customer"
|
id="shipment-customer"
|
||||||
v-model="form.customerId"
|
v-model="form.customerId"
|
||||||
@@ -56,17 +57,17 @@
|
|||||||
}))"
|
}))"
|
||||||
:loading="isLoadingCustomers"
|
:loading="isLoadingCustomers"
|
||||||
wrapper-class="col-start-1 row-start-5"
|
wrapper-class="col-start-1 row-start-5"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Adresse du client -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="shipment-address"
|
id="shipment-address"
|
||||||
v-model="form.addressId"
|
v-model="form.addressId"
|
||||||
:options="customerAddressOptions"
|
:options="addressOptions"
|
||||||
:disabled="isLoadingCustomers || customerAddresses.length === 0"
|
:disabled="isLoadingCustomers || ownerAddresses.length === 0"
|
||||||
label="Adresse"
|
label="Adresse"
|
||||||
wrapper-class="col-start-2 row-start-1"
|
wrapper-class="col-start-2 row-start-1"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Camion -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="shipment-truck"
|
id="shipment-truck"
|
||||||
v-model="form.truckId"
|
v-model="form.truckId"
|
||||||
@@ -77,8 +78,8 @@
|
|||||||
}))"
|
}))"
|
||||||
:loading="isLoadingTrucks"
|
:loading="isLoadingTrucks"
|
||||||
wrapper-class="col-start-2 row-start-2"
|
wrapper-class="col-start-2 row-start-2"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Transporteur -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="shipment-carrier"
|
id="shipment-carrier"
|
||||||
v-model="form.carrierId"
|
v-model="form.carrierId"
|
||||||
@@ -88,15 +89,15 @@
|
|||||||
label: carrier.name
|
label: carrier.name
|
||||||
}))"
|
}))"
|
||||||
wrapper-class="col-start-2 row-start-3"
|
wrapper-class="col-start-2 row-start-3"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Plaque d'immatriculation (hors LIOT) -->
|
|
||||||
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
|
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
|
||||||
<UiLicensePlateInput
|
<UiLicensePlateInput
|
||||||
v-model="form.licencePlate"
|
v-model="form.licensePlate"
|
||||||
v-model:allowAny="allowAnyLicensePlate"
|
v-model:allowAny="allowAnyLicensePlate"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Immatriculation (LIOT) -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
v-if="isLiotCarrier"
|
v-if="isLiotCarrier"
|
||||||
id="shipment-vehicle"
|
id="shipment-vehicle"
|
||||||
@@ -109,8 +110,8 @@
|
|||||||
:loading="isLoadingVehicles"
|
:loading="isLoadingVehicles"
|
||||||
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
|
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
|
||||||
wrapper-class="col-start-2 row-start-4"
|
wrapper-class="col-start-2 row-start-4"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<!-- Chauffeur (LIOT) -->
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="shipment-driver"
|
id="shipment-driver"
|
||||||
v-model="form.driverId"
|
v-model="form.driverId"
|
||||||
@@ -122,67 +123,36 @@
|
|||||||
:loading="isLoadingDrivers"
|
:loading="isLoadingDrivers"
|
||||||
wrapper-class="col-start-2 row-start-5"
|
wrapper-class="col-start-2 row-start-5"
|
||||||
v-if="isLiotCarrier"
|
v-if="isLiotCarrier"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<UiButton
|
<UiButton
|
||||||
type="submit"
|
type="submit"
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||||
|
@click="submitted = true"
|
||||||
>Valider
|
>Valider
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useFormDataLoading } from '~/composables/useFormDataLoading'
|
||||||
|
import { useLiotHandling } from '~/composables/useLiotHandling'
|
||||||
|
import { useAddressSync } from '~/composables/useAddressSync'
|
||||||
|
import type { CustomerData } from '~/services/dto/customer-data'
|
||||||
|
import { getCustomerList } from '~/services/customer'
|
||||||
|
import type { ShipmentFormData } from '~/services/dto/shipment-data'
|
||||||
|
import { useShipmentStore } from '~/stores/shipment'
|
||||||
|
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||||
|
import { getShipmentTypeList } from '~/services/shipment-type'
|
||||||
|
|
||||||
import type {UserData} from '~/services/dto/user-data'
|
|
||||||
import type {CustomerData} from '~/services/dto/customer-data'
|
|
||||||
import type {TruckData} from '~/services/dto/truck-data'
|
|
||||||
import type {CarrierData} from '~/services/dto/carrier-data'
|
|
||||||
import type {DriverData} from '~/services/dto/driver-data'
|
|
||||||
import type {VehicleData} from '~/services/dto/vehicle-data'
|
|
||||||
import type {AddressData} from '~/services/dto/address-data'
|
|
||||||
import {getUsers} from '~/services/auth'
|
|
||||||
import {getCustomerList} from '~/services/customer'
|
|
||||||
import {getTruckList} from '~/services/truck'
|
|
||||||
import {getCarrierList} from '~/services/carrier'
|
|
||||||
import {getVehicleList} from '~/services/vehicle'
|
|
||||||
import {getDriverList} from '~/services/driver'
|
|
||||||
import type {ShipmentFormData} from '~/services/dto/shipment-data'
|
|
||||||
import {SUPPLIER_CODE} from "~/utils/constants"
|
|
||||||
import {useAuthStore} from '~/stores/auth'
|
|
||||||
import {useShipmentStore} from '~/stores/shipment'
|
|
||||||
import {computed, reactive, ref, watch, onMounted} from 'vue'
|
|
||||||
import type {ShipmentTypeData} from "~/services/dto/shipment-type-data";
|
|
||||||
import {getShipmentTypeList} from "~/services/shipment-type";
|
|
||||||
|
|
||||||
const users = ref<UserData[]>([])
|
|
||||||
const customers = ref<CustomerData[]>([])
|
|
||||||
const trucks = ref<TruckData[]>([])
|
|
||||||
const carriers = ref<CarrierData[]>([])
|
|
||||||
const drivers = ref<DriverData[]>([])
|
|
||||||
const vehicles = ref<VehicleData[]>([])
|
|
||||||
|
|
||||||
const isLoadingUsers = ref(false)
|
|
||||||
const isLoadingShipmentTypes = ref(false)
|
|
||||||
const isLoadingCustomers = ref(false)
|
|
||||||
const isLoadingTrucks = ref(false)
|
|
||||||
const isLoadingCarriers = ref(false)
|
|
||||||
const isHydrating = ref(false)
|
|
||||||
const isLoadingVehicles = ref(false)
|
|
||||||
const allowAnyLicensePlate = ref(false)
|
|
||||||
const isLoadingDrivers = ref(false)
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const shipmentStore = useShipmentStore()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const bovineShipment = ref<ShipmentTypeData[]>([])
|
const shipmentStore = useShipmentStore()
|
||||||
const selectedShipmentTypeId = ref('')
|
const isHydrating = ref(false)
|
||||||
const shipmentQuantity = ref<number | null>(0)
|
const submitted = ref(false)
|
||||||
// Transporteur sélectionné dans le formulaire
|
const formRef = ref<HTMLFormElement | null>(null)
|
||||||
const selectedCarrier = computed(() =>
|
|
||||||
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
|
|
||||||
)
|
|
||||||
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
|
|
||||||
|
|
||||||
const form = reactive<ShipmentFormData>({
|
const form = reactive<ShipmentFormData>({
|
||||||
userId: '',
|
userId: '',
|
||||||
@@ -193,60 +163,26 @@ const form = reactive<ShipmentFormData>({
|
|||||||
carrierId: '',
|
carrierId: '',
|
||||||
driverId: '',
|
driverId: '',
|
||||||
vehicleId: '',
|
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 () => {
|
const customers = ref<CustomerData[]>([])
|
||||||
isLoadingShipmentTypes.value = true
|
const isLoadingCustomers = ref(false)
|
||||||
try {
|
const bovineShipment = ref<ShipmentTypeData[]>([])
|
||||||
bovineShipment.value = await getShipmentTypeList()
|
const selectedShipmentTypeId = ref('')
|
||||||
} finally {
|
const shipmentQuantity = ref<number | null>(0)
|
||||||
isLoadingShipmentTypes.value = false
|
|
||||||
}
|
const { users, trucks, carriers, isLoadingUsers, isLoadingTrucks, isLoadingCarriers, loadCommonData } =
|
||||||
}
|
useFormDataLoading(form)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLiotCarrier, filteredDrivers, filteredVehicles,
|
||||||
|
isLoadingDrivers, isLoadingVehicles, allowAnyLicensePlate,
|
||||||
|
loadDrivers, loadVehicles
|
||||||
|
} = useLiotHandling(form, carriers, isHydrating)
|
||||||
|
|
||||||
|
const customerIdRef = computed(() => form.customerId)
|
||||||
|
const { ownerAddresses, addressOptions } = useAddressSync(form, customerIdRef, customers)
|
||||||
|
|
||||||
const loadCustomers = async () => {
|
const loadCustomers = async () => {
|
||||||
isLoadingCustomers.value = true
|
isLoadingCustomers.value = true
|
||||||
@@ -255,214 +191,53 @@ const loadCustomers = async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
isLoadingCustomers.value = false
|
isLoadingCustomers.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
|
||||||
const loadTrucks = async () => {
|
|
||||||
isLoadingTrucks.value = true
|
|
||||||
try {
|
|
||||||
trucks.value = await getTruckList()
|
|
||||||
} finally {
|
|
||||||
isLoadingTrucks.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const loadCarriers = async () => {
|
|
||||||
isLoadingCarriers.value = true
|
|
||||||
try {
|
|
||||||
carriers.value = await getCarrierList()
|
|
||||||
} finally {
|
|
||||||
isLoadingCarriers.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const loadVehicles = async () => {
|
|
||||||
isLoadingVehicles.value = true
|
|
||||||
try {
|
|
||||||
vehicles.value = await getVehicleList()
|
|
||||||
} finally {
|
|
||||||
isLoadingVehicles.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const loadDrivers = async () => {
|
|
||||||
isLoadingDrivers.value = true
|
|
||||||
try {
|
|
||||||
drivers.value = await getDriverList()
|
|
||||||
} finally {
|
|
||||||
isLoadingDrivers.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// On met le user connecté par défaut dans le select
|
|
||||||
const setDefaultUser = () => {
|
|
||||||
if (form.userId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (authStore.user?.id) {
|
|
||||||
form.userId = String(authStore.user.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Chargement initial des données
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadShipmentType()
|
|
||||||
await loadUsers()
|
|
||||||
await loadCustomers()
|
|
||||||
await loadTrucks()
|
|
||||||
await loadCarriers()
|
|
||||||
await loadVehicles()
|
|
||||||
await loadDrivers()
|
|
||||||
await authStore.ensureSession()
|
|
||||||
setDefaultUser()
|
|
||||||
})
|
|
||||||
// Hydrate le formulaire depuis l'expédition en cours
|
|
||||||
watch(
|
watch(
|
||||||
() => shipmentStore.current,
|
() => shipmentStore.current,
|
||||||
(shipment) => {
|
(shipment) => {
|
||||||
isHydrating.value = true
|
isHydrating.value = true
|
||||||
form.licencePlate = shipment?.licencePlate ?? ''
|
form.licensePlate = shipment?.licensePlate ?? ''
|
||||||
form.shipmentDate = shipment?.shipmentDate ?? new Date().toISOString().slice(0, 10)
|
form.shipmentDate = shipment?.shipmentDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)
|
||||||
form.userId = shipment?.user?.id ? String(shipment.user.id) :
|
form.userId = shipment?.user?.id ? String(shipment.user.id) : form.userId
|
||||||
form.userId
|
form.customerId = shipment?.customer?.id ? String(shipment.customer.id) : ''
|
||||||
form.customerId = shipment?.customer?.id ?
|
|
||||||
String(shipment.customer.id) : ''
|
|
||||||
form.addressId = shipment?.address?.id ? String(shipment.address.id) : ''
|
form.addressId = shipment?.address?.id ? String(shipment.address.id) : ''
|
||||||
form.truckId = shipment?.truck?.id ? String(shipment.truck.id) : ''
|
form.truckId = shipment?.truck?.id ? String(shipment.truck.id) : ''
|
||||||
form.carrierId = shipment?.carrier?.id ? String(shipment.carrier.id) : ''
|
form.carrierId = shipment?.carrier?.id ? String(shipment.carrier.id) : ''
|
||||||
form.driverId = shipment?.driver?.id ? String(shipment.driver.id) : ''
|
form.driverId = shipment?.driver?.id ? String(shipment.driver.id) : ''
|
||||||
form.vehicleId = shipment?.vehicle?.id ? String(shipment.vehicle.id) : ''
|
form.vehicleId = shipment?.vehicle?.id ? String(shipment.vehicle.id) : ''
|
||||||
|
selectedShipmentTypeId.value = shipment?.shipmentType?.id ? String(shipment.shipmentType.id) : ''
|
||||||
|
|
||||||
selectedShipmentTypeId.value = shipment?.shipmentType?.id
|
|
||||||
? String(shipment.shipmentType.id)
|
|
||||||
: ''
|
|
||||||
|
|
||||||
shipmentQuantity.value = shipment?.nbBovinSend ?? 0
|
shipmentQuantity.value = shipment?.nbBovinSend ?? 0
|
||||||
|
|
||||||
|
|
||||||
isHydrating.value = false
|
isHydrating.value = false
|
||||||
},
|
},
|
||||||
{immediate: true}
|
{ immediate: true }
|
||||||
)
|
|
||||||
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
|
|
||||||
watch(
|
|
||||||
() => [form.customerId, form.addressId, customers.value],
|
|
||||||
() => {
|
|
||||||
if (!form.customerId) {
|
|
||||||
form.addressId = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!form.addressId && customerAddresses.value.length === 1) {
|
|
||||||
form.addressId = String(customerAddresses.value[0].id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!form.addressId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const matches = customerAddresses.value.some(
|
|
||||||
(address) => String(address.id) === form.addressId
|
|
||||||
)
|
|
||||||
if (!matches) {
|
|
||||||
if (customerAddresses.value.length === 1) {
|
|
||||||
form.addressId = String(customerAddresses.value[0].id)
|
|
||||||
} else {
|
|
||||||
form.addressId = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{immediate: true}
|
|
||||||
)
|
|
||||||
// Valide/auto-sélectionne le véhicule selon camion + transporteur (LIOT)
|
|
||||||
const applyLiotDefaults = () => {
|
|
||||||
if (isHydrating.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!form.carrierId) {
|
|
||||||
form.driverId = ''
|
|
||||||
form.vehicleId = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isLiotCarrier.value) {
|
|
||||||
form.driverId = ''
|
|
||||||
form.vehicleId = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (filteredDrivers.value.length === 1) {
|
|
||||||
form.driverId = String(filteredDrivers.value[0].id)
|
|
||||||
}
|
|
||||||
if (filteredVehicles.value.length === 1) {
|
|
||||||
form.vehicleId = String(filteredVehicles.value[0].id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
watch(
|
|
||||||
() => form.carrierId,
|
|
||||||
() => {
|
|
||||||
applyLiotDefaults()
|
|
||||||
},
|
|
||||||
{immediate: true}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Extra watcher for LIOT defaults after hydration
|
||||||
watch(
|
watch(
|
||||||
() => isHydrating.value,
|
() => isHydrating.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (!value) {
|
if (!value && isLiotCarrier.value) {
|
||||||
applyLiotDefaults()
|
if (filteredDrivers.value.length === 1 && !form.driverId) {
|
||||||
|
form.driverId = String(filteredDrivers.value[0].id)
|
||||||
}
|
}
|
||||||
}
|
if (filteredVehicles.value.length === 1 && !form.vehicleId) {
|
||||||
)
|
|
||||||
// Récupère la plaque depuis le véhicule choisi (LIOT)
|
|
||||||
watch(
|
|
||||||
() => [form.truckId, form.carrierId, vehicles.value],
|
|
||||||
() => {
|
|
||||||
if (!isLiotCarrier.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (filteredVehicles.value.length === 1) {
|
|
||||||
form.vehicleId = String(filteredVehicles.value[0].id)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
bovineShipment.value = await getShipmentTypeList()
|
||||||
|
await loadCustomers()
|
||||||
|
await loadCommonData()
|
||||||
|
await loadVehicles()
|
||||||
|
await loadDrivers()
|
||||||
|
})
|
||||||
|
|
||||||
const buildPayload = () => {
|
const buildPayload = () => {
|
||||||
const normalizedLicensePlate = form.licencePlate.trim()
|
const normalizedLicensePlate = form.licensePlate.trim()
|
||||||
const normalizedShipmentDate = form.shipmentDate.trim()
|
const normalizedShipmentDate = form.shipmentDate.trim()
|
||||||
const normalizedCustomerId = form.customerId.trim()
|
const normalizedCustomerId = form.customerId.trim()
|
||||||
const normalizedTruckId = form.truckId.trim()
|
const normalizedTruckId = form.truckId.trim()
|
||||||
@@ -470,35 +245,21 @@ const buildPayload = () => {
|
|||||||
const normalizedDriverId = form.driverId.trim()
|
const normalizedDriverId = form.driverId.trim()
|
||||||
const normalizedUserId = form.userId.trim()
|
const normalizedUserId = form.userId.trim()
|
||||||
const normalizedAddressId = form.addressId.trim()
|
const normalizedAddressId = form.addressId.trim()
|
||||||
const customerIri = normalizedCustomerId
|
|
||||||
? `/api/customers/${normalizedCustomerId}`
|
const customerIri = normalizedCustomerId ? `/api/customers/${normalizedCustomerId}` : null
|
||||||
: null
|
const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
|
||||||
const truckIri = normalizedTruckId
|
const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
|
||||||
? `/api/trucks/${normalizedTruckId}`
|
const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
|
||||||
: null
|
const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
|
||||||
const carrierIri = normalizedCarrierId
|
const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
|
||||||
? `/api/carriers/${normalizedCarrierId}`
|
|
||||||
: null
|
|
||||||
const userIri = normalizedUserId
|
|
||||||
? `/api/users/${normalizedUserId}`
|
|
||||||
: null
|
|
||||||
const driverIri = normalizedDriverId
|
|
||||||
? `/api/drivers/${normalizedDriverId}`
|
|
||||||
: null
|
|
||||||
const addressIri = normalizedAddressId
|
|
||||||
? `/api/addresses/${normalizedAddressId}`
|
|
||||||
: null
|
|
||||||
const normalizedShipmentTypeId = selectedShipmentTypeId.value.trim()
|
const normalizedShipmentTypeId = selectedShipmentTypeId.value.trim()
|
||||||
const shipmentTypeIri = normalizedShipmentTypeId
|
const shipmentTypeIri = normalizedShipmentTypeId ? `/api/shipment_types/${normalizedShipmentTypeId}` : null
|
||||||
? `/api/shipment_types/${normalizedShipmentTypeId}`
|
|
||||||
: null
|
|
||||||
|
|
||||||
const rawQuantity = Number(shipmentQuantity.value ?? 0)
|
const rawQuantity = Number(shipmentQuantity.value ?? 0)
|
||||||
const normalizedQuantity = Number.isFinite(rawQuantity) ? Math.max(0,
|
const normalizedQuantity = Number.isFinite(rawQuantity) ? Math.max(0, Math.trunc(rawQuantity)) : 0
|
||||||
Math.trunc(rawQuantity)) : 0
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
licencePlate: normalizedLicensePlate,
|
licensePlate: normalizedLicensePlate,
|
||||||
shipmentDate: normalizedShipmentDate,
|
shipmentDate: normalizedShipmentDate,
|
||||||
customer: customerIri,
|
customer: customerIri,
|
||||||
truck: truckIri,
|
truck: truckIri,
|
||||||
@@ -520,15 +281,19 @@ const saveDraft = async () => {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await shipmentStore.updateShipment(shipmentStore.current.id, {
|
await shipmentStore.updateShipment(shipmentStore.current.id, {
|
||||||
currentStep: shipmentStore.current.currentStep,
|
currentStep: shipmentStore.current.currentStep,
|
||||||
...payload
|
...payload
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({saveDraft})
|
const validateFields = () => {
|
||||||
// Valide le formulaire et crée/met à jour l'expédition
|
submitted.value = true
|
||||||
|
return formRef.value?.reportValidity() ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ saveDraft, validateFields })
|
||||||
|
|
||||||
const validate = async () => {
|
const validate = async () => {
|
||||||
const payload = buildPayload()
|
const payload = buildPayload()
|
||||||
if (!shipmentStore.current) {
|
if (!shipmentStore.current) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center gap-[118px]">
|
<div class="flex flex-col items-center gap-[150px]">
|
||||||
<h1 class="font-bold text-5xl uppercase text-primary-500">Chargement des bovins</h1>
|
<h1 class="font-bold text-5xl uppercase text-primary-500">Chargement des bovins</h1>
|
||||||
<div
|
<div
|
||||||
class="w-full flex flex-col items-center justify-center">
|
class="w-full flex flex-col items-center justify-center">
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<div class="flex flex-col items-center w-[660px]">
|
|
||||||
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
|
|
||||||
<!--@TODO Voir comment faire pour savoir si le pont-bascule et bien connecté + ajouter un icon comme sur la maquette-->
|
|
||||||
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
|
|
||||||
<div
|
|
||||||
v-if="showLoadingBox"
|
|
||||||
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
|
|
||||||
<UiLoadingDots />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="displayWeight !== null" class="w-full">
|
|
||||||
<div
|
|
||||||
class="w-full flex flex-col items-center justify-center border border-primary-500 h-[90px] mt-12 mb-[25px] text-4xl text-primary-500">
|
|
||||||
{{ displayWeight }} kg
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center mt-[54px]">
|
|
||||||
<UiButton
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
|
||||||
@click="fetchWeight"
|
|
||||||
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
|
|
||||||
<UiButton
|
|
||||||
v-if="displayWeight !== null && !showGenerateReceipt"
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
|
|
||||||
@click="saveWeight"
|
|
||||||
>Valider la pesée</UiButton>
|
|
||||||
<UiButton
|
|
||||||
v-if="showGenerateReceipt"
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
|
|
||||||
@click="printReceipt"
|
|
||||||
>Générer le bon</UiButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { useWeighingShipment } from '~/composables/useWeighing'
|
|
||||||
import { usePdfPrinter } from '~/composables/usePdfPrinter'
|
|
||||||
import { useShipmentStore } from '~/stores/shipment'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
mode: 'gross' | 'tare'
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const shipmentStore = useShipmentStore()
|
|
||||||
const { current: storeShipment } = storeToRefs(shipmentStore)
|
|
||||||
const { printPdf } = usePdfPrinter()
|
|
||||||
const {
|
|
||||||
displayWeight,
|
|
||||||
title,
|
|
||||||
showLoadingBox,
|
|
||||||
fetchWeight,
|
|
||||||
saveWeight
|
|
||||||
} = useWeighingShipment({
|
|
||||||
modeShipment: props.mode,
|
|
||||||
shipment: storeShipment,
|
|
||||||
updateShipment: shipmentStore.updateShipment,
|
|
||||||
loadShipment: shipmentStore.loadShipment
|
|
||||||
})
|
|
||||||
// Affiche le bouton de génération du bon à l'étape tare
|
|
||||||
const showGenerateReceipt = computed(
|
|
||||||
() => props.mode === 'tare' && displayWeight.value !== null
|
|
||||||
)
|
|
||||||
|
|
||||||
// Génère le bon d'expédition, puis clôture l'expédition
|
|
||||||
const printReceipt = async () => {
|
|
||||||
if (!import.meta.client || !shipmentStore.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await saveWeight()
|
|
||||||
const shipment = shipmentStore.current
|
|
||||||
const filename = `${shipment.identificationNumber ?? shipment.id}_${shipment.customer?.label ?? 'client'}_${shipment.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>
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
:is="'button'"
|
:is="'button'"
|
||||||
:type="type"
|
:type="type"
|
||||||
:disabled="isDisabled"
|
:disabled="isDisabled"
|
||||||
class="inline-flex items-center justify-center rounded-md"
|
class="inline-flex min-w-[194px] items-center justify-center rounded-md"
|
||||||
:class="[
|
:class="[
|
||||||
isDisabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
|
isDisabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
|
||||||
buttonClass
|
buttonClass
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="wrapperClass">
|
<div :class="wrapperClass">
|
||||||
<label
|
<label
|
||||||
class="flex items-center gap-2 cursor-pointer text-primary-500"
|
class="flex items-center gap-2 cursor-pointer text-primary-700"
|
||||||
:class="labelClass"
|
:class="labelClass"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="checked"
|
:checked="checked"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:class="['cursor-pointer text-primary-500', inputClass]"
|
:class="['h-4 w-4 cursor-pointer text-primary-500', inputClass]"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
>
|
>
|
||||||
<span v-if="label">{{ label }}</span>
|
<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: () => [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
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="id"
|
:for="id"
|
||||||
class="font-bold uppercase text-xl text-primary-500"
|
class="font-bold uppercase text-xl text-primary-700"
|
||||||
:class="labelClass"
|
:class="labelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
@@ -14,9 +14,10 @@
|
|||||||
:value="modelValue ?? ''"
|
:value="modelValue ?? ''"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
class="border-b border-black justify-self-start text-xl text-primary-500 py-[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="[
|
:class="[
|
||||||
isEmpty ? 'text-neutral-400' : 'text-black',
|
sizeClass,
|
||||||
|
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||||
inputClass
|
inputClass
|
||||||
]"
|
]"
|
||||||
@@ -36,12 +37,14 @@ const props = withDefaults(
|
|||||||
label?: string
|
label?: string
|
||||||
modelValue: string | null | undefined
|
modelValue: string | null | undefined
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
size?: 'default' | 'compact'
|
||||||
wrapperClass?: string
|
wrapperClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
inputClass?: string
|
inputClass?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
size: 'default',
|
||||||
wrapperClass: '',
|
wrapperClass: '',
|
||||||
labelClass: '',
|
labelClass: '',
|
||||||
inputClass: ''
|
inputClass: ''
|
||||||
@@ -54,6 +57,11 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
const isEmpty = computed(() => !props.modelValue)
|
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 onInput = (event: Event) => {
|
||||||
const target = event.target as HTMLInputElement
|
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>
|
<template>
|
||||||
<div :class="['flex flex-row items-center gap-2', wrapperClass]">
|
<div :class="['flex', wrapperClass]">
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="id"
|
:for="id"
|
||||||
class="text-xl flex items-center gap-2 text-primary-500"
|
class="text-xl flex items-center gap-2 text-primary-700"
|
||||||
:class="labelClass"
|
:class="labelClass"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
:step="step"
|
:step="step"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
class="border-b border-black text-xl bg-transparent w-16 text-primary-500"
|
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
|
||||||
:class="[
|
:class="[
|
||||||
isEmpty ? 'text-neutral-400' : 'text-black',
|
isEmpty ? 'text-neutral-400' : 'text-black',
|
||||||
disabled ? 'cursor-not-allowed' : 'cursor-text',
|
disabled ? 'cursor-not-allowed' : 'cursor-text',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div :class="['flex flex-col', wrapperClass]">
|
<div :class="['flex flex-col', wrapperClass]">
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
class="font-bold uppercase text-xl text-primary-500"
|
class="font-bold uppercase text-xl text-primary-700"
|
||||||
:class="labelClass"
|
:class="labelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="String(option.value)"
|
:key="String(option.value)"
|
||||||
:for="`${id || 'radio'}-${option.value}`"
|
:for="`${id || 'radio'}-${option.value}`"
|
||||||
class="flex items-center gap-2 text-primary-500"
|
class="flex items-center gap-2 text-primary-700"
|
||||||
:class="itemClass"
|
:class="itemClass"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
:checked="String(modelValue ?? '') === String(option.value)"
|
:checked="String(modelValue ?? '') === String(option.value)"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
class="h-4 w-4 border-slate-300 text-primary-500 focus:ring-primary-500"
|
class="h-4 w-4 border-primary-700/50 text-primary-700 focus:ring-primary-700"
|
||||||
:class="[
|
:class="[
|
||||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||||
inputClass
|
inputClass
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="id"
|
:for="id"
|
||||||
class="font-bold uppercase text-xl text-primary-500"
|
class="font-bold uppercase text-xl text-primary-700"
|
||||||
:class="labelClass"
|
:class="labelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
@@ -13,22 +13,23 @@
|
|||||||
:value="modelValue ?? ''"
|
:value="modelValue ?? ''"
|
||||||
:disabled="disabled || loading"
|
:disabled="disabled || loading"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
class="border-b border-black justify-self-start text-xl text-primary-500 py-[6px] bg-transparent"
|
class="w-full min-w-0 border-b border-primary-700 justify-self-start text-primary-700 bg-transparent"
|
||||||
:class="[
|
:class="[
|
||||||
isEmpty ? 'text-neutral-400' : 'text-black',
|
sizeClass,
|
||||||
|
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||||
disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer',
|
disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||||
selectClass
|
selectClass
|
||||||
]"
|
]"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
>
|
>
|
||||||
<option value="" disabled class="text-neutral-400">
|
<option value="" class="text-neutral-400">
|
||||||
{{ placeholderText }}
|
{{ placeholderText }}
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
class="text-black"
|
class="text-primary-700"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</option>
|
</option>
|
||||||
@@ -55,6 +56,7 @@ const props = withDefaults(
|
|||||||
options: SelectOption[]
|
options: SelectOption[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
size?: 'default' | 'compact'
|
||||||
wrapperClass?: string
|
wrapperClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
selectClass?: string
|
selectClass?: string
|
||||||
@@ -63,6 +65,7 @@ const props = withDefaults(
|
|||||||
placeholder: 'Sélectionner',
|
placeholder: 'Sélectionner',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
size: 'default',
|
||||||
wrapperClass: '',
|
wrapperClass: '',
|
||||||
labelClass: '',
|
labelClass: '',
|
||||||
selectClass: ''
|
selectClass: ''
|
||||||
@@ -77,6 +80,11 @@ const attrs = useAttrs()
|
|||||||
|
|
||||||
const isEmpty = computed(() => props.modelValue === '' || props.modelValue === null || props.modelValue === undefined)
|
const isEmpty = computed(() => props.modelValue === '' || props.modelValue === null || props.modelValue === undefined)
|
||||||
const placeholderText = computed(() => props.placeholder || 'Sélectionner')
|
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 onChange = (event: Event) => {
|
||||||
const target = event.target as HTMLSelectElement
|
const target = event.target as HTMLSelectElement
|
||||||
|
|||||||
35
frontend/components/ui/UiTabs.vue
Normal file
35
frontend/components/ui/UiTabs.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
|
||||||
|
<h1
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="font-bold text-3xl uppercase px-12 cursor-pointer"
|
||||||
|
:class="[
|
||||||
|
modelValue === tab.key
|
||||||
|
? 'border-b-[6px] border-primary-500 text-primary-500'
|
||||||
|
: 'text-primary-500/50',
|
||||||
|
tab.error ? '!text-red-500 !border-red-500' : ''
|
||||||
|
]"
|
||||||
|
@click="emit('update:modelValue', tab.key)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="T extends string">
|
||||||
|
export interface UiTab<K extends string = string> {
|
||||||
|
key: K
|
||||||
|
label: string
|
||||||
|
error?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: T
|
||||||
|
tabs: UiTab<T>[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: T): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="id"
|
:for="id"
|
||||||
class="font-bold uppercase text-xl text-primary-500"
|
class="font-bold uppercase text-xl text-primary-700"
|
||||||
:class="labelClass"
|
:class="labelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
@@ -16,9 +16,10 @@
|
|||||||
:maxlength="maxlength"
|
:maxlength="maxlength"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
class="border-b border-black text-xl py-[6px] bg-transparent text-primary-500"
|
class="w-full min-w-0 border-b border-primary-700 bg-transparent"
|
||||||
:class="[
|
:class="[
|
||||||
isEmpty ? 'text-neutral-400' : 'text-black',
|
sizeClass,
|
||||||
|
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||||
disabled ? 'cursor-not-allowed' : 'cursor-text',
|
disabled ? 'cursor-not-allowed' : 'cursor-text',
|
||||||
inputClass
|
inputClass
|
||||||
]"
|
]"
|
||||||
@@ -40,6 +41,7 @@ const props = withDefaults(
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
maxlength?: number | string
|
maxlength?: number | string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
size?: 'default' | 'compact'
|
||||||
wrapperClass?: string
|
wrapperClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
inputClass?: string
|
inputClass?: string
|
||||||
@@ -48,6 +50,7 @@ const props = withDefaults(
|
|||||||
placeholder: '',
|
placeholder: '',
|
||||||
maxlength: undefined,
|
maxlength: undefined,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
size: 'default',
|
||||||
wrapperClass: '',
|
wrapperClass: '',
|
||||||
labelClass: '',
|
labelClass: '',
|
||||||
inputClass: ''
|
inputClass: ''
|
||||||
@@ -60,6 +63,11 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
const isEmpty = computed(() => !props.modelValue)
|
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 onInput = (event: Event) => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
type="text"
|
type="text"
|
||||||
:maxlength="maxLength"
|
:maxlength="maxLength"
|
||||||
:placeholder="placeholderText"
|
:placeholder="placeholderText"
|
||||||
class="border-b border-black flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
|
: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"
|
@input="handleInput"
|
||||||
/>
|
/>
|
||||||
<UiCheckbox
|
<UiCheckbox
|
||||||
@@ -32,12 +33,14 @@ type Props = {
|
|||||||
allowAny?: boolean
|
allowAny?: boolean
|
||||||
label?: string
|
label?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
required?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
allowAny: false,
|
allowAny: false,
|
||||||
label: 'Immatriculation',
|
label: 'Immatriculation',
|
||||||
id: 'license-plate'
|
id: 'license-plate',
|
||||||
|
required: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
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 {computed, ref} from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import type {ReceptionData, ReceptionPayload, WeightEntryData} from '~/services/dto/reception-data'
|
import type { WeightEntryData } from '~/services/dto/reception-data'
|
||||||
import type {WeightData} from '~/services/dto/weight-data'
|
import type { WeightData } from '~/services/dto/weight-data'
|
||||||
import {getWeight} from '~/services/reception'
|
import { createWeight, updateWeight } from '~/services/weight'
|
||||||
import {getWeightShipment} from '~/services/shipment'
|
|
||||||
import {createWeight, updateWeight} from '~/services/weight'
|
|
||||||
import type {UseWeighingShipmentOptions, UseWeighingOptions} from '~/services/weight'
|
|
||||||
import type {WeightShipmentEntryData} from "~/services/dto/shipment-data";
|
|
||||||
|
|
||||||
export type WeighingMode = 'gross' | 'tare'
|
export type WeighingMode = 'gross' | 'tare'
|
||||||
|
|
||||||
|
export interface UseWeighingOptions {
|
||||||
|
mode: WeighingMode
|
||||||
|
entity: Ref<{ id: number; currentStep: number; isValid: boolean; weights?: WeightEntryData[] | null } | null>
|
||||||
|
entityName: 'reception' | 'shipment'
|
||||||
|
apiResource: string
|
||||||
|
titleLabel: string
|
||||||
|
isFinal?: boolean
|
||||||
|
getWeightFromScale: () => Promise<WeightData>
|
||||||
|
updateEntity: (id: number, payload: any) => Promise<any>
|
||||||
|
loadEntity?: (id: number) => Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
export const useWeighing = ({
|
export const useWeighing = ({
|
||||||
mode,
|
mode,
|
||||||
reception,
|
entity,
|
||||||
updateReception,
|
entityName,
|
||||||
loadReception
|
apiResource,
|
||||||
}: UseWeighingOptions) => {
|
titleLabel,
|
||||||
|
isFinal = false,
|
||||||
|
getWeightFromScale,
|
||||||
|
updateEntity,
|
||||||
|
loadEntity
|
||||||
|
}: UseWeighingOptions) => {
|
||||||
const weightData = ref<WeightData | null>(null)
|
const weightData = ref<WeightData | null>(null)
|
||||||
const isFetching = ref(false)
|
const isFetching = ref(false)
|
||||||
|
|
||||||
const currentWeightEntry = computed<WeightEntryData | null>(() => {
|
const currentWeightEntry = computed<WeightEntryData | null>(() => {
|
||||||
const weights = reception.value?.weights ?? []
|
const weights = entity.value?.weights ?? []
|
||||||
return weights.find((entry) => entry.type === mode) ?? null
|
return weights.find((entry) => entry.type === mode) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null)
|
const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null)
|
||||||
const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-')
|
const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-')
|
||||||
const title = computed(() => (mode === 'gross' ? 'Pesée à plein' : 'Pesée à vide'))
|
const title = computed(() => titleLabel)
|
||||||
const showLoadingBox = computed(
|
const showLoadingBox = computed(() => isFetching.value)
|
||||||
() => isFetching.value || (displayWeight.value === null && currentWeightEntry.value === null)
|
|
||||||
)
|
|
||||||
|
|
||||||
const fetchWeight = async () => {
|
const fetchWeight = async () => {
|
||||||
isFetching.value = true
|
isFetching.value = true
|
||||||
weightData.value = await getWeight().finally(() => {
|
weightData.value = await getWeightFromScale().finally(() => {
|
||||||
isFetching.value = false
|
isFetching.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveWeight = async () => {
|
const saveWeight = async () => {
|
||||||
if (!reception.value) {
|
if (!entity.value) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingEntry = currentWeightEntry.value
|
const existingEntry = currentWeightEntry.value
|
||||||
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
|
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
|
||||||
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
|
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
|
||||||
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
|
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
|
||||||
|
|
||||||
if (baseWeight === null) {
|
if (baseWeight === null) return
|
||||||
return
|
|
||||||
}
|
const relationPayload: Record<string, string> = {}
|
||||||
|
relationPayload[entityName] = `/api/${apiResource}/${entity.value.id}`
|
||||||
|
|
||||||
if (existingEntry?.id) {
|
if (existingEntry?.id) {
|
||||||
await updateWeight(existingEntry.id, {
|
await updateWeight(existingEntry.id, {
|
||||||
@@ -62,7 +71,7 @@ export const useWeighing = ({
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await createWeight({
|
await createWeight({
|
||||||
reception: `api/receptions/${reception.value.id}`,
|
...relationPayload,
|
||||||
type: mode,
|
type: mode,
|
||||||
dsd: baseDsd,
|
dsd: baseDsd,
|
||||||
weight: baseWeight,
|
weight: baseWeight,
|
||||||
@@ -70,16 +79,48 @@ export const useWeighing = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextStep = mode === 'tare'
|
const nextStep = isFinal
|
||||||
? reception.value.currentStep
|
? entity.value.currentStep
|
||||||
: reception.value.currentStep + 1
|
: entity.value.currentStep + 1
|
||||||
await updateReception(reception.value.id, {
|
await updateEntity(entity.value.id, {
|
||||||
currentStep: nextStep,
|
currentStep: nextStep,
|
||||||
isValid: reception.value.isValid
|
isValid: entity.value.isValid
|
||||||
})
|
})
|
||||||
|
|
||||||
if (loadReception) {
|
if (loadEntity) {
|
||||||
await loadReception(reception.value.id)
|
await loadEntity(entity.value.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveWeightDraft = async () => {
|
||||||
|
if (!entity.value) return
|
||||||
|
if (!weightData.value && !currentWeightEntry.value) return
|
||||||
|
|
||||||
|
const existingEntry = currentWeightEntry.value
|
||||||
|
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
|
||||||
|
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
|
||||||
|
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
|
||||||
|
|
||||||
|
if (baseWeight === null) return
|
||||||
|
|
||||||
|
const relationPayload: Record<string, string> = {}
|
||||||
|
relationPayload[entityName] = `/api/${apiResource}/${entity.value.id}`
|
||||||
|
|
||||||
|
if (existingEntry?.id) {
|
||||||
|
await updateWeight(existingEntry.id, {
|
||||||
|
type: mode,
|
||||||
|
dsd: baseDsd,
|
||||||
|
weight: baseWeight,
|
||||||
|
weighedAt: baseWeighedAt
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createWeight({
|
||||||
|
...relationPayload,
|
||||||
|
type: mode,
|
||||||
|
dsd: baseDsd,
|
||||||
|
weight: baseWeight,
|
||||||
|
weighedAt: baseWeighedAt
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,90 +132,34 @@ export const useWeighing = ({
|
|||||||
title,
|
title,
|
||||||
showLoadingBox,
|
showLoadingBox,
|
||||||
fetchWeight,
|
fetchWeight,
|
||||||
saveWeight
|
saveWeight,
|
||||||
|
saveWeightDraft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backward-compatible aliases
|
||||||
export const useWeighingShipment = ({
|
export const useWeighingShipment = ({
|
||||||
modeShipment,
|
modeShipment,
|
||||||
shipment,
|
shipment,
|
||||||
updateShipment,
|
updateShipment,
|
||||||
loadShipment
|
loadShipment
|
||||||
}: UseWeighingShipmentOptions) => {
|
}: {
|
||||||
const weightData = ref<WeightData | null>(null)
|
modeShipment: WeighingMode
|
||||||
const isFetching = ref(false)
|
shipment: Ref<any>
|
||||||
|
updateShipment: (id: number, payload: any) => Promise<any>
|
||||||
const currentWeightEntry = computed<WeightShipmentEntryData | null>(() => {
|
loadShipment?: (id: number) => Promise<any>
|
||||||
const weights = shipment.value?.weights ?? []
|
}) => {
|
||||||
return weights.find((entry) => entry.type === modeShipment) ?? null
|
return useWeighing({
|
||||||
|
mode: modeShipment,
|
||||||
|
entity: shipment,
|
||||||
|
entityName: 'shipment',
|
||||||
|
apiResource: 'shipments',
|
||||||
|
titleLabel: modeShipment === 'gross' ? 'Pesée à plein' : 'Pesée à vide',
|
||||||
|
getWeightFromScale: async () => {
|
||||||
|
const { getWeightShipment } = await import('~/services/shipment')
|
||||||
|
return getWeightShipment()
|
||||||
|
},
|
||||||
|
updateEntity: updateShipment,
|
||||||
|
loadEntity: loadShipment
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null)
|
|
||||||
const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-')
|
|
||||||
const title = computed(() => (modeShipment === 'gross' ? 'Pesée à 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,22 +0,0 @@
|
|||||||
export enum StepLabel {
|
|
||||||
Reception = 'Réception',
|
|
||||||
GrossWeighing = 'Pesée à plein',
|
|
||||||
Selection = 'Sélection réception',
|
|
||||||
TareWeighing = 'Pesée à vide',
|
|
||||||
Shipment = 'Expédition',
|
|
||||||
ShipmentLoading = 'Chargement',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RECEPTION_STEP_LABELS = [
|
|
||||||
StepLabel.Reception,
|
|
||||||
StepLabel.GrossWeighing,
|
|
||||||
StepLabel.Selection,
|
|
||||||
StepLabel.TareWeighing
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SHIPMENT_STEP_LABELS = [
|
|
||||||
StepLabel.Shipment,
|
|
||||||
StepLabel.TareWeighing,
|
|
||||||
StepLabel.ShipmentLoading,
|
|
||||||
StepLabel.GrossWeighing,
|
|
||||||
]
|
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"fetch": "Impossible de récupérer la réception.",
|
"fetch": "Impossible de récupérer la réception.",
|
||||||
"create": "Impossible de créer la réception.",
|
"create": "Impossible de créer la réception.",
|
||||||
"update": "Impossible de mettre à jour la réception.",
|
"update": "Impossible de mettre à jour la réception.",
|
||||||
|
"delete": "Impossible de supprimer la réception.",
|
||||||
"weight": "Impossible de récupérer la pesée."
|
"weight": "Impossible de récupérer la pesée."
|
||||||
},
|
},
|
||||||
"weight": {
|
"weight": {
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"fetch": "Impossible de récupérer l'éxpeditions.",
|
"fetch": "Impossible de récupérer l'éxpeditions.",
|
||||||
"create": "Impossible de créer l'éxpeditions.",
|
"create": "Impossible de créer l'éxpeditions.",
|
||||||
"update": "Impossible de mettre à jour l'éxpeditions.",
|
"update": "Impossible de mettre à jour l'éxpeditions.",
|
||||||
|
"delete": "Impossible de supprimer l'expédition.",
|
||||||
"weigh": "Impossible de récupérer la pesée."
|
"weigh": "Impossible de récupérer la pesée."
|
||||||
},
|
},
|
||||||
"shipmentBovine": {
|
"shipmentBovine": {
|
||||||
@@ -82,7 +84,13 @@
|
|||||||
"list": "Impossible de récupérer la liste des camions."
|
"list": "Impossible de récupérer la liste des camions."
|
||||||
},
|
},
|
||||||
"bovin": {
|
"bovin": {
|
||||||
"list": "Impossible de récupérer la liste des races de bovins."
|
"list": "Impossible de récupérer la liste des races de bovins.",
|
||||||
|
"fetch": "Impossible de récupérer le type bovin.",
|
||||||
|
"create": "Impossible de créer le type bovin.",
|
||||||
|
"update": "Impossible de mettre à jour le type bovin."
|
||||||
|
},
|
||||||
|
"bovine": {
|
||||||
|
"create": "Impossible d'enregistrer le bovin."
|
||||||
},
|
},
|
||||||
"carrier": {
|
"carrier": {
|
||||||
"list": "Impossible de récupérer la liste des transporteurs.",
|
"list": "Impossible de récupérer la liste des transporteurs.",
|
||||||
@@ -106,10 +114,14 @@
|
|||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"reception": {
|
"reception": {
|
||||||
"update": "Réception mise à jour avec succès."
|
"create": "Réception créée avec succès",
|
||||||
|
"update": "Réception mise à jour avec succès.",
|
||||||
|
"delete": "Réception supprimée avec succès."
|
||||||
},
|
},
|
||||||
"shipment": {
|
"shipment": {
|
||||||
"update": "Éxpedition mise à jour avec succès."
|
"create": "Éxpedition créée avec succès",
|
||||||
|
"update": "Éxpedition mise à jour avec succès.",
|
||||||
|
"delete": "Expédition supprimée avec succès."
|
||||||
},
|
},
|
||||||
"supplier": {
|
"supplier": {
|
||||||
"create": "Fournisseur créé avec succès.",
|
"create": "Fournisseur créé avec succès.",
|
||||||
@@ -133,6 +145,13 @@
|
|||||||
"update": "Transporteur mis à jour",
|
"update": "Transporteur mis à jour",
|
||||||
"create": "Transporteur créé"
|
"create": "Transporteur créé"
|
||||||
},
|
},
|
||||||
|
"bovin": {
|
||||||
|
"update": "Type bovin mis à jour avec succès.",
|
||||||
|
"create": "Type bovin créé avec succès."
|
||||||
|
},
|
||||||
|
"bovine": {
|
||||||
|
"create": "Bovin enregistré avec succès."
|
||||||
|
},
|
||||||
"weight": {
|
"weight": {
|
||||||
"update": "Pesée mis à jour"
|
"update": "Pesée mis à jour"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,40 +38,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink
|
|
||||||
v-if="auth.isAdmin"
|
|
||||||
to="/admin/supplier/supplier-list"
|
|
||||||
custom
|
|
||||||
v-slot="{ href, navigate }"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
:href="href"
|
|
||||||
@click="navigate"
|
|
||||||
:class="route.path.startsWith('/admin/supplier')
|
|
||||||
? 'opacity-100'
|
|
||||||
: 'opacity-65 hover:opacity-100 transition'"
|
|
||||||
>
|
|
||||||
Fournisseurs
|
|
||||||
</a>
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<NuxtLink
|
|
||||||
v-if="auth.isAdmin"
|
|
||||||
to="/admin/carrier/carrier-list"
|
|
||||||
custom
|
|
||||||
v-slot="{ href, navigate }"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
:href="href"
|
|
||||||
@click="navigate"
|
|
||||||
:class="route.path.startsWith('/admin/carrier')
|
|
||||||
? 'opacity-100'
|
|
||||||
: 'opacity-65 hover:opacity-100 transition'"
|
|
||||||
>
|
|
||||||
Transporteurs
|
|
||||||
</a>
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="auth.isAdmin"
|
v-if="auth.isAdmin"
|
||||||
to="/admin/user/list"
|
to="/admin/user/list"
|
||||||
@@ -89,6 +55,23 @@
|
|||||||
</a>
|
</a>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
v-if="auth.isAdmin"
|
||||||
|
to="/admin/supplier/supplier-list"
|
||||||
|
custom
|
||||||
|
v-slot="{ href, navigate }"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="href"
|
||||||
|
@click="navigate"
|
||||||
|
:class="route.path.startsWith('/admin/supplier')
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-65 hover:opacity-100 transition'"
|
||||||
|
>
|
||||||
|
Fournisseurs
|
||||||
|
</a>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="auth.isAdmin"
|
v-if="auth.isAdmin"
|
||||||
to="/admin/customer/customer-list"
|
to="/admin/customer/customer-list"
|
||||||
@@ -108,7 +91,24 @@
|
|||||||
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="auth.isAdmin"
|
v-if="auth.isAdmin"
|
||||||
to="/admin/bovin/list"
|
to="/admin/carrier/carrier-list"
|
||||||
|
custom
|
||||||
|
v-slot="{ href, navigate }"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="href"
|
||||||
|
@click="navigate"
|
||||||
|
:class="route.path.startsWith('/admin/carrier')
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-65 hover:opacity-100 transition'"
|
||||||
|
>
|
||||||
|
Transporteurs
|
||||||
|
</a>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
v-if="auth.isAdmin"
|
||||||
|
to="/admin/bovin/bovin-list"
|
||||||
custom
|
custom
|
||||||
v-slot="{ href, navigate }"
|
v-slot="{ href, navigate }"
|
||||||
>
|
>
|
||||||
@@ -122,6 +122,23 @@
|
|||||||
Bovins
|
Bovins
|
||||||
</a>
|
</a>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
v-if="auth.isAdmin"
|
||||||
|
to="/scan"
|
||||||
|
custom
|
||||||
|
v-slot="{ href, navigate }"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="href"
|
||||||
|
@click="navigate"
|
||||||
|
:class="route.path.startsWith('/scan')
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-65 hover:opacity-100 transition'"
|
||||||
|
>
|
||||||
|
Scanner
|
||||||
|
</a>
|
||||||
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
|
<!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
|
||||||
@@ -134,7 +151,8 @@
|
|||||||
class="inline-flex items-center py-2 -my-2 text-xl leading-none transition hover:opacity-80"
|
class="inline-flex items-center py-2 -my-2 text-xl leading-none transition hover:opacity-80"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
<span class="capitalize font-bold">{{ userDisplayName }}</span>
|
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
|
||||||
|
<span class="capitalize font-bold ml-4">{{ userDisplayName }}</span>
|
||||||
<span
|
<span
|
||||||
class="ml-[6px] inline-flex items-center font-bold transition-transform group-hover:rotate-180 group-focus-within:rotate-180">
|
class="ml-[6px] inline-flex items-center font-bold transition-transform group-hover:rotate-180 group-focus-within:rotate-180">
|
||||||
<Icon name="mdi:chevron-down" size="20"/>
|
<Icon name="mdi:chevron-down" size="20"/>
|
||||||
@@ -214,9 +232,12 @@
|
|||||||
<NuxtLink v-if="auth.isAdmin" to="/admin/customer/customer-list" @click="closeMenu">
|
<NuxtLink v-if="auth.isAdmin" to="/admin/customer/customer-list" @click="closeMenu">
|
||||||
Clients
|
Clients
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/list" @click="closeMenu">
|
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/bovin-list" @click="closeMenu">
|
||||||
Bovins
|
Bovins
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/scan" @click="closeMenu">
|
||||||
|
Scanner
|
||||||
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -230,7 +251,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</transition>
|
</transition>
|
||||||
</header>
|
</header>
|
||||||
<main class="mx-auto w-full max-w-[1280px] mt-16">
|
<main class="md:mx-auto w-full md:max-w-[1280px] mt-4 md:mt-16">
|
||||||
<slot/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">
|
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">
|
||||||
|
|||||||
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'],
|
css: ['~/assets/css/main.css', '~/assets/css/toast.css'],
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
apiBase: process.env.NUXT_PUBLIC_API_BASE
|
apiBase: process.env.NUXT_PUBLIC_API_BASE,
|
||||||
|
geoApiBase: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toast: {
|
toast: {
|
||||||
|
|||||||
@@ -1,34 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="validate">
|
<form :class="{ submitted }" @submit.prevent="validate">
|
||||||
<div class="text-primary-500 flex items-center justify-between">
|
<div class="flex items-center justify-between relative">
|
||||||
<h1 class="text-3xl font-bold uppercase">
|
<div class="flex flex-row absolute -left-[60px]">
|
||||||
{{ route.params.id ? 'Modifier bovin' : 'Ajout bovin' }}
|
<Icon
|
||||||
|
@click="router.push('/admin/bovin/bovin-list')"
|
||||||
|
name="gg:arrow-left-o"
|
||||||
|
size="40"
|
||||||
|
class="cursor-pointer text-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
||||||
|
{{ route.params.id ? 'Modifications du type bovin' : 'Ajout d\'un type bovin' }}
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
|
||||||
|
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" required />
|
||||||
|
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" required />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center items-center">
|
||||||
<UiButton
|
<UiButton
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading || isHydrating"
|
:disabled="isLoading || isHydrating"
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] hover:opacity-80"
|
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||||
|
@click="submitted = true"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:check" size="28" />
|
Valider
|
||||||
{{ isEdit ? 'Modifier' : 'Ajouter' }}
|
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 py-12">
|
|
||||||
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" />
|
|
||||||
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" />
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Type de bovin' })
|
||||||
|
|
||||||
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
|
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
|
||||||
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
|
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isHydrating = ref(false)
|
const isHydrating = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
const idBovin = computed(() => resolveId(route.params.id))
|
||||||
|
const isEdit = computed(() => idBovin.value !== null)
|
||||||
|
|
||||||
function resolveId(param: unknown) {
|
function resolveId(param: unknown) {
|
||||||
const idStr = Array.isArray(param) ? param[0] : param
|
const idStr = Array.isArray(param) ? param[0] : param
|
||||||
@@ -37,9 +51,6 @@ function resolveId(param: unknown) {
|
|||||||
return Number.isFinite(id) ? id : null
|
return Number.isFinite(id) ? id : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const idBovin = computed(() => resolveId(route.params.id))
|
|
||||||
const isEdit = computed(() => idBovin.value !== null)
|
|
||||||
|
|
||||||
const form = reactive<BovinFormData>({
|
const form = reactive<BovinFormData>({
|
||||||
label: '',
|
label: '',
|
||||||
code: ''
|
code: ''
|
||||||
@@ -92,7 +103,6 @@ async function validate() {
|
|||||||
} else {
|
} else {
|
||||||
await createBovin(basePayload)
|
await createBovin(basePayload)
|
||||||
}
|
}
|
||||||
await navigate()
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
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,72 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-3xl font-bold text-primary-500 uppercase">Liste des types bovins</h1>
|
|
||||||
<NuxtLink
|
|
||||||
to="/admin/bovin"
|
|
||||||
class="inline-flex items-center justify-center
|
|
||||||
text-xl text-white uppercase
|
|
||||||
bg-primary-500 h-[50px] px-8 rounded
|
|
||||||
hover:opacity-80 gap-2"
|
|
||||||
@click="handleAddClick"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:plus" size="28" />
|
|
||||||
Ajouter
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
<div v-if="auth.isAdmin" class="mt-6 border border-slate-200 mb-16">
|
|
||||||
<div class="max-h-96 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
class="sticky
|
|
||||||
grid grid-cols-2 gap-4
|
|
||||||
bg-slate-100 px-4 py-3
|
|
||||||
font-semibold uppercase
|
|
||||||
tracking-wide"
|
|
||||||
>
|
|
||||||
<div class="col-span-1">Nom</div>
|
|
||||||
<div class="col-span-1">Code</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="bovinList.length === 0" class="px-4 py-6 text-slate-400">
|
|
||||||
Aucun type de bovin.
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<div
|
|
||||||
v-for="bovin in bovinList"
|
|
||||||
:key="bovin.id"
|
|
||||||
class="grid grid-cols-2 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer"
|
|
||||||
@click="goToBovin(bovin.id)"
|
|
||||||
>
|
|
||||||
<div class="col-span-1">{{ bovin.label }}</div>
|
|
||||||
<div class="col-span-1">{{ bovin.code }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
|
||||||
Accès réservé aux administrateurs.
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { getBovineTypeList } from "~/services/bovine-type"
|
|
||||||
import type { BovineTypeData } from "~/services/dto/bovine-type-data"
|
|
||||||
import { useAuthStore } from "~/stores/auth"
|
|
||||||
|
|
||||||
const bovinList = ref<BovineTypeData[]>([])
|
|
||||||
const router = useRouter()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
const goToBovin = (id: number) => {
|
|
||||||
if (!auth.isAdmin) return
|
|
||||||
router.push(`/admin/bovin/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddClick = (event: Event) => {
|
|
||||||
if (auth.isAdmin) return
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!auth.isAdmin) return
|
|
||||||
bovinList.value = await getBovineTypeList()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,36 +1,51 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="validate">
|
<form :class="{ submitted }" @submit.prevent="validate">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between relative">
|
||||||
<h1 class="text-3xl font-bold uppercase">
|
<div class="flex flex-row absolute -left-[60px]">
|
||||||
{{ route.params.id ? 'Modifier transporteur' : 'Ajout transporteur' }}
|
<Icon
|
||||||
|
@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>
|
</h1>
|
||||||
|
|
||||||
<UiButton
|
|
||||||
type="submit"
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
|
||||||
>Enregistrer
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 py-12">
|
<div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
label = "nom du fournisseur"
|
label="Nom du transporteur"
|
||||||
id="carrier-name"
|
id="carrier-name"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
label = "code fournisseur"
|
label="Code transporteur"
|
||||||
id="code-id"
|
id="code-id"
|
||||||
v-model="form.code"
|
v-model="form.code"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-center items-center">
|
||||||
|
<UiButton
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||||
|
@click="submitted = true"
|
||||||
|
>
|
||||||
|
Valider
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Transporteur' })
|
||||||
|
|
||||||
import {createCarrier, getCarrier, updateCarrier} from "~/services/carrier";
|
import {createCarrier, getCarrier, updateCarrier} from "~/services/carrier";
|
||||||
import type {CarrierData, CarrierFormData} from "~/services/dto/carrier-data";
|
import type {CarrierData, CarrierFormData} from "~/services/dto/carrier-data";
|
||||||
import {computed} from "vue";
|
import {computed} from "vue";
|
||||||
@@ -39,6 +54,7 @@ const route = useRoute()
|
|||||||
const idCarrier = computed(() => resolveId(route.params.id))
|
const idCarrier = computed(() => resolveId(route.params.id))
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isHydrating = ref(false)
|
const isHydrating = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
const resolveId = (param: unknown) => {
|
const resolveId = (param: unknown) => {
|
||||||
const idStr = Array.isArray(param) ? param[0] : param
|
const idStr = Array.isArray(param) ? param[0] : param
|
||||||
@@ -52,10 +68,6 @@ const form = reactive<CarrierFormData>({
|
|||||||
name:''
|
name:''
|
||||||
})
|
})
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'default'
|
|
||||||
})
|
|
||||||
|
|
||||||
const hydrateFromUser = (carrier: CarrierData | null) => {
|
const hydrateFromUser = (carrier: CarrierData | null) => {
|
||||||
if (!carrier) {
|
if (!carrier) {
|
||||||
return
|
return
|
||||||
@@ -95,11 +107,10 @@ async function validate() {
|
|||||||
|
|
||||||
if(idCarrier.value){
|
if(idCarrier.value){
|
||||||
await updateCarrier(idCarrier.value, basePayload)
|
await updateCarrier(idCarrier.value, basePayload)
|
||||||
navigate()
|
|
||||||
return
|
return
|
||||||
}
|
}else{
|
||||||
await createCarrier(basePayload)
|
await createCarrier(basePayload)
|
||||||
navigate()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigate(){
|
function navigate(){
|
||||||
|
|||||||
@@ -1,53 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="px-[86px]">
|
||||||
<div class="flex items-center justify-between ">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
|
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/carrier"
|
to="/admin/carrier"
|
||||||
class="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded"
|
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" />
|
<Icon name="mdi:plus" size="28" />
|
||||||
Ajouter
|
Ajouter
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 border border-slate-200 mb-16 ">
|
<div class="mt-6 mb-16">
|
||||||
<div class="grid grid-cols-2 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
<UiDataTable
|
||||||
<div>Label</div>
|
v-model:page="page"
|
||||||
<div>Code</div>
|
v-model:per-page="perPage"
|
||||||
</div>
|
:columns="columns"
|
||||||
<div
|
:items="items"
|
||||||
v-for="carrier in carrierList"
|
:total-items="totalItems"
|
||||||
:key="carrier.id"
|
:loading="loading"
|
||||||
class="grid grid-cols-2 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
|
row-clickable
|
||||||
role="button"
|
@row-click="goToCarrier"
|
||||||
tabindex="0"
|
|
||||||
@click="goToCarrier(carrier.id)"
|
|
||||||
@keydown.enter="goToCarrier(carrier.id)"
|
|
||||||
>
|
>
|
||||||
<div>{{ carrier.name}}</div>
|
<template #header-name>
|
||||||
<div>{{ carrier.code }}</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {CarrierData} from "~/services/dto/carrier-data";
|
useHead({ title: 'Transporteurs' })
|
||||||
import {getCarrierList} from "~/services/carrier";
|
|
||||||
|
import type { CarrierData } from '~/services/dto/carrier-data'
|
||||||
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
|
|
||||||
const carrierList = ref<CarrierData[]>()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const goToCarrier = (id: number) => {
|
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||||
router.push(`/admin/carrier/${id}`)
|
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({
|
onMounted(reload)
|
||||||
layout: 'default'
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
carrierList.value = await getCarrierList(false)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,53 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="validate">
|
<form :class="{ submitted }" @submit.prevent="validate">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center relative">
|
||||||
<h1 class="text-3xl font-bold uppercase">
|
<div class="flex flex-row absolute -left-[60px] ">
|
||||||
{{ customerId ? "Modifications du client" : "Ajout d'un client" }}
|
<Icon @click="router.push('/admin/customer/customer-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
||||||
|
{{ customerId ? "Modification du client" : "Ajout d'un client" }}
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
|
||||||
|
<UiTextInput id="customer-name" v-model="form.name" label="Nom du client" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
|
||||||
|
<UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
|
||||||
|
<UiTextInput id="customer-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
|
||||||
|
</div>
|
||||||
|
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
|
||||||
|
<UiTextInput id="address-street" v-model="addressForm.street" label="Rue" wrapper-class="w-[280px]" required />
|
||||||
|
<UiTextInput id="address-street2" v-model="addressForm.street2" label="Complément" wrapper-class="w-[280px]" />
|
||||||
|
<UiTextInput id="address-country" v-model="addressForm.countryCode" label="Pays (code)" wrapper-class="w-[280px]" />
|
||||||
|
</div>
|
||||||
|
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
|
||||||
|
<UiTextInput id="address-postalCode" v-model="addressForm.postalCode" label="Code postal" wrapper-class="w-[280px]" required />
|
||||||
|
<UiSelect id="address-city" v-model="addressForm.city" label="Ville"
|
||||||
|
:options="communeOptions" :loading="isLoadingCities"
|
||||||
|
wrapper-class="w-[280px]" required />
|
||||||
|
<div class="w-[280px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
<UiButton
|
<UiButton
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading || !auth.isAdmin"
|
:disabled="isLoading || !auth.isAdmin"
|
||||||
|
@click="submitted = true"
|
||||||
>
|
>
|
||||||
{{ customerId ? "Sauvegarder" : "Ajouter" }}
|
<Icon :name="customerId ? '' : 'mdi:plus'" size="28" />
|
||||||
|
{{ customerId ? "Valider" : "Ajouter" }}
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-y-8 gap-x-80 mb-10 py-12">
|
<template v-if="customerId">
|
||||||
<UiTextInput id="customer-name" v-model="form.name" label="Nom du client" :disabled="!auth.isAdmin"/>
|
<div class="flex items-center justify-between mb-7">
|
||||||
<UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin"/>
|
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du client</h2>
|
||||||
<UiTextInput id="customer-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="overflow-x-auto mb-11 text-primary-700">
|
||||||
<div class="mx-24 mb-4 py-6 border-t border-black"></div>
|
<table class="w-full border-collapse text-primary-700">
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h2 class="text-3xl font-bold uppercase">Adresses client</h2>
|
|
||||||
<UiButton
|
|
||||||
type="button"
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
|
||||||
:disabled="customerId === null || !auth.isAdmin"
|
|
||||||
@click="goToAddAddress"
|
|
||||||
>
|
|
||||||
Ajouter
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto mb-10">
|
|
||||||
<table class="w-full border-collapse">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left border-b border-gray-200">
|
<tr class="text-left border bg-slate-100 border-gray-200">
|
||||||
<th class="py-3 pr-4 text-sm uppercase">Libellé</th>
|
<th class="py-3 px-4 text-sm uppercase">Rue</th>
|
||||||
<th class="py-3 pr-4 text-sm uppercase">Rue</th>
|
<th class="py-3 px-4 text-sm uppercase">Complément</th>
|
||||||
<th class="py-3 pr-4 text-sm uppercase">Complément</th>
|
<th class="py-3 px-4 text-sm uppercase">Code postal</th>
|
||||||
<th class="py-3 pr-4 text-sm uppercase">Code postal</th>
|
<th class="py-3 px-4 text-sm uppercase">Ville</th>
|
||||||
<th class="py-3 pr-4 text-sm uppercase">Ville</th>
|
<th class="py-3 px-4 text-sm uppercase">Pays</th>
|
||||||
<th class="py-3 pr-4 text-sm uppercase">Pays</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-if="form.addresses.length === 0">
|
<template v-if="form.addresses.length === 0">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="py-4 text-slate-400">
|
<td colspan="5" class="py-4 text-slate-400">
|
||||||
Aucune adresse.
|
Aucune adresse.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -56,32 +66,45 @@
|
|||||||
<tr
|
<tr
|
||||||
v-for="(address, index) in form.addresses"
|
v-for="(address, index) in form.addresses"
|
||||||
:key="address.id ?? index"
|
:key="address.id ?? index"
|
||||||
class="border-b border-gray-100 hover:bg-slate-50"
|
class="border border-gray-100 hover:bg-slate-50"
|
||||||
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||||
@click="goToEditAddress(address.id ?? null)"
|
@click="goToEditAddress(address.id ?? null)"
|
||||||
>
|
>
|
||||||
<td class="py-3 pr-4">{{ address.label || "—" }}</td>
|
<td class="py-3 px-4">{{ address.street || "—" }}</td>
|
||||||
<td class="py-3 pr-4">{{ address.street || "—" }}</td>
|
<td class="py-3 px-4">{{ address.street2 || "—" }}</td>
|
||||||
<td class="py-3 pr-4">{{ address.street2 || "—" }}</td>
|
<td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
|
||||||
<td class="py-3 pr-4">{{ address.postalCode || "—" }}</td>
|
<td class="py-3 px-4">{{ address.city || "—" }}</td>
|
||||||
<td class="py-3 pr-4">{{ address.city || "—" }}</td>
|
<td class="py-3 px-4">{{ address.countryCode || "—" }}</td>
|
||||||
<td class="py-3 pr-4">{{ address.countryCode || "—" }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-center items-center">
|
||||||
|
<UiButton
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center text-xl gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||||
|
:disabled="customerId === null || !auth.isAdmin"
|
||||||
|
@click="goToAddAddress"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:plus" size="28" />
|
||||||
|
Ajouter
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Client' })
|
||||||
|
|
||||||
import {computed, reactive, ref, watch} from "vue"
|
import {computed, reactive, ref, watch} from "vue"
|
||||||
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
|
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
|
||||||
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
|
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
|
||||||
|
import {createAddress, type AddressPayload} from "~/services/address"
|
||||||
|
import {getCommunesByPostalCode, type CommuneData} from "~/services/geo"
|
||||||
import {useAuthStore} from "~/stores/auth"
|
import {useAuthStore} from "~/stores/auth"
|
||||||
|
|
||||||
definePageMeta({layout: "default"})
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -94,6 +117,7 @@ const resolveId = (param: unknown) => {
|
|||||||
}
|
}
|
||||||
const customerId = computed(() => resolveId(route.params.id))
|
const customerId = computed(() => resolveId(route.params.id))
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
const form = reactive<CustomerFormData>({
|
const form = reactive<CustomerFormData>({
|
||||||
name: "",
|
name: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
@@ -101,6 +125,30 @@ const form = reactive<CustomerFormData>({
|
|||||||
addresses: [],
|
addresses: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Address form (creation mode only)
|
||||||
|
const addressForm = reactive<AddressPayload>({
|
||||||
|
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
|
||||||
|
})
|
||||||
|
const communes = ref<CommuneData[]>([])
|
||||||
|
const isLoadingCities = ref(false)
|
||||||
|
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
watch(() => addressForm.postalCode, (cp) => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
|
||||||
|
if (cp.length === 5) {
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
isLoadingCities.value = true
|
||||||
|
try {
|
||||||
|
communes.value = await getCommunesByPostalCode(cp)
|
||||||
|
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
|
||||||
|
else addressForm.city = ''
|
||||||
|
} finally { isLoadingCities.value = false }
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const goToAddAddress = () => {
|
const goToAddAddress = () => {
|
||||||
if (customerId.value === null || !auth.isAdmin) return
|
if (customerId.value === null || !auth.isAdmin) return
|
||||||
router.push({
|
router.push({
|
||||||
@@ -138,7 +186,6 @@ const hydrateFromCustomer = (customer: CustomerData | null) => {
|
|||||||
|
|
||||||
form.addresses = customer.addresses.map((address) => ({
|
form.addresses = customer.addresses.map((address) => ({
|
||||||
id: address.id ?? null,
|
id: address.id ?? null,
|
||||||
label: address.label ?? "",
|
|
||||||
street: address.street ?? "",
|
street: address.street ?? "",
|
||||||
street2: address.street2 ?? null,
|
street2: address.street2 ?? null,
|
||||||
postalCode: address.postalCode ?? "",
|
postalCode: address.postalCode ?? "",
|
||||||
@@ -183,7 +230,14 @@ async function validate() {
|
|||||||
await updateCustomer(customerId.value, customerPayload)
|
await updateCustomer(customerId.value, customerPayload)
|
||||||
targetId = customerId.value
|
targetId = customerId.value
|
||||||
} else {
|
} else {
|
||||||
const created = await createCustomer(customerPayload)
|
const addressData = await createAddress({ ...addressForm })
|
||||||
|
const addressIRI = `/api/addresses/${addressData.id}`
|
||||||
|
const creationPayload = {
|
||||||
|
...customerPayload,
|
||||||
|
addresses: [addressIRI],
|
||||||
|
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
|
||||||
|
}
|
||||||
|
const created = await createCustomer(creationPayload)
|
||||||
targetId = created.id
|
targetId = created.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Adresse client' })
|
||||||
|
|
||||||
import type { AddressData, AddressPayload } from "~/services/address"
|
import type { AddressData, AddressPayload } from "~/services/address"
|
||||||
import { createAddress, getAddress, updateAddress } from "~/services/address"
|
import { createAddress, getAddress, updateAddress } from "~/services/address"
|
||||||
import { getCustomer, updateCustomer } from "~/services/customer"
|
import { getCustomer, updateCustomer } from "~/services/customer"
|
||||||
import type { CustomerData } from "~/services/dto/customer-data"
|
import type { CustomerData } from "~/services/dto/customer-data"
|
||||||
|
|
||||||
definePageMeta({ layout: "default" })
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const customerId = computed(() => Number(route.query.customerId))
|
const customerId = computed(() => Number(route.query.customerId))
|
||||||
@@ -18,13 +18,10 @@ const addressId = computed(() => (route.query.addressId !== undefined ? Number(r
|
|||||||
const address = ref<AddressData | null>(null)
|
const address = ref<AddressData | null>(null)
|
||||||
|
|
||||||
const validate = async (payload: AddressPayload) => {
|
const validate = async (payload: AddressPayload) => {
|
||||||
try {
|
|
||||||
if (addressId.value !== null) {
|
if (addressId.value !== null) {
|
||||||
await updateAddress(addressId.value, payload)
|
await updateAddress(addressId.value, payload)
|
||||||
} else {
|
} else {
|
||||||
await addAddress(payload)
|
await addAddress(payload)
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await router.push("/admin/customer/" + customerId.value)
|
await router.push("/admin/customer/" + customerId.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,117 +1,82 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="px-[86px]">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-3xl font-bold uppercase text-primary-500">Liste des Clients</h1>
|
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
|
v-if="auth.isAdmin"
|
||||||
to="/admin/customer"
|
to="/admin/customer"
|
||||||
class="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded-md"
|
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
|
||||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
|
||||||
@click="handleAddClick"
|
|
||||||
>
|
>
|
||||||
<Icon name="mdi:plus" size="28" />
|
<Icon name="mdi:plus" size="28" />
|
||||||
Ajouter
|
Ajouter
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="auth.isAdmin" class="mt-6 border border-slate-200 mb-16">
|
<div v-if="auth.isAdmin" class="mt-6 mb-16">
|
||||||
<div class="max-h-96 overflow-y-auto">
|
<UiDataTable
|
||||||
<div
|
v-model:page="page"
|
||||||
class="sticky top-0 z-10 grid grid-cols-8 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
|
v-model:per-page="perPage"
|
||||||
|
:columns="columns"
|
||||||
|
:items="items"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:loading="loading"
|
||||||
|
row-clickable
|
||||||
|
@row-click="goToCustomer"
|
||||||
>
|
>
|
||||||
<div>Nom</div>
|
<template #header-name>
|
||||||
<div>Téléphone</div>
|
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
|
||||||
<div>Email</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 client.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-for="customer in customerList" :key="customer.id">
|
|
||||||
<div
|
|
||||||
v-if="!customer.addresses || customer.addresses.length === 0"
|
|
||||||
class="grid grid-cols-8 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer"
|
|
||||||
@click="goToCustomer(customer.id)"
|
|
||||||
>
|
|
||||||
<div class="truncate">{{ customer.name || "—" }}</div>
|
|
||||||
<div class="truncate">{{ customer.phone || "—" }}</div>
|
|
||||||
<div class="truncate">{{ customer.email || "—" }}</div>
|
|
||||||
<div class="col-span-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-8 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
|
|
||||||
:class="idx > 0 ? 'pl-4 border-l-4 border-l-slate-200 bg-slate-50' : ''"
|
|
||||||
@click="goToCustomer(customer.id)"
|
|
||||||
>
|
|
||||||
<div class="truncate">
|
|
||||||
{{ idx === 0 ? (customer.name || "—") : "↳" }}
|
|
||||||
</div>
|
|
||||||
<div class="truncate">{{ idx === 0 ? (customer.phone || "—") : "" }}</div>
|
|
||||||
<div class="truncate">{{ idx === 0 ? (customer.email || "—") : "" }}</div>
|
|
||||||
<div class="truncate">{{ address.street || "—" }}</div>
|
|
||||||
<div class="truncate">{{ address.street2 || "—" }}</div>
|
|
||||||
<div>{{ address.postalCode || "—" }}</div>
|
|
||||||
<div class="uppercase truncate">{{ address.city || "—" }}</div>
|
|
||||||
<div class="uppercase truncate">{{ address.countryCode || "—" }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
<template #header-phone>
|
||||||
<template v-else>
|
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
|
||||||
<div
|
|
||||||
class="grid grid-cols-8 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
|
|
||||||
@click="goToCustomer(customer.id)"
|
|
||||||
>
|
|
||||||
<div class="truncate">{{ customer.name || "—" }}</div>
|
|
||||||
<div class="truncate">{{ customer.phone || "—" }}</div>
|
|
||||||
<div class="truncate">{{ customer.email || "—" }}</div>
|
|
||||||
<div class="col-span-5 text-slate-400">
|
|
||||||
Adresses non chargées
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
<template #header-email>
|
||||||
</div>
|
<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>
|
||||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||||
Accès réservé aux administrateurs.
|
Accès réservé aux administrateurs.
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getCustomerList } from "~/services/customer"
|
useHead({ title: 'Clients' })
|
||||||
import type { CustomerData } from "~/services/dto/customer-data"
|
|
||||||
import { useAuthStore } from "~/stores/auth"
|
|
||||||
|
|
||||||
definePageMeta({ layout: "default" })
|
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 router = useRouter()
|
||||||
const auth = useAuthStore()
|
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
|
if (!auth.isAdmin) return
|
||||||
router.push(`/admin/customer/${id}`)
|
router.push(`/admin/customer/${customer.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddClick = (event: Event) => {
|
onMounted(() => {
|
||||||
if (auth.isAdmin) return
|
if (auth.isAdmin) reload()
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!auth.isAdmin) return
|
|
||||||
customerList.value = await getCustomerList()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'default'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,53 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="validate">
|
<form :class="{ submitted }" @submit.prevent="validate">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-3xl font-bold uppercase">
|
|
||||||
{{ supplierId ? "Modifications du fournisseur" : "Ajout d'un fournisseur" }}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
|
<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
|
<UiButton
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading || !auth.isAdmin"
|
:disabled="isLoading || !auth.isAdmin"
|
||||||
|
@click="submitted = true"
|
||||||
>
|
>
|
||||||
{{ supplierId ? "Sauvegarder" : "Ajouter" }}
|
<Icon :name="supplierId ? '' : 'mdi:plus'" size="28" />
|
||||||
|
{{ supplierId ? "Valider" : "Ajouter" }}
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-y-8 gap-x-80 mb-10 py-12">
|
<template v-if="supplierId">
|
||||||
<UiTextInput id="supplier-name" v-model="form.name" label="Nom du fournisseur" :disabled="!auth.isAdmin"/>
|
<div class="flex items-center justify-between mb-7">
|
||||||
<UiTextInput id="supplier-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin"/>
|
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du fournisseur</h2>
|
||||||
<UiTextInput id="supplier-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="overflow-x-auto mb-11 text-primary-700">
|
||||||
<div class="mx-24 mb-4 py-6 border-t border-black"></div>
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h2 class="text-3xl font-bold uppercase">Adresses fournisseur</h2>
|
|
||||||
<UiButton
|
|
||||||
type="button"
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
|
||||||
:disabled="supplierId === null || !auth.isAdmin"
|
|
||||||
@click="goToAddAddress"
|
|
||||||
>
|
|
||||||
Ajouter
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto mb-10">
|
|
||||||
<table class="w-full border-collapse">
|
<table class="w-full border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left border-b border-gray-200">
|
<tr class="text-left border bg-slate-100 border-gray-200">
|
||||||
<th class="py-3 pr-4 text-sm uppercase">Libellé</th>
|
<th class="py-3 px-4 text-sm uppercase">Rue</th>
|
||||||
<th class="py-3 pr-4 text-sm uppercase">Rue</th>
|
<th class="py-3 px-4 text-sm uppercase">Complément</th>
|
||||||
<th class="py-3 pr-4 text-sm uppercase">Complément</th>
|
<th class="py-3 px-4 text-sm uppercase">Code postal</th>
|
||||||
<th class="py-3 pr-4 text-sm uppercase">Code postal</th>
|
<th class="py-3 px-4 text-sm uppercase">Ville</th>
|
||||||
<th class="py-3 pr-4 text-sm uppercase">Ville</th>
|
<th class="py-3 px-4 text-sm uppercase">Pays</th>
|
||||||
<th class="py-3 pr-4 text-sm uppercase">Pays</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-if="form.addresses.length === 0">
|
<template v-if="form.addresses.length === 0">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="py-4 text-slate-400">
|
<td colspan="5" class="py-4 text-slate-400">
|
||||||
Aucune adresse.
|
Aucune adresse.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -56,32 +67,45 @@
|
|||||||
<tr
|
<tr
|
||||||
v-for="(address, index) in form.addresses"
|
v-for="(address, index) in form.addresses"
|
||||||
:key="address.id ?? index"
|
:key="address.id ?? index"
|
||||||
class="border-b border-gray-100 hover:bg-slate-50"
|
class="border border-gray-100 hover:bg-slate-50"
|
||||||
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||||
@click="goToEditAddress(address.id ?? null)"
|
@click="goToEditAddress(address.id ?? null)"
|
||||||
>
|
>
|
||||||
<td class="py-3 pr-4">{{ address.label || "—" }}</td>
|
<td class="py-3 px-4">{{ address.street || "—" }}</td>
|
||||||
<td class="py-3 pr-4">{{ address.street || "—" }}</td>
|
<td class="py-3 px-4">{{ address.street2 || "—" }}</td>
|
||||||
<td class="py-3 pr-4">{{ address.street2 || "—" }}</td>
|
<td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
|
||||||
<td class="py-3 pr-4">{{ address.postalCode || "—" }}</td>
|
<td class="py-3 px-4">{{ address.city || "—" }}</td>
|
||||||
<td class="py-3 pr-4">{{ address.city || "—" }}</td>
|
<td class="py-3 px-4">{{ address.countryCode || "—" }}</td>
|
||||||
<td class="py-3 pr-4">{{ address.countryCode || "—" }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-center items-center">
|
||||||
|
<UiButton
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center text-xl gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||||
|
:disabled="supplierId === null || !auth.isAdmin"
|
||||||
|
@click="goToAddAddress"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:plus" size="28" />
|
||||||
|
Ajouter
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Fournisseur' })
|
||||||
|
|
||||||
import {computed, reactive, ref, watch} from "vue"
|
import {computed, reactive, ref, watch} from "vue"
|
||||||
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
|
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
|
||||||
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
|
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
|
||||||
|
import {createAddress, type AddressPayload} from "~/services/address"
|
||||||
|
import {getCommunesByPostalCode, type CommuneData} from "~/services/geo"
|
||||||
import {useAuthStore} from "~/stores/auth"
|
import {useAuthStore} from "~/stores/auth"
|
||||||
|
|
||||||
definePageMeta({layout: "default"})
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -94,6 +118,7 @@ const resolveId = (param: unknown) => {
|
|||||||
}
|
}
|
||||||
const supplierId = computed(() => resolveId(route.params.id))
|
const supplierId = computed(() => resolveId(route.params.id))
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
const form = reactive<SupplierFormData>({
|
const form = reactive<SupplierFormData>({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
@@ -101,6 +126,30 @@ const form = reactive<SupplierFormData>({
|
|||||||
addresses: [],
|
addresses: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Address form (creation mode only)
|
||||||
|
const addressForm = reactive<AddressPayload>({
|
||||||
|
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
|
||||||
|
})
|
||||||
|
const communes = ref<CommuneData[]>([])
|
||||||
|
const isLoadingCities = ref(false)
|
||||||
|
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
watch(() => addressForm.postalCode, (cp) => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
|
||||||
|
if (cp.length === 5) {
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
isLoadingCities.value = true
|
||||||
|
try {
|
||||||
|
communes.value = await getCommunesByPostalCode(cp)
|
||||||
|
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
|
||||||
|
else addressForm.city = ''
|
||||||
|
} finally { isLoadingCities.value = false }
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const goToAddAddress = () => {
|
const goToAddAddress = () => {
|
||||||
if (supplierId.value === null || !auth.isAdmin) return
|
if (supplierId.value === null || !auth.isAdmin) return
|
||||||
router.push({
|
router.push({
|
||||||
@@ -140,7 +189,6 @@ const hydrateFromSupplier = (supplier: SupplierData | null) => {
|
|||||||
|
|
||||||
form.addresses = supplier.addresses.map((address) => ({
|
form.addresses = supplier.addresses.map((address) => ({
|
||||||
id: address.id ?? null,
|
id: address.id ?? null,
|
||||||
label: address.label ?? "",
|
|
||||||
street: address.street ?? "",
|
street: address.street ?? "",
|
||||||
street2: address.street2 ?? null,
|
street2: address.street2 ?? null,
|
||||||
postalCode: address.postalCode ?? "",
|
postalCode: address.postalCode ?? "",
|
||||||
@@ -185,7 +233,14 @@ async function validate() {
|
|||||||
await updateSupplier(supplierId.value, supplierPayload)
|
await updateSupplier(supplierId.value, supplierPayload)
|
||||||
targetId = supplierId.value
|
targetId = supplierId.value
|
||||||
} else {
|
} else {
|
||||||
const created = await createSupplier(supplierPayload)
|
const addressData = await createAddress({ ...addressForm })
|
||||||
|
const addressIRI = `/api/addresses/${addressData.id}`
|
||||||
|
const creationPayload = {
|
||||||
|
...supplierPayload,
|
||||||
|
addresses: [addressIRI],
|
||||||
|
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
|
||||||
|
}
|
||||||
|
const created = await createSupplier(creationPayload)
|
||||||
targetId = created.id
|
targetId = created.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Adresse fournisseur' })
|
||||||
|
|
||||||
import type {AddressData, AddressPayload} from "~/services/address";
|
import type {AddressData, AddressPayload} from "~/services/address";
|
||||||
import {createAddress, getAddress, updateAddress} from "~/services/address";
|
import {createAddress, getAddress, updateAddress} from "~/services/address";
|
||||||
import {getSupplier, updateSupplier} from "~/services/supplier";
|
import {getSupplier, updateSupplier} from "~/services/supplier";
|
||||||
import type {SupplierData} from "~/services/dto/supplier-data";
|
import type {SupplierData} from "~/services/dto/supplier-data";
|
||||||
|
|
||||||
definePageMeta({ layout: "default" })
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const supplierId = computed(() => { return Number(route.query.supplierId) })
|
const supplierId = computed(() => { return Number(route.query.supplierId) })
|
||||||
@@ -18,13 +18,10 @@ const addressId = computed(() => { return route.query.addressId !== undefined ?
|
|||||||
const address = ref<AddressData|null>(null)
|
const address = ref<AddressData|null>(null)
|
||||||
|
|
||||||
const validate = async (address: AddressPayload) => {
|
const validate = async (address: AddressPayload) => {
|
||||||
try {
|
|
||||||
if (addressId.value !== null) {
|
if (addressId.value !== null) {
|
||||||
await updateAddress(addressId.value, address)
|
await updateAddress(addressId.value, address)
|
||||||
} else {
|
} else {
|
||||||
await addAddress(address)
|
await addAddress(address)
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await router.push('/admin/supplier/' + supplierId.value)
|
await router.push('/admin/supplier/' + supplierId.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +1,82 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="px-[86px]">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-3xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1>
|
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
|
v-if="auth.isAdmin"
|
||||||
to="/admin/supplier"
|
to="/admin/supplier"
|
||||||
class="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded"
|
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
|
||||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
|
||||||
@click="handleAddClick"
|
|
||||||
>
|
>
|
||||||
<Icon name="mdi:plus" size="28" />
|
<Icon name="mdi:plus" size="28" />
|
||||||
Ajouter
|
Ajouter
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="auth.isAdmin" class="mt-6 border border-slate-200 mb-16">
|
<div v-if="auth.isAdmin" class="mt-6 mb-16">
|
||||||
<div class="max-h-96 overflow-y-auto">
|
<UiDataTable
|
||||||
<div
|
v-model:page="page"
|
||||||
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"
|
v-model:per-page="perPage"
|
||||||
|
:columns="columns"
|
||||||
|
:items="items"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:loading="loading"
|
||||||
|
row-clickable
|
||||||
|
@row-click="goToSupplier"
|
||||||
>
|
>
|
||||||
<div>Nom</div>
|
<template #header-name>
|
||||||
<div>Mail</div>
|
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
|
||||||
<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>
|
||||||
|
<template #header-phone>
|
||||||
<template v-else>
|
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
|
||||||
<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>
|
</template>
|
||||||
</div>
|
<template #header-email>
|
||||||
</div>
|
<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>
|
||||||
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
|
||||||
Accès réservé aux administrateurs.
|
Accès réservé aux administrateurs.
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getSupplierList } from "~/services/supplier"
|
useHead({ title: 'Fournisseurs' })
|
||||||
import type { SupplierData } from "~/services/dto/supplier-data"
|
|
||||||
import { useAuthStore } from "~/stores/auth"
|
|
||||||
|
|
||||||
definePageMeta({ layout: "default" })
|
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 router = useRouter()
|
||||||
const auth = useAuthStore()
|
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
|
if (!auth.isAdmin) return
|
||||||
router.push(`/admin/supplier/${id}`)
|
router.push(`/admin/supplier/${supplier.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddClick = (event: Event) => {
|
onMounted(() => {
|
||||||
if (auth.isAdmin) return
|
if (auth.isAdmin) reload()
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!auth.isAdmin) return
|
|
||||||
supplierList.value = await getSupplierList()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,57 +1,94 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="validate">
|
<form :class="{ submitted }" @submit.prevent="validate">
|
||||||
<div
|
<div class="flex items-center relative">
|
||||||
class="flex items-center justify-between gap-10">
|
<div class="flex flex-row absolute -left-[60px]">
|
||||||
<h1 class="text-3xl font-bold uppercase">
|
<Icon
|
||||||
{{ userId ? "Modifications de l'utilisateur" : "Ajout d'un utilisateur" }}
|
@click="router.push('/admin/user/list')"
|
||||||
|
name="gg:arrow-left-o"
|
||||||
|
size="40"
|
||||||
|
class="cursor-pointer text-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
||||||
|
{{ userId ? "Modification de l'utilisateur" : "Ajout d'un utilisateur" }}
|
||||||
</h1>
|
</h1>
|
||||||
<UiButton
|
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{{ userId ? 'Sauvegarder' : 'Ajouter' }}
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-y-16 gap-x-40 py-12">
|
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
id="user-name"
|
id="user-name"
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
label="Nom de l'utilisateur"
|
label="Nom de l'utilisateur"
|
||||||
|
:disabled="!auth.isAdmin"
|
||||||
|
wrapper-class="w-[280px]"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UiSelect
|
<UiSelect
|
||||||
id="user-role"
|
id="user-role"
|
||||||
v-model="form.role"
|
v-model="form.role"
|
||||||
label="Rôle de l'utilisateur"
|
label="Role de l'utilisateur"
|
||||||
:options="ROLE"
|
:options="ROLE"
|
||||||
|
:disabled="!auth.isAdmin"
|
||||||
|
wrapper-class="w-[280px]"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
id="user-password"
|
id="user-password"
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
label="Mot de passe"
|
label="Mot de passe"
|
||||||
type="password"
|
type="password"
|
||||||
|
:disabled="!auth.isAdmin"
|
||||||
|
wrapper-class="w-[280px]"
|
||||||
|
:required="!userId"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center mb-11">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
id="user-locked"
|
||||||
|
v-model="form.isLocked"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="!auth.isAdmin"
|
||||||
|
class="w-5 h-5 accent-primary-500"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-primary-700">Verrouiller le compte</span>
|
||||||
|
</label>
|
||||||
|
<p class="ml-4 text-xs text-slate-400">Un compte verrouillé ne peut plus se connecter.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<UiButton
|
||||||
|
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||||
|
type="submit"
|
||||||
|
:disabled="isLoading || isHydrating || !auth.isAdmin"
|
||||||
|
@click="submitted = true"
|
||||||
|
>
|
||||||
|
<Icon :name="userId ? '' : 'mdi:plus'" size="28" />
|
||||||
|
{{ userId ? 'Valider' : 'Ajouter' }}
|
||||||
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'default'
|
|
||||||
})
|
|
||||||
|
|
||||||
import {computed, reactive, ref, watch} from 'vue'
|
<script setup lang="ts">
|
||||||
import {ROLE} from '~/utils/constants'
|
useHead({ title: 'Utilisateur' })
|
||||||
import {createUser, updateUser, getUser} from '~/services/auth'
|
|
||||||
import type {UserData, UserFormData, UserPayload} from '~/services/dto/user-data'
|
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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
const userId = computed(() => resolveUserId(route.params.id))
|
const userId = computed(() => resolveUserId(route.params.id))
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isHydrating = ref(false)
|
const isHydrating = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
const resolveUserId = (param: unknown) => {
|
const resolveUserId = (param: unknown) => {
|
||||||
const idStr = Array.isArray(param) ? param[0] : param
|
const idStr = Array.isArray(param) ? param[0] : param
|
||||||
@@ -62,11 +99,11 @@ const resolveUserId = (param: unknown) => {
|
|||||||
return Number.isFinite(id) ? id : null
|
return Number.isFinite(id) ? id : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const form = reactive<UserFormData>({
|
const form = reactive<UserFormData>({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
role: ''
|
role: '',
|
||||||
|
isLocked: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const hydrateFromUser = (user: UserData | null) => {
|
const hydrateFromUser = (user: UserData | null) => {
|
||||||
@@ -76,9 +113,10 @@ const hydrateFromUser = (user: UserData | null) => {
|
|||||||
isHydrating.value = true
|
isHydrating.value = true
|
||||||
form.username = user.username ?? ''
|
form.username = user.username ?? ''
|
||||||
const roles = user.roles ?? []
|
const roles = user.roles ?? []
|
||||||
const hasAdmin = roles.includes("ROLE_ADMIN")
|
const hasAdmin = roles.includes('ROLE_ADMIN')
|
||||||
form.role = hasAdmin ? "ROLE_ADMIN" : "ROLE_USER"
|
form.role = hasAdmin ? 'ROLE_ADMIN' : 'ROLE_USER'
|
||||||
form.password = ''
|
form.password = ''
|
||||||
|
form.isLocked = user.isLocked ?? false
|
||||||
isHydrating.value = false
|
isHydrating.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +134,11 @@ watch(
|
|||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{immediate: true}
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
async function validate() {
|
async function validate() {
|
||||||
|
if (!auth.isAdmin) return
|
||||||
|
|
||||||
const normalizedUsername = form.username.trim()
|
const normalizedUsername = form.username.trim()
|
||||||
const normalizedRole = form.role.trim()
|
const normalizedRole = form.role.trim()
|
||||||
@@ -108,6 +147,7 @@ async function validate() {
|
|||||||
const basePayload: UserPayload = {
|
const basePayload: UserPayload = {
|
||||||
username: normalizedUsername,
|
username: normalizedUsername,
|
||||||
roles: normalizedRole ? [normalizedRole] : undefined,
|
roles: normalizedRole ? [normalizedRole] : undefined,
|
||||||
|
isLocked: form.isLocked,
|
||||||
}
|
}
|
||||||
if (normalizedPassword) {
|
if (normalizedPassword) {
|
||||||
basePayload.password = normalizedPassword
|
basePayload.password = normalizedPassword
|
||||||
@@ -115,13 +155,12 @@ async function validate() {
|
|||||||
|
|
||||||
if (userId.value) {
|
if (userId.value) {
|
||||||
await updateUser(userId.value, basePayload)
|
await updateUser(userId.value, basePayload)
|
||||||
await router.push(`/admin/user/list/`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await createUser(basePayload)
|
const created = await createUser(basePayload)
|
||||||
if (created) {
|
if (created) {
|
||||||
await router.push(`/admin/user/list/`)
|
await router.push('/admin/user/list')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,70 +1,110 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="px-[86px]">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-3xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
|
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
class="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded-md"
|
v-if="auth.isAdmin"
|
||||||
@click="router.push('/admin/user/')"
|
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"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:plus" size="28" />
|
<Icon name="mdi:plus" size="28" />
|
||||||
Ajouter
|
Ajouter
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div v-if="auth.isAdmin" class="mt-6 mb-16">
|
||||||
<div class="mt-6 border border-slate-200 mb-16 ">
|
<UiDataTable
|
||||||
<div class="grid grid-cols-3 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
v-model:page="page"
|
||||||
<div>Username</div>
|
v-model:per-page="perPage"
|
||||||
<div>Role</div>
|
:columns="columns"
|
||||||
</div>
|
:items="items"
|
||||||
<div
|
:total-items="totalItems"
|
||||||
v-for="user in userList"
|
:loading="loading"
|
||||||
:key="user.id"
|
row-clickable
|
||||||
class="grid grid-cols-3 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t items-center"
|
@row-click="goToUser"
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
@click="goToUser(user.id)"
|
|
||||||
>
|
>
|
||||||
<div>
|
<template #header-username>
|
||||||
{{ user.username }}
|
<UiTextInput
|
||||||
</div>
|
v-model="filters.username"
|
||||||
<div>
|
placeholder="Utilisateur"
|
||||||
{{ getRoleLabels(user.roles) }}
|
size="compact"
|
||||||
</div>
|
/>
|
||||||
|
</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>
|
||||||
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({
|
useHead({ title: 'Utilisateurs' })
|
||||||
layout: 'default'
|
|
||||||
})
|
|
||||||
|
|
||||||
import type {UserData} from "~/services/dto/user-data";
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import {getAdminUsers} from "~/services/auth";
|
import { ROLE } from '~/utils/constants'
|
||||||
import {ROLE} from "~/utils/constants";
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
|
|
||||||
const userList = ref<UserData[]>([])
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const roleLabelByValue = new Map(ROLE.map((role) => [role.value, role.label]))
|
const auth = useAuthStore()
|
||||||
|
const roleLabelByValue = new Map(ROLE.map(role => [role.value, role.label]))
|
||||||
|
|
||||||
const goToUser = (id: number) => {
|
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||||
router.push(`/admin/user/${id}`)
|
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[]) => {
|
const getRoleLabels = (roles?: string[]) => {
|
||||||
if (!roles || roles.length === 0) {
|
if (!roles || roles.length === 0) return '---'
|
||||||
return ' ---'
|
return roles.map(role => roleLabelByValue.get(role) ?? role).join(', ')
|
||||||
}
|
|
||||||
|
|
||||||
return roles
|
|
||||||
.map((role) => roleLabelByValue.get(role) ?? role)
|
|
||||||
.join(', ')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
const goToUser = (user: UserData) => {
|
||||||
userList.value = await getAdminUsers()
|
if (!auth.isAdmin) return
|
||||||
|
router.push(`/admin/user/${user.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (auth.isAdmin) reload()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
358
frontend/pages/bovine/[id].vue
Normal file
358
frontend/pages/bovine/[id].vue
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
<template>
|
||||||
|
<div class="px-[86px]">
|
||||||
|
<div class="flex items-center justify-between relative mb-10">
|
||||||
|
<div class="flex flex-row absolute -left-[60px]">
|
||||||
|
<Icon
|
||||||
|
@click="goBack"
|
||||||
|
name="gg:arrow-left-o"
|
||||||
|
size="44"
|
||||||
|
class="cursor-pointer text-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 class="font-bold text-3xl uppercase text-primary-500">Vie du bovin</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UiTabs
|
||||||
|
v-model="activeTab"
|
||||||
|
:tabs="tabs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="auth.isBureau" v-show="activeTab === 'mouvement'">
|
||||||
|
<form :class="{ submitted: movementSubmitted }" @submit.prevent="submitMovement">
|
||||||
|
<div class="flex flex-cols-3 justify-between mb-10">
|
||||||
|
<UiSelect
|
||||||
|
id="movement-building"
|
||||||
|
v-model="newMovementBuildingId"
|
||||||
|
label="Bâtiment"
|
||||||
|
:options="buildingOptions"
|
||||||
|
wrapper-class="w-[280px]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<UiSelect
|
||||||
|
id="movement-case"
|
||||||
|
v-model="newMovementCaseId"
|
||||||
|
label="Case"
|
||||||
|
:options="caseOptions"
|
||||||
|
:disabled="!newMovementBuildingId"
|
||||||
|
wrapper-class="w-[280px]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<UiDateInput
|
||||||
|
id="movement-date"
|
||||||
|
v-model="newMovementDate"
|
||||||
|
label="Date mouvement"
|
||||||
|
wrapper-class="w-[280px]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center mb-11">
|
||||||
|
<UiButton
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center gap-2 text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80"
|
||||||
|
:disabled="isSubmittingMovement"
|
||||||
|
:loading="isSubmittingMovement"
|
||||||
|
@click="movementSubmitted = true"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:plus" size="28" />
|
||||||
|
Ajouter
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<UiDataTable
|
||||||
|
:columns="movementColumns"
|
||||||
|
:items="filteredMovementRows"
|
||||||
|
:per-page="10"
|
||||||
|
>
|
||||||
|
<template #header-building>
|
||||||
|
<UiTextInput
|
||||||
|
v-model="movementFilters.building"
|
||||||
|
placeholder="Bâtiment"
|
||||||
|
size="compact"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #header-case>
|
||||||
|
<UiTextInput
|
||||||
|
v-model="movementFilters.case"
|
||||||
|
placeholder="Case"
|
||||||
|
size="compact"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #header-enteredAt>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Du" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #header-leftAt>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Au" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #header-duration>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Durée" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #cell-leftAt="{ item }">
|
||||||
|
<span v-if="item.leftAt">{{ item.leftAt }}</span>
|
||||||
|
<span v-else class="italic text-slate-500">En cours</span>
|
||||||
|
</template>
|
||||||
|
</UiDataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activeTab === 'passeport'">
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
|
||||||
|
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
|
||||||
|
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Veau</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Sexe</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
|
||||||
|
<div class="border-b border-black px-2 py-1 text-center font-semibold text-sm">Date de naissance</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.nationalNumber) }}</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.workNumber) }}</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.sex) }}</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.bovineType?.code) }}</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.bovineType?.label) }}</div>
|
||||||
|
<div class="px-2 py-1 text-center">{{ formatDate(bovine?.birthDate) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-9">
|
||||||
|
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
|
||||||
|
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
|
||||||
|
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Père</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
|
||||||
|
<div class="col-span-2 border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
|
||||||
|
<div class="col-span-2 border-b border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.fatherNationalNumber) }}</div>
|
||||||
|
<div class="col-span-2 border-r border-black px-2 py-1 text-center">{{ display(workNumberFromNational(bovine?.fatherNationalNumber)) }}</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.fatherBovineType?.code) }}</div>
|
||||||
|
<div class="col-span-2 px-2 py-1 text-center">{{ display(bovine?.fatherBovineType?.label) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-9">
|
||||||
|
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
|
||||||
|
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
|
||||||
|
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Mère</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
|
||||||
|
<div class="col-span-2 border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
|
||||||
|
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
|
||||||
|
<div class="col-span-2 border-b border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.motherNationalNumber) }}</div>
|
||||||
|
<div class="col-span-2 border-r border-black px-2 py-1 text-center">{{ display(workNumberFromNational(bovine?.motherNationalNumber)) }}</div>
|
||||||
|
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.motherBovineType?.code) }}</div>
|
||||||
|
<div class="col-span-2 px-2 py-1 text-center">{{ display(bovine?.motherBovineType?.label) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activeTab === 'sante'">
|
||||||
|
<div class="border-2 border-dashed border-primary-500 rounded-md py-16 text-center text-primary-500 font-bold uppercase text-2xl">
|
||||||
|
À venir
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getBuildingList } from '~/services/building'
|
||||||
|
import type { BuildingData } from '~/services/dto/building-data'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
|
useHead({ title: 'Vie du bovin' })
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
type BovineTab = 'mouvement' | 'passeport' | 'sante'
|
||||||
|
const tabs = computed(() => [
|
||||||
|
...(auth.isBureau ? [{ key: 'mouvement' as const, label: 'Mouvement' }] : []),
|
||||||
|
{ key: 'passeport' as const, label: 'Passeport bovin' },
|
||||||
|
{ key: 'sante' as const, label: 'Santé' }
|
||||||
|
])
|
||||||
|
const activeTab = ref<BovineTab>(auth.isBureau ? 'mouvement' : 'passeport')
|
||||||
|
|
||||||
|
interface BovineTypeRef {
|
||||||
|
id: number
|
||||||
|
label: string | null
|
||||||
|
code: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildingRef {
|
||||||
|
label: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildingCaseRef {
|
||||||
|
caseNumber: number | null
|
||||||
|
building: BuildingRef | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BovineMovementData {
|
||||||
|
id: number
|
||||||
|
enteredAt: string
|
||||||
|
leftAt: string | null
|
||||||
|
buildingCase: BuildingCaseRef | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BovinePassportData {
|
||||||
|
id: number
|
||||||
|
nationalNumber: string
|
||||||
|
workNumber: string | null
|
||||||
|
sex: string | null
|
||||||
|
birthDate: string | null
|
||||||
|
exitedAt: string | null
|
||||||
|
exitDate: string | null
|
||||||
|
bovineType: BovineTypeRef | null
|
||||||
|
motherNationalNumber: string | null
|
||||||
|
motherBovineType: BovineTypeRef | null
|
||||||
|
fatherNationalNumber: string | null
|
||||||
|
fatherBovineType: BovineTypeRef | null
|
||||||
|
movements: BovineMovementData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
if (window.history.state?.back) {
|
||||||
|
router.back()
|
||||||
|
} else {
|
||||||
|
router.push('/inventory')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayIso = () => new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const bovine = ref<BovinePassportData | null>(null)
|
||||||
|
const buildings = ref<BuildingData[]>([])
|
||||||
|
const newMovementBuildingId = ref<string | number | null>(null)
|
||||||
|
const newMovementCaseId = ref<string | number | null>(null)
|
||||||
|
const newMovementDate = ref<string>(todayIso())
|
||||||
|
const isSubmittingMovement = ref(false)
|
||||||
|
const movementSubmitted = ref(false)
|
||||||
|
const movementFilters = ref({ building: '', case: '' })
|
||||||
|
|
||||||
|
const bovineId = computed(() => {
|
||||||
|
const raw = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
|
const n = Number(raw)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const display = (value: string | null | undefined) => (value && value !== '' ? value : '—')
|
||||||
|
|
||||||
|
const workNumberFromNational = (nationalNumber: string | null | undefined) => {
|
||||||
|
if (!nationalNumber) return null
|
||||||
|
return nationalNumber.slice(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: string | null | undefined) => {
|
||||||
|
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 buildingOptions = computed(() =>
|
||||||
|
buildings.value.map(b => ({ value: b.id, label: b.label }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const caseOptions = computed(() => {
|
||||||
|
const building = buildings.value.find(b => b.id === Number(newMovementBuildingId.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(newMovementBuildingId, () => {
|
||||||
|
newMovementCaseId.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
const movementColumns = [
|
||||||
|
{ key: 'building', label: 'Bâtiment' },
|
||||||
|
{ key: 'case', label: 'Case' },
|
||||||
|
{ key: 'enteredAt', label: 'Du' },
|
||||||
|
{ key: 'leftAt', label: 'Au' },
|
||||||
|
{ key: 'duration', label: 'Durée' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const movementEndDate = (movement: BovineMovementData): string | null => {
|
||||||
|
return movement.leftAt ?? bovine.value?.exitedAt ?? bovine.value?.exitDate ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (movement: BovineMovementData): string => {
|
||||||
|
const start = new Date(movement.enteredAt)
|
||||||
|
if (isNaN(start.getTime())) return '—'
|
||||||
|
const endRaw = movementEndDate(movement)
|
||||||
|
const end = endRaw ? new Date(endRaw) : new Date()
|
||||||
|
if (isNaN(end.getTime())) return '—'
|
||||||
|
const days = Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86_400_000))
|
||||||
|
return `${days} j`
|
||||||
|
}
|
||||||
|
|
||||||
|
const movementRows = computed(() => {
|
||||||
|
const list = bovine.value?.movements ?? []
|
||||||
|
return list.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
building: m.buildingCase?.building?.label ?? '—',
|
||||||
|
case: m.buildingCase?.caseNumber != null ? `Case ${m.buildingCase.caseNumber}` : '—',
|
||||||
|
enteredAt: formatDate(m.enteredAt),
|
||||||
|
leftAt: m.leftAt ? formatDate(m.leftAt) : null,
|
||||||
|
duration: formatDuration(m)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredMovementRows = computed(() => {
|
||||||
|
const buildingFilter = movementFilters.value.building.trim().toLowerCase()
|
||||||
|
const caseFilter = movementFilters.value.case.trim().toLowerCase()
|
||||||
|
return movementRows.value.filter(row => {
|
||||||
|
if (buildingFilter && !row.building.toLowerCase().includes(buildingFilter)) return false
|
||||||
|
if (caseFilter && !row.case.toLowerCase().includes(caseFilter)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitMovement = async () => {
|
||||||
|
if (!newMovementCaseId.value || !newMovementDate.value || bovineId.value === null) return
|
||||||
|
|
||||||
|
const buildingLabel = buildingOptions.value.find(o => o.value === Number(newMovementBuildingId.value))?.label ?? '—'
|
||||||
|
const caseLabel = caseOptions.value.find(o => o.value === Number(newMovementCaseId.value))?.label ?? '—'
|
||||||
|
const dateLabel = formatDate(newMovementDate.value)
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Confirmer la création du mouvement ?\n\nBâtiment : ${buildingLabel}\nCase : ${caseLabel}\nDate : ${dateLabel}`
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
isSubmittingMovement.value = true
|
||||||
|
try {
|
||||||
|
await api.post('bovine_movements', {
|
||||||
|
bovine: `/api/bovines/${bovineId.value}`,
|
||||||
|
buildingCase: `/api/building_cases/${newMovementCaseId.value}`,
|
||||||
|
enteredAt: newMovementDate.value
|
||||||
|
}, { toastSuccessMessage: 'Mouvement enregistré' })
|
||||||
|
bovine.value = await api.get<BovinePassportData>(`bovines/${bovineId.value}`)
|
||||||
|
newMovementBuildingId.value = null
|
||||||
|
newMovementCaseId.value = null
|
||||||
|
newMovementDate.value = todayIso()
|
||||||
|
movementSubmitted.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmittingMovement.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (bovineId.value === null) return
|
||||||
|
const [bovineData, buildingList] = await Promise.all([
|
||||||
|
api.get<BovinePassportData>(`bovines/${bovineId.value}`),
|
||||||
|
getBuildingList()
|
||||||
|
])
|
||||||
|
bovine.value = bovineData
|
||||||
|
buildings.value = buildingList
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Accueil' })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap justify-center pb-16 gap-12">
|
<div class="flex flex-wrap justify-center pb-16 gap-12">
|
||||||
@@ -18,9 +19,9 @@
|
|||||||
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
|
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
|
||||||
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
|
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
|
||||||
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
|
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
|
||||||
<card-link link="/" iconName="mdi:cow">
|
<card-link link="/inventory" iconName="mdi:cow">
|
||||||
<template #label>
|
<template #label>
|
||||||
PASSEPORT<br>DU BOVIN
|
INVENTAIRE<br>BOVINS
|
||||||
</template>
|
</template>
|
||||||
</card-link>
|
</card-link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen">
|
<div class="px-[86px]">
|
||||||
<!-- En-tête de page avec retour et titre -->
|
<div class="flex items-center justify-between relative">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex flex-row absolute -left-[60px]">
|
||||||
<div class="flex items-center gap-10">
|
|
||||||
<Icon
|
<Icon
|
||||||
@click="router.push('/')"
|
@click="router.push('/')"
|
||||||
name="gg:arrow-left-o"
|
name="gg:arrow-left-o"
|
||||||
size="44"
|
size="44"
|
||||||
class="cursor-pointer text-primary-500"
|
class="cursor-pointer text-primary-500"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-[86px] space-y-6">
|
<div class="mt-6 space-y-6">
|
||||||
<!-- Liste des bâtiments + rendu du plan de chaque bâtiment -->
|
<!-- Liste des bâtiments + rendu du plan de chaque bâtiment -->
|
||||||
<div
|
<div
|
||||||
v-for="entry in buildingLayouts"
|
v-for="entry in buildingLayouts"
|
||||||
@@ -36,7 +35,7 @@
|
|||||||
v-for="cell in entry.cells"
|
v-for="cell in entry.cells"
|
||||||
:key="cell.key"
|
:key="cell.key"
|
||||||
class="relative text-white flex h-[50px] items-center justify-center border-y-[3px] border-y-black bg-white hover:opacity-85 focus-visible:outline-none"
|
class="relative text-white flex h-[50px] items-center justify-center border-y-[3px] border-y-black bg-white hover:opacity-85 focus-visible:outline-none"
|
||||||
:class="[cell.sideBorderClass, activeLegendStatutId !== null && cell.caseStatusId !== activeLegendStatutId ? 'opacity-35 hover:opacity-70' : '']"
|
:class="[cell.sideBorderClass, activeLegendLabel !== null && cell.caseStatusLabel !== activeLegendLabel ? 'opacity-35 hover:opacity-70' : '']"
|
||||||
:style="[cell.spanStyle, cell.sideBorderStyle]"
|
:style="[cell.spanStyle, cell.sideBorderStyle]"
|
||||||
:to="cell.caseId ? `/infrastructure/case?id=${cell.caseId}` : '/infrastructure/case'"
|
:to="cell.caseId ? `/infrastructure/case?id=${cell.caseId}` : '/infrastructure/case'"
|
||||||
:title="cell.caseStatusLabel ?? undefined"
|
:title="cell.caseStatusLabel ?? undefined"
|
||||||
@@ -58,25 +57,19 @@
|
|||||||
|
|
||||||
<!-- Légende : survol d'un statut => atténue les autres cases -->
|
<!-- Légende : survol d'un statut => atténue les autres cases -->
|
||||||
<div class="py-4">
|
<div class="py-4">
|
||||||
<!-- 3 zones fixes pour forcer gauche / centre / droite sur toute la largeur -->
|
<div class="flex gap-6">
|
||||||
<div class="grid w-full grid-cols-3 gap-3">
|
|
||||||
<div
|
<div
|
||||||
v-for="(statut, index) in statutLegend"
|
v-for="statut in statutLegend"
|
||||||
:key="statut.id"
|
:key="statut.label"
|
||||||
class="flex min-w-0 cursor-pointer items-center gap-2 py-1"
|
class="flex cursor-pointer items-center gap-2 py-1"
|
||||||
:class="[
|
@mouseenter="activeLegendLabel = statut.label"
|
||||||
index === 0 ? 'justify-self-start' : '',
|
@mouseleave="activeLegendLabel = null"
|
||||||
index === statutLegend.length - 1 ? 'justify-self-end' : '',
|
|
||||||
index > 0 && index < statutLegend.length - 1 ? 'justify-self-center' : ''
|
|
||||||
]"
|
|
||||||
@mouseenter="activeLegendStatutId = statut.id"
|
|
||||||
@mouseleave="activeLegendStatutId = null"
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="h-5 w-5 border border-slate-300"
|
class="h-5 w-5 border border-slate-300"
|
||||||
:style="statut.couleur ? { backgroundColor: statut.couleur } : {}"
|
:style="statut.couleur ? { backgroundColor: statut.couleur } : {}"
|
||||||
></span>
|
></span>
|
||||||
<span class="truncate text-sm uppercase text-slate-700">
|
<span class="text-sm uppercase text-slate-700">
|
||||||
{{ statut.label }}
|
{{ statut.label }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,27 +80,32 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Bâtiments' })
|
||||||
|
|
||||||
import type {BuildingData} from "~/services/dto/building-data"
|
import type {BuildingData} from "~/services/dto/building-data"
|
||||||
import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
|
import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
|
||||||
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"
|
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"
|
||||||
import type {BuildingCaseStatusData} from "~/services/dto/building-case-status-data"
|
|
||||||
import {getBuildingList} from "~/services/building"
|
import {getBuildingList} from "~/services/building"
|
||||||
import {getStatutList} from "~/services/statut"
|
|
||||||
|
|
||||||
definePageMeta({layout: "default"})
|
definePageMeta({layout: "default"})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
// Données brutes chargées depuis l'API
|
// Données brutes chargées depuis l'API
|
||||||
const buildingList = ref<BuildingData[]>([])
|
const buildingList = ref<BuildingData[]>([])
|
||||||
const statutLegend = ref<BuildingCaseStatusData[]>([])
|
const statutLegend = [
|
||||||
|
{ label: 'Libre', couleur: '#A3B18A' },
|
||||||
|
{ label: 'Occupé', couleur: '#3A506B' },
|
||||||
|
{ label: 'Malade', couleur: '#E07A5F' },
|
||||||
|
]
|
||||||
// Statut actuellement survolé dans la légende (pour filtrage visuel)
|
// Statut actuellement survolé dans la légende (pour filtrage visuel)
|
||||||
const activeLegendStatutId = ref<number | null>(null)
|
const activeLegendLabel = ref<string | null>(null)
|
||||||
// Modèle de vue prêt pour le template (layout + cellules + styles de grille)
|
// Modèle de vue prêt pour le template (layout + cellules + styles de grille)
|
||||||
const buildingLayouts = computed(() =>
|
const buildingLayouts = computed(() =>
|
||||||
buildingList.value.map((building) => {
|
buildingList.value
|
||||||
// On affiche uniquement le premier layout du bâtiment
|
.filter((building) => building.layouts && building.layouts.length > 0)
|
||||||
const layout = building.layouts?.[0] ?? null
|
.map((building) => {
|
||||||
const view = layout ? buildLayoutView(layout) : null
|
const layout = building.layouts![0]
|
||||||
|
const view = buildLayoutView(layout)
|
||||||
return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}}
|
return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -116,7 +114,6 @@ type GridCell = {
|
|||||||
key: string
|
key: string
|
||||||
caseId: number | null
|
caseId: number | null
|
||||||
display: string
|
display: string
|
||||||
caseStatusId: number | null
|
|
||||||
caseStatusLabel: string | null
|
caseStatusLabel: string | null
|
||||||
// Couleur de fond de la case (dépend du statut)
|
// Couleur de fond de la case (dépend du statut)
|
||||||
caseStyle?: Record<string, string>
|
caseStyle?: Record<string, string>
|
||||||
@@ -130,7 +127,8 @@ type GridCell = {
|
|||||||
contentInsetClass: string
|
contentInsetClass: string
|
||||||
}
|
}
|
||||||
// Type intermédiaire : garde des infos utiles au calcul des bordures, retirées ensuite
|
// Type intermédiaire : garde des infos utiles au calcul des bordures, retirées ensuite
|
||||||
type GridCellDraft = Omit<GridCell, "sideBorderClass" | "sideBorderStyle" | "contentInsetClass"> & { x: number; columnSpan: number }
|
type GridCellDraft = Omit<GridCell, "sideBorderClass" | "sideBorderStyle" | "contentInsetClass"> & { x: number; columnSpan: number}
|
||||||
|
|
||||||
|
|
||||||
// Nettoie la couleur de statut pour éviter les chaînes vides / espaces
|
// Nettoie la couleur de statut pour éviter les chaînes vides / espaces
|
||||||
const normalizeCaseStatusColor = (value: string | null | undefined): string | null => {
|
const normalizeCaseStatusColor = (value: string | null | undefined): string | null => {
|
||||||
@@ -181,7 +179,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
|
|||||||
// Métadonnées utiles au rendu / navigation / légende
|
// Métadonnées utiles au rendu / navigation / légende
|
||||||
const caseId = (position.buildingCase?.id ?? null) as number | null
|
const caseId = (position.buildingCase?.id ?? null) as number | null
|
||||||
const caseNumber = (position.buildingCase?.caseNumber ?? null) as number | null
|
const caseNumber = (position.buildingCase?.caseNumber ?? null) as number | null
|
||||||
const caseStatusId = position.buildingCase?.statut?.id ?? null
|
|
||||||
const caseStatusLabel = position.buildingCase?.statut?.label ?? null
|
const caseStatusLabel = position.buildingCase?.statut?.label ?? null
|
||||||
const statusColor = normalizeCaseStatusColor(position.buildingCase?.statut?.couleur)
|
const statusColor = normalizeCaseStatusColor(position.buildingCase?.statut?.couleur)
|
||||||
|
|
||||||
@@ -191,7 +188,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
|
|||||||
columnSpan,
|
columnSpan,
|
||||||
caseId,
|
caseId,
|
||||||
display: caseNumber !== null ? String(caseNumber) : "Case",
|
display: caseNumber !== null ? String(caseNumber) : "Case",
|
||||||
caseStatusId,
|
|
||||||
caseStatusLabel,
|
caseStatusLabel,
|
||||||
caseStyle: statusColor ? {backgroundColor: statusColor} : undefined,
|
caseStyle: statusColor ? {backgroundColor: statusColor} : undefined,
|
||||||
// Exemple : "14 / span 1" => commence en colonne 14 et occupe 1 colonne
|
// Exemple : "14 / span 1" => commence en colonne 14 et occupe 1 colonne
|
||||||
@@ -230,13 +226,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Chargement initial des bâtiments et de la légende des statuts
|
buildingList.value = await getBuildingList()
|
||||||
const buildings = await getBuildingList()
|
|
||||||
const statuts = await getStatutList()
|
|
||||||
buildingList.value = buildings
|
|
||||||
// Tri alphabétique FR pour une légende stable
|
|
||||||
statutLegend.value = [...statuts].sort((a, b) =>
|
|
||||||
(a.label ?? "").localeCompare(b.label ?? "", "fr", {sensitivity: "base"})
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,27 +1,272 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex justify-center items-center">
|
<div class="px-[86px]">
|
||||||
<UiButton
|
<div class="flex items-center justify-between relative">
|
||||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
<div class="flex flex-row absolute -left-[60px]">
|
||||||
:disabled="!hasCaseId"
|
<Icon
|
||||||
|
@click="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"
|
@click="printCaseReport"
|
||||||
>
|
>
|
||||||
Imprimer
|
<Icon name="mdi:printer-outline" size="32" class="text-white" />
|
||||||
</UiButton>
|
</div>
|
||||||
|
</div>
|
||||||
|
</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
|
||||||
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
|
import { useBovineColumns } from '~/composables/useBovineColumns'
|
||||||
|
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const { printPdf } = usePdfPrinter()
|
const { printPdf } = usePdfPrinter()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
const caseId = computed(() => Number(route.query.id))
|
const caseId = computed(() => Number(route.query.id))
|
||||||
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
|
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
|
||||||
|
|
||||||
const printCaseReport = async () => {
|
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) {
|
if (!hasCaseId.value) {
|
||||||
|
stats.value = { total: 0, over24: 0, between22And24: 0, between20And22: 0 }
|
||||||
return
|
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 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`
|
const filename = `tableau_poids_case_${caseId.value}.pdf`
|
||||||
await printPdf(`/building_cases/${caseId.value}/weights-report`, filename)
|
await printPdf(`/building_cases/${caseId.value}/weights-report`, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToBovine = (bovine: BovineData) => {
|
||||||
|
router.push(`/bovine/${bovine.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|||||||
316
frontend/pages/inventory.vue
Normal file
316
frontend/pages/inventory.vue
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<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"
|
||||||
|
row-clickable
|
||||||
|
@row-click="(item: BovineData) => router.push(`/bovine/${item.id}`)"
|
||||||
|
>
|
||||||
|
<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.buildingCase?.building?.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',
|
||||||
|
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
|
<select
|
||||||
id="user-select"
|
id="user-select"
|
||||||
v-model="selectedUsername"
|
v-model="selectedUsername"
|
||||||
|
autocomplete="username"
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
|
||||||
:disabled="isLoadingUsers"
|
:disabled="isLoadingUsers"
|
||||||
>
|
>
|
||||||
@@ -52,6 +53,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Connexion' })
|
||||||
|
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { getUsers } from '~/services/auth'
|
import { getUsers } from '~/services/auth'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="flex justify-between h-[52px] mb-[80px]">
|
<div class="flex justify-between h-[52px] mb-[80px]">
|
||||||
<div class="flex flex-1 mr-16">
|
<div class="flex flex-1 mr-16">
|
||||||
<UiStepper
|
<UiStepper
|
||||||
:labels="RECEPTION_STEP_LABELS"
|
:labels="stepLabels"
|
||||||
:current-step="storeReception?.currentStep ?? 0"
|
:current-step="storeReception?.currentStep ?? 0"
|
||||||
@select="handleStepSelect"
|
@select="handleStepSelect"
|
||||||
/>
|
/>
|
||||||
@@ -14,77 +14,91 @@
|
|||||||
>Mettre en attente
|
>Mettre en attente
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/>
|
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0" ref="receptionFormRef"/>
|
||||||
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/>
|
<WorkflowWeight
|
||||||
|
v-if="storeReception?.currentStep === 1"
|
||||||
|
ref="grossWeightRef"
|
||||||
|
mode="gross"
|
||||||
|
entity-name="reception"
|
||||||
|
api-resource="receptions"
|
||||||
|
:title-label="receptionConfig.weighingLabels.gross"
|
||||||
|
:is-final="false"
|
||||||
|
:entity="storeReception"
|
||||||
|
:get-weight-from-scale="getWeight"
|
||||||
|
:update-entity="receptionStore.updateReception"
|
||||||
|
:load-entity="receptionStore.loadReception"
|
||||||
|
:clear-entity="receptionStore.clearCurrent"
|
||||||
|
:build-receipt-filename="receptionConfig.buildReceiptFilename"
|
||||||
|
/>
|
||||||
<ReceptionProductReceived
|
<ReceptionProductReceived
|
||||||
v-if="storeReception?.currentStep === 2 &&
|
v-if="storeReception?.currentStep === 2 &&
|
||||||
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"/>
|
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"/>
|
||||||
<ReceptionBovineReceived
|
<ReceptionBovineReceived
|
||||||
v-if="storeReception?.currentStep === 2 &&
|
v-if="storeReception?.currentStep === 2 &&
|
||||||
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/>
|
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/>
|
||||||
<ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/>
|
<WorkflowWeight
|
||||||
|
v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3"
|
||||||
|
ref="tareWeightRef"
|
||||||
|
mode="tare"
|
||||||
|
entity-name="reception"
|
||||||
|
api-resource="receptions"
|
||||||
|
:title-label="receptionConfig.weighingLabels.tare"
|
||||||
|
:is-final="true"
|
||||||
|
:entity="storeReception"
|
||||||
|
:get-weight-from-scale="getWeight"
|
||||||
|
:update-entity="receptionStore.updateReception"
|
||||||
|
:load-entity="receptionStore.loadReception"
|
||||||
|
:clear-entity="receptionStore.clearCurrent"
|
||||||
|
:build-receipt-filename="receptionConfig.buildReceiptFilename"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {useReceptionStore} from '~/stores/reception'
|
useHead({ title: 'Réception' })
|
||||||
import {storeToRefs} from 'pinia'
|
|
||||||
import {RECEPTION_STEP_LABELS} from '~/constants/steps'
|
|
||||||
import {RECEPTION_TYPE_CODES} from "~/utils/constants";
|
|
||||||
|
|
||||||
const route = useRoute()
|
import { useReceptionStore } from '~/stores/reception'
|
||||||
const router = useRouter()
|
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 receptionStore = useReceptionStore()
|
||||||
const {current: storeReception} = storeToRefs(receptionStore)
|
const { current: storeReception } = storeToRefs(receptionStore)
|
||||||
|
const receptionFormRef = ref<{ saveDraft: () => Promise<void>, validateFields: () => boolean } | null>(null)
|
||||||
|
const grossWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
|
||||||
|
const tareWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
|
||||||
|
|
||||||
const resolveReceptionId = (param: unknown) => {
|
const { stepLabels, handleStepSelect } = useWorkflowSteps(receptionConfig, receptionStore)
|
||||||
const idStr = Array.isArray(param) ? param[0] : param
|
|
||||||
if (!idStr) {
|
const router = useRouter()
|
||||||
return null
|
|
||||||
|
const saveAndHold = async () => {
|
||||||
|
if (receptionFormRef.value) {
|
||||||
|
if (!receptionFormRef.value.validateFields()) return
|
||||||
|
await receptionFormRef.value.saveDraft()
|
||||||
|
} else {
|
||||||
|
if (grossWeightRef.value) await grossWeightRef.value.saveWeightDraft()
|
||||||
|
if (tareWeightRef.value) await tareWeightRef.value.saveWeightDraft()
|
||||||
}
|
}
|
||||||
const id = Number(idStr)
|
await router.push('/')
|
||||||
return Number.isFinite(id) ? id : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Init route watcher
|
||||||
|
const route = useRoute()
|
||||||
watch(
|
watch(
|
||||||
() => route.params.id,
|
() => route.params.id,
|
||||||
async (param) => {
|
async (param) => {
|
||||||
const id = resolveReceptionId(param)
|
const idStr = Array.isArray(param) ? param[0] : param
|
||||||
if (id === null) {
|
if (!idStr) {
|
||||||
receptionStore.clearCurrent()
|
receptionStore.clearCurrent()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const id = Number(idStr)
|
||||||
|
if (Number.isFinite(id)) {
|
||||||
await receptionStore.loadReception(id)
|
await receptionStore.loadReception(id)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{immediate: true}
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
const saveAndHold = async () => {
|
|
||||||
if (!receptionStore.current) {
|
|
||||||
await router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await receptionStore.updateReception(receptionStore.current.id, {
|
|
||||||
currentStep: receptionStore.current.currentStep,
|
|
||||||
licensePlate: receptionStore.current.licensePlate,
|
|
||||||
receptionDate: receptionStore.current.receptionDate
|
|
||||||
})
|
|
||||||
await router.push('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStepSelect = async (step: number) => {
|
|
||||||
if (!receptionStore.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === receptionStore.current.currentStep) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await receptionStore.updateReception(receptionStore.current.id, {
|
|
||||||
currentStep: step
|
|
||||||
})
|
|
||||||
await receptionStore.loadReception(receptionStore.current.id)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,45 +1,146 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-start gap-10">
|
<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"/>
|
<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>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des réceptions finies</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
<div class="mt-6 border border-slate-200 mb-16 ">
|
<div class="mt-6 mb-16">
|
||||||
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
<UiDataTable
|
||||||
<div>Numéro</div>
|
v-model:page="page"
|
||||||
<div>Date</div>
|
v-model:per-page="perPage"
|
||||||
<div>Fournisseur</div>
|
:columns="columns"
|
||||||
<div>Adresse</div>
|
:items="items"
|
||||||
<div>Type réception</div>
|
:total-items="totalItems"
|
||||||
<div>Poids</div>
|
:loading="loading"
|
||||||
</div>
|
row-clickable
|
||||||
<div
|
@row-click="goToReception"
|
||||||
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>{{ reception.identificationNumber}}</div>
|
<template #header-identificationNumber>
|
||||||
<div>{{ reception.receptionDate}}</div>
|
<UiTextInput
|
||||||
<div>{{ reception.supplier?.name }}</div>
|
v-model="filters.identificationNumber"
|
||||||
<div>{{ reception.address?.fullAddress }}</div>
|
placeholder="Numéro"
|
||||||
<div>{{ reception.receptionType?.label }}</div>
|
size="compact"
|
||||||
<div>{{ formatWeighing(reception) }}</div>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {ReceptionData} from "~/services/dto/reception-data";
|
useHead({ title: 'Validation réception' })
|
||||||
import {getReceptionList} from "~/services/reception";
|
|
||||||
import type {ShipmentData} from "~/services/dto/shipment-data";
|
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 router = useRouter()
|
||||||
|
const receptionTypes = ref<ReceptionTypeData[]>([])
|
||||||
|
|
||||||
|
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 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 formatWeighing = (reception: ReceptionData) => {
|
||||||
const gross = reception.weights?.find((weight) => weight.type === 'gross')?.weight
|
const gross = reception.weights?.find((weight) => weight.type === 'gross')?.weight
|
||||||
@@ -52,11 +153,12 @@ const formatWeighing = (reception: ReceptionData) => {
|
|||||||
return `${gross - tare} kg`
|
return `${gross - tare} kg`
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToReception = (id: number) => {
|
const goToReception = (reception: ReceptionData) => {
|
||||||
router.push(`/reception/update/${id}`)
|
router.push(`/reception/update/${reception.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
receptionList.value = await getReceptionList(true)
|
receptionTypes.value = await getReceptionTypeList()
|
||||||
|
reload()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,166 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-start gap-10">
|
||||||
<div class="flex items-center gap-10">
|
|
||||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
<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>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des réceptions en attente</h1>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
<div class="mt-6 border border-slate-200 mb-16">
|
<div class="mt-6 mb-16">
|
||||||
<div class="grid grid-cols-5 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
<UiDataTable
|
||||||
<div>Fournisseur</div>
|
v-model:page="page"
|
||||||
<div>Adresse</div>
|
v-model:per-page="perPage"
|
||||||
<div>Type réception</div>
|
:columns="columns"
|
||||||
<div>Transporteur</div>
|
:items="items"
|
||||||
<div>Immatriculation</div>
|
:total-items="totalItems"
|
||||||
</div>
|
:loading="loading"
|
||||||
<div
|
:show-actions="auth.isAdmin"
|
||||||
v-for="reception in receptionList"
|
row-clickable
|
||||||
:key="reception.id"
|
@row-click="goToReception"
|
||||||
class="grid grid-cols-5 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
@click="goToReception(reception.id)"
|
|
||||||
@keydown.enter="goToReception(reception.id)"
|
|
||||||
>
|
>
|
||||||
<div>{{ reception.supplier?.name }}</div>
|
<template #header-receptionDate>
|
||||||
<div>{{ reception.address?.fullAddress }}</div>
|
<UiDateMaskedInput v-model="receptionDateFilter" placeholder="Date" size="compact" />
|
||||||
<div>{{ reception.receptionType?.label }}</div>
|
</template>
|
||||||
<div>{{ reception.carrier?.name }}</div>
|
<template #header-supplier.name>
|
||||||
<div>{{ reception.licensePlate }}</div>
|
<UiTextInput
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {ReceptionData} from "~/services/dto/reception-data";
|
useHead({ title: 'Réceptions en attente' })
|
||||||
import {getReceptionList} from "~/services/reception";
|
|
||||||
|
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 router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const receptionTypes = ref<ReceptionTypeData[]>([])
|
||||||
|
|
||||||
const goToReception = (id: number) => {
|
const receptionTypeOptions = computed(() =>
|
||||||
router.push(`/reception/${id}`)
|
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 () => {
|
onMounted(async () => {
|
||||||
receptionList.value = await getReceptionList(false)
|
receptionTypes.value = await getReceptionTypeList()
|
||||||
|
reload()
|
||||||
})
|
})
|
||||||
</script>
|
</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,10 +3,9 @@
|
|||||||
<div class="flex justify-between h-[52px] mb-[80px]">
|
<div class="flex justify-between h-[52px] mb-[80px]">
|
||||||
<div class="flex flex-1 mr-16">
|
<div class="flex flex-1 mr-16">
|
||||||
<UiStepper
|
<UiStepper
|
||||||
:labels="SHIPMENT_STEP_LABELS"
|
:labels="stepLabels"
|
||||||
:current-step="storeShipment?.currentStep ?? 0"
|
:current-step="storeShipment?.currentStep ?? 0"
|
||||||
@select="handleStepSelect"
|
@select="handleStepSelect"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<UiButton
|
<UiButton
|
||||||
@@ -17,67 +16,85 @@
|
|||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
|
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
|
||||||
<ShipmentWeight v-if="storeShipment?.currentStep === 1" mode="gross"/>
|
<WorkflowWeight
|
||||||
|
v-if="storeShipment?.currentStep === 1"
|
||||||
|
ref="tareWeightRef"
|
||||||
|
mode="tare"
|
||||||
|
entity-name="shipment"
|
||||||
|
api-resource="shipments"
|
||||||
|
:title-label="shipmentConfig.weighingLabels.tare"
|
||||||
|
:is-final="false"
|
||||||
|
:entity="storeShipment"
|
||||||
|
:get-weight-from-scale="getWeightShipment"
|
||||||
|
:update-entity="shipmentStore.updateShipment"
|
||||||
|
:load-entity="shipmentStore.loadShipment"
|
||||||
|
:clear-entity="shipmentStore.clearCurrent"
|
||||||
|
:build-receipt-filename="shipmentConfig.buildReceiptFilename"
|
||||||
|
/>
|
||||||
<ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
|
<ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
|
||||||
<ShipmentWeight v-if="storeShipment?.currentStep === 3" mode="tare"/>
|
<WorkflowWeight
|
||||||
|
v-if="storeShipment?.currentStep === 3"
|
||||||
|
ref="grossWeightRef"
|
||||||
|
mode="gross"
|
||||||
|
entity-name="shipment"
|
||||||
|
api-resource="shipments"
|
||||||
|
:title-label="shipmentConfig.weighingLabels.gross"
|
||||||
|
:is-final="true"
|
||||||
|
:entity="storeShipment"
|
||||||
|
:get-weight-from-scale="getWeightShipment"
|
||||||
|
:update-entity="shipmentStore.updateShipment"
|
||||||
|
:load-entity="shipmentStore.loadShipment"
|
||||||
|
:clear-entity="shipmentStore.clearCurrent"
|
||||||
|
:build-receipt-filename="shipmentConfig.buildReceiptFilename"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
|
||||||
|
|
||||||
import {SHIPMENT_STEP_LABELS} from "~/constants/steps";
|
<script setup lang="ts">
|
||||||
import {storeToRefs} from "pinia";
|
useHead({ title: 'Expédition' })
|
||||||
import {useShipmentStore} from "~/stores/shipment";
|
|
||||||
|
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'
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
const shipmentStore = useShipmentStore()
|
const shipmentStore = useShipmentStore()
|
||||||
const {current: storeShipment} = storeToRefs(shipmentStore)
|
const { current: storeShipment } = storeToRefs(shipmentStore)
|
||||||
const shipmentFormRef = ref<{ saveDraft: () => Promise<void> } | null>(null)
|
const shipmentFormRef = ref<{ saveDraft: () => Promise<void>, validateFields: () => boolean } | null>(null)
|
||||||
|
const grossWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
|
||||||
|
const tareWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
|
||||||
|
|
||||||
|
const { stepLabels, handleStepSelect } = useWorkflowSteps(shipmentConfig, shipmentStore)
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const resolveShipmentId = (param: unknown) => {
|
watch(
|
||||||
const idStr = Array.isArray(param) ? param[0] : param
|
|
||||||
if (!idStr) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const id = Number(idStr)
|
|
||||||
return Number.isFinite(id) ? id : null
|
|
||||||
}
|
|
||||||
|
|
||||||
watch (
|
|
||||||
() => route.params.id,
|
() => route.params.id,
|
||||||
async (param) => {
|
async (param) => {
|
||||||
const id = resolveShipmentId(param)
|
const idStr = Array.isArray(param) ? param[0] : param
|
||||||
if (id === null) {
|
if (!idStr) {
|
||||||
shipmentStore.clearCurrent()
|
shipmentStore.clearCurrent()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const id = Number(idStr)
|
||||||
|
if (Number.isFinite(id)) {
|
||||||
await shipmentStore.loadShipment(id)
|
await shipmentStore.loadShipment(id)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{immediate: true}
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
const saveAndHold = async () => {
|
const saveAndHold = async () => {
|
||||||
if (shipmentFormRef.value) {
|
if (shipmentFormRef.value) {
|
||||||
|
if (!shipmentFormRef.value.validateFields()) return
|
||||||
await shipmentFormRef.value.saveDraft()
|
await shipmentFormRef.value.saveDraft()
|
||||||
|
} else {
|
||||||
|
if (grossWeightRef.value) await grossWeightRef.value.saveWeightDraft()
|
||||||
|
if (tareWeightRef.value) await tareWeightRef.value.saveWeightDraft()
|
||||||
}
|
}
|
||||||
await router.push('/')
|
await router.push('/')
|
||||||
}
|
}
|
||||||
const handleStepSelect = async (step: number) => {
|
|
||||||
if (!shipmentStore.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === shipmentStore.current.currentStep) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await shipmentStore.updateShipment(shipmentStore.current.id, {
|
|
||||||
currentStep: step
|
|
||||||
})
|
|
||||||
await shipmentStore.loadShipment(shipmentStore.current.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,55 +1,154 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-start gap-10">
|
<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"/>
|
<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>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des expéditions finies</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
<div class="mt-6 border border-slate-200 mb-16 ">
|
<div class="mt-6 mb-16">
|
||||||
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
<UiDataTable
|
||||||
<div>Numéro</div>
|
v-model:page="page"
|
||||||
<div>Date</div>
|
v-model:per-page="perPage"
|
||||||
<div>Client</div>
|
:columns="columns"
|
||||||
<div>Adresse</div>
|
:items="items"
|
||||||
<div>Type d'expéditon</div>
|
:total-items="totalItems"
|
||||||
<div>Poids</div>
|
:loading="loading"
|
||||||
</div>
|
row-clickable
|
||||||
<div
|
@row-click="goToShipment"
|
||||||
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="goShipment(shipment.id)"
|
|
||||||
>
|
>
|
||||||
<div>{{ shipment.identificationNumber }}</div>
|
<template #header-identificationNumber>
|
||||||
<div>{{ shipment.shipmentDate }}</div>
|
<UiTextInput
|
||||||
<div>{{ shipment.customer?.name }}</div>
|
v-model="filters.identificationNumber"
|
||||||
<div>{{ shipment.address?.fullAddress }}</div>
|
placeholder="Numéro"
|
||||||
<div>
|
size="compact"
|
||||||
<template v-if="formatShipmentLines(shipment).length">
|
/>
|
||||||
|
</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
|
<div
|
||||||
v-for="(line, index) in formatShipmentLines(shipment)"
|
v-for="(line, index) in formatShipmentLines(item)"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="leading-5"
|
class="leading-5"
|
||||||
>
|
>
|
||||||
{{ line }}
|
{{ line }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
<template v-else>—</template>
|
||||||
<div>{{ formatWeighing(shipment) }}</div>
|
</template>
|
||||||
</div>
|
<template #cell-weighing="{ item }">
|
||||||
|
{{ formatWeighing(item) }}
|
||||||
|
</template>
|
||||||
|
</UiDataTable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {ShipmentData} from "~/services/dto/shipment-data";
|
useHead({ title: 'Validation expédition' })
|
||||||
import {getShipmentList} from "~/services/shipment";
|
|
||||||
|
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 router = useRouter()
|
||||||
|
const shipmentTypes = ref<ShipmentTypeData[]>([])
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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 formatWeighing = (shipment: ShipmentData) => {
|
||||||
const gross = shipment.weights?.find((weight) => weight.type === 'gross')?.weight
|
const gross = shipment.weights?.find((weight) => weight.type === 'gross')?.weight
|
||||||
@@ -62,24 +161,12 @@ const formatWeighing = (shipment: ShipmentData) => {
|
|||||||
return `${gross - tare} kg`
|
return `${gross - tare} kg`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToShipment = (shipment: ShipmentData) => {
|
||||||
const formatShipmentLines = (shipment: ShipmentData) => {
|
router.push(`/shipment/update/${shipment.id}`)
|
||||||
if (!shipment.shipmentType && shipment.nbBovinSend == null) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = typeof shipment.shipmentType === 'string'
|
|
||||||
? shipment.shipmentType
|
|
||||||
: shipment.shipmentType?.label
|
|
||||||
|
|
||||||
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
|
|
||||||
}
|
|
||||||
|
|
||||||
const goShipment = (id: number) => {
|
|
||||||
router.push(`/shipment/update/${id}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
shipmentList.value = await getShipmentList(true)
|
shipmentTypes.value = await getShipmentTypeList()
|
||||||
|
reload()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
662
frontend/pages/shipment/update/[[id]].vue
Normal file
662
frontend/pages/shipment/update/[[id]].vue
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
<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">
|
||||||
|
<UiTabs
|
||||||
|
v-model="activeTab"
|
||||||
|
:tabs="[
|
||||||
|
{ key: 'weightsEmpty', label: 'pesée à vide', error: hasTareWeightError },
|
||||||
|
{ key: 'weights', label: 'pesée à plein', error: hasGrossWeightError }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
@@ -1,74 +1,188 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-start gap-10">
|
||||||
<div class="flex items-center gap-10">
|
|
||||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
<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 en attente</h1>
|
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des expéditions en attente</h1>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
<div class="mt-6 border border-slate-200 mb-16 ">
|
<div class="mt-6 mb-16">
|
||||||
<div class="grid grid-cols-5 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
<UiDataTable
|
||||||
<div>Client</div>
|
v-model:page="page"
|
||||||
<div>Adresse</div>
|
v-model:per-page="perPage"
|
||||||
<div>Type d'expéditions</div>
|
:columns="columns"
|
||||||
<div>Transporteur</div>
|
:items="items"
|
||||||
<div>Immatriculation</div>
|
:total-items="totalItems"
|
||||||
</div>
|
:loading="loading"
|
||||||
<div
|
:show-actions="auth.isAdmin"
|
||||||
v-for="shipment in shipmentList"
|
row-clickable
|
||||||
:key="shipment.id"
|
@row-click="goToShipment"
|
||||||
class="grid grid-cols-5 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
@click="goToShipment(shipment.id)"
|
|
||||||
@keydown.enter="goToShipment(shipment.id)"
|
|
||||||
>
|
>
|
||||||
<div>{{ shipment.customer?.name }}</div>
|
<template #header-shipmentDate>
|
||||||
<div>{{ shipment.address?.fullAddress }}</div>
|
<UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
|
||||||
<div>
|
</template>
|
||||||
<template v-if="formatShipmentLines(shipment).length">
|
<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é."
|
||||||
|
:options="shipmentTypeOptions"
|
||||||
|
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-shipmentDate="{ item }">
|
||||||
|
{{ formatDate(item.shipmentDate) }}
|
||||||
|
</template>
|
||||||
|
<template #cell-shipmentType.label="{ item }">
|
||||||
|
<template v-if="formatShipmentLines(item).length">
|
||||||
<div
|
<div
|
||||||
v-for="(line, index) in formatShipmentLines(shipment)"
|
v-for="(line, index) in formatShipmentLines(item)"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="leading-5"
|
class="leading-5"
|
||||||
>
|
>
|
||||||
{{ line }}
|
{{ line }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
<template v-else>—</template>
|
||||||
<div>{{ shipment.carrier?.name }}</div>
|
</template>
|
||||||
<div>{{ shipment.licencePlate }}</div>
|
<template #actions="{ item }">
|
||||||
</div>
|
<Icon
|
||||||
|
name="mdi:delete-outline"
|
||||||
|
size="24"
|
||||||
|
class="cursor-pointer text-red-500 hover:text-red-700"
|
||||||
|
@click="confirmDelete(item)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UiDataTable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Expéditions en attente' })
|
||||||
|
|
||||||
import type {ShipmentData} from "~/services/dto/shipment-data";
|
import type { ShipmentData } from '~/services/dto/shipment-data'
|
||||||
import {getShipmentList} from "~/services/shipment";
|
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||||
|
import { deleteShipment } from '~/services/shipment'
|
||||||
|
import { getShipmentTypeList } from '~/services/shipment-type'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
|
|
||||||
const shipmentList = ref<ShipmentData[]>()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const shipmentTypes = ref<ShipmentTypeData[]>([])
|
||||||
|
|
||||||
const goToShipment = (id: number) => {
|
const shipmentTypeOptions = computed(() =>
|
||||||
router.push(`/shipment/${id}`)
|
shipmentTypes.value.map(st => ({ value: st.id, label: st.label }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||||
|
useDataTableServerState<ShipmentData>(
|
||||||
|
'shipments',
|
||||||
|
{
|
||||||
|
isValid: false,
|
||||||
|
'customer.name': '',
|
||||||
|
'carrier.name': '',
|
||||||
|
'licensePlate': '',
|
||||||
|
'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 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ 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é.", 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 formatShipmentLines = (shipment: ShipmentData) => {
|
const formatShipmentLines = (shipment: ShipmentData) => {
|
||||||
if (!shipment.shipmentType && shipment.nbBovinSend == null) {
|
if (!shipment.shipmentType && shipment.nbBovinSend == null) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = typeof shipment.shipmentType === 'string'
|
const label = typeof shipment.shipmentType === 'string'
|
||||||
? shipment.shipmentType
|
? shipment.shipmentType
|
||||||
: shipment.shipmentType?.label
|
: shipment.shipmentType?.label
|
||||||
|
|
||||||
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
|
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToShipment = (shipment: ShipmentData) => {
|
||||||
|
router.push(`/shipment/${shipment.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async (shipment: ShipmentData) => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Êtes-vous sûr de vouloir supprimer l'expédition ${shipment.identificationNumber ?? `#${shipment.id}`} ? Toutes les données liées seront supprimées.`
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
await deleteShipment(shipment.id)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
shipmentList.value = await getShipmentList(false)
|
shipmentTypes.value = await getShipmentTypeList()
|
||||||
|
reload()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import type { AddressData } from '~/services/dto/address-data'
|
|
||||||
export interface AddressPayload {
|
export interface AddressPayload {
|
||||||
label: string
|
|
||||||
street: string
|
street: string
|
||||||
street2?: string | null
|
street2?: string | null
|
||||||
postalCode: string
|
postalCode: string
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export async function logout() {
|
|||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<void>('logout', {}, {
|
return api.post<void>('logout', {}, {
|
||||||
toastErrorKey: 'errors.auth.logout',
|
toastErrorKey: 'errors.auth.logout',
|
||||||
toastSuccessKey: 'success.auth.logout',
|
toastSuccessKey: 'success.auth.logout'
|
||||||
redirect: 'manual'
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,19 +24,27 @@ export async function getBovineTypeList(): Promise<BovineTypeData[]> {
|
|||||||
|
|
||||||
export async function getBovin(id: number): Promise<BovineTypeData> {
|
export async function getBovin(id: number): Promise<BovineTypeData> {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const response = await api.get<BovineTypeData>(`bovine_types/${id}`)
|
const response = await api.get<BovineTypeData>(`bovine_types/${id}`, {}, {
|
||||||
|
toastErrorKey: 'errors.bovin.fetch'
|
||||||
|
})
|
||||||
return mapToBovineTypeData(response)
|
return mapToBovineTypeData(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBovin(payload: BovinPayload = {}): Promise<BovineTypeData> {
|
export async function createBovin(payload: BovinPayload = {}): Promise<BovineTypeData> {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const response = await api.post<BovineTypeData>('bovine_types', toBovineTypePayload(payload))
|
const response = await api.post<BovineTypeData>('bovine_types', toBovineTypePayload(payload), {
|
||||||
|
toastErrorKey: 'errors.bovin.create',
|
||||||
|
toastSuccessKey: 'success.bovin.create'
|
||||||
|
})
|
||||||
return mapToBovineTypeData(response)
|
return mapToBovineTypeData(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBovin(id: number, payload: BovinPayload = {}): Promise<BovineTypeData> {
|
export async function updateBovin(id: number, payload: BovinPayload = {}): Promise<BovineTypeData> {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const response = await api.patch<BovineTypeData>(`bovine_types/${id}`, toBovineTypePayload(payload))
|
const response = await api.patch<BovineTypeData>(`bovine_types/${id}`, toBovineTypePayload(payload), {
|
||||||
|
toastErrorKey: 'errors.bovin.update',
|
||||||
|
toastSuccessKey: 'success.bovin.update'
|
||||||
|
})
|
||||||
return mapToBovineTypeData(response)
|
return mapToBovineTypeData(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
frontend/services/bovine.ts
Normal file
11
frontend/services/bovine.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import type { BovineData, BovinePayload } from '~/services/dto/bovine-data'
|
||||||
|
|
||||||
|
export async function createBovine(payload: BovinePayload) {
|
||||||
|
const api = useApi()
|
||||||
|
return api.post<BovineData>('bovines', payload, {
|
||||||
|
headers: { 'Content-Type': 'application/ld+json' },
|
||||||
|
toastErrorKey: 'errors.bovine.create',
|
||||||
|
toastSuccessKey: 'success.bovine.create'
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export interface AddressData {
|
export interface AddressData {
|
||||||
id: number
|
id: number
|
||||||
label: string
|
label?: string | null
|
||||||
street: string
|
street: string
|
||||||
street2?: string | null
|
street2?: string | null
|
||||||
postalCode: string
|
postalCode: string
|
||||||
@@ -11,7 +11,7 @@ export interface AddressData {
|
|||||||
|
|
||||||
export interface AddressFormData {
|
export interface AddressFormData {
|
||||||
id?: number | null
|
id?: number | null
|
||||||
label: string
|
label?: string | null
|
||||||
street: string
|
street: string
|
||||||
street2?: string | null
|
street2?: string | null
|
||||||
postalCode: string
|
postalCode: string
|
||||||
|
|||||||
31
frontend/services/dto/bovine-data.ts
Normal file
31
frontend/services/dto/bovine-data.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export interface BovineBuildingRef {
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BovineBuildingCaseRef {
|
||||||
|
caseNumber: number | null
|
||||||
|
building: BovineBuildingRef | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BovineData {
|
||||||
|
id: number
|
||||||
|
nationalNumber: string
|
||||||
|
receivedWeight: number | null
|
||||||
|
pricePerKg: number | null
|
||||||
|
finalPrice: number | null
|
||||||
|
arrivalDate: string | null
|
||||||
|
exitDate: string | null
|
||||||
|
buildingCase: BovineBuildingCaseRef | null
|
||||||
|
supplier: string | null
|
||||||
|
workNumber: string | null
|
||||||
|
birthDate: string | null
|
||||||
|
bovineType: { id: number; label: string; code: string } | null
|
||||||
|
sex: string | null
|
||||||
|
ageMonths: number | null
|
||||||
|
exitedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BovinePayload = {
|
||||||
|
nationalNumber?: string
|
||||||
|
buildingCase?: string | null
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { BuildingCaseStatusData } from '~/services/dto/building-case-status-data'
|
import type { BovineData } from '~/services/dto/bovine-data'
|
||||||
|
|
||||||
|
export interface BuildingSummary {
|
||||||
|
id: number
|
||||||
|
label: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface BuildingCaseData {
|
export interface BuildingCaseData {
|
||||||
id: number
|
id: number
|
||||||
caseNumber: number | null
|
caseNumber: number | null
|
||||||
code: string | null
|
code: string | null
|
||||||
capacity: number | null
|
capacity: number | null
|
||||||
statut?: BuildingCaseStatusData | null
|
statut?: { label: string; couleur: string } | null
|
||||||
|
building?: BuildingSummary | null
|
||||||
|
bovines: BovineData[]
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user