Compare commits

..

8 Commits

209 changed files with 4298 additions and 13753 deletions

View File

@@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npm run:*)",
"WebFetch(domain:geo.api.gouv.fr)",
"Bash(pip3 install:*)",
"Bash(python3 -c \":*)",
"Bash(make cache-clear *)",
"Bash(make test *)"
]
}
}

View File

@@ -36,7 +36,7 @@ jobs:
run: | run: |
cd frontend cd frontend
npm ci npm ci
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ NUXT_PUBLIC_GEO_API_BASE=https://geo.api.gouv.fr npm run generate CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
test -f .output/public/index.html test -f .output/public/index.html
- name: Build artefact - name: Build artefact

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

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="ferme" uuid="f407a514-c6b4-4b26-9555-445a85892502">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/ferme</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="Ferme recette" uuid="ae622167-c834-4e7b-87a5-c1721036f5dc">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/postgres</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

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

5
.idea/ferme.iml generated
View File

@@ -155,11 +155,6 @@
<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
View File

@@ -174,11 +174,6 @@
<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" />

737
.idea/workspace.xml generated
View File

@@ -4,16 +4,10 @@
<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 : label age bovin"> <list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : corrections diverses">
<change beforePath="$PROJECT_DIR$/.claude/settings.local.json" beforeDir="false" afterPath="$PROJECT_DIR$/.claude/settings.local.json" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/data_source_mapping.xml" beforeDir="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$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" 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/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" />
@@ -36,16 +30,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="feat/entree-sortie" /> <entry key="$PROJECT_DIR$" value="feat/276-lister-expeditions-terminees" />
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -217,11 +211,6 @@
<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">{
@@ -234,62 +223,57 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"RunOnceActivity.MCP Project settings loaded": "true", &quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"codeWithMe.voiceChat.enabledByDefault": "false", &quot;git-widget-placeholder&quot;: &quot;fix/325-corrections-diverses&quot;,
"git-widget-placeholder": "feat/vie-du-bovin", &quot;last_opened_file_path&quot;: &quot;//wsl.localhost/Ubuntu-24.04/home/kevin/Stage/Ferme/frontend/pages/shipment&quot;,
"git.auto.fetch.suggestion.counter": "3", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;,
"nodejs_package_manager_path": "npm", &quot;ts.external.directory.path&quot;: &quot;/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external&quot;,
"settings.editor.selected.configurable": "advanced.settings", &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
"ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external",
"vue.rearranger.settings.migration": "true"
}, },
"keyToStringList": { &quot;keyToStringList&quot;: {
"DatabaseDriversLRU": [ &quot;DatabaseDriversLRU&quot;: [
"postgresql" &quot;postgresql&quot;
], ],
"com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File": [ &quot;com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File&quot;: [
"TEXT" &quot;TEXT&quot;
], ],
"vue.recent.templates": [ &quot;vue.recent.templates&quot;: [
"Vue Composition API Component" &quot;Vue Composition API Component&quot;
] ]
} }
}]]></component> }</component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="\\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-022fa7b8ab75-com.jetbrains.php.sharedIndexes-PS-261.23567.149" /> <option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.30387.85" />
</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="" />
@@ -324,30 +308,294 @@
<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" />
<workItem from="1772466451823" duration="598000" /> </task>
<workItem from="1772626984813" duration="969000" /> <task id="LOCAL-00007" summary="test : ajout de TU sur les services et providers">
<workItem from="1772786360430" duration="21000" /> <option name="closed" value="true" />
<workItem from="1772786475316" duration="3016000" /> <created>1768318921478</created>
<workItem from="1773049125640" duration="406000" /> <option name="number" value="00007" />
<workItem from="1773049540928" duration="539000" /> <option name="presentableId" value="LOCAL-00007" />
<workItem from="1773050154207" duration="1879000" /> <option name="project" value="LOCAL" />
<workItem from="1773212999001" duration="652000" /> <updated>1768318921478</updated>
<workItem from="1773215356754" duration="5754000" /> </task>
<workItem from="1773756072697" duration="5450000" /> <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="1773766075191" duration="6202000" /> <option name="closed" value="true" />
<workItem from="1773824491213" duration="24805000" /> <created>1768498751836</created>
<workItem from="1774275549972" duration="51000" /> <option name="number" value="00008" />
<workItem from="1774276665015" duration="33750000" /> <option name="presentableId" value="LOCAL-00008" />
<workItem from="1776755742205" duration="88521000" /> <option name="project" value="LOCAL" />
<workItem from="1777453284124" duration="86000" /> <updated>1768498751836</updated>
<workItem from="1777453433907" duration="337000" /> </task>
<workItem from="1777454070632" duration="17254000" /> <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="1777540415843" duration="13205000" /> <option name="closed" value="true" />
<workItem from="1777877316149" duration="29389000" /> <created>1768555180530</created>
<workItem from="1777982616362" duration="23909000" /> <option name="number" value="00009" />
<workItem from="1778482021120" duration="1280000" /> <option name="presentableId" value="LOCAL-00009" />
<workItem from="1778656317630" duration="279000" /> <option name="project" value="LOCAL" />
<workItem from="1778664396844" duration="2576000" /> <updated>1768555180530</updated>
</task>
<task id="LOCAL-00010" summary="feat : ajout de l'authentification avec lexik">
<option name="closed" value="true" />
<created>1768832208350</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1768832208350</updated>
</task>
<task id="LOCAL-00011" summary="feat : update du CHANGELOG.md">
<option name="closed" value="true" />
<created>1768832516587</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1768832516587</updated>
</task>
<task id="LOCAL-00012" summary="fix : correction de l'accès au swagger en mode dev qui n'était plus accessible">
<option name="closed" value="true" />
<created>1768940104944</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1768940104944</updated>
</task>
<task id="LOCAL-00013" summary="feat : ajout de la conf pour le déploiement en recette">
<option name="closed" value="true" />
<created>1769005220331</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1769005220331</updated>
</task>
<task id="LOCAL-00014" summary="fix : fix de la conf pour le déploiement en recette">
<option name="closed" value="true" />
<created>1769008700008</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1769008700008</updated>
</task>
<task id="LOCAL-00015" summary="fix : fix de la conf pour le déploiement en recette">
<option name="closed" value="true" />
<created>1769014602062</created>
<option name="number" value="00015" />
<option name="presentableId" value="LOCAL-00015" />
<option name="project" value="LOCAL" />
<updated>1769014602062</updated>
</task>
<task id="LOCAL-00016" summary="fix : migration apache vers nginx pour un déploiement plus simple">
<option name="closed" value="true" />
<created>1769019284586</created>
<option name="number" value="00016" />
<option name="presentableId" value="LOCAL-00016" />
<option name="project" value="LOCAL" />
<updated>1769019284586</updated>
</task>
<task id="LOCAL-00017" summary="fix : dernière modification pour le déploiement en recette et le changement de conf vers nginx">
<option name="closed" value="true" />
<created>1769021756823</created>
<option name="number" value="00017" />
<option name="presentableId" value="LOCAL-00017" />
<option name="project" value="LOCAL" />
<updated>1769021756823</updated>
</task>
<task id="LOCAL-00018" summary="ci : auto tag + release artefact">
<option name="closed" value="true" />
<created>1769021818384</created>
<option name="number" value="00018" />
<option name="presentableId" value="LOCAL-00018" />
<option name="project" value="LOCAL" />
<updated>1769021818384</updated>
</task>
<task id="LOCAL-00019" summary="ci : fix release artefact">
<option name="closed" value="true" />
<created>1769022071620</created>
<option name="number" value="00019" />
<option name="presentableId" value="LOCAL-00019" />
<option name="project" value="LOCAL" />
<updated>1769022071620</updated>
</task>
<task id="LOCAL-00020" summary="ci : fix release artefact">
<option name="closed" value="true" />
<created>1769024603812</created>
<option name="number" value="00020" />
<option name="presentableId" value="LOCAL-00020" />
<option name="project" value="LOCAL" />
<updated>1769024603812</updated>
</task>
<task id="LOCAL-00021" summary="ci : ajout du script et de la doc déploiement">
<option name="closed" value="true" />
<created>1769026716634</created>
<option name="number" value="00021" />
<option name="presentableId" value="LOCAL-00021" />
<option name="project" value="LOCAL" />
<updated>1769026716634</updated>
</task>
<task id="LOCAL-00022" summary="fix : correction du path URI pour la création d'un poids dans une réception">
<option name="closed" value="true" />
<created>1769073690382</created>
<option name="number" value="00022" />
<option name="presentableId" value="LOCAL-00022" />
<option name="project" value="LOCAL" />
<updated>1769073690382</updated>
</task>
<task id="LOCAL-00023" summary="feat : Ajout du bundle Monolog pour la gestion des logs">
<option name="closed" value="true" />
<created>1769075990984</created>
<option name="number" value="00023" />
<option name="presentableId" value="LOCAL-00023" />
<option name="project" value="LOCAL" />
<updated>1769075990984</updated>
</task>
<task id="LOCAL-00024" summary="fix : affiche plus détail dans les logs en recette/prod">
<option name="closed" value="true" />
<created>1769077633390</created>
<option name="number" value="00024" />
<option name="presentableId" value="LOCAL-00024" />
<option name="project" value="LOCAL" />
<updated>1769077633390</updated>
</task>
<task id="LOCAL-00025" summary="fix : modification du script de déploiement pour corriger le problème d'écriture des logs de prod">
<option name="closed" value="true" />
<created>1769079030808</created>
<option name="number" value="00025" />
<option name="presentableId" value="LOCAL-00025" />
<option name="project" value="LOCAL" />
<updated>1769079030808</updated>
</task>
<task id="LOCAL-00026" summary="fix : doc de déploiement">
<option name="closed" value="true" />
<created>1769094376813</created>
<option name="number" value="00026" />
<option name="presentableId" value="LOCAL-00026" />
<option name="project" value="LOCAL" />
<updated>1769094376813</updated>
</task>
<task id="LOCAL-00027" summary="fix : doc et script de déploiement">
<option name="closed" value="true" />
<created>1769096187792</created>
<option name="number" value="00027" />
<option name="presentableId" value="LOCAL-00027" />
<option name="project" value="LOCAL" />
<updated>1769096187792</updated>
</task>
<task id="LOCAL-00028" summary="fix : doc et script de déploiement">
<option name="closed" value="true" />
<created>1769097091268</created>
<option name="number" value="00028" />
<option name="presentableId" value="LOCAL-00028" />
<option name="project" value="LOCAL" />
<updated>1769097091268</updated>
</task>
<task id="LOCAL-00029" summary="fix : gitea workflow">
<option name="closed" value="true" />
<created>1769097476629</created>
<option name="number" value="00029" />
<option name="presentableId" value="LOCAL-00029" />
<option name="project" value="LOCAL" />
<updated>1769097476629</updated>
</task>
<task id="LOCAL-00030" summary="fix : script de déploiement">
<option name="closed" value="true" />
<created>1769098182184</created>
<option name="number" value="00030" />
<option name="presentableId" value="LOCAL-00030" />
<option name="project" value="LOCAL" />
<updated>1769098182184</updated>
</task>
<task id="LOCAL-00031" summary="feat : ajout plus d'information sur la liste des réceptions côté front sur la page d'accueil">
<option name="closed" value="true" />
<created>1769098861988</created>
<option name="number" value="00031" />
<option name="presentableId" value="LOCAL-00031" />
<option name="project" value="LOCAL" />
<updated>1769098861988</updated>
</task>
<task id="LOCAL-00032" summary="fix : redirige sur le login sur une 401 et reset du auth state + doc + timeout du toaster">
<option name="closed" value="true" />
<created>1769100048933</created>
<option name="number" value="00032" />
<option name="presentableId" value="LOCAL-00032" />
<option name="project" value="LOCAL" />
<updated>1769100048933</updated>
</task>
<task id="LOCAL-00033" summary="feat : ajout de la debug bar en mod dev">
<option name="closed" value="true" />
<created>1769177611987</created>
<option name="number" value="00033" />
<option name="presentableId" value="LOCAL-00033" />
<option name="project" value="LOCAL" />
<updated>1769177611987</updated>
</task>
<task id="LOCAL-00034" summary="feat : ajout du bundle Malio ednotif pour l'utilisation des WS">
<option name="closed" value="true" />
<created>1769184861047</created>
<option name="number" value="00034" />
<option name="presentableId" value="LOCAL-00034" />
<option name="project" value="LOCAL" />
<updated>1769184861047</updated>
</task>
<task id="LOCAL-00035" summary="fix : modification de la conf du bundle ednotif">
<option name="closed" value="true" />
<created>1769434793487</created>
<option name="number" value="00035" />
<option name="presentableId" value="LOCAL-00035" />
<option name="project" value="LOCAL" />
<updated>1769434793487</updated>
</task>
<task id="LOCAL-00036" summary="feat : update du CHANGELOG.md">
<option name="closed" value="true" />
<created>1769435038236</created>
<option name="number" value="00036" />
<option name="presentableId" value="LOCAL-00036" />
<option name="project" value="LOCAL" />
<updated>1769435038236</updated>
</task>
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)">
<option name="closed" value="true" />
<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" />
@@ -453,295 +701,7 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1770969471135</updated> <updated>1770969471135</updated>
</task> </task>
<task id="LOCAL-00056" summary="fix : corrections frontend"> <option name="localTasksCounter" value="56" />
<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">
@@ -791,32 +751,32 @@
</option> </option>
</component> </component>
<component name="VcsManagerConfiguration"> <component name="VcsManagerConfiguration">
<MESSAGE value="fix : espacement" /> <MESSAGE value="fix : gitea workflow" />
<MESSAGE value="fix : text" /> <MESSAGE value="fix : script de déploiement" />
<MESSAGE value="feat : front page admin bovin et changelog" /> <MESSAGE value="feat : ajout plus d'information sur la liste des réceptions côté front sur la page d'accueil" />
<MESSAGE value="fix : on ne bloque plus le poids max d'une pesée" /> <MESSAGE value="fix : redirige sur le login sur une 401 et reset du auth state + doc + timeout du toaster" />
<MESSAGE value="feat : ajout de supplier dans la feed et fixtures" /> <MESSAGE value="feat : ajout de la debug bar en mod dev" />
<MESSAGE value="feat : ajout de bâtiment dans les fixtures et seed + organisation du menu" /> <MESSAGE value="feat : ajout du bundle Malio ednotif pour l'utilisation des WS" />
<MESSAGE value="fix : on ne pèse plus automatiquement + fix message de création réception" /> <MESSAGE value="fix : modification de la conf du bundle ednotif" />
<MESSAGE value="fix : correction des retours de la V0" /> <MESSAGE value="feat : update du CHANGELOG.md" />
<MESSAGE value="feat : ajout de l'api de l'état pour chercher les villes via le CP" /> <MESSAGE value="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)" />
<MESSAGE value="fix : script de déploiement + CI/CD build de l'app" /> <MESSAGE value="feat : ajout du numéro identification des receptions et ajustement du bon de reception" />
<MESSAGE value="fix : order navbar + modification création fournisseur et client" /> <MESSAGE value="feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception" />
<MESSAGE value="fix : order récéption/expédition + correction style bouton récéption" /> <MESSAGE value="feat : mise en place de composant UI pour les select, checkbox, date, text" />
<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 : amélioration du tableau bovin" /> <MESSAGE value="feat : ajout de commentaire" />
<MESSAGE value="fix : label age 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" />
<option name="LAST_COMMIT_MESSAGE" value="fix : label age bovin" /> <MESSAGE value="feat : ajout de colonne pour les Supplier, Address et modification du numéro de réception" />
<MESSAGE value="feat : ajout de colonne pour les Supplier, Address. Modification du numéro de réception et ajout de fixtures" />
<MESSAGE value="feat : mise à jour du bon de réception" />
<MESSAGE value="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)" />
<MESSAGE value="feat : creer une nouvelle expedtion (WIP)" />
<MESSAGE value="feat : ajout d'une page de creation d'une expedition" />
<MESSAGE value="feat : changelog" />
<MESSAGE value="feat : lister les expeditions terminees" />
<MESSAGE value="fix: corrections diverses" />
<MESSAGE value="fix : corrections diverses" />
<option name="LAST_COMMIT_MESSAGE" value="fix : corrections diverses" />
</component> </component>
<component name="XDebuggerManager"> <component name="XDebuggerManager">
<breakpoint-manager> <breakpoint-manager>
@@ -835,6 +795,11 @@
<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>

59
AGENTS.md Normal file
View File

@@ -0,0 +1,59 @@
# AGENTS.md
Project overview
- Symfony 8 + API Platform 4 backend, Nuxt 3 frontend in `frontend/`.
- Apache vhost serves API under `/api` and frontend from `frontend/dist`.
- API base URL on frontend uses `NUXT_PUBLIC_API_BASE` (see `frontend/.env`).
Backend conventions
- Use English for code identifiers/messages; keep “pont-bascule” as domain term.
- API Platform operations are defined on Doctrine entities.
- Reception entity is in `src/Entity/Reception.php`, with custom weigh endpoint `/receptions/weigh`.
- Reception fields: `date_reception`, `license_plate`, `current_step` (default 0), `is_valid` (default false).
- Reception also has `identification_number` (auto `N-BR-####`), `merchandise_type`, `merchandise_detail`, `buildings` (M2M), and `pellet_buildings` (via `reception_pellet_building`).
- `date_reception` is set by the UI, stored as `DateTimeImmutable`, serialized as `Y-m-d`.
- Weight entity (`src/Entity/Weight.php`) is 1N with Reception, each row stores `type` (`gross` or `tare`), `dsd`, `weight`, `weighed_at` (all nullable except `type`).
- Weigh endpoint `/receptions/weigh` returns `PontBasculeReading` with `dsd`, `weight`, `weighedAt` (formatted `Y-m-d`).
- Custom exception: `App\Exception\PontBasculeException` with French messages, mapped to 500 in provider.
- Parsing of pont-bascule payload is in `src/Service/PontBasculePayloadDecoder.php`.
- `config/reference.php` is auto-generated; keep it.
Frontend conventions
- Nuxt SSR disabled; Tailwind used.
- Layout in `frontend/layouts/default.vue`: max width `1050px`, header full width.
- Tailwind custom color palette is `primary` (e.g. `bg-primary-500`).
- Global font stack uses Helvetica via Tailwind (`font-sans`) and `frontend/assets/css/main.css`.
- API composable in `frontend/composables/useApi.ts` with `get/post/put/patch/delete` and default JSON/PATCH content types.
- API errors/success toasts can be customized via `toastErrorMessage`/`toastSuccessMessage` or i18n keys `toastErrorKey`/`toastSuccessKey`. Global method fallbacks use `errors.http.*` keys.
- `useApi` uses `useNuxtApp().$i18n` (not `useI18n`) to avoid setup-only constraint in service calls.
- Pinia store: `frontend/stores/reception.ts` is the source of truth for the current reception.
- Zod is used for form validation (e.g. `frontend/components/reception/reception-form.vue`); shared helpers live in `frontend/utils/zod-errors.ts`.
- Weighing logic is shared via `frontend/composables/useWeighing.ts`.
- Reception step UI uses store state (`currentStep`) in `frontend/pages/reception/[[id]].vue`.
- Step 2 uses `frontend/components/reception/reception-product-received.vue` for merchandise selection; type codes in `frontend/utils/constants.ts`.
- Active nav styles in header use `NuxtLink` with `custom` slot.
- Reusable UI components live under `frontend/components/ui/` and are auto-imported with `Ui` prefix (e.g. `UiLoadingDots`).
- Service layer lives in `frontend/services/` with typed DTOs in `frontend/services/dto/`.
- Reception service uses `receptions`, `receptions/{id}`, `receptions/weigh` and supports success/error toast keys.
- Reception receipt endpoint is `receptions/{id}/receipt` (PDF) via `frontend/composables/usePdfPrinter.ts`.
Environment & routing
- Frontend dev server: `npm run dev` in `frontend/`.
- API base for local dev: `http://localhost:8080/api` (set in `frontend/.env` via `NUXT_PUBLIC_API_BASE`).
- CORS handled by Nelmio; `.env` includes `CORS_ALLOW_ORIGIN` regex for localhost.
- Nuxt i18n locales live in `frontend/i18n/locales` (configured via `langDir: 'locales'`).
- Default locale is `fr`; translations in `frontend/i18n/locales/fr.json`.
Notes
- Do not add a GET that creates resources; use POST + PATCH.
- Keep endpoints in plural (API Platform convention).
- New reference data added:
- Reception types (`reception_type`, fields: `label`, `code`), selectable on reception form.
- Merchandise types (`merchandise_type`, fields: `label`, `code`) and pellet types (`pellet_type`, fields: `label`, `code`).
- Buildings (`building`, fields: `label`, `code`) and reception allocations (`reception_building` M2M, `reception_pellet_building` unique on reception/pellet/building).
- Suppliers (`supplier`) with addresses (`address`, fields: `label`, `street`, `postal_code`, `city`, `country_code` ISO2), via `supplier_address` join table.
- Trucks (`truck`, field: `name`), linked to receptions.
- Carriers (`carrier`, fields: `name`, nullable `code`), Drivers (`driver`, fields: `name`, `carrier_id`), Vehicles (`vehicle`, fields: `plate`, `carrier_id`, `truck_id`) used for LIOT logic.
- Reception links: `reception_type_id`, `supplier_id`, `address_id`, `truck_id`, `carrier_id`, `driver_id`, `user_id`.
- Address exposes `fullAddress` via getter for display.
- LIOT behavior in reception form: if carrier code = `LIOT`, show driver + vehicle selects and hide manual license plate input; vehicle list filters by truck type and carrier; selected vehicle sets `license_plate`.

View File

@@ -49,25 +49,6 @@ Ajouter dans le fichier .env du frontend
* fix layout admin * fix layout admin
* Creation page admin listing bovins * Creation page admin listing bovins
* Creation page admin ajout/modification bovins * Creation page admin ajout/modification bovins
* [#331] Mettre à jour l'entité Shipment et bovin_shipment
* [#278] Plan du site
* [#334] Correctifs
* [#332] Refonte écran réception terminée
* [#327] afficher/modifier écran expédition terminée
* [#352] modification front admin fournisseur
* [#355] modification front admin transporteur
* [#356] front page admin bovin
* [#353] modification front admin client
* [#353] modification front admin utilisateur
* [#FER-11] Corriger le problème de bearer token
* [#FER-12] Ajouter un blocage des utilisateurs
* [#FER-13] Faire des recherches sur le scanner des bêtes
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
* [#FER-17] Ecran d'ajout de bovin
* [#FER-18] Mise à jour du tableau d'arrivage
* [#FER-26] Passeport du bovin
### Changed ### Changed
### Fixed ### Fixed

169
CLAUDE.md
View File

@@ -1,169 +0,0 @@
# CLAUDE.md
## Stack
- **Backend:** Symfony 8 + API Platform 4 (PHP 8.4)
- **Frontend:** Nuxt 4 (Vue 3, Pinia, Tailwind, Zod) in `frontend/`
- **Infra:** Docker (PHP-FPM + Nginx), Apache vhost serves API sous `/api` et frontend depuis `frontend/dist`
## Commands
```bash
# Docker
make start # Démarrer les containers
make stop # Arrêter les containers
make restart # Redémarrer les containers
make shell # Shell dans le container PHP
# Install complet
make install # composer install + migrations + build frontend
# Backend
make composer-install # Installer dépendances PHP
make migration-migrate # Lancer les migrations
make fixtures # Charger les fixtures
make cache-clear # Vider le cache Symfony
make test # Lancer les tests PHPUnit
make test FILES=tests/path/to/TestFile.php # Test spécifique
make php-cs-fixer-allow-risky FILES=src/... # Fixer le style
# Frontend
make build-nuxtJS # npm install + build:dist (dans le container)
make dev-nuxt # Serveur dev Nuxt (dans le container)
# Ou directement dans frontend/ :
cd frontend && npm run dev # Dev server (port 3000)
cd frontend && npm run build:dist # Build production
# Base de données
make db-reset # ⚠️ Supprime et recrée la BDD + migrations + fixtures
```
## Architecture backend
```
src/
├── ApiResource/ # Ressources API Platform custom
├── Command/ # Commandes Symfony (dont app:seed)
├── DataFixtures/ # Fixtures Doctrine
├── Dto/ # DTOs (ex: PontBasculeReading)
├── Entity/ # Entités Doctrine (= ressources API Platform)
├── Exception/ # Exceptions custom (PontBasculeException)
├── Kernel.php
├── Service/ # Services métier (PontBasculePayloadDecoder…)
└── State/ # State providers/processors API Platform
```
## Architecture frontend
```
frontend/
├── components/
│ ├── ui/ # Composants réutilisables, auto-importés avec préfixe Ui (ex: UiLoadingDots)
│ ├── reception/ # Composants métier réception
│ ├── shipment/ # Composants métier expédition
│ ├── workflow/ # Composants partagés réception/expédition (workflow-weight, workflow-waiting-list, workflow-liot-fields)
│ └── commun/ # Composants communs (update-weight)
├── composables/ # useApi, useWeighing, usePdfPrinter, useAppVersion, useLiotHandling, useFormDataLoading, useAddressSync, useWorkflowSteps
│ └── steps/ # useWeighingStep (logique étape pesée)
├── config/ # reception.config.ts, shipment.config.ts (WorkflowConfig)
├── types/ # workflow.ts (interfaces partagées WorkflowEntity, WorkflowConfig, StepDefinition)
├── services/ # Couche service avec DTOs typés dans services/dto/
│ └── workflow-service.ts # Factory service API (createWorkflowService)
├── stores/ # Pinia stores (reception, shipment, auth)
│ └── workflow-store.ts # Factory store (useWorkflowStoreLogic)
├── pages/ # Pages Nuxt (file-based routing)
├── layouts/ # Layout default : max-width 1050px
├── i18n/locales/ # Traductions (défaut: fr)
├── utils/ # Constants, zod-errors helpers
└── assets/css/ # Tailwind config, main.css (font Helvetica)
```
## Conventions backend
- Code en anglais ; "pont-bascule" est un terme métier conservé tel quel.
- Les opérations API Platform sont définies directement sur les entités Doctrine.
- 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
View File

@@ -1,87 +1,68 @@
# Projet Ferme t # Projet Ferme
## 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)
- 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"
- DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" * PONT_BASCULE_BYPASS (doit être à true en dev)
- PONT_BASCULE_BYPASS (doit être à true en dev) * PONT_BASCULE_URL
- PONT_BASCULE_URL * JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
- JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair) * JWT_PUBLIC_KEY
- JWT_PUBLIC_KEY * JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
- 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)
- 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
- Name : ferme-docker * Host : localhost
- Host : localhost * Port : 8080
- Port : 8080 * Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
- 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:
@@ -91,164 +72,63 @@ Le frontend ne lit jamais directement le token, le navigateur envoie automatique
- La déconnexion utilise `POST /api/logout` et redirige vers `/login`. - 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/dev.log * tail -f var/log/prod.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)

View File

@@ -14,10 +14,9 @@
"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.6", "malio/ednotif-bundle": ">=0.0.4",
"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

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,10 @@ api_platform:
version: 1.0.0 version: 1.0.0
defaults: defaults:
stateless: true stateless: true
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
pagination_client_items_per_page: true pagination_client_items_per_page: true
pagination_maximum_items_per_page: 100 pagination_maximum_items_per_page: 100
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
formats: formats:
json: ['application/json'] json: ['application/json']
jsonld: ['application/ld+json'] jsonld: ['application/ld+json']

View File

@@ -1,11 +1,4 @@
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'
@@ -27,7 +20,6 @@ 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
@@ -38,7 +30,6 @@ security:
pattern: ^/ pattern: ^/
stateless: true stateless: true
provider: app_user_provider provider: app_user_provider
user_checker: App\Security\UserChecker
jwt: ~ jwt: ~
logout: logout:
path: /api/logout path: /api/logout

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content. // This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
namespace Symfony\Component\DependencyInjection\Loader\Configurator; namespace Symfony\Component\DependencyInjection\Loader\Configurator;
@@ -210,18 +208,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 +230,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 +455,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 +627,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 +672,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 +752,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 +764,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 +772,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 +834,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 +938,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 +952,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 +1034,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 +1205,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 +1318,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 +1335,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 +1343,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{
@@ -1472,7 +1470,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* mercure?: bool|array{ * mercure?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
* hub_url?: scalar|Param|null, // The URL sent in the Link HTTP header. If not set, will default to the URL for MercureBundle's default hub. // Default: null * hub_url?: scalar|Param|null, // The URL sent in the Link HTTP header. If not set, will default to the URL for MercureBundle's default hub. // Default: null
* include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false * include_type?: bool|Param, // Always include @type in updates (including delete ones). // Default: false
* }, * },
* messenger?: bool|array{ * messenger?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
@@ -1489,7 +1487,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 +1603,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,
* ...<string, mixed> * ...<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 +1733,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 +1750,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>, * }>,
* } * }
* @psalm-type EdnotifConfig = array{ * @psalm-type EdnotifConfig = array{
* guichet_wsdl?: scalar|Param|null, // Default: "/var/www/html/vendor/malio/ednotif-bundle/resources/ednotif-ws/WsGuichet.wsdl" * guichet_wsdl: scalar|Param|null,
* metier_wsdl?: scalar|Param|null, // Default: "/var/www/html/vendor/malio/ednotif-bundle/resources/ednotif-ws/wsIpBNotif.wsdl" * metier_wsdl: scalar|Param|null,
* exploitation_code?: scalar|Param|null, * exploitation_code: scalar|Param|null,
* zone?: scalar|Param|null, // Default: null * zone?: scalar|Param|null, // Default: null
* application?: scalar|Param|null, // Default: null * application?: scalar|Param|null, // Default: null
* login?: scalar|Param|null, * login: scalar|Param|null,
* password?: scalar|Param|null, * password: scalar|Param|null,
* exploitation_number?: scalar|Param|null, * exploitation_number: scalar|Param|null,
* exploitation_country_code?: scalar|Param|null, // Default: "FR" * exploitation_country_code?: scalar|Param|null, // Default: "FR"
* token_ttl_seconds?: int|Param, // Default: 900 * token_ttl_seconds?: int|Param, // Default: 900
* soap_options?: array{ * soap_options?: array{

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.0.101' app.version: '0.0.51'

View File

@@ -1,123 +0,0 @@
# 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`

View File

@@ -7,17 +7,3 @@
@apply font-sans; @apply font-sans;
} }
} }
@layer utilities {
.submitted :invalid {
@apply border-red-500 text-red-500;
}
.submitted :has(:invalid) > label {
@apply text-red-500;
}
.submitted label:has(:invalid) {
@apply text-red-500;
}
}

View File

@@ -1,45 +1,35 @@
<template> <template>
<form :class="{ submitted }" @submit.prevent="validateForm"> <form @submit.prevent="validateForm">
<div class="flex items-center mb-11 justify-between relative"> <div class="flex items-center justify-between gap-10">
<div class="flex flex-row absolute -left-[60px] "> <div>
<Icon @click="goBack" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/> <h1 class="text-3xl font-bold uppercase">
</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>
<div class="grid grid-cols-2 gap-y-16 gap-x-[200px] mb-16"> <button
<UiTextInput id="address-street" v-model="form.street" label="Rue" required /> class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
<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"
> >
Valider {{ props.address? "Sauvegarder" : "Ajouter" }}
</UiButton> </button>
</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 type { AddressPayload } from "~/services/address" import { AddressPayload } from "~/services/address"
import { getCommunesByPostalCode, type CommuneData } from "~/services/geo"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -50,45 +40,26 @@ 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: "FR", countryCode: "",
}) })
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 || "FR" form.countryCode = data.countryCode ?? ""
} }
watch( watch(
@@ -99,50 +70,11 @@ 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
}>() }>()

View File

@@ -2,17 +2,17 @@
<template> <template>
<NuxtLink :to="link"> <NuxtLink :to="link">
<div class="w-[300px] h-[216px] border border-primary-700 rounded-lg p-6 flex flex-col justify-between gap-4"> <div class="w-[300px] h-[216px] border border-black 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" class="!text-primary-700" size="44" /> <Icon :name="iconName" style="color: black" 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-700"> <p class="text-3xl text-primary-500">
<slot name="label">{{ label }}</slot> <slot name="label">{{ label }}</slot>
</p> </p>
</div> </div>

View File

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

View File

@@ -1,96 +0,0 @@
<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é&nbsp;: 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>

View File

@@ -1,16 +1,14 @@
<template> <template>
<form <div
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS" v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"
class="flex flex-col gap-16" class="flex flex-col items-center gap-16">
@submit.prevent="goNext"
>
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1> <h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1>
<div <div
class="flex flex-row gap-8 items-center w-full"> class="flex flex-row gap-8 items-center">
<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 w-full"> class="mt-8 flex flex-row mb-2 gap-6">
<UiNumberInput <UiNumberInput
:id="type.id" :id="type.id"
:label="type.label" :label="type.label"
@@ -19,8 +17,6 @@
:placeholder="0" :placeholder="0"
:min="0" :min="0"
:max="10" :max="10"
class="max-w-[150px]"
wrapper-class="gap-3"
/> />
</div> </div>
<div <div
@@ -28,22 +24,15 @@
<UiNumberInput <UiNumberInput
label="Autres" label="Autres"
v-model="otherQuantity" v-model="otherQuantity"
class="max-w-[80px]"
wrapper-class="gap-3"
/> />
</div> </div>
</div> </div>
<p class="text-red-500 text-sm" :class="showBovineError ? '' : 'invisible'"> <button
Veuillez saisir au moins une race bovine. class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
</p> @click="goNext"
<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
</UiButton> </button>
</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";
@@ -62,7 +51,6 @@ 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)
@@ -174,13 +162,7 @@ async function goNext() {
return return
} }
showBovineError.value = false // @TODO Ajouter un composable pour le toaster qui gère les key i18n
if (totalBovines.value === 0) {
showBovineError.value = true
return
}
if (totalBovines.value > 52) { if (totalBovines.value > 52) {
toast.error({ toast.error({
title: 'Erreur', title: 'Erreur',

View File

@@ -1,7 +1,8 @@
<template> <template>
<form ref="formRef" :class="{ submitted }" @submit.prevent="validate"> <form @submit.prevent="validate">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16"> <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"
@@ -12,15 +13,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"
@@ -30,8 +31,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"
@@ -42,17 +43,20 @@
}))" }))"
: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="addressOptions" :options="supplierAddresses.map((address) => ({
:disabled="isLoadingSuppliers || ownerAddresses.length === 0" value: String(address.id),
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"
@@ -63,8 +67,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"
@@ -76,15 +80,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"
@@ -97,8 +101,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"
@@ -110,39 +114,42 @@
: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 mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="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 { useFormDataLoading } from '~/composables/useFormDataLoading'
import { useLiotHandling } from '~/composables/useLiotHandling'
import { useAddressSync } from '~/composables/useAddressSync'
import type {ReceptionTypeData} from '~/services/dto/reception-type-data' import type {ReceptionTypeData} from '~/services/dto/reception-type-data'
import {getReceptionTypeList} from '~/services/reception-type' import {getReceptionTypeList} from '~/services/reception-type'
import type {UserData} from '~/services/dto/user-data'
import {getUsers} from '~/services/auth'
import {useAuthStore} from '~/stores/auth'
import type {SupplierData} from '~/services/dto/supplier-data' import type {SupplierData} from '~/services/dto/supplier-data'
import {getSupplierList} from '~/services/supplier' import {getSupplierList} from '~/services/supplier'
import { RECEPTION_TYPE_CODES } from '~/utils/constants' import type {TruckData} from '~/services/dto/truck-data'
import { deleteReceptionBovine, getReceptionBovineList } from '~/services/reception-bovine' import {getTruckList} from '~/services/truck'
import type { ReceptionFormData } from '~/services/dto/reception-data' import type {CarrierData} from '~/services/dto/carrier-data'
import {getCarrierList} from '~/services/carrier'
import type {DriverData} from '~/services/dto/driver-data'
import {getDriverList} from '~/services/driver'
import type {VehicleData} from '~/services/dto/vehicle-data'
import {getVehicleList} from '~/services/vehicle'
import {RECEPTION_TYPE_CODES, SUPPLIER_CODE} from "~/utils/constants";
import {deleteReceptionBovine, getReceptionBovineList} from "~/services/reception-bovine";
import type {ReceptionFormData} from "~/services/dto/reception-data";
const router = useRouter() const 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),
@@ -155,27 +162,62 @@ 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)
const { users, trucks, carriers, isLoadingUsers, isLoadingTrucks, isLoadingCarriers, loadCommonData } = // Transporteur sélectionné dans le formulaire
useFormDataLoading(form) const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
const { )
isLiotCarrier, filteredDrivers, filteredVehicles, // Indique si le transporteur est LIOT
isLoadingDrivers, isLoadingVehicles, allowAnyLicensePlate, const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
loadDrivers, loadVehicles // Adresses disponibles pour le fournisseur sélectionné
} = useLiotHandling(form, carriers, isHydrating) const supplierAddresses = computed(() => {
const supplierId = Number(form.supplierId)
const supplierIdRef = computed(() => form.supplierId) if (!Number.isFinite(supplierId)) {
const { ownerAddresses, addressOptions } = useAddressSync(form, supplierIdRef, suppliers) return []
}
return suppliers.value.find((supplier) => supplier.id === supplierId)?.addresses ?? []
})
// Chauffeurs filtrés par transporteur (LIOT)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) {
return []
}
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
// Véhicules filtrés par transporteur + type de camion
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) {
return []
}
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
const selectedReceptionType = computed(() => 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) {
@@ -183,6 +225,50 @@ const clearReceptionBovines = async (receptionIri: string) => {
} }
} }
// Hydrate le formulaire depuis la réception en cours
watch(
() => receptionStore.current,
(reception) => {
isHydrating.value = true
form.licensePlate = reception?.licensePlate ?? ''
form.receptionDate = reception?.receptionDate ?? new Date().toISOString().slice(0, 10)
form.receptionTypeId = reception?.receptionType?.id
? String(reception.receptionType.id)
: ''
form.userId = reception?.user?.id
? String(reception.user.id)
: form.userId
form.supplierId = reception?.supplier?.id
? String(reception.supplier.id)
: ''
form.addressId = reception?.address?.id
? String(reception.address.id)
: ''
form.truckId = reception?.truck?.id
? String(reception.truck.id)
: ''
form.carrierId = reception?.carrier?.id
? String(reception.carrier.id)
: ''
form.driverId = reception?.driver?.id
? String(reception.driver.id)
: ''
isHydrating.value = false
},
{immediate: true}
)
// Charge la liste des users pour le select
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
// Charge la liste des fournisseurs pour le select
const loadSuppliers = async () => { const loadSuppliers = async () => {
isLoadingSuppliers.value = true isLoadingSuppliers.value = true
try { try {
@@ -192,33 +278,186 @@ const loadSuppliers = async () => {
} }
} }
// Charge la liste des camions pour le select
const loadTrucks = async () => {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
// Charge la liste des transporteurs pour le select
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
// Charge la liste des chauffeurs pour le select
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
// Charge la liste des véhicules pour le select
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
// On met le user connecté par défaut dans le select
const setDefaultUser = () => {
if (form.userId) {
return
}
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
// On récupère toutes les données des selects au chargement du composant
onMounted(async () => {
receptionTypes.value = await getReceptionTypeList()
await loadUsers()
await loadSuppliers()
await loadTrucks()
await loadCarriers()
await loadDrivers()
await loadVehicles()
await authStore.ensureSession()
setDefaultUser()
})
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
watch( watch(
() => receptionStore.current, () => [form.supplierId, form.addressId, suppliers.value],
(reception) => { () => {
isHydrating.value = true if (!form.supplierId) {
form.licensePlate = reception?.licensePlate ?? '' form.addressId = ''
form.receptionDate = reception?.receptionDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10) return
form.receptionTypeId = reception?.receptionType?.id ? String(reception.receptionType.id) : '' }
form.userId = reception?.user?.id ? String(reception.user.id) : form.userId if (!form.addressId && supplierAddresses.value.length === 1) {
form.supplierId = reception?.supplier?.id ? String(reception.supplier.id) : '' form.addressId = String(supplierAddresses.value[0].id)
form.addressId = reception?.address?.id ? String(reception.address.id) : '' return
form.truckId = reception?.truck?.id ? String(reception.truck.id) : '' }
form.carrierId = reception?.carrier?.id ? String(reception.carrier.id) : '' if (!form.addressId) {
form.driverId = reception?.driver?.id ? String(reception.driver.id) : '' return
isHydrating.value = false }
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} {immediate: true}
) )
onMounted(async () => { // Valide/auto-sélectionne le véhicule selon camion + transporteur (LIOT)
receptionTypes.value = await getReceptionTypeList() watch(
await loadSuppliers() () => form.carrierId,
await loadCommonData() () => {
await loadDrivers() if (isHydrating.value) {
await loadVehicles() 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}
)
const buildPayload = () => { // 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()
@@ -228,16 +467,29 @@ const buildPayload = () => {
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 receptionTypeIri = normalizedReceptionTypeId ? `/api/reception_types/${normalizedReceptionTypeId}` : null const basePayload = {
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,
@@ -245,35 +497,13 @@ const buildPayload = () => {
supplier: supplierIri, supplier: supplierIri,
address: addressIri, address: addressIri,
truck: truckIri, truck: truckIri,
carrier: carrierIri, carrier: carrierIri
}
const payload = {
...basePayload,
...(isLiotCarrier.value && driverIri ? {driver: driverIri} : {}) ...(isLiotCarrier.value && driverIri ? {driver: driverIri} : {})
} }
}
const saveDraft = async () => {
const payload = buildPayload()
if (!receptionStore.current) {
await receptionStore.createReception({
currentStep: 0,
...payload
})
return
}
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: receptionStore.current.currentStep,
...payload
})
}
const validateFields = () => {
submitted.value = true
return formRef.value?.reportValidity() ?? false
}
defineExpose({ saveDraft, validateFields })
async function validate() {
const payload = buildPayload()
if (!receptionStore.current) { if (!receptionStore.current) {
const created = await receptionStore.createReception({ const created = await receptionStore.createReception({
@@ -302,4 +532,5 @@ async function validate() {
...payload ...payload
}) })
} }
</script> </script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<form :class="['flex flex-col items-center gap-16', { submitted }]" @submit.prevent="goNext"> <div class="flex flex-col items-center gap-16">
<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,7 +10,6 @@
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"
@@ -22,15 +21,13 @@
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 flex-col gap-4 w-[550px]" class="flex gap-4 w-[550px] justify-between"
> >
<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"
@@ -43,10 +40,6 @@
/> />
</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"
@@ -69,20 +62,14 @@
</div> </div>
</div> </div>
</div> </div>
<p class="text-red-500 text-sm" :class="showBuildingError ? '' : 'invisible'">
Veuillez sélectionner au moins un bâtiment.
</p>
</div> </div>
</div> </div>
<div class="flex justify-center"> <button
<UiButton class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
type="submit" @click="goNext"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="submitted = true"
>Valider >Valider
</UiButton> </button>
</div> </div>
</form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -110,9 +97,6 @@ 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 => {
@@ -195,23 +179,6 @@ async function goNext() {
return return
} }
showBuildingError.value = false
showPelletBuildingError.value = false
if (!isGranule.value && !isAutres.value && selectedBuildingIds.value.length === 0) {
showBuildingError.value = true
return
}
if (isGranule.value) {
const hasAnyPelletBuilding = Object.values(selectedPelletBuildingIds.value)
.some((ids) => ids.length > 0)
if (!hasAnyPelletBuilding) {
showPelletBuildingError.value = true
return
}
}
const nextStep = receptionStore.current.currentStep + 1 const nextStep = receptionStore.current.currentStep + 1
const receptionIri = `/api/receptions/${receptionStore.current.id}` const receptionIri = `/api/receptions/${receptionStore.current.id}`

View File

@@ -0,0 +1,101 @@
<template>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
<!--@TODO Voir comment faire pour savoir si le pont-bascule et bien connecté + ajouter un icon comme sur la maquette-->
<p class="text-primary-500 uppercase text-2xl text-primary-500 mt-2">Pont-bascule connecté</p>
<div
v-if="showLoadingBox"
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
<UiLoadingDots />
</div>
<div v-else-if="displayWeight !== null" class="w-full">
<div
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl text-primary-500">
{{ displayWeight }} kg
</div>
</div>
</div>
</div>
<div class="flex justify-center mt-[54px]">
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
<UiButton
v-if="displayWeight !== null && !showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="saveWeight"
>Valider la pesée</UiButton>
<UiButton
v-if="showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="printReceipt"
>Générer le bon</UiButton>
</div>
</template>
<script setup lang="ts">
import {computed, onMounted} from 'vue'
import { storeToRefs } from 'pinia'
import { useWeighing } from '~/composables/useWeighing'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
import { useReceptionStore } from '~/stores/reception'
const props = defineProps<{
mode: 'gross' | 'tare'
}>()
const router = useRouter()
const receptionStore = useReceptionStore()
const { current: storeReception } = storeToRefs(receptionStore)
const { printPdf } = usePdfPrinter()
const {
displayWeight,
title,
showLoadingBox,
fetchWeight,
saveWeight
} = useWeighing({
mode: props.mode,
reception: storeReception,
updateReception: receptionStore.updateReception,
loadReception: receptionStore.loadReception
})
// Affiche le bouton de génération du bon à l'étape tare
const showGenerateReceipt = computed(
() => props.mode === 'tare' && displayWeight.value !== null
)
// Génère le bon de réception, puis clôture la réception
const printReceipt = async () => {
if (!import.meta.client || !receptionStore.current) {
return
}
await saveWeight()
const reception = receptionStore.current
const filename = `${reception.identificationNumber ?? reception.id}_${reception.supplier?.name ?? 'fournisseur'}_${reception.licensePlate ?? 'immat'}.pdf`
await printPdf(`/receptions/${reception.id}/receipt`, filename)
// Laisse le temps a la boite de dialogue d'impression de s'ouvrir.
await new Promise((resolve) => setTimeout(resolve, 600))
const result = await receptionStore.updateReception(receptionStore.current.id, {
isValid: true
})
if (!result) {
return
}
receptionStore.clearCurrent()
await router.push('/')
}
// Récupère le poids dès l'arrivée sur l'écran
onMounted(() => {
if (displayWeight.value === null) {
fetchWeight()
}
})
</script>

View File

@@ -1,161 +1,183 @@
<template> <template>
<form> <form @submit.prevent="validate">
<div class="flex flex-row justify-between gap-x-12 font-bold uppercase mb-8">
<div <div
v-for="type in bovineTypes" class="flex flex-col items-center gap-16">
<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="localQuantities[String(type.id)]" v-model="bovineQuantities[String(type.id)]"
:disabled="!isAdmin" :disabled="!auth.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="localOtherQuantity" v-model="otherQuantity"
:disabled="!isAdmin" :disabled="!auth.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 { onMounted, reactive, ref, watch } from 'vue' import type {BovineTypeData} from "~/services/dto/bovine-type-data";
import { getBovineTypeList } from '~/services/bovine-type' import {getBovineTypeList} from "~/services/bovine-type";
import type { BovineTypeData } from '~/services/dto/bovine-type-data' import {
import type { ReceptionBovineTypeData } from '~/services/dto/reception-bovine-data' createReceptionBovine,
deleteReceptionBovine,
getReceptionBovineList,
updateReceptionBovine
} from "~/services/reception-bovine";
import {computed, onMounted, reactive, ref, watch} from "vue";
import {getReception, updateReception} from "~/services/reception";
const toast = useToast()
const isLoadingBovineType = ref(false)
const bovineType = ref<BovineTypeData[]>([])
const bovineQuantities = reactive<Record<string, number | null>>({})
const otherQuantity = ref<number | null>(0)
const auth = useAuthStore()
const props = defineProps<{ const props = defineProps<{
modelValue: ReceptionBovineTypeData[] idReception: number
otherQuantity: number | null
isAdmin: boolean
}>() }>()
const receptionId = props.idReception
const reception = await getReception(receptionId)
const emit = defineEmits<{ const receptionIri = computed(() =>
(event: 'update:modelValue', value: ReceptionBovineTypeData[]): void receptionId ? `/api/receptions/${receptionId}` : null
(event: 'update:otherQuantity', value: number | null): void
}>()
const bovineTypes = ref<BovineTypeData[]>([])
const localQuantities = reactive<Record<string, number | null>>({})
const localOtherQuantity = ref<number | null>(props.otherQuantity ?? 0)
// Verrou pour éviter les boucles props -> local -> emit -> props.
const isSyncing = ref(false)
function entriesEqualByTypeAndQuantity(
left: ReceptionBovineTypeData[],
right: ReceptionBovineTypeData[]
): boolean {
const toMap = (entries: ReceptionBovineTypeData[]) => {
const map = new Map<number, number>()
for (const entry of entries) {
const typeId = entry.bovineType?.id ?? 0
map.set(typeId, entry.quantity ?? 0)
}
return map
}
const a = toMap(left)
const b = toMap(right)
if (a.size !== b.size) {
return false
}
for (const [typeId, quantity] of a.entries()) {
if ((b.get(typeId) ?? 0) !== quantity) {
return false
}
}
return true
}
function buildEntriesFromLocal(): ReceptionBovineTypeData[] {
return bovineTypes.value.map((type) => {
const existing = props.modelValue.find((entry) => entry.bovineType.id === type.id)
return {
id: existing?.id ?? 0,
bovineType: type,
quantity: localQuantities[String(type.id)] ?? 0
}
})
}
function syncLocalFromProps() {
isSyncing.value = true
try {
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 {
isSyncing.value = false
}
}
watch(
() => props.otherQuantity,
(value) => {
if (isSyncing.value) {
return
}
const next = value ?? 0
isSyncing.value = true
localOtherQuantity.value = next
isSyncing.value = false
}
) )
const totalBovines = computed(() => {
const base = Object.values(bovineQuantities).reduce((sum, value) => {
return sum + (value ?? 0)
}, 0)
return base + (otherQuantity.value ?? 0)
})
watch(localOtherQuantity, (value) => { const loadBovineType = async () => {
if (isSyncing.value) { isLoadingBovineType.value = true
return try {
bovineType.value = await getBovineTypeList()
} finally {
isLoadingBovineType.value = false
}
} }
const next = value ?? 0 onMounted(async () => {
emit('update:otherQuantity', next) await loadBovineType()
}) })
watch( watch(
() => props.modelValue, [() => receptionId, () => bovineType.value],
() => { async ([id, types]) => {
// Hydratation locale uniquement quand le parent change. if (!id || !receptionIri.value || types.length === 0) {
syncLocalFromProps() return
}
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}
) )
watch( async function syncBovineSelections(receptionIri: string) {
localQuantities, const existing = await getReceptionBovineList(receptionIri)
() => { 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 }
)
onMounted(async () => { await syncBovineSelections(receptionIri.value)
bovineTypes.value = await getBovineTypeList()
syncLocalFromProps() await updateReception(receptionId, {
merchandiseType: null,
merchandiseDetail: null,
bovineDetail: otherQuantity.value ? String(otherQuantity.value) : null,
}) })
}
</script> </script>

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
<template> <template>
<form ref="formRef" :class="{ submitted }" @submit.prevent="validate"> <form @submit.prevent="validate">
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16"> <div class="grid grid-cols-2 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"
@@ -12,33 +13,30 @@
}))" }))"
:loading="isLoadingUsers" :loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2" wrapper-class="col-start-1 row-start-2"
required
/> />
<!-- Date de l'éxpedition -->
<UiDateInput <UiDateInput
id="shipment-date" id="shipment-date"
v-model="form.shipmentDate" v-model="form.shipmentDate"
label="Date du jour" label="Date du jour"
wrapper-class="col-start-1 row-start-3" wrapper-class="col-start-1 row-start-3"
required
/> />
<!-- Type d'expédition -->
<div class="col-start-1 row-start-4 h-[64px]"> <div class="col-start-1 row-start-4 h-[64px]">
<div class="flex w-full items-end gap-[104px]"> <div class="flex items-end gap-8 justify-between">
<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"
@@ -47,6 +45,7 @@
/> />
</div> </div>
</div> </div>
<!-- Client -->
<UiSelect <UiSelect
id="shipment-customer" id="shipment-customer"
v-model="form.customerId" v-model="form.customerId"
@@ -57,17 +56,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="addressOptions" :options="customerAddressOptions"
:disabled="isLoadingCustomers || ownerAddresses.length === 0" :disabled="isLoadingCustomers || customerAddresses.length === 0"
label="Adresse" label="Adresse"
wrapper-class="col-start-2 row-start-1" wrapper-class="col-start-2 row-start-1"
required
/> />
<!-- Camion -->
<UiSelect <UiSelect
id="shipment-truck" id="shipment-truck"
v-model="form.truckId" v-model="form.truckId"
@@ -78,8 +77,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"
@@ -89,15 +88,15 @@
label: carrier.name label: carrier.name
}))" }))"
wrapper-class="col-start-2 row-start-3" wrapper-class="col-start-2 row-start-3"
required
/> />
<!-- Plaque d'immatriculation (hors LIOT) -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4"> <div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput <UiLicensePlateInput
v-model="form.licensePlate" v-model="form.licencePlate"
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"
@@ -110,8 +109,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"
@@ -123,36 +122,73 @@
:loading="isLoadingDrivers" :loading="isLoadingDrivers"
wrapper-class="col-start-2 row-start-5" wrapper-class="col-start-2 row-start-5"
v-if="isLiotCarrier" v-if="isLiotCarrier"
required
/> />
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<UiButton <UiButton
type="submit" type="submit"
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end" class="text-xl 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'
const router = useRouter() import type {UserData} from '~/services/dto/user-data'
const shipmentStore = useShipmentStore() import type {CustomerData} from '~/services/dto/customer-data'
import type {TruckData} from '~/services/dto/truck-data'
import type {CarrierData} from '~/services/dto/carrier-data'
import type {DriverData} from '~/services/dto/driver-data'
import type {VehicleData} from '~/services/dto/vehicle-data'
import type {AddressData} from '~/services/dto/address-data'
import {getUsers} from '~/services/auth'
import {getCustomerList} from '~/services/customer'
import {getTruckList} from '~/services/truck'
import {getCarrierList} from '~/services/carrier'
import {getVehicleList} from '~/services/vehicle'
import {getDriverList} from '~/services/driver'
import type {ShipmentFormData} from '~/services/dto/shipment-data'
import {SUPPLIER_CODE} from "~/utils/constants"
import {useAuthStore} from '~/stores/auth'
import {useShipmentStore} from '~/stores/shipment'
import { computed, reactive, ref, watch, onMounted } from 'vue'
import type {ShipmentTypeData} from "~/services/dto/shipment-type-data";
import {getShipmentTypeList} from "~/services/shipment-type";
import {
createShipmentBovine,
deleteShipmentBovine,
getBovinShipmentList,
updateShipmentBovine
} from "~/services/bovin-shipment";
const users = ref<UserData[]>([])
const customers = ref<CustomerData[]>([])
const trucks = ref<TruckData[]>([])
const carriers = ref<CarrierData[]>([])
const drivers = ref<DriverData[]>([])
const vehicles = ref<VehicleData[]>([])
const isLoadingUsers = ref(false)
const isLoadingShipmentTypes = ref(false)
const isLoadingCustomers = ref(false)
const isLoadingTrucks = ref(false)
const isLoadingCarriers = ref(false)
const isHydrating = ref(false) const isHydrating = ref(false)
const submitted = ref(false) const isLoadingVehicles = ref(false)
const formRef = ref<HTMLFormElement | null>(null) const allowAnyLicensePlate = ref(false)
const isLoadingDrivers = ref(false)
const authStore = useAuthStore()
const shipmentStore = useShipmentStore()
const router = useRouter()
const bovineShipment = ref<ShipmentTypeData[]>([])
const selectedShipmentTypeId = ref('')
const shipmentQuantity = ref<number | null>(0)
// Transporteur sélectionné dans le formulaire
const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
const form = reactive<ShipmentFormData>({ const form = reactive<ShipmentFormData>({
userId: '', userId: '',
@@ -163,26 +199,60 @@ const form = reactive<ShipmentFormData>({
carrierId: '', carrierId: '',
driverId: '', driverId: '',
vehicleId: '', vehicleId: '',
licensePlate: '', licencePlate: '',
}) })
// Adresses liées au client sélectionné
const customerAddresses = computed<AddressData[]>(() => {
const customerId = Number(form.customerId)
if (!Number.isFinite(customerId)) {
return []
}
return customers.value.find((customer) => customer.id === customerId)?.addresses ?? []
})
// Options pour le select des adresses du client
const customerAddressOptions = computed(() =>
customerAddresses.value
.map((address) => ({
value: String(address.id),
label: address.fullAddress
}))
)
// Chauffeurs liés au transporteur sélectionné (LIOT)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) {
return []
}
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
// Véhicules liés au transporteur + camion sélectionnés (LIOT)
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) {
return []
}
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
// Chargement des données pour les selects
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
const customers = ref<CustomerData[]>([]) const loadShipmentType = async () => {
const isLoadingCustomers = ref(false) isLoadingShipmentTypes.value = true
const bovineShipment = ref<ShipmentTypeData[]>([]) try {
const selectedShipmentTypeId = ref('') bovineShipment.value = await getShipmentTypeList()
const shipmentQuantity = ref<number | null>(0) } finally {
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
@@ -191,53 +261,283 @@ const loadCustomers = async () => {
} finally { } finally {
isLoadingCustomers.value = false isLoadingCustomers.value = false
} }
}
}
const loadTrucks = async () => {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
// On met le user connecté par défaut dans le select
const setDefaultUser = () => {
if (form.userId) {
return
}
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
// Chargement initial des données
onMounted(async () => {
await loadShipmentType()
await loadUsers()
await loadCustomers()
await loadTrucks()
await loadCarriers()
await loadVehicles()
await loadDrivers()
await authStore.ensureSession()
setDefaultUser()
})
// Hydrate le formulaire depuis l'expédition en cours
watch( watch(
() => shipmentStore.current, () => shipmentStore.current,
(shipment) => { (shipment) => {
isHydrating.value = true isHydrating.value = true
form.licensePlate = shipment?.licensePlate ?? '' form.licencePlate = shipment?.licencePlate ?? ''
form.shipmentDate = shipment?.shipmentDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10) form.shipmentDate = shipment?.shipmentDate ?? new Date().toISOString().slice(0, 10)
form.userId = shipment?.user?.id ? String(shipment.user.id) : form.userId form.userId = shipment?.user?.id ? String(shipment.user.id) :
form.customerId = shipment?.customer?.id ? String(shipment.customer.id) : '' form.userId
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) : '' if (!shipment || !shipment.bovinShipments) {
shipmentQuantity.value = shipment?.nbBovinSend ?? 0 selectedShipmentTypeId.value = ''
shipmentQuantity.value = 0
} else {
const selectedEntry = shipment.bovinShipments.find((entry) => {
const typeId = entry.shipmentType?.id
return Boolean(typeId) && Number(entry.nbBovinSend ?? 0) > 0
}) ?? shipment.bovinShipments.find((entry) => Boolean(entry.shipmentType?.id))
if (!selectedEntry?.shipmentType?.id) {
selectedShipmentTypeId.value = ''
shipmentQuantity.value = 0
} else {
selectedShipmentTypeId.value = String(selectedEntry.shipmentType.id)
shipmentQuantity.value = selectedEntry.nbBovinSend ?? 0
}
}
isHydrating.value = false isHydrating.value = false
}, },
{immediate: true} {immediate: true}
) )
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
// Extra watcher for LIOT defaults after hydration
watch( watch(
() => isHydrating.value, () => [form.customerId, form.addressId, customers.value],
(value) => { () => {
if (!value && isLiotCarrier.value) { if (!form.customerId) {
if (filteredDrivers.value.length === 1 && !form.driverId) { 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) form.driverId = String(filteredDrivers.value[0].id)
} }
if (filteredVehicles.value.length === 1 && !form.vehicleId) { if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id) form.vehicleId = String(filteredVehicles.value[0].id)
} }
} }
watch(
() => form.carrierId,
() => {
applyLiotDefaults()
},
{immediate: true}
)
watch(
() => isHydrating.value,
(value) => {
if (!value) {
applyLiotDefaults()
}
} }
) )
// Récupère la plaque depuis le véhicule choisi (LIOT)
watch(
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
return
}
if (!form.vehicleId) {
return
}
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
}
},
{immediate: true}
)
// Auto-renseigne le véhicule si la plaque correspond (LIOT)
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (isHydrating.value) {
return
}
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licencePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
watch(
() => [form.licencePlate, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) {
return
}
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licencePlate
)
if (match) {
form.vehicleId = String(match.id)
}
}
)
const buildDesiredBovinShipments = () => {
const typeId = Number(selectedShipmentTypeId.value)
if (!Number.isFinite(typeId)) {
return []
}
const type = bovineShipment.value.find((entry) => entry.id === typeId)
if (!type) {
return []
}
const raw = shipmentQuantity.value
const quantity = raw === null || raw === undefined ? 0 : Number(raw)
const normalizedQuantity = Number.isFinite(quantity) ? Math.max(0, Math.trunc(quantity)) : 0
if (normalizedQuantity <= 0) {
return []
}
onMounted(async () => { return [{type, quantity: normalizedQuantity}]
bovineShipment.value = await getShipmentTypeList() }
await loadCustomers() const syncBovinShipments = async (
await loadCommonData() shipmentId: number,
await loadVehicles() existing: Array<{ id?: number; nbBovinSend: number | null; shipmentType?: unknown }> = []
await loadDrivers() ) => {
const shipmentIri = `/api/shipments/${shipmentId}`
const desired = buildDesiredBovinShipments()
const desiredByTypeId = new Map<number, number>()
for (const entry of desired) {
desiredByTypeId.set(entry.type.id, entry.quantity)
}
for (const entry of existing) {
if (!entry.id) {
continue
}
const rawType = entry.shipmentType
let typeId: number | null = null
if (rawType && typeof rawType === 'object' && 'id' in rawType) {
typeId = Number((rawType as { id: number }).id)
} else if (typeof rawType === 'string') {
const match = rawType.match(/\/shipment_types\/(\\d+)$/)
typeId = match ? Number(match[1]) : null
}
if (!typeId) {
continue
}
const desiredQuantity = desiredByTypeId.get(typeId)
if (!desiredQuantity) {
await deleteShipmentBovine(entry.id)
continue
}
if (entry.nbBovinSend !== desiredQuantity) {
await updateShipmentBovine(entry.id, {nbBovinSend: desiredQuantity})
}
desiredByTypeId.delete(typeId)
}
for (const [typeId, quantity] of desiredByTypeId.entries()) {
await createShipmentBovine({
shipment: shipmentIri,
shipmentType: `/api/shipment_types/${typeId}`,
nbBovinSend: quantity
}) })
}
}
const buildPayload = () => { const buildPayload = () => {
const normalizedLicensePlate = form.licensePlate.trim() const normalizedLicensePlate = form.licencePlate.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()
@@ -245,55 +545,62 @@ 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
const customerIri = normalizedCustomerId ? `/api/customers/${normalizedCustomerId}` : null ? `/api/customers/${normalizedCustomerId}`
const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null : null
const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null const truckIri = normalizedTruckId
const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null ? `/api/trucks/${normalizedTruckId}`
const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null : null
const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null const carrierIri = normalizedCarrierId
const normalizedShipmentTypeId = selectedShipmentTypeId.value.trim() ? `/api/carriers/${normalizedCarrierId}`
const shipmentTypeIri = normalizedShipmentTypeId ? `/api/shipment_types/${normalizedShipmentTypeId}` : null : null
const userIri = normalizedUserId
const rawQuantity = Number(shipmentQuantity.value ?? 0) ? `/api/users/${normalizedUserId}`
const normalizedQuantity = Number.isFinite(rawQuantity) ? Math.max(0, Math.trunc(rawQuantity)) : 0 : null
const driverIri = normalizedDriverId
? `/api/drivers/${normalizedDriverId}`
: null
const addressIri = normalizedAddressId
? `/api/addresses/${normalizedAddressId}`
: null
return { return {
licensePlate: normalizedLicensePlate, licencePlate: normalizedLicensePlate,
shipmentDate: normalizedShipmentDate, shipmentDate: normalizedShipmentDate,
customer: customerIri, customer: customerIri,
truck: truckIri, truck: truckIri,
carrier: carrierIri, carrier: carrierIri,
driver: driverIri, driver: driverIri,
user: userIri, user: userIri,
address: addressIri, address: addressIri
shipmentType: shipmentTypeIri,
nbBovinSend: normalizedQuantity,
} }
} }
const saveDraft = async () => { const saveDraft = async () => {
const payload = buildPayload() const payload = buildPayload()
if (!shipmentStore.current) { if (!shipmentStore.current) {
await shipmentStore.createShipment({ const created = await shipmentStore.createShipment({
currentStep: 0, currentStep: 0,
...payload ...payload
}) })
if (created) {
await syncBovinShipments(created.id, [])
}
return return
} }
await shipmentStore.updateShipment(shipmentStore.current.id, { await shipmentStore.updateShipment(shipmentStore.current.id, {
currentStep: shipmentStore.current.currentStep, currentStep: shipmentStore.current.currentStep,
...payload ...payload
}) })
await syncBovinShipments(
shipmentStore.current.id,
shipmentStore.current?.bovinShipments ?? []
)
} }
const validateFields = () => { defineExpose({saveDraft})
submitted.value = true // Valide le formulaire et crée/met à jour l'expédition
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) {
@@ -303,6 +610,7 @@ const validate = async () => {
}) })
if (created) { if (created) {
await shipmentStore.loadShipment(created.id) await shipmentStore.loadShipment(created.id)
await syncBovinShipments(created.id, shipmentStore.current?.bovinShipments ?? [])
await router.push(`/shipment/${created.id}`) await router.push(`/shipment/${created.id}`)
} }
return return
@@ -313,5 +621,6 @@ const validate = async () => {
...payload ...payload
}) })
await shipmentStore.loadShipment(shipmentStore.current.id) await shipmentStore.loadShipment(shipmentStore.current.id)
await syncBovinShipments(shipmentStore.current.id, shipmentStore.current?.bovinShipments ?? [])
} }
</script> </script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex flex-col items-center gap-[150px]"> <div class="flex flex-col items-center gap-[118px]">
<h1 class="font-bold text-5xl uppercase text-primary-500">Chargement des bovins</h1> <h1 class="font-bold text-5xl uppercase text-primary-500">Charment 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">
<UiLoadingDots /> <UiLoadingDots />

View File

@@ -0,0 +1,101 @@
<template>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase 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>

View File

@@ -3,7 +3,7 @@
:is="'button'" :is="'button'"
:type="type" :type="type"
:disabled="isDisabled" :disabled="isDisabled"
class="inline-flex min-w-[194px] items-center justify-center rounded-md" class="inline-flex 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

View File

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

View File

@@ -1,238 +1,436 @@
<template> <template>
<div class="w-full"> <div class="mt-6 mx-[6px]">
<div class="relative border border-slate-200"> <table class="w-full border border-slate-300 table-fixed">
<div <thead class="bg-slate-100 capitalize tracking-wide">
class="grid items-center gap-6 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide" <tr>
:style="{ gridTemplateColumns: gridCols }" <th
v-for="column in normalizedColumns"
:key="column.key"
class="border border-slate-300 px-2 py-1"
> >
<div v-for="col in columns" :key="col.key" class="min-w-0"> <div class="flex flex-col gap-1">
<slot :name="`header-${col.key}`" :column="col">{{ col.label }}</slot> <UiSelect
</div> v-if="column.isSearchable && column.type === 'selectTypeReception'"
<div v-if="showActions" class="min-w-0"> v-model="searchValues[column.key]"
<slot name="header-actions">Actions</slot> :placeholder="column.label"
</div> select-class="w-full !text-sm !py-1"
</div> :options="[
{ value: '__all__', label: 'Tous' },
<div :class="dimRows ? 'opacity-50 transition-opacity' : ''" :aria-busy="loading || undefined"> ...receptionTypes.map((type) => ({
<template v-if="paginatedItems.length"> value: type.label,
<div label: type.label
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" <UiSelect
:tabindex="rowClickable ? 0 : undefined" v-else-if="column.isSearchable && column.type === 'selectTypeShipment'"
@click="onRowClick(item)" v-model="searchValues[column.key]"
@keydown.enter="onRowClick(item)" :placeholder="column.label"
@keydown.space.prevent="onRowClick(item)" select-class="w-full !text-sm !py-1"
:options="[
{ value: '__all__', label: 'Tous' },
...shipmentTypes.map((type) => ({
value: type.label,
label: type.label
}))
]"
/>
<div v-else-if="column.isSearchable" class="relative">
<UiTextInput
v-model="searchValues[column.key]"
:placeholder="column.label"
input-class="min-w-full !text-sm !py-1 !pr-7"
/>
<Icon
name="gg:search"
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-slate-400"
/>
</div>
<span v-else>{{ column.label }}</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td
class="border border-slate-300 px-2 py-2 whitespace-pre-line"
:colspan="normalizedColumns.length || 1"
> >
<div v-for="col in columns" :key="col.key" class="min-w-0 truncate"> Chargement...
<slot :name="`cell-${col.key}`" :item="item" :column="col"> </td>
{{ getNestedValue(item, col.key) }} </tr>
</slot> <tr v-else-if="displayedRows.length === 0">
</div> <td
<div v-if="showActions" @click.stop> class="border border-slate-300 px-3 py-2 text-left text-slate-500"
<slot name="actions" :item="item" /> :colspan="normalizedColumns.length || 1"
</div> >
</div> Aucune donnée
</td>
</tr>
<template v-else>
<tr
v-for="(row, rowIndex) in displayedRows"
class="hover:bg-primary-500 hover:bg-opacity-15"
:key="rowIndex"
:class="props.rowClickable ? 'cursor-pointer' : ''"
@click="props.rowClickable ? onRowClick(row) : null"
>
<td
v-for="column in normalizedColumns"
:key="column.key"
class="border border-slate-300 px-2 py-2 whitespace-pre-line "
>
{{ formatColumnValue(row, column) }}
</td>
</tr>
</template> </template>
<div </tbody>
v-else-if="loading" </table>
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 <div class="flex items-center justify-between mt-4">
v-if="dimRows" <p class="text-slate-600">
class="pointer-events-none absolute inset-0 flex items-center justify-center" {{ pageLabel }}
role="status" </p>
aria-live="polite" <div class="flex items-center gap-2">
>
<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&nbsp;:</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 <button
type="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" class="rounded border border-slate-300 px-2 py-1 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="currentPage <= 1" :disabled="currentPage <= 1 || loading"
aria-label="Page précédente" @click="currentPage = currentPage - 1"
@click="goToPage(currentPage - 1)"
> >
Précédent Précédent
</button> </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 <button
v-else v-for="(item, index) in paginationItems"
:key="`${item}-${index}`"
type="button" type="button"
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors" class="min-w-9 rounded border px-2 py-1"
:class="entry === currentPage :class="item === currentPage
? 'bg-primary-500 font-semibold text-white' ? 'border-primary-500 bg-primary-500 text-white'
: 'text-slate-700 hover:bg-slate-100'" : 'border-slate-300'"
:aria-current="entry === currentPage ? 'page' : undefined" :disabled="loading || item === '...'"
@click="goToPage(entry)" @click="typeof item === 'number' ? (currentPage = item) : null"
> >
{{ entry }} {{ item }}
</button> </button>
</template>
<button <button
type="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" class="rounded border border-slate-300 px-2 py-1 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="currentPage >= totalPages" :disabled="currentPage >= totalPages || loading"
aria-label="Page suivante" @click="currentPage = currentPage + 1"
@click="goToPage(currentPage + 1)"
> >
Suivant Suivant
</button> </button>
</nav> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts" generic="T extends Record<string, any>"> <script setup lang="ts">
import { computed, useId } from 'vue' import {Row, ColumnConfig, AnyCollection, PaginationItem} from '~/services/dto/datatable-data'
import {useApi} from '~/composables/useApi'
interface Column { import type {ReceptionTypeData} from '~/services/dto/reception-type-data'
key: string import {getReceptionTypeList} from '~/services/reception-type'
label: string import type {ShipmentTypeData} from "~/services/dto/shipment-data";
width?: string import {getShipmentTypeList} from "~/services/shipment-type";
}
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 api = useApi()
const receptionTypes = ref<ReceptionTypeData[]>([])
const shipmentTypes = ref<ShipmentTypeData[]>([])
const loading = ref(false)
const currentPage = ref(1)
const rows = ref<Row[]>([])
const total = ref(0)
const searchValues = reactive<Record<string, string>>({})
const isNestedMode = computed(() => Boolean(props.responsePath))
const effectiveTotal = computed(() => total.value)
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:page', value: number): void rowClick: [row: Row]
(e: 'update:perPage', value: number): void
(e: 'row-click', item: T): void
}>() }>()
const perPageId = useId() const props = withDefaults(defineProps<{
url?: string
responsePath?: string
columns?: ColumnConfig[]
query?: Record<string, unknown>
itemsPerPage?: number
rowClickable?: boolean
}>(), {
url: '',
responsePath: '',
columns: () => [],
query: () => ({}),
itemsPerPage: 10,
rowClickable: true
})
const displayedRows = computed<Row[]>(() => {
if (!isNestedMode.value) return rows.value
const currentPage = computed(() => props.page) const startIndex = (currentPage.value - 1) * props.itemsPerPage
const currentPerPage = computed(() => props.perPage) const endIndex = startIndex + props.itemsPerPage
return rows.value.slice(startIndex, endIndex)
})
onMounted(async () => {
receptionTypes.value = await getReceptionTypeList()
shipmentTypes.value = await getShipmentTypeList()
const isServerSide = computed(() => props.totalItems !== undefined) })
const total = computed(() => props.totalItems ?? props.items.length) const normalizedColumns = computed(() => {
if (props.columns.length > 0) {
return props.columns.map((column) => ({
key: column.key,
label: column.label ?? column.key,
format: column.format,
isSearchable: column.isSearchable ?? false,
type: column.type
}))
}
const totalPages = computed(() => if (displayedRows.value.length === 0) {
Math.max(1, Math.ceil(total.value / currentPerPage.value)) return []
}
return Object.keys(displayedRows.value[0])
.filter((key) => !key.startsWith('@'))
.map((key) => ({
key,
label: key
}))
})
const totalPages = computed(() => Math.max(1, Math.ceil(effectiveTotal.value / props.itemsPerPage)))
function getVisiblePages(page: number, lastPage: number): number[] {
const candidates = new Set([1, page - 1, page, page + 1, lastPage])
return Array.from(candidates)
.filter((p) => p >= 1 && p <= lastPage)
.sort((a, b) => a - b)
}
function insertEllipses(sortedPages: number[]): PaginationItem[] {
const items: PaginationItem[] = []
for (let i = 0; i < sortedPages.length; i++) {
const current = sortedPages[i]
const previous = sortedPages[i - 1]
if (previous != null && current - previous > 1) {
items.push('...')
}
items.push(current)
}
return items
}
const paginationItems = computed<PaginationItem[]>(() => {
const pages = getVisiblePages(currentPage.value, totalPages.value)
return insertEllipses(pages)
})
const pageLabel = computed(() => {
if (!effectiveTotal.value) return '0 résultat'
const start = (currentPage.value - 1) * props.itemsPerPage + 1
const end = Math.min(currentPage.value * props.itemsPerPage, effectiveTotal.value)
return `${start}-${end} sur ${effectiveTotal.value}`
})
watch(
() => [props.url, props.itemsPerPage, JSON.stringify(props.query ?? {}), props.responsePath],
async () => {
if (currentPage.value !== 1) {
currentPage.value = 1
if (!isNestedMode.value) return
}
await loadPage()
},
{immediate: true}
) )
const paginatedItems = computed(() => { let timeout: ReturnType<typeof setTimeout>
if (isServerSide.value) return props.items
const start = (currentPage.value - 1) * currentPerPage.value watch(
return props.items.slice(start, start + currentPerPage.value) () => ({...searchValues}),
() => {
clearTimeout(timeout)
timeout = setTimeout(() => {
currentPage.value = 1
if (!isNestedMode.value) loadPage()
}, 750)
},
{deep: true}
)
watch(
() => currentPage.value,
async () => {
if (isNestedMode.value) return
await loadPage()
}
)
watch(
() => [totalPages.value, currentPage.value],
() => {
if (currentPage.value > totalPages.value) {
currentPage.value = totalPages.value
}
},
{immediate: true}
)
function buildDateInterval(value: string): { after: string; before: string } | null {
const trimmed = value.trim()
// YYYY
if (/^\d{4}$/.test(trimmed)) {
const year = Number(trimmed)
return {
after: `${year}-01-01`,
before: `${year + 1}-01-01`
}
}
// YYYY-MM
if (/^\d{4}-\d{2}$/.test(trimmed)) {
const [year, month] = trimmed.split('-').map(Number)
const nextMonth = month === 12 ? 1 : month + 1
const nextYear = month === 12 ? year + 1 : year
return {
after: `${year}-${String(month).padStart(2, '0')}-01`,
before: `${nextYear}-${String(nextMonth).padStart(2, '0')}-01`
}
}
// YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const date = new Date(`${trimmed}T00:00:00`)
const nextDay = new Date(date)
nextDay.setDate(date.getDate() + 1)
const yyyy = nextDay.getFullYear()
const mm = String(nextDay.getMonth() + 1).padStart(2, '0')
const dd = String(nextDay.getDate()).padStart(2, '0')
return {
after: trimmed,
before: `${yyyy}-${mm}-${dd}`
}
}
return null
}
// Construit la requête, charge les données et normalise la réponse, puis met à jour rows et total
async function loadPage(): Promise<void> {
if (!props.url) {
rows.value = []
total.value = 0
return
}
loading.value = true
try {
if (isNestedMode.value) {
const response = await api.get<Row>(props.url, props.query, {
headers: {
Accept: 'application/ld+json'
}
})
const nestedRows = readPath(response, props.responsePath)
rows.value = Array.isArray(nestedRows) ? nestedRows as Row[] : []
total.value = rows.value.length
return
}
const searchQuery: Record<string, string> = {}
for (const column of normalizedColumns.value) {
if (!column.isSearchable) continue
const rawValue = searchValues[column.key] ?? ''
const raw = rawValue === '__all__' ? '' : rawValue.trim()
if (!raw) continue
const paramBase = column.key
if (column.type === 'date') {
const interval = buildDateInterval(raw)
if (interval) {
searchQuery[`${paramBase}[after]`] = interval.after
searchQuery[`${paramBase}[before]`] = interval.before
}
continue
}
searchQuery[paramBase] = raw
}
const requestQuery: Record<string, unknown> = {
...props.query,
...searchQuery,
page: currentPage.value,
itemsPerPage: props.itemsPerPage,
}
const response = await api.get<AnyCollection<Row> | Row[]>(props.url, requestQuery, {
headers: {
Accept: 'application/ld+json'
}
}) })
const gridCols = computed(() => { if (Array.isArray(response)) {
const dataCols = props.columns.map(c => c.width ?? '1fr').join(' ') rows.value = response
return props.showActions ? `${dataCols} 60px` : dataCols total.value = response.length
}) return
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 | '...')[] = [] const mappedRows = response['hydra:member'] ?? response.member ?? response.items ?? []
pages.push(1) rows.value = Array.isArray(mappedRows) ? mappedRows : []
total.value = Number(response['hydra:totalItems'] ?? response.totalItems ?? rows.value.length)
if (cp > 3) pages.push('...') } finally {
loading.value = false
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) => { function onRowClick(row: Row): void {
emit('update:perPage', Number(value)) emit('rowClick', row)
emit('update:page', 1)
} }
const onRowClick = (item: T) => { // Lit une valeur imbriquée dans une ligne à partir d'un chemin de type "objet.sousObjet.cle".
if (!props.rowClickable) return function readPath(source: Row, path: string): unknown {
emit('row-click', item) return path.split('.').reduce<unknown>((acc, key) => (acc as Row | undefined)?.[key], source)
} }
const getNestedValue = (obj: any, path: string): string => { // Formate une valeur brute pour l'affichage dans une cellule (vide, tableau, objet ou valeur simple).
const value = path.split('.').reduce((acc, key) => acc?.[key], obj) function formatCell(value: unknown): string {
return value ?? '' if (value == null || value === '') return '-'
if (Array.isArray(value)) return value.length ? value.map(formatCell).join(', ') : '-'
if (typeof value === 'object') {
const objectValue = value as Row
return String(objectValue.label ?? objectValue.name ?? objectValue.code ?? objectValue.id ?? '[object]')
}
return String(value)
}
function formatColumnValue(
row: Row,
column: { key: string; format?: (value: unknown, row: Row) => string }
): string {
const value = readPath(row, column.key)
if (column.format) {
return column.format(value, row)
}
return formatCell(value)
} }
</script> </script>

View File

@@ -3,7 +3,7 @@
<label <label
v-if="label" v-if="label"
:for="id" :for="id"
class="font-bold uppercase text-xl text-primary-700" class="font-bold uppercase text-xl text-primary-500"
:class="labelClass" :class="labelClass"
> >
{{ label }} {{ label }}
@@ -14,10 +14,9 @@
:value="modelValue ?? ''" :value="modelValue ?? ''"
:disabled="disabled" :disabled="disabled"
v-bind="attrs" v-bind="attrs"
class="w-full min-w-0 border-b border-primary-700 justify-self-start text-primary-700 bg-transparent appearance-none" class="border-b border-black justify-self-start text-xl text-primary-500 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
:class="[ :class="[
sizeClass, isEmpty ? 'text-neutral-400' : 'text-black',
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-pointer', disabled ? 'cursor-not-allowed' : 'cursor-pointer',
inputClass inputClass
]" ]"
@@ -37,14 +36,12 @@ 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: ''
@@ -57,11 +54,6 @@ 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

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
// flex row passer en class wraper class flex col ainsi que le wfull 34
<template> <template>
<div :class="['flex', wrapperClass]"> <div :class="['flex flex-row items-center gap-2', wrapperClass]">
<label <label
v-if="label" v-if="label"
:for="id" :for="id"
class="text-xl flex items-center gap-2 text-primary-700" class="text-xl flex items-center gap-2 text-primary-500"
:class="labelClass" :class="labelClass"
> >
<span <span
@@ -26,7 +25,7 @@
:step="step" :step="step"
:disabled="disabled" :disabled="disabled"
v-bind="attrs" v-bind="attrs"
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] uppercase bg-transparent appearance-none h-[34px]" class="border-b border-black text-xl bg-transparent w-16 text-primary-500"
: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',

View File

@@ -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-700" class="font-bold uppercase text-xl text-primary-500"
: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-700" class="flex items-center gap-2 text-primary-500"
: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-primary-700/50 text-primary-700 focus:ring-primary-700" class="h-4 w-4 border-slate-300 text-primary-500 focus:ring-primary-500"
:class="[ :class="[
disabled ? 'cursor-not-allowed' : 'cursor-pointer', disabled ? 'cursor-not-allowed' : 'cursor-pointer',
inputClass inputClass

View File

@@ -3,7 +3,7 @@
<label <label
v-if="label" v-if="label"
:for="id" :for="id"
class="font-bold uppercase text-xl text-primary-700" class="font-bold uppercase text-xl text-primary-500"
:class="labelClass" :class="labelClass"
> >
{{ label }} {{ label }}
@@ -13,23 +13,22 @@
:value="modelValue ?? ''" :value="modelValue ?? ''"
:disabled="disabled || loading" :disabled="disabled || loading"
v-bind="attrs" v-bind="attrs"
class="w-full min-w-0 border-b border-primary-700 justify-self-start text-primary-700 bg-transparent" class="border-b border-black justify-self-start text-xl text-primary-500 py-[6px] bg-transparent"
:class="[ :class="[
sizeClass, isEmpty ? 'text-neutral-400' : 'text-black',
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="" class="text-neutral-400"> <option value="" disabled 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-primary-700" class="text-black"
> >
{{ option.label }} {{ option.label }}
</option> </option>
@@ -56,7 +55,6 @@ 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
@@ -65,7 +63,6 @@ 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: ''
@@ -80,11 +77,6 @@ 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

View File

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

View File

@@ -3,7 +3,7 @@
<label <label
v-if="label" v-if="label"
:for="id" :for="id"
class="font-bold uppercase text-xl text-primary-700" class="font-bold uppercase text-xl text-primary-500"
:class="labelClass" :class="labelClass"
> >
{{ label }} {{ label }}
@@ -16,10 +16,9 @@
:maxlength="maxlength" :maxlength="maxlength"
:disabled="disabled" :disabled="disabled"
v-bind="attrs" v-bind="attrs"
class="w-full min-w-0 border-b border-primary-700 bg-transparent" class="border-b border-black text-xl py-[6px] bg-transparent text-primary-500"
:class="[ :class="[
sizeClass, isEmpty ? 'text-neutral-400' : 'text-black',
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-text', disabled ? 'cursor-not-allowed' : 'cursor-text',
inputClass inputClass
]" ]"
@@ -41,7 +40,6 @@ 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
@@ -50,7 +48,6 @@ const props = withDefaults(
placeholder: '', placeholder: '',
maxlength: undefined, maxlength: undefined,
disabled: false, disabled: false,
size: 'default',
wrapperClass: '', wrapperClass: '',
labelClass: '', labelClass: '',
inputClass: '' inputClass: ''
@@ -63,11 +60,6 @@ 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

View File

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

View File

@@ -1,57 +0,0 @@
<template>
<template v-if="!isLiotCarrier">
<div :class="wrapperClass">
<UiLicensePlateInput
v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate"
/>
</div>
</template>
<template v-if="isLiotCarrier">
<UiSelect
:id="`${idPrefix}-vehicle`"
v-model="form.vehicleId"
label="Immatriculation"
:options="filteredVehicles.map((vehicle) => ({
value: String(vehicle.id),
label: vehicle.plate
}))"
:loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
:wrapper-class="wrapperClass"
/>
<UiSelect
:id="`${idPrefix}-driver`"
v-model="form.driverId"
label="Nom du chauffeur si LIOT"
:options="filteredDrivers.map((driver) => ({
value: String(driver.id),
label: driver.name
}))"
:loading="isLoadingDrivers"
:wrapper-class="driverWrapperClass"
/>
</template>
</template>
<script setup lang="ts">
import type { DriverData } from '~/services/dto/driver-data'
import type { VehicleData } from '~/services/dto/vehicle-data'
defineProps<{
idPrefix: string
form: { licensePlate: string; vehicleId: string; driverId: string }
isLiotCarrier: boolean
allowAnyLicensePlate: boolean
filteredVehicles: VehicleData[]
filteredDrivers: DriverData[]
isLoadingVehicles: boolean
isLoadingDrivers: boolean
wrapperClass?: string
driverWrapperClass?: string
}>()
defineEmits<{
'update:allowAnyLicensePlate': [value: boolean]
}>()
</script>

View File

@@ -1,72 +0,0 @@
<template>
<div class="flex items-center justify-between">
<div class="flex items-center gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
<h1 class="text-3xl font-bold uppercase text-primary-500">{{ title }}</h1>
</div>
</div>
<div class="px-[86px]">
<div class="mt-6 border border-slate-200 mb-16">
<div
class="grid gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
:style="{ gridTemplateColumns: gridCols }"
>
<div v-for="col in columns" :key="col.key">{{ col.label }}</div>
<div v-if="showActions">Actions</div>
</div>
<div
v-for="item in items"
:key="item.id"
class="grid gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
:style="{ gridTemplateColumns: gridCols }"
role="button"
tabindex="0"
@click="goToItem(item.id)"
@keydown.enter="goToItem(item.id)"
>
<div v-for="col in columns" :key="col.key">
<slot :name="`cell-${col.key}`" :item="item">
{{ getNestedValue(item, col.key) }}
</slot>
</div>
<div v-if="showActions" @click.stop>
<slot name="actions" :item="item" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Column {
key: string
label: string
}
const props = withDefaults(defineProps<{
title: string
columns: Column[]
items: any[]
routePrefix: string
showActions?: boolean
}>(), {
showActions: false
})
const router = useRouter()
const gridCols = computed(() => {
const dataCols = props.columns.map(() => '1fr').join(' ')
return props.showActions ? `${dataCols} 60px` : dataCols
})
const goToItem = (id: number) => {
router.push(`${props.routePrefix}/${id}`)
}
const getNestedValue = (obj: any, path: string): string => {
const value = path.split('.').reduce((acc, key) => acc?.[key], obj)
return value || '—'
}
</script>

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
import type { Ref } from 'vue'
import type { AddressData } from '~/services/dto/address-data'
interface AddressOwner {
id: number
addresses?: AddressData[]
}
export const useAddressSync = (
form: { addressId: string },
ownerId: Ref<string>,
owners: Ref<AddressOwner[]>
) => {
const ownerAddresses = computed<AddressData[]>(() => {
const id = Number(ownerId.value)
if (!Number.isFinite(id)) return []
return owners.value.find((owner) => owner.id === id)?.addresses ?? []
})
const addressOptions = computed(() =>
ownerAddresses.value.map((address) => ({
value: String(address.id),
label: address.fullAddress
}))
)
watch(
() => [ownerId.value, form.addressId, owners.value],
() => {
if (!ownerId.value) {
form.addressId = ''
return
}
if (!form.addressId && ownerAddresses.value.length === 1) {
form.addressId = String(ownerAddresses.value[0].id)
return
}
if (!form.addressId) return
const matches = ownerAddresses.value.some(
(address) => String(address.id) === form.addressId
)
if (!matches) {
if (ownerAddresses.value.length === 1) {
form.addressId = String(ownerAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{ immediate: true }
)
return { ownerAddresses, addressOptions }
}

View File

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

View File

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

View File

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

View File

@@ -1,73 +0,0 @@
import type { UserData } from '~/services/dto/user-data'
import type { TruckData } from '~/services/dto/truck-data'
import type { CarrierData } from '~/services/dto/carrier-data'
import { getUsers } from '~/services/auth'
import { getTruckList } from '~/services/truck'
import { getCarrierList } from '~/services/carrier'
import { useAuthStore } from '~/stores/auth'
export const useFormDataLoading = (form: { userId: string }) => {
const users = ref<UserData[]>([])
const trucks = ref<TruckData[]>([])
const carriers = ref<CarrierData[]>([])
const isLoadingUsers = ref(false)
const isLoadingTrucks = ref(false)
const isLoadingCarriers = ref(false)
const authStore = useAuthStore()
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
const loadTrucks = async () => {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
const setDefaultUser = () => {
if (form.userId) return
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
const loadCommonData = async () => {
await loadUsers()
await loadTrucks()
await loadCarriers()
await authStore.ensureSession()
setDefaultUser()
}
return {
users,
trucks,
carriers,
isLoadingUsers,
isLoadingTrucks,
isLoadingCarriers,
loadUsers,
loadTrucks,
loadCarriers,
setDefaultUser,
loadCommonData
}
}

View File

@@ -1,153 +0,0 @@
import type { Ref } from 'vue'
import type { CarrierData } from '~/services/dto/carrier-data'
import type { DriverData } from '~/services/dto/driver-data'
import type { VehicleData } from '~/services/dto/vehicle-data'
import { getDriverList } from '~/services/driver'
import { getVehicleList } from '~/services/vehicle'
import { SUPPLIER_CODE } from '~/utils/constants'
interface LiotForm {
carrierId: string
truckId: string
driverId: string
vehicleId: string
licensePlate: string
}
export const useLiotHandling = (
form: LiotForm,
carriers: Ref<CarrierData[]>,
isHydrating: Ref<boolean>
) => {
const drivers = ref<DriverData[]>([])
const vehicles = ref<VehicleData[]>([])
const isLoadingDrivers = ref(false)
const isLoadingVehicles = ref(false)
const allowAnyLicensePlate = ref(false)
const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) return []
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) return []
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
// Auto-select driver/vehicle when carrier changes
watch(
() => form.carrierId,
() => {
if (isHydrating.value) return
if (!form.carrierId) {
form.driverId = ''
form.vehicleId = ''
return
}
if (!isLiotCarrier.value) {
form.driverId = ''
form.vehicleId = ''
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
},
{ immediate: true }
)
// Validate/auto-select vehicle when truck/carrier changes
watch(
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) return
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
return
}
if (!form.vehicleId) return
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
}
},
{ immediate: true }
)
// Sync license plate from selected vehicle
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) return
if (isHydrating.value) return
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
// Auto-select vehicle from license plate
watch(
() => [form.licensePlate, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) return
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
form.vehicleId = String(match.id)
}
}
)
return {
drivers,
vehicles,
isLoadingDrivers,
isLoadingVehicles,
allowAnyLicensePlate,
isLiotCarrier,
filteredDrivers,
filteredVehicles,
loadDrivers,
loadVehicles
}
}

View File

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

View File

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

View File

@@ -1,25 +0,0 @@
import type { WorkflowConfig, WorkflowEntity } from '~/types/workflow'
export const receptionConfig: WorkflowConfig = {
entityName: 'reception',
apiResource: 'receptions',
steps: [
{ label: 'Réception' },
{ label: 'Pesée à plein', weighingMode: 'gross' },
{ label: 'Sélection réception' },
{ label: 'Pesée à vide', weighingMode: 'tare', isFinal: true }
],
weighingLabels: {
gross: 'Pesée à plein',
tare: 'Pesée à vide'
},
buildReceiptFilename: (entity: WorkflowEntity) => {
const rec = entity as any
return `${rec.identificationNumber ?? rec.id}_${rec.supplier?.name ?? 'fournisseur'}_${rec.licensePlate ?? 'immat'}.pdf`
},
routePrefix: '/reception',
toastPrefix: 'reception',
dateField: 'receptionDate'
}
export const RECEPTION_STEP_LABELS = receptionConfig.steps.map(s => s.label)

View File

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

View File

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

View File

@@ -12,7 +12,6 @@
"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": {
@@ -23,7 +22,6 @@
"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": {
@@ -84,13 +82,7 @@
"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.",
@@ -114,14 +106,10 @@
}, },
"success": { "success": {
"reception": { "reception": {
"create": "Réception créée avec succès", "update": "Réception mise à jour avec succès."
"update": "Réception mise à jour avec succès.",
"delete": "Réception supprimée avec succès."
}, },
"shipment": { "shipment": {
"create": "Éxpedition créée avec succès", "update": "Éxpedition mise à jour 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.",
@@ -145,13 +133,6 @@
"update": "Transporteur mis à jour", "update": "Transporteur mis à jour",
"create": "Transporteur créé" "create": "Transporteur créé"
}, },
"bovin": {
"update": "Type bovin mis à jour avec succès.",
"create": "Type bovin créé avec succès."
},
"bovine": {
"create": "Bovin enregistré avec succès."
},
"weight": { "weight": {
"update": "Pesée mis à jour" "update": "Pesée mis à jour"
} }

View File

@@ -38,23 +38,6 @@
</a> </a>
</NuxtLink> </NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/user/list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/user')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Utilisateurs
</a>
</NuxtLink>
<NuxtLink <NuxtLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
to="/admin/supplier/supplier-list" to="/admin/supplier/supplier-list"
@@ -72,23 +55,6 @@
</a> </a>
</NuxtLink> </NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/customer/customer-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/customer')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Clients
</a>
</NuxtLink>
<NuxtLink <NuxtLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
to="/admin/carrier/carrier-list" to="/admin/carrier/carrier-list"
@@ -108,7 +74,41 @@
<NuxtLink <NuxtLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
to="/admin/bovin/bovin-list" to="/admin/user/list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/user')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Utilisateurs
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/customer/customer-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/customer')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Clients
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/bovin/list"
custom custom
v-slot="{ href, navigate }" v-slot="{ href, navigate }"
> >
@@ -122,23 +122,6 @@
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) -->
@@ -151,8 +134,7 @@
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"
> >
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/> <span class="capitalize font-bold">{{ userDisplayName }}</span>
<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"/>
@@ -232,12 +214,9 @@
<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/bovin-list" @click="closeMenu"> <NuxtLink v-if="auth.isAdmin" to="/admin/bovin/list" @click="closeMenu">
Bovins Bovins
</NuxtLink> </NuxtLink>
<NuxtLink to="/scan" @click="closeMenu">
Scanner
</NuxtLink>
</nav> </nav>
<button <button
@@ -251,7 +230,7 @@
</aside> </aside>
</transition> </transition>
</header> </header>
<main class="md:mx-auto w-full md:max-w-[1280px] mt-4 md:mt-16"> <main class="mx-auto w-full max-w-[1280px] mt-16">
<slot/> <slot/>
</main> </main>
<footer class="w-full mt-auto bg-primary-500 px-6 py-3"> <footer class="w-full mt-auto bg-primary-500 px-6 py-3">

View File

@@ -1,27 +0,0 @@
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('/')
}
})

View File

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

View File

@@ -1,48 +1,34 @@
<template> <template>
<form :class="{ submitted }" @submit.prevent="validate"> <form @submit.prevent="validate">
<div class="flex items-center justify-between relative"> <div class="text-primary-500 flex items-center justify-between">
<div class="flex flex-row absolute -left-[60px]"> <h1 class="text-3xl font-bold uppercase">
<Icon {{ route.params.id ? 'Modifier bovin' : 'Ajout bovin' }}
@click="router.push('/admin/bovin/bovin-list')"
name="gg:arrow-left-o"
size="40"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ route.params.id ? 'Modifications du type bovin' : 'Ajout d\'un type bovin' }}
</h1> </h1>
</div>
<div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" required />
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" required />
</div>
<div class="flex justify-center items-center">
<UiButton <UiButton
type="submit" type="submit"
:disabled="isLoading || isHydrating" :disabled="isLoading || isHydrating"
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] hover:opacity-80"
@click="submitted = true"
> >
Valider <Icon name="mdi:check" size="28" />
{{ 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
@@ -51,6 +37,9 @@ 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: ''
@@ -103,6 +92,7 @@ async function validate() {
} else { } else {
await createBovin(basePayload) await createBovin(basePayload)
} }
await navigate()
} finally { } finally {
isLoading.value = false isLoading.value = false
} }

View File

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

View File

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

View File

@@ -1,51 +1,36 @@
<template> <template>
<form :class="{ submitted }" @submit.prevent="validate"> <form @submit.prevent="validate">
<div class="flex items-center justify-between relative"> <div class="flex items-center justify-between">
<div class="flex flex-row absolute -left-[60px]"> <h1 class="text-3xl font-bold uppercase">
<Icon {{ route.params.id ? 'Modifier transporteur' : 'Ajout transporteur' }}
@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>
</div>
<div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
<UiTextInput
label="Nom du transporteur"
id="carrier-name"
v-model="form.name"
required
/>
<UiTextInput
label="Code transporteur"
id="code-id"
v-model="form.code"
required
/>
</div>
<div class="flex justify-center items-center">
<UiButton <UiButton
type="submit" type="submit"
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="submitted = true" >Enregistrer
>
Valider
</UiButton> </UiButton>
</div> </div>
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 py-12">
<UiTextInput
label = "nom du fournisseur"
id="carrier-name"
v-model="form.name"
/>
<UiTextInput
label = "code fournisseur"
id="code-id"
v-model="form.code"
/>
</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";
@@ -54,7 +39,6 @@ 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
@@ -68,6 +52,10 @@ 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
@@ -107,10 +95,11 @@ async function validate() {
if(idCarrier.value){ if(idCarrier.value){
await updateCarrier(idCarrier.value, basePayload) await updateCarrier(idCarrier.value, basePayload)
navigate()
return return
}else{
await createCarrier(basePayload)
} }
await createCarrier(basePayload)
navigate()
} }
function navigate(){ function navigate(){

View File

@@ -1,63 +1,43 @@
<template> <template>
<div class="px-[86px]">
<div class="flex items-center justify-between "> <div class="flex items-center justify-between ">
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1> <h1 class="text-3xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
<NuxtLink <NuxtLink
to="/admin/carrier" to="/admin/carrier"
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="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded"
> >
<Icon name="mdi:plus" size="28"/> <Icon name="mdi:plus" size="28"/>
Ajouter Ajouter
</NuxtLink> </NuxtLink>
</div> </div>
<div class="mt-6 mb-16">
<UiDataTable <UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns" :columns="columns"
:items="items" url="carriers"
:total-items="totalItems" @row-click="onCarrierRowClick"
:loading="loading" />
row-clickable
@row-click="goToCarrier"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Label" size="compact" />
</template>
<template #header-code>
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
</template>
</UiDataTable>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ title: 'Transporteurs' }) import type {ColumnConfig, Row} from "~/services/dto/datatable-data";
import type { CarrierData } from '~/services/dto/carrier-data'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const router = useRouter() const router = useRouter()
const { items, totalItems, page, perPage, filters, loading, reload } = const columns: ColumnConfig[] = [
useDataTableServerState<CarrierData>( {key: "name", label: "Label"},
'carriers', {key: "code", label: "Code"},
{
name: '',
code: ''
}
)
const columns = [
{ key: 'name', label: 'Label' },
{ key: 'code', label: 'Code' }
] ]
const goToCarrier = (carrier: CarrierData) => { const goToCarrier = (id: number) => {
router.push(`/admin/carrier/${carrier.id}`) router.push(`/admin/carrier/${id}`)
} }
onMounted(reload) const onCarrierRowClick = (row: Row) => {
const id = Number(row.id)
if (!Number.isFinite(id)) return
goToCarrier(id)
}
definePageMeta({
layout: 'default'
})
</script> </script>

View File

@@ -1,110 +1,58 @@
<template> <template>
<form :class="{ submitted }" @submit.prevent="validate"> <form @submit.prevent="validate">
<div class="flex items-center relative"> <div class="flex items-center justify-between">
<div class="flex flex-row absolute -left-[60px] "> <h1 class="text-3xl font-bold uppercase">
<Icon @click="router.push('/admin/customer/customer-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/> {{ customerId ? "Modifications du client" : "Ajout d'un client" }}
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ customerId ? "Modification du client" : "Ajout d'un client" }}
</h1> </h1>
</div>
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput id="customer-name" v-model="form.name" label="Nom du client" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="customer-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
</div>
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-street" v-model="addressForm.street" label="Rue" wrapper-class="w-[280px]" required />
<UiTextInput id="address-street2" v-model="addressForm.street2" label="Complément" wrapper-class="w-[280px]" />
<UiTextInput id="address-country" v-model="addressForm.countryCode" label="Pays (code)" wrapper-class="w-[280px]" />
</div>
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-postalCode" v-model="addressForm.postalCode" label="Code postal" wrapper-class="w-[280px]" required />
<UiSelect id="address-city" v-model="addressForm.city" label="Ville"
:options="communeOptions" :loading="isLoadingCities"
wrapper-class="w-[280px]" required />
<div class="w-[280px]" />
</div>
<div class="flex items-center justify-center">
<UiButton <UiButton
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
type="submit" type="submit"
:disabled="isLoading || !auth.isAdmin" :disabled="isLoading || !auth.isAdmin"
@click="submitted = true"
> >
<Icon :name="customerId ? '' : 'mdi:plus'" size="28" /> {{ customerId ? "Sauvegarder" : "Ajouter" }}
{{ customerId ? "Valider" : "Ajouter" }}
</UiButton> </UiButton>
</div> </div>
<template v-if="customerId"> <div class="grid grid-cols-2 gap-y-8 gap-x-80 mb-10 py-12">
<div class="flex items-center justify-between mb-7"> <UiTextInput id="customer-name" v-model="form.name" label="Nom du client" :disabled="!auth.isAdmin"/>
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du client</h2> <UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin"/>
<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">
<table class="w-full border-collapse text-primary-700"> <div class="mx-24 mb-4 py-6 border-t border-black"></div>
<thead> <div class="flex items-center justify-between mb-4">
<tr class="text-left border bg-slate-100 border-gray-200"> <h2 class="text-3xl font-bold uppercase">Adresses client</h2>
<th class="py-3 px-4 text-sm uppercase">Rue</th>
<th class="py-3 px-4 text-sm uppercase">Complément</th>
<th class="py-3 px-4 text-sm uppercase">Code postal</th>
<th class="py-3 px-4 text-sm uppercase">Ville</th>
<th class="py-3 px-4 text-sm uppercase">Pays</th>
</tr>
</thead>
<tbody>
<template v-if="form.addresses.length === 0">
<tr>
<td colspan="5" class="py-4 text-slate-400">
Aucune adresse.
</td>
</tr>
</template>
<template v-else>
<tr
v-for="(address, index) in form.addresses"
:key="address.id ?? index"
class="border border-gray-100 hover:bg-slate-50"
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)"
>
<td class="py-3 px-4">{{ address.street || "—" }}</td>
<td class="py-3 px-4">{{ address.street2 || "—" }}</td>
<td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
<td class="py-3 px-4">{{ address.city || "—" }}</td>
<td class="py-3 px-4">{{ address.countryCode || "—" }}</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="flex justify-center items-center">
<UiButton <UiButton
type="button" 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" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="customerId === null || !auth.isAdmin" :disabled="customerId === null || !auth.isAdmin"
@click="goToAddAddress" @click="goToAddAddress"
> >
<Icon name="mdi:plus" size="28" />
Ajouter Ajouter
</UiButton> </UiButton>
</div> </div>
</template> <UiDataTable
class="mb-10"
:columns="addressColumns"
:url="customerId !== null ? `customers/${customerId}` : ''"
response-path="addresses"
:items-per-page="5"
:row-clickable="auth.isAdmin"
@row-click="onAddressRowClick"
/>
</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 type {ColumnConfig, Row} from "~/services/dto/datatable-data"
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()
@@ -117,37 +65,20 @@ 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: "",
email: "", email: "",
addresses: [], addresses: [],
}) })
const addressColumns: ColumnConfig[] = [
// Address form (creation mode only) {key: "label", label: "Libellé"},
const addressForm = reactive<AddressPayload>({ {key: "street", label: "Rue"},
street: "", street2: null, postalCode: "", city: "", countryCode: "FR", {key: "street2", label: "Complément"},
}) {key: "postalCode", label: "Code postal"},
const communes = ref<CommuneData[]>([]) {key: "city", label: "Ville"},
const isLoadingCities = ref(false) {key: "countryCode", label: "Pays"},
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
@@ -170,28 +101,16 @@ const goToEditAddress = (addressId: number | null) => {
}) })
} }
const onAddressRowClick = (row: Row) => {
const id = Number(row.id)
goToEditAddress(Number.isFinite(id) ? id : null)
}
const hydrateFromCustomer = (customer: CustomerData | null) => { const hydrateFromCustomer = (customer: CustomerData | null) => {
if (!customer) return if (!customer) return
form.name = customer.name ?? "" form.name = customer.name ?? ""
form.phone = customer.phone ?? "" form.phone = customer.phone ?? ""
form.email = customer.email ?? "" form.email = customer.email ?? ""
if (!Array.isArray(customer.addresses) || customer.addresses.length === 0) {
form.addresses = []
return
}
if (typeof customer.addresses[0] === "string") {
form.addresses = []
return
}
form.addresses = customer.addresses.map((address) => ({
id: address.id ?? null,
street: address.street ?? "",
street2: address.street2 ?? null,
postalCode: address.postalCode ?? "",
city: address.city ?? "",
countryCode: address.countryCode ?? "",
}))
} }
watch( watch(
@@ -230,14 +149,7 @@ async function validate() {
await updateCustomer(customerId.value, customerPayload) await updateCustomer(customerId.value, customerPayload)
targetId = customerId.value targetId = customerId.value
} else { } else {
const addressData = await createAddress({ ...addressForm }) const created = await createCustomer(customerPayload)
const addressIRI = `/api/addresses/${addressData.id}`
const creationPayload = {
...customerPayload,
addresses: [addressIRI],
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
}
const created = await createCustomer(creationPayload)
targetId = created.id targetId = created.id
} }

View File

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

View File

@@ -1,82 +1,59 @@
<template> <template>
<div class="px-[86px]">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1> <h1 class="text-3xl 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 text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2" class="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded-md"
: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 mb-16">
<UiDataTable <UiDataTable
v-model:page="page" v-if="auth.isAdmin"
v-model:per-page="perPage"
:columns="columns" :columns="columns"
:items="items" url="customers"
:total-items="totalItems" @row-click="onCustomerRowClick"
:loading="loading" />
row-clickable
@row-click="goToCustomer"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
</template>
<template #header-phone>
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
</template>
<template #header-email>
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
</template>
<template #header-createdBy.username>
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
</template>
</UiDataTable>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400"> <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">
useHead({ title: 'Clients' }) import type { ColumnConfig, Row } from "~/services/dto/datatable-data"
import { formatAddresses } from "~/utils/datatable-formatters"
import { useAuthStore } from "~/stores/auth"
import type { CustomerData } from '~/services/dto/customer-data' definePageMeta({ layout: "default" })
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const { items, totalItems, page, perPage, filters, loading, reload } = const columns: ColumnConfig[] = [
useDataTableServerState<CustomerData>( { key: "name", label: "Nom", isSearchable:true},
'customers', { key: "phone", label: "Téléphone" },
{ { key: "email", label: "Email" },
name: '', { key: "addresses", label: "Adresses", format: formatAddresses },
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) => { const goToCustomer = (id: number) => {
if (!auth.isAdmin) return if (!auth.isAdmin) return
router.push(`/admin/customer/${customer.id}`) router.push(`/admin/customer/${id}`)
}
const onCustomerRowClick = (row: Row) => {
const id = Number(row.id)
if (!Number.isFinite(id)) return
goToCustomer(id)
}
const handleAddClick = (event: Event) => {
if (auth.isAdmin) return
event.preventDefault()
} }
onMounted(() => {
if (auth.isAdmin) reload()
})
</script> </script>

View File

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

View File

@@ -1,111 +1,58 @@
<template> <template>
<form :class="{ submitted }" @submit.prevent="validate"> <form @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] "> {{ supplierId ? "Modifications du fournisseur" : "Ajout d'un fournisseur" }}
<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> </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="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
type="submit" type="submit"
:disabled="isLoading || !auth.isAdmin" :disabled="isLoading || !auth.isAdmin"
@click="submitted = true"
> >
<Icon :name="supplierId ? '' : 'mdi:plus'" size="28" /> {{ supplierId ? "Sauvegarder" : "Ajouter" }}
{{ supplierId ? "Valider" : "Ajouter" }}
</UiButton> </UiButton>
</div> </div>
<template v-if="supplierId"> <div class="grid grid-cols-2 gap-y-8 gap-x-80 mb-10 py-12">
<div class="flex items-center justify-between mb-7"> <UiTextInput id="supplier-name" v-model="form.name" label="Nom du fournisseur" :disabled="!auth.isAdmin"/>
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du fournisseur</h2> <UiTextInput id="supplier-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin"/>
<UiTextInput id="supplier-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin"/>
</div> </div>
<div class="overflow-x-auto mb-11 text-primary-700">
<table class="w-full border-collapse"> <div class="mx-24 mb-4 py-6 border-t border-black"></div>
<thead> <div class="flex items-center justify-between mb-4">
<tr class="text-left border bg-slate-100 border-gray-200"> <h2 class="text-3xl font-bold uppercase">Adresses fournisseur</h2>
<th class="py-3 px-4 text-sm uppercase">Rue</th>
<th class="py-3 px-4 text-sm uppercase">Complément</th>
<th class="py-3 px-4 text-sm uppercase">Code postal</th>
<th class="py-3 px-4 text-sm uppercase">Ville</th>
<th class="py-3 px-4 text-sm uppercase">Pays</th>
</tr>
</thead>
<tbody>
<template v-if="form.addresses.length === 0">
<tr>
<td colspan="5" class="py-4 text-slate-400">
Aucune adresse.
</td>
</tr>
</template>
<template v-else>
<tr
v-for="(address, index) in form.addresses"
:key="address.id ?? index"
class="border border-gray-100 hover:bg-slate-50"
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)"
>
<td class="py-3 px-4">{{ address.street || "—" }}</td>
<td class="py-3 px-4">{{ address.street2 || "—" }}</td>
<td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
<td class="py-3 px-4">{{ address.city || "—" }}</td>
<td class="py-3 px-4">{{ address.countryCode || "—" }}</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="flex justify-center items-center">
<UiButton <UiButton
type="button" 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" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="supplierId === null || !auth.isAdmin" :disabled="supplierId === null || !auth.isAdmin"
@click="goToAddAddress" @click="goToAddAddress"
> >
<Icon name="mdi:plus" size="28" />
Ajouter Ajouter
</UiButton> </UiButton>
</div> </div>
</template> <UiDataTable
class="mb-10"
:columns="addressColumns"
:url="supplierId !== null ? `suppliers/${supplierId}` : ''"
response-path="addresses"
:items-per-page="5"
:row-clickable="auth.isAdmin"
@row-click="onAddressRowClick"
/>
</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 type {ColumnConfig, Row} from "~/services/dto/datatable-data"
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()
@@ -118,37 +65,20 @@ 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: "",
phone: "", phone: "",
addresses: [], addresses: [],
}) })
const addressColumns: ColumnConfig[] = [
// Address form (creation mode only) {key: "label", label: "Libellé"},
const addressForm = reactive<AddressPayload>({ {key: "street", label: "Rue"},
street: "", street2: null, postalCode: "", city: "", countryCode: "FR", {key: "street2", label: "Complément"},
}) {key: "postalCode", label: "Code postal"},
const communes = ref<CommuneData[]>([]) {key: "city", label: "Ville"},
const isLoadingCities = ref(false) {key: "countryCode", label: "Pays"},
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
@@ -173,28 +103,16 @@ const goToEditAddress = (addressId: number | null) => {
}) })
} }
const onAddressRowClick = (row: Row) => {
const id = Number(row.id)
goToEditAddress(Number.isFinite(id) ? id : null)
}
const hydrateFromSupplier = (supplier: SupplierData | null) => { const hydrateFromSupplier = (supplier: SupplierData | null) => {
if (!supplier) return if (!supplier) return
form.name = supplier.name ?? "" form.name = supplier.name ?? ""
form.email = supplier.email ?? "" form.email = supplier.email ?? ""
form.phone = supplier.phone ?? "" form.phone = supplier.phone ?? ""
if (!Array.isArray(supplier.addresses) || supplier.addresses.length === 0) {
form.addresses = []
return
}
if (typeof supplier.addresses[0] === "string") {
form.addresses = []
return
}
form.addresses = supplier.addresses.map((address) => ({
id: address.id ?? null,
street: address.street ?? "",
street2: address.street2 ?? null,
postalCode: address.postalCode ?? "",
city: address.city ?? "",
countryCode: address.countryCode ?? "",
}))
} }
watch( watch(
@@ -233,14 +151,7 @@ async function validate() {
await updateSupplier(supplierId.value, supplierPayload) await updateSupplier(supplierId.value, supplierPayload)
targetId = supplierId.value targetId = supplierId.value
} else { } else {
const addressData = await createAddress({ ...addressForm }) const created = await createSupplier(supplierPayload)
const addressIRI = `/api/addresses/${addressData.id}`
const creationPayload = {
...supplierPayload,
addresses: [addressIRI],
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
}
const created = await createSupplier(creationPayload)
targetId = created.id targetId = created.id
} }

View File

@@ -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,10 +18,13 @@ 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)
} }
} }

View File

@@ -1,82 +1,58 @@
<template> <template>
<div class="px-[86px]">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1> <h1 class="text-3xl font-bold uppercase 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 text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2" class="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded"
: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 mb-16">
<UiDataTable <UiDataTable
v-model:page="page" v-if="auth.isAdmin"
v-model:per-page="perPage"
:columns="columns" :columns="columns"
:items="items" url="suppliers"
:total-items="totalItems" @row-click="onSupplierRowClick"
:loading="loading" />
row-clickable
@row-click="goToSupplier"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
</template>
<template #header-phone>
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
</template>
<template #header-email>
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
</template>
<template #header-createdBy.username>
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
</template>
</UiDataTable>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400"> <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">
useHead({ title: 'Fournisseurs' }) import type { ColumnConfig, Row } from "~/services/dto/datatable-data"
import {formatAddresses} from "~/utils/datatable-formatters"
import { useAuthStore } from "~/stores/auth"
import type { SupplierData } from '~/services/dto/supplier-data' definePageMeta({ layout: "default" })
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const { items, totalItems, page, perPage, filters, loading, reload } = const columns: ColumnConfig[] = [
useDataTableServerState<SupplierData>( { key: "name", label: "Nom", isSearchable:true },
'suppliers', { key: "email", label: "Mail" },
{ { key: "addresses", label: "Adresses", format: formatAddresses },
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) => { const goToSupplier = (id: number) => {
if (!auth.isAdmin) return if (!auth.isAdmin) return
router.push(`/admin/supplier/${supplier.id}`) router.push(`/admin/supplier/${id}`)
}
const onSupplierRowClick = (row: Row) => {
const id = Number(row.id)
if (!Number.isFinite(id)) return
goToSupplier(id)
}
const handleAddClick = (event: Event) => {
if (auth.isAdmin) return
event.preventDefault()
} }
onMounted(() => {
if (auth.isAdmin) reload()
})
</script> </script>

View File

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

View File

@@ -1,110 +1,44 @@
<template> <template>
<div class="px-[86px]">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1> <h1 class="text-3xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
<NuxtLink <NuxtLink
v-if="auth.isAdmin" class="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded-md"
to="/admin/user" @click="router.push('/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 v-if="auth.isAdmin" class="mt-6 mb-16">
<UiDataTable <UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns" :columns="columns"
:items="items" url="admin/users"
:total-items="totalItems" @row-click="onUserRowClick"
:loading="loading"
row-clickable
@row-click="goToUser"
>
<template #header-username>
<UiTextInput
v-model="filters.username"
placeholder="Utilisateur"
size="compact"
/> />
</template> </template>
<template #header-roles>
<UiTextInput :model-value="''" placeholder="Role" size="compact" disabled />
</template>
<template #header-isLocked>
<UiSelect
v-model="filters.isLocked"
placeholder="Statut"
:options="statusOptions"
size="compact"
/>
</template>
<template #cell-roles="{ item }">
{{ getRoleLabels(item.roles) }}
</template>
<template #cell-isLocked="{ item }">
<span
v-if="item.isLocked"
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-red-100 text-red-700"
>Verrouillé</span>
<span
v-else
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-green-100 text-green-700"
>Actif</span>
</template>
</UiDataTable>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs.
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ title: 'Utilisateurs' }) definePageMeta({
layout: 'default'
})
import type { UserData } from '~/services/dto/user-data' import {ROLE} from "~/utils/constants";
import { ROLE } from '~/utils/constants' import type {ColumnConfig, Row} from "~/services/dto/datatable-data";
import { useAuthStore } from '~/stores/auth' import {formatRoleLabels} from "~/utils/datatable-formatters";
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const roleLabelByValue = new Map(ROLE.map((role) => [role.value, role.label]))
const roleLabelByValue = new Map(ROLE.map(role => [role.value, role.label]))
const { items, totalItems, page, perPage, filters, loading, reload } = const columns: ColumnConfig[] = [
useDataTableServerState<UserData>( { key: "username", label: "Username" },
'admin/users', { key: "roles", label: "Role", format: (value) => formatRoleLabels(value, roleLabelByValue) },
{
username: '',
isLocked: ''
}
)
const statusOptions = [
{ value: 'false', label: 'Actif' },
{ value: 'true', label: 'Verrouillé' }
] ]
const columns = [ const onUserRowClick = (row: Row) => {
{ key: 'username', label: 'Utilisateur' }, const id = Number(row.id)
{ key: 'roles', label: 'Role' }, if (!Number.isFinite(id)) return
{ key: 'isLocked', label: 'Statut', width: '160px' } router.push(`/admin/user/${id}`)
]
const getRoleLabels = (roles?: string[]) => {
if (!roles || roles.length === 0) return '---'
return roles.map(role => roleLabelByValue.get(role) ?? role).join(', ')
} }
const goToUser = (user: UserData) => {
if (!auth.isAdmin) return
router.push(`/admin/user/${user.id}`)
}
onMounted(() => {
if (auth.isAdmin) reload()
})
</script> </script>

View File

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

View File

@@ -1,27 +1,26 @@
<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">
<card-link label="NOUVELLE RÉCEPTION" link="/reception" iconName="mdi:truck-outline" /> <card-link label="NOUVELLE RÉCEPTION" link="/reception" iconName="mdi:truck-outline" />
<card-link label="NOUVELLE EXPÉDITION" link="/shipment" iconName="mdi:truck-fast-outline" /> <card-link label="NOUVELLE EXPÉDITION" link="/shipment" iconName="mdi:truck-fast-outline" />
<card-link label="PLAN DE SITE" link="/infrastructure/building" iconName="material-symbols:warehouse-outline-rounded" /> <card-link label="PLAN DE SITE" link="/" iconName="material-symbols:warehouse-outline-rounded" />
<card-link link="/reception/waiting-reception" iconName="mdi:truck-remove-outline"> <card-link label="" link="/reception/waiting-reception" iconName="mdi:truck-remove-outline">
<template #label> <template #label>
Réceptions<br>EN ATTENTE Réceptions<br>EN ATTENTE
</template> </template>
</card-link> </card-link>
<card-link link="/shipment/waiting-shipment" iconName="mdi:truck-cargo-container"> <card-link label="" link="/shipment/waiting-shipment" iconName="mdi:truck-cargo-container">
<template #label> <template #label>
EXPÉDITIONS<br>EN ATTENTE EXPÉDITIONS<br>EN ATTENTE
</template> </template>
</card-link> </card-link>
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" /> <card-link label="CASES" link="/" 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="/inventory" iconName="mdi:cow"> <card-link label="" link="/" iconName="mdi:cow">
<template #label> <template #label>
INVENTAIRE<br>BOVINS PASSEPORT<br>DU BOVIN
</template> </template>
</card-link> </card-link>
</div> </div>

View File

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

View File

@@ -1,272 +0,0 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="router.push('/infrastructure/building')"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500"
/>
</div>
<div class="flex items-center gap-4">
<h1 class="font-bold text-4xl text-primary-500 uppercase">
{{ title }}
</h1>
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
<div
v-if="hasCaseId"
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer"
title="Imprimer"
@click="printCaseReport"
>
<Icon name="mdi:printer-outline" size="32" class="text-white" />
</div>
</div>
</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>
</template>
<script setup lang="ts">
useHead({ title: 'Cases' })
import type { BuildingCaseData } from '~/services/dto/building-case-data'
import type { BovineData } from '~/services/dto/bovine-data'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { useBovineColumns } from '~/composables/useBovineColumns'
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
const route = useRoute()
const router = useRouter()
const { printPdf } = usePdfPrinter()
const api = useApi()
const caseId = computed(() => Number(route.query.id))
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
const buildingCase = ref<BuildingCaseData | null>(null)
interface InventoryStats {
total: number
over24: number
between22And24: number
between20And22: number
}
const stats = ref<InventoryStats>({
total: 0,
over24: 0,
between22And24: 0,
between20And22: 0
})
const loadStats = async () => {
if (!hasCaseId.value) {
stats.value = { total: 0, over24: 0, between22And24: 0, between20And22: 0 }
return
}
try {
stats.value = await api.get<InventoryStats>('bovines/inventory-stats', {
buildingCaseId: caseId.value
}, { toast: false })
} catch {
// silencieux
}
}
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<BovineData>(
'bovines',
{
'exists[exitedAt]': 'false',
buildingCase: '',
nationalNumber: '',
workNumber: '',
'bovineType.label': '',
sex: '',
'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '',
'birthDate[after]': '',
'birthDate[strictly_before]': ''
},
{ initialPerPage: 10 }
)
const sexOptions = [
{ value: 'M', label: 'Mâle' },
{ value: 'F', label: 'Femelle' }
]
const addOneDay = (dateString: string): string => {
const [year, month, day] = dateString.split('-').map(Number)
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
const singleDateFilter = (afterKey: string, beforeKey: string) =>
computed<string>({
get: () => (filters.value[afterKey] as string) ?? '',
set: (value: string) => {
if (!value) {
filters.value[afterKey] = ''
filters.value[beforeKey] = ''
return
}
filters.value[afterKey] = value
filters.value[beforeKey] = addOneDay(value)
}
})
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
const { columns } = useBovineColumns({ variant: 'case' })
const title = computed(() => {
if (!buildingCase.value) return ''
const buildingLabel = buildingCase.value.building?.label ?? ''
const caseNumber = buildingCase.value.caseNumber ?? ''
return `${buildingLabel} case ${caseNumber}`.trim()
})
const formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date)
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const formatPrice = (price: number | null) => {
if (price === null || price === undefined) return '—'
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
const loadCase = async () => {
if (!hasCaseId.value) {
buildingCase.value = null
return
}
buildingCase.value = await api.get<BuildingCaseData>(`/building_cases/${caseId.value}`)
}
const printCaseReport = async () => {
if (!hasCaseId.value) return
const filename = `tableau_poids_case_${caseId.value}.pdf`
await printPdf(`/building_cases/${caseId.value}/weights-report`, filename)
}
const goToBovine = (bovine: BovineData) => {
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>

View File

@@ -1,316 +0,0 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="router.push('/')"
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=" 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>

View File

@@ -16,7 +16,6 @@
<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"
> >
@@ -53,8 +52,6 @@
</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'

View File

@@ -2,7 +2,7 @@
<div class="flex justify-between h-[52px] mb-[80px]"> <div class="flex justify-between h-[52px] mb-[80px]">
<div class="flex flex-1 mr-16"> <div class="flex flex-1 mr-16">
<UiStepper <UiStepper
:labels="stepLabels" :labels="RECEPTION_STEP_LABELS"
:current-step="storeReception?.currentStep ?? 0" :current-step="storeReception?.currentStep ?? 0"
@select="handleStepSelect" @select="handleStepSelect"
/> />
@@ -14,91 +14,77 @@
>Mettre en attente >Mettre en attente
</UiButton> </UiButton>
</div> </div>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0" ref="receptionFormRef"/> <ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/>
<WorkflowWeight <ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/>
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"/>
<WorkflowWeight <ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/>
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">
useHead({ title: 'Réception' })
import {useReceptionStore} from '~/stores/reception' import {useReceptionStore} from '~/stores/reception'
import {storeToRefs} from 'pinia' import {storeToRefs} from 'pinia'
import { useWorkflowSteps } from '~/composables/useWorkflowSteps' import {RECEPTION_STEP_LABELS} from '~/constants/steps'
import { receptionConfig } from '~/config/reception.config' import {RECEPTION_TYPE_CODES} from "~/utils/constants";
import { getWeight } from '~/services/reception'
import { RECEPTION_TYPE_CODES } from '~/utils/constants' const route = useRoute()
const router = useRouter()
const receptionStore = useReceptionStore() const receptionStore = useReceptionStore()
const {current: storeReception} = storeToRefs(receptionStore) const {current: storeReception} = storeToRefs(receptionStore)
const receptionFormRef = ref<{ saveDraft: () => Promise<void>, validateFields: () => boolean } | null>(null)
const grossWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
const tareWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
const { stepLabels, handleStepSelect } = useWorkflowSteps(receptionConfig, receptionStore) const resolveReceptionId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param
const router = useRouter() if (!idStr) {
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()
} }
await router.push('/') const id = Number(idStr)
return Number.isFinite(id) ? id : null
} }
// Init route watcher
const route = useRoute()
watch( watch(
() => route.params.id, () => route.params.id,
async (param) => { async (param) => {
const idStr = Array.isArray(param) ? param[0] : param const id = resolveReceptionId(param)
if (!idStr) { if (id === null) {
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>

View File

@@ -1,164 +1,39 @@
<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">liste des réceptions finies</h1> <h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions finie</h1>
</div> </div>
<div class="px-[86px]">
<div class="mt-6 mb-16">
<UiDataTable <UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns" :columns="columns"
:items="items" url="receptions"
:total-items="totalItems" class="ps-20"
:loading="loading" :query="{ isValid: true }"
row-clickable
@row-click="goToReception" @row-click="goToReception"
>
<template #header-identificationNumber>
<UiTextInput
v-model="filters.identificationNumber"
placeholder="Numéro"
size="compact"
/> />
</template> </template>
<template #header-receptionDate>
<UiDateMaskedInput
v-model="receptionDateFilter"
placeholder="Date"
size="compact"
/>
</template>
<template #header-supplier.name>
<UiTextInput
v-model="filters['supplier.name']"
placeholder="Fournisseur"
size="compact"
/>
</template>
<template #header-address.fullAddress>
<UiTextInput
:model-value="''"
placeholder="Adresse"
size="compact"
disabled
/>
</template>
<template #header-receptionType.label>
<UiSelect
v-model="filters['receptionType.id']"
placeholder="Type réception"
:options="receptionTypeOptions"
size="compact"
/>
</template>
<template #header-weighing>
<UiTextInput
:model-value="''"
placeholder="Poids"
size="compact"
disabled
/>
</template>
<template #cell-receptionDate="{ item }">
{{ formatDate(item.receptionDate) }}
</template>
<template #cell-weighing="{ item }">
{{ formatWeighing(item) }}
</template>
</UiDataTable>
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ title: 'Validation réception' })
import type { ReceptionData } from '~/services/dto/reception-data' import {formatWeights} from "~/utils/datatable-formatters";
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import { getReceptionTypeList } from '~/services/reception-type' type ReceptionRow = {
import { useDataTableServerState } from '~/composables/useDataTableServerState' id?: number | string
}
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 = [ const columns = [
{ key: 'identificationNumber', label: 'Numéro', width: '75px' }, { key: 'identificationNumber', label: 'Numero', isSearchable:true },
{ key: 'receptionDate', label: 'Date', width: '120px' }, { key: 'receptionDate', label: 'Date de livraison', isSearchable: true, type: 'date' },
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' }, { key: 'supplier.name', label: 'Fournisseur', isSearchable: true },
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' }, { key: 'address.fullAddress', label: 'Adresse', isSearchable: true },
{ key: 'receptionType.label', label: 'Type réception', width: '0.9fr' }, { key: 'receptionType.label', label: 'Type', isSearchable: true, type:'selectTypeReception' },
{ key: 'weighing', label: 'Poids', width: '82px' } { key: 'weights', label: 'Poids', format: formatWeights }
] ]
const formatDate = (date: string | null) => { const goToReception = (row: ReceptionRow) => {
if (!date) return '—' const id = Number(row?.id)
const d = new Date(date.replace(' ', 'T')) if (!Number.isFinite(id)) return
if (isNaN(d.getTime())) return date router.push(`/reception/update/${id}`)
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} }
const formatWeighing = (reception: ReceptionData) => {
const gross = reception.weights?.find((weight) => weight.type === 'gross')?.weight
const tare = reception.weights?.find((weight) => weight.type === 'tare')?.weight
if (gross == null || tare == null) {
return '—'
}
return `${gross - tare} kg`
}
const goToReception = (reception: ReceptionData) => {
router.push(`/reception/update/${reception.id}`)
}
onMounted(async () => {
receptionTypes.value = await getReceptionTypeList()
reload()
})
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,166 +1,36 @@
<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">liste des réceptions en attente</h1> <h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions en attente</h1>
</div> </div>
<div class="px-[86px]">
<div class="mt-6 mb-16">
<UiDataTable <UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns" :columns="columns"
:items="items" url="receptions"
:total-items="totalItems" :query="{ isValid: false }"
:loading="loading"
:show-actions="auth.isAdmin"
row-clickable
@row-click="goToReception" @row-click="goToReception"
>
<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>
<template #header-address.fullAddress>
<UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
</template>
<template #header-receptionType.label>
<UiSelect
v-model="filters['receptionType.id']"
placeholder="Type réception"
:options="receptionTypeOptions"
size="compact"
/>
</template>
<template #header-carrier.name>
<UiTextInput
v-model="filters['carrier.name']"
placeholder="Transporteur"
size="compact"
/>
</template>
<template #header-licensePlate>
<UiTextInput
v-model="filters['licensePlate']"
placeholder="Immatriculation"
size="compact"
/>
</template>
<template #header-actions>
<UiTextInput :model-value="''" placeholder="Actions" size="compact" disabled />
</template>
<template #cell-receptionDate="{ item }">
{{ formatDate(item.receptionDate) }}
</template>
<template #actions="{ item }">
<Icon
name="mdi:delete-outline"
size="24"
class="cursor-pointer text-red-500 hover:text-red-700"
@click="confirmDelete(item)"
/>
</template>
</UiDataTable>
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ title: 'Réceptions en attente' })
import type { ReceptionData } from '~/services/dto/reception-data'
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import { deleteReception } from '~/services/reception'
import { getReceptionTypeList } from '~/services/reception-type'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const router = useRouter() const router = useRouter()
const auth = useAuthStore()
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: 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 = [ const columns = [
{ key: 'receptionDate', label: 'Date', width: '120px' }, {key: 'supplier.name', label: 'Fournisseur', isSearchable:true},
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' }, { key: 'address.fullAddress', label: 'Adresse', isSearchable: true },
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' }, {key: 'carrier.name', label: 'Transporteur', isSearchable:true},
{ key: 'receptionType.label', label: 'Type réception', width: '1.1fr' }, {key: 'receptionType.label', label: 'Type', isSearchable:true, type:'selectTypeReception'},
{ key: 'carrier.name', label: 'Transporteur' }, {key: 'licensePlate', label: 'Immatriculation', isSearchable:true, type:'licensePlate'},
{ key: 'licensePlate', label: 'Immatriculation', width: '110px' }
] ]
const formatDate = (date: string | null) => {
if (!date) return '—' type ReceptionRow = {
const d = new Date(date.replace(' ', 'T')) id?: number | string
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 = (row: ReceptionRow) => {
const goToReception = (reception: ReceptionData) => { const id = Number(row?.id)
router.push(`/reception/${reception.id}`) if (!Number.isFinite(id)) return
router.push(`/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 () => {
receptionTypes.value = await getReceptionTypeList()
reload()
})
</script> </script>

View File

@@ -1,231 +0,0 @@
<template>
<div class="flex flex-col gap-4 px-4">
<h1 class="text-2xl text-primary-500 font-bold uppercase">Scanner des bovins</h1>
<!-- Message si non supporté -->
<div v-if="!scanner.isSupported.value" class="bg-red-50 border border-red-200 rounded p-4 text-red-700 flex flex-col w-full">
<p class="font-bold">Scanner non disponible</p>
<p class="text-sm mt-1">BarcodeDetector n'est pas supportée par ce navigateur. Utilisez Chrome sur Android.</p>
</div>
<!-- Erreur caméra -->
<div v-if="scanner.error.value" class="bg-red-50 border border-red-200 rounded p-4 text-red-700">
<p>{{ scanner.error.value }}</p>
</div>
<div class="flex flex-wrap gap-4">
<UiSelect
id="scan-building"
v-model="selectedBuildingId"
label="Bâtiment"
:options="buildingOptions"
wrapper-class="w-full max-w-[280px]"
required
/>
<UiSelect
id="scan-case"
v-model="selectedCaseId"
label="Case"
:options="caseOptions"
:disabled="!selectedBuildingId"
wrapper-class="w-full max-w-[280px]"
required
/>
</div>
<!-- Zone caméra pleine hauteur -->
<div v-if="showScanner" class="fixed inset-0 z-50 flex flex-col bg-black overflow-hidden">
<!-- Header scanner -->
<div class="flex items-center justify-between px-4 py-3 bg-black/90">
<div class="text-white text-sm font-semibold">
{{ scannedCount }} bovin{{ scannedCount > 1 ? 's' : '' }} scanné{{ scannedCount > 1 ? 's' : '' }}
</div>
<UiButton
type="button"
class="text-md font-bold uppercase bg-red-500 text-white h-[40px] px-4"
@click="stopScanning"
>
Arrêter
</UiButton>
</div>
<div class="flex-1 relative">
<video
ref="videoRef"
class="w-full h-full object-cover"
playsinline
muted
/>
<!-- Overlay zone de scan -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="w-[90%] h-48 border-2 border-white/70 rounded-lg" />
</div>
<!-- Dernier scan -->
<div v-if="lastScanned" class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-green-600/80 text-white px-4 py-2 rounded-full text-sm font-semibold">
{{ lastScanned }}
</div>
</div>
</div>
<!-- Bouton démarrer -->
<div v-if="!showScanner" class="flex gap-3">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] w-full max-w-[272px]"
:disabled="!scanner.isSupported.value"
@click="startScanning"
>
Démarrer le scanner
</UiButton>
</div>
<!-- Liste des numéros scannés -->
<div class="flex flex-col gap-3">
<p class="text-sm text-slate-500 font-semibold">
{{ scannedCount }} bovin{{ scannedCount > 1 ? 's' : '' }} scanné{{ scannedCount > 1 ? 's' : '' }}
</p>
<div
v-for="(entry, index) in entries"
:key="index"
class="flex items-center gap-2"
>
<UiTextInput
:id="`scan-entry-${index}`"
:ref="(el: any) => setInputRef(el?.$el?.querySelector('input') ?? el, index)"
:model-value="entries[index]"
placeholder="Numéro national"
wrapper-class="flex-1 max-w-md"
@update:model-value="(val: string) => entries[index] = val ?? ''"
/>
<button
v-if="entries.length > 1"
type="button"
class="text-red-400 hover:text-red-600"
@click="removeEntry(index)"
>
<Icon name="mdi:close-circle" size="24" />
</button>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 mt-4">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] w-full"
:disabled="scannedCount === 0 || isSubmitting || !selectedCaseId"
:loading="isSubmitting"
@click="submit"
>
Valider ({{ scannedCount }})
</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
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>

View File

@@ -3,9 +3,10 @@
<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="stepLabels" :labels="SHIPMENT_STEP_LABELS"
:current-step="storeShipment?.currentStep ?? 0" :current-step="storeShipment?.currentStep ?? 0"
@select="handleStepSelect" @select="handleStepSelect"
/> />
</div> </div>
<UiButton <UiButton
@@ -16,85 +17,67 @@
</UiButton> </UiButton>
</div> </div>
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/> <ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
<WorkflowWeight <ShipmentWeight v-if="storeShipment?.currentStep === 1" mode="gross"/>
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"/>
<WorkflowWeight <ShipmentWeight v-if="storeShipment?.currentStep === 3" mode="tare"/>
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"> <script setup lang="ts">
useHead({ title: 'Expédition' })
import { storeToRefs } from 'pinia' import {SHIPMENT_STEP_LABELS} from "~/constants/steps";
import { useShipmentStore } from '~/stores/shipment' import {storeToRefs} from "pinia";
import { useWorkflowSteps } from '~/composables/useWorkflowSteps' import {useShipmentStore} from "~/stores/shipment";
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>, validateFields: () => boolean } | null>(null) const shipmentFormRef = ref<{ saveDraft: () => Promise<void> } | 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) => {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) {
return null
}
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
watch ( watch (
() => route.params.id, () => route.params.id,
async (param) => { async (param) => {
const idStr = Array.isArray(param) ? param[0] : param const id = resolveShipmentId(param)
if (!idStr) { if (id === null) {
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>

View File

@@ -1,172 +1,35 @@
<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">liste des expéditions finies</h1> <h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions finie</h1>
</div> </div>
<div class="px-[86px]">
<div class="mt-6 mb-16">
<UiDataTable <UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns" :columns="columns"
:items="items" url="shipments"
:total-items="totalItems" :query="{ isValid: true }"
:loading="loading"
row-clickable
@row-click="goToShipment" @row-click="goToShipment"
>
<template #header-identificationNumber>
<UiTextInput
v-model="filters.identificationNumber"
placeholder="Numéro"
size="compact"
/> />
</template> </template>
<template #header-shipmentDate>
<UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-customer.name>
<UiTextInput
v-model="filters['customer.name']"
placeholder="Client"
size="compact"
/>
</template>
<template #header-address.fullAddress>
<UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
</template>
<template #header-shipmentType.label>
<UiSelect
v-model="filters['shipmentType.id']"
placeholder="Type d'expédition"
:options="shipmentTypeOptions"
size="compact"
/>
</template>
<template #header-weighing>
<UiTextInput :model-value="''" placeholder="Poids" size="compact" disabled />
</template>
<template #cell-shipmentDate="{ item }">
{{ formatDate(item.shipmentDate) }}
</template>
<template #cell-shipmentType.label="{ item }">
<template v-if="formatShipmentLines(item).length">
<div
v-for="(line, index) in formatShipmentLines(item)"
:key="index"
class="leading-5"
>
{{ line }}
</div>
</template>
<template v-else></template>
</template>
<template #cell-weighing="{ item }">
{{ formatWeighing(item) }}
</template>
</UiDataTable>
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ title: 'Validation expédition' }) import {formatBovinShipments, formatWeights} from "~/utils/datatable-formatters";
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 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 = [ const columns = [
{ key: 'identificationNumber', label: 'Numéro', width: '75px' }, {key: 'identificationNumber', label: 'Numero',isSearchable:true},
{ key: 'shipmentDate', label: 'Date', width: '120px' }, {key: 'shipmentDate', label: 'Date de livraison',isSearchable:true, type:'date'},
{ key: 'customer.name', label: 'Client', width: '1.5fr' }, {key: 'customer.name', label: 'Client',isSearchable:true},
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' }, {key: 'address.fullAddress', label: 'Adresse',isSearchable:true},
{ key: 'shipmentType.label', label: "Type d'expédition", width: '1.1fr' }, {key: 'bovinShipments', label: 'Type', format:formatBovinShipments},
{ key: 'weighing', label: 'Poids', width: '82px' } {key: 'weights', label: 'Poids', format: formatWeights}
] ]
type ReceptionRow = {
const formatDate = (date: string | null) => { id?: number | string
if (!date) return '—'
const d = new Date(date.replace(' ', 'T'))
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} }
const goToShipment = (row: ReceptionRow) => {
const formatShipmentLines = (shipment: ShipmentData) => { const id = Number(row?.id)
if (!shipment.shipmentType && shipment.nbBovinSend == null) { if (!Number.isFinite(id)) return
return [] router.push(`/shipment/update/${id}`)
} }
const label = typeof shipment.shipmentType === 'string'
? shipment.shipmentType
: shipment.shipmentType?.label
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
}
const formatWeighing = (shipment: ShipmentData) => {
const gross = shipment.weights?.find((weight) => weight.type === 'gross')?.weight
const tare = shipment.weights?.find((weight) => weight.type === 'tare')?.weight
if (gross == null || tare == null) {
return '—'
}
return `${gross - tare} kg`
}
const goToShipment = (shipment: ShipmentData) => {
router.push(`/shipment/update/${shipment.id}`)
}
onMounted(async () => {
shipmentTypes.value = await getShipmentTypeList()
reload()
})
</script> </script>

View File

@@ -1,662 +0,0 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
<div class="flex items-center justify-between gap-10 relative col-start-1 row-start-1">
<div class="flex flex-row absolute -left-[60px] justify-between">
<Icon @click="router.push('/shipment/finish-shipment')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
</div>
<h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Expédition {{ form.identificationNumber }}</h1>
<div class="bg-primary-500 p-1 rounded-md flex items-center" title="Imprimer" @click="printReceipt">
<Icon name="mdi:printer-outline" size="32" class="cursor-pointer text-white"/>
</div>
</div>
<UiSelect
id="shipment-user"
v-model="form.userId"
label="Nom de l'utilisateur"
:options="users.map((user) => ({
value: String(user.id),
label: user.username
}))"
:loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2"
required
/>
<UiDateInput
id="shipment-date"
v-model="form.shipmentDate"
label="Date d'expédition"
wrapper-class="col-start-1 row-start-3"
required
/>
<div class="col-start-1 row-start-4 h-[64px]">
<div class="flex w-full items-end gap-[104px]">
<UiRadioGroup
id="shipment-type"
name="shipment-type"
label="Type d'expédition bovine"
input-class="accent-primary-700 focus:ring-primary-700"
group-class="flex flex-row gap-[104px] w-[160px_160px] h-[32px]"
v-model="selectedShipmentTypeId"
:options="bovineShipment.map((type) => ({
value: String(type.id),
label: type.label
}))"
required
/>
<UiNumberInput
id="shipment-type-quantity"
v-model="shipmentQuantity"
:placeholder="0"
:min="0"
:max="1200"
:disabled="!selectedShipmentTypeId"
/>
</div>
</div>
<UiSelect
id="shipment-customer"
v-model="form.customerId"
label="Client"
:options="customers.map((customer) => ({
value: String(customer.id),
label: customer.name || `Client #${customer.id}`
}))"
:loading="isLoadingCustomers"
wrapper-class="col-start-1 row-start-5"
required
/>
<UiSelect
id="shipment-address"
v-model="form.addressId"
:options="customerAddressOptions"
:disabled="isLoadingCustomers || customerAddresses.length === 0"
label="Adresse"
wrapper-class="col-start-2 row-start-1"
required
/>
<UiSelect
id="shipment-truck"
v-model="form.truckId"
label="Camion"
:options="trucks.map((truck) => ({
value: String(truck.id),
label: truck.name
}))"
:loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2"
required
/>
<UiSelect
id="shipment-carrier"
v-model="form.carrierId"
label="Transporteur"
:options="carriers.map((carrier) => ({
value: String(carrier.id),
label: carrier.name
}))"
wrapper-class="col-start-2 row-start-3"
required
/>
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput
v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate"
required
/>
</div>
<UiSelect
v-if="isLiotCarrier"
id="shipment-vehicle"
v-model="form.vehicleId"
label="Immatriculation"
:options="filteredVehicles.map((vehicle) => ({
value: String(vehicle.id),
label: vehicle.plate
}))"
:loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4"
required
/>
<div class="col-start-2 row-start-5 min-h-[72px]">
<UiSelect
v-if="isLiotCarrier"
id="shipment-driver"
v-model="form.driverId"
label="Nom du chauffeur si LIOT"
:options="filteredDrivers.map((driver) => ({
value: String(driver.id),
label: driver.name
}))"
:loading="isLoadingDrivers"
required
/>
</div>
</div>
<div v-if="formIsLoading">
<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>

View File

@@ -1,188 +1,38 @@
<template> <template>
<div class="flex items-center justify-start gap-10"> <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"/> <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">liste des expéditions en attente</h1> <h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions en attente</h1>
</div>
</div> </div>
<div class="px-[86px]">
<div class="mt-6 mb-16">
<UiDataTable <UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns" :columns="columns"
:items="items" url="shipments"
:total-items="totalItems" :query="{ isValid: false }"
:loading="loading"
:show-actions="auth.isAdmin"
row-clickable
@row-click="goToShipment" @row-click="goToShipment"
>
<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>
<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
v-for="(line, index) in formatShipmentLines(item)"
:key="index"
class="leading-5"
>
{{ line }}
</div>
</template>
<template v-else></template>
</template>
<template #actions="{ item }">
<Icon
name="mdi:delete-outline"
size="24"
class="cursor-pointer text-red-500 hover:text-red-700"
@click="confirmDelete(item)"
/>
</template>
</UiDataTable>
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ title: 'Expéditions en attente' }) import {formatBovinShipments} from "~/utils/datatable-formatters";
import type { ShipmentData } from '~/services/dto/shipment-data'
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 router = useRouter() const router = useRouter()
const auth = useAuthStore()
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: 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 = [ const columns = [
{ key: 'shipmentDate', label: 'Date', width: '120px' }, {key: 'customer.name', label: 'Client', isSearchable:true},
{ key: 'customer.name', label: 'Client', width: '1.5fr' }, {key: 'address.fullAddress', label: 'Adresse', isSearchable:true},
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' }, {key: 'carrier.name', label: 'Transporteur', isSearchable:true},
{ key: 'shipmentType.label', label: "Type d'expé.", width: '1.1fr' }, {key: 'bovinShipments', label: 'Type', format:formatBovinShipments},
{ key: 'carrier.name', label: 'Transporteur' }, {key: 'licencePlate', label: 'Immatriculation', isSearchable:true},
{ key: 'licensePlate', label: 'Immatriculation', width: '110px' }
] ]
const formatDate = (date: string | null) => { type ReceptionRow = {
if (!date) return '—' id?: number | string
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 goToShipment = (row: ReceptionRow) => {
if (!shipment.shipmentType && shipment.nbBovinSend == null) { const id = Number(row?.id)
return [] if (!Number.isFinite(id)) return
router.push(`/shipment/${id}`)
} }
const label = typeof shipment.shipmentType === 'string'
? shipment.shipmentType
: shipment.shipmentType?.label
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 () => {
shipmentTypes.value = await getShipmentTypeList()
reload()
})
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
import { useApi } from '~/composables/useApi'
import type { BovineData, BovinePayload } from '~/services/dto/bovine-data'
export async function createBovine(payload: BovinePayload) {
const api = useApi()
return api.post<BovineData>('bovines', payload, {
headers: { 'Content-Type': 'application/ld+json' },
toastErrorKey: 'errors.bovine.create',
toastSuccessKey: 'success.bovine.create'
})
}

View File

@@ -1,6 +1,6 @@
export interface AddressData { export interface AddressData {
id: number id: number
label?: string | null label: string
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 | null label: string
street: string street: string
street2?: string | null street2?: string | null
postalCode: string postalCode: string

View File

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

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