Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
023d71381e | ||
| e2695335e7 | |||
|
|
91152c0ed8 | ||
| 1b4764878e | |||
|
|
b94c3a95be | ||
| 394c69e84a | |||
|
|
c2074df562 | ||
| 29bfeeb4ee | |||
|
|
5ac03e359f | ||
|
|
340aa2a3c0 | ||
|
|
6eb2ee2578 | ||
| 34c1d162d8 | |||
|
|
bbd05cea3e | ||
| 7f78454553 | |||
|
|
696100a622 | ||
| 97f21ab35c | |||
|
|
fa7b44fb02 | ||
| 9be2e0c379 | |||
|
|
fee7bbb2ec | ||
| b707aae0e8 | |||
|
|
d0beb80199 | ||
| c378b402c4 | |||
|
|
6e707484a0 | ||
| 0067e51e6e | |||
|
|
1c0cdeb085 | ||
| 465339cdd6 | |||
|
|
2bc484574f | ||
| ea1e3b074c | |||
|
|
4944611088 | ||
| fbfc7acfe4 | |||
|
|
92f54f600f | ||
| a905c6a1de | |||
|
|
995e7de2cc | ||
| 2408ccab67 | |||
| 82af4d4c1e | |||
|
|
11491b02c5 | ||
| 024af5887e | |||
|
|
91c0125876 | ||
| b510cdcc42 | |||
|
|
d0213c3212 | ||
| 3ac676689d | |||
|
|
9f47e81efd | ||
| 257b93e691 | |||
|
|
dc5320b324 | ||
| 09a641e5cf | |||
|
|
a0557b077b | ||
| 2d2b38eae4 | |||
|
|
d3581b8ce6 | ||
| 9e53be8ac3 | |||
|
|
2aafa2082a | ||
| 2b64f024b6 | |||
|
|
47cac04257 | ||
| 59d76c5f14 | |||
|
|
c48cc477da | ||
| 5967665e9f | |||
|
|
393c420983 | ||
| 456623b403 | |||
|
|
e2a8e89e55 | ||
| 92a5c48e5e | |||
|
|
6766985713 | ||
| c0d05264df | |||
|
|
9505201499 | ||
| 624591c096 | |||
|
|
e31bdce713 | ||
| 5d72beaf8d | |||
| 43f34015c6 | |||
|
|
ac5ce07e61 | ||
| e9fb36cc24 | |||
|
|
06a41c5f85 | ||
| f263a11fe8 | |||
|
|
c52f22472d | ||
| e7421e985e | |||
|
|
0d258ae9c6 | ||
| 7dd615ea34 | |||
|
|
6eee0745a7 | ||
| 845f94db8c | |||
|
|
86c0e74074 | ||
| be29daf4d1 | |||
|
|
08e7c1508c | ||
| 358da6a8ad | |||
|
|
67428186f6 | ||
| 09d108a1d5 | |||
|
|
f58dc36a0d | ||
| 15c0f414af | |||
|
|
9ed0ba702e | ||
| 93edd0a563 | |||
|
|
c361ef9bb9 | ||
| 7f3d9ef9c6 | |||
|
|
22b959de85 | ||
| d3bc2e11f1 | |||
|
|
d8b16f5e15 | ||
| 43213bc6d6 | |||
|
|
09666d9319 | ||
| 05ea33735d | |||
|
|
89c67f7e97 | ||
| d527e94bac | |||
|
|
579bdba65b | ||
| b1c3952d09 | |||
|
|
ab6de16319 | ||
| 800ab1d432 | |||
|
|
fade51d3ee | ||
| 9ca0a7511b | |||
|
|
d3dfde7060 | ||
| 90c2cfc665 | |||
|
|
9fc3e2f9bc | ||
| 329bb4cee5 | |||
|
|
d3af654858 | ||
| 168d8c78eb | |||
|
|
338d903cef | ||
| 42ce1e2d08 | |||
|
|
0d0aa788db | ||
| c010bdc262 | |||
|
|
0e905bfcbe | ||
| e6bb4ddf6a | |||
| 299ea84e87 | |||
| bb0b0092da | |||
| 33d21f6ae6 | |||
| 98ee62294d | |||
| 820386b87b | |||
| c17f7aa08a | |||
| 4a0d38d307 | |||
| e9948d6ac3 | |||
| 80d87b7c9b | |||
| a69556c554 | |||
| 13e8698673 | |||
| a34bdbfe8d | |||
| d8e1cdc72c | |||
| 2c54a8c950 | |||
| 7c85d91c78 | |||
| 149bced1c5 | |||
| 086279f962 | |||
| 1ce6357c1d | |||
| 9ae073e69e | |||
| 2cd05a39ba | |||
| 66e9c52914 | |||
| 80d6d72e37 | |||
| b883546575 | |||
| 038596951d | |||
| ac5a3493e7 | |||
| 7dc4fdd1c0 | |||
| 5395dfefda | |||
| c4f4107512 |
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)",
|
||||
"WebFetch(domain:geo.api.gouv.fr)",
|
||||
"Bash(pip3 install:*)",
|
||||
"Bash(python3 -c \":*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -16,30 +16,50 @@ jobs:
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
persist-credentials: true
|
||||
|
||||
- name: Create next tag v0.0.X
|
||||
- name: Create next tag from config/version.yaml
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Skip if current commit already has a v0.0.* tag
|
||||
if git tag --points-at HEAD | grep -qE '^v0\.0\.'; then
|
||||
# Skip if current commit already has a vX.Y.Z tag
|
||||
if git tag --points-at HEAD | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Tag already exists on this commit. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
last_tag="$(git tag -l 'v0.0.*' --sort=-v:refname | head -n1 || true)"
|
||||
if [ -z "$last_tag" ]; then
|
||||
next_tag="v0.0.1"
|
||||
else
|
||||
patch="${last_tag##v0.0.}"
|
||||
if ! [[ "$patch" =~ ^[0-9]+$ ]]; then
|
||||
echo "Unexpected tag format: $last_tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
next_tag="v0.0.$((patch + 1))"
|
||||
changed_version=false
|
||||
if git diff --name-only "${{ gitea.event.before }}" "${{ gitea.event.after }}" | grep -q '^config/version\.yaml$'; then
|
||||
changed_version=true
|
||||
fi
|
||||
|
||||
read_version() {
|
||||
awk -F': *' '/app\.version:/{print $2}' config/version.yaml | tr -d '[:space:]' | tr -d "'\""
|
||||
}
|
||||
|
||||
if $changed_version; then
|
||||
version="$(read_version)"
|
||||
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Invalid version in version.yaml: $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
last_tag="$(git tag -l 'v*' --sort=-v:refname | head -n1 || true)"
|
||||
if [ -z "$last_tag" ]; then
|
||||
version="0.1.0"
|
||||
else
|
||||
base="${last_tag#v}"
|
||||
IFS='.' read -r major minor patch <<< "$base"
|
||||
version="${major}.${minor}.$((patch + 1))"
|
||||
fi
|
||||
|
||||
printf "parameters:\\n app.version: '%s'\\n" "$version" > config/version.yaml
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "gitea-actions@local"
|
||||
git tag "$next_tag"
|
||||
git push origin "$next_tag"
|
||||
git add config/version.yaml
|
||||
git commit -m "chore: bump version to v$version" || true
|
||||
git push origin develop || true
|
||||
fi
|
||||
|
||||
tag="v$version"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
|
||||
@@ -36,7 +36,8 @@ jobs:
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
|
||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ NUXT_PUBLIC_GEO_API_BASE=https://geo.api.gouv.fr npm run generate
|
||||
test -f .output/public/index.html
|
||||
|
||||
- name: Build artefact
|
||||
shell: bash
|
||||
@@ -54,7 +55,7 @@ jobs:
|
||||
composer.json \
|
||||
composer.lock \
|
||||
symfony.lock \
|
||||
frontend/.output/public
|
||||
frontend/.output
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AgentMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AskMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Ask2AgentMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="EditMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
19
.idea/dataSources.xml
generated
19
.idea/dataSources.xml
generated
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="ferme" uuid="f407a514-c6b4-4b26-9555-445a85892502">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://localhost:5432/ferme</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="Ferme recette" uuid="ae622167-c834-4e7b-87a5-c1721036f5dc">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://localhost:5432/postgres</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/data_source_mapping.xml
generated
7
.idea/data_source_mapping.xml
generated
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourcePerFileMappings">
|
||||
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/ae622167-c834-4e7b-87a5-c1721036f5dc/console.sql" value="ae622167-c834-4e7b-87a5-c1721036f5dc" />
|
||||
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f407a514-c6b4-4b26-9555-445a85892502/console.sql" value="f407a514-c6b4-4b26-9555-445a85892502" />
|
||||
</component>
|
||||
</project>
|
||||
2
.idea/db-forest-config.xml
generated
2
.idea/db-forest-config.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="db-tree-configuration">
|
||||
<option name="data" value="---------------------------------------- 1:0:f407a514-c6b4-4b26-9555-445a85892502 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc " />
|
||||
<option name="data" value="---------------------------------------- 1:0:f407a514-c6b4-4b26-9555-445a85892502 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc 3:0:9cad43df-2147-4989-b7a4-443067034884 4:0:09e221b8-067a-488b-9c1d-4e155a333079 " />
|
||||
</component>
|
||||
</project>
|
||||
5
.idea/ferme.iml
generated
5
.idea/ferme.iml
generated
@@ -147,9 +147,14 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/thecodingmachine/safe" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/lcobucci/jwt" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/lexik/jwt-authentication-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/malio/ednotif-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||
<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/symfony/maker-bundle" />
|
||||
<excludePattern pattern="reference.php" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
|
||||
296
.idea/php.xml
generated
296
.idea/php.xml
generated
@@ -4,161 +4,184 @@
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCSFixerOptionsConfiguration">
|
||||
<option name="codingStandard" value="Custom" />
|
||||
<option name="rulesetPath" value="$PROJECT_DIR$/.php-cs-fixer.dist.php" />
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCodeSnifferOptionsConfiguration">
|
||||
<option name="highlightLevel" value="WARNING" />
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PhpCSFixer">
|
||||
<phpcsfixer_settings>
|
||||
<PhpCSFixerConfiguration tool_path="$PROJECT_DIR$/vendor/bin/php-cs-fixer" />
|
||||
<phpcs_fixer_by_interpreter asDefaultInterpreter="true" interpreter_id="990ff521-e6e9-4080-9cc9-228367d597f9" tool_path="\\wsl.localhost\Ubuntu-24.04\home\matte\Ferme\vendor\bin\php-cs-fixer" timeout="30000" />
|
||||
</phpcsfixer_settings>
|
||||
</component>
|
||||
<component name="PhpCodeSniffer">
|
||||
<phpcs_settings>
|
||||
<phpcs_by_interpreter asDefaultInterpreter="true" interpreter_id="8475dcce-5d1d-4a2c-9e2f-7454868f1931" timeout="30000" />
|
||||
</phpcs_settings>
|
||||
</component>
|
||||
<component name="PhpIncludePathManager">
|
||||
<include_path>
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/log" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/container" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/link" />
|
||||
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
||||
<path value="$PROJECT_DIR$/vendor/lexik/jwt-authentication-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/dns" />
|
||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/socket" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/child-process" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/promise" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/stream" />
|
||||
<path value="$PROJECT_DIR$/vendor/dompdf/dompdf" />
|
||||
<path value="$PROJECT_DIR$/vendor/dompdf/php-font-lib" />
|
||||
<path value="$PROJECT_DIR$/vendor/nelmio/cors-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/dompdf/php-svg-lib" />
|
||||
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
|
||||
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
|
||||
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/common" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
|
||||
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/jsonld" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/hydra" />
|
||||
<path value="$PROJECT_DIR$/vendor/willdurand/negotiation" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/http-cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/serializer" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-orm" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/state" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/metadata" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/symfony" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/openapi" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/documentation" />
|
||||
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/json-schema" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/log" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/link" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/container" />
|
||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/promise" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
||||
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/stream" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/child-process" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
|
||||
<path value="$PROJECT_DIR$/vendor/nelmio/cors-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/socket" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/dns" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
||||
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
||||
<path value="$PROJECT_DIR$/vendor/dompdf/dompdf" />
|
||||
<path value="$PROJECT_DIR$/vendor/dompdf/php-svg-lib" />
|
||||
<path value="$PROJECT_DIR$/vendor/sabberworm/php-css-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/dompdf/php-font-lib" />
|
||||
<path value="$PROJECT_DIR$/vendor/lexik/jwt-authentication-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
|
||||
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
|
||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/common" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
|
||||
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
|
||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/willdurand/negotiation" />
|
||||
<path value="$PROJECT_DIR$/vendor/sabberworm/php-css-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/http-cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/documentation" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/metadata" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-orm" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/hydra" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/symfony" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/jsonld" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/serializer" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/json-schema" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/openapi" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/state" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
||||
<path value="$PROJECT_DIR$/vendor/malio/ednotif-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
||||
</include_path>
|
||||
</component>
|
||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||
<component name="PhpStan">
|
||||
<PhpStan_settings>
|
||||
<phpstan_by_interpreter asDefaultInterpreter="true" interpreter_id="8475dcce-5d1d-4a2c-9e2f-7454868f1931" timeout="60000" />
|
||||
</PhpStan_settings>
|
||||
</component>
|
||||
<component name="PhpStanOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
@@ -167,6 +190,11 @@
|
||||
<PhpUnitSettings custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" />
|
||||
</phpunit_settings>
|
||||
</component>
|
||||
<component name="Psalm">
|
||||
<Psalm_settings>
|
||||
<psalm_fixer_by_interpreter asDefaultInterpreter="true" interpreter_id="8475dcce-5d1d-4a2c-9e2f-7454868f1931" timeout="60000" />
|
||||
</Psalm_settings>
|
||||
</component>
|
||||
<component name="PsalmOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
|
||||
871
.idea/workspace.xml
generated
871
.idea/workspace.xml
generated
File diff suppressed because it is too large
Load Diff
46
AGENTS.md
46
AGENTS.md
@@ -1,46 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
Project overview
|
||||
- Symfony 8 + API Platform 4 backend, Nuxt 3 frontend in `frontend/`.
|
||||
- Apache vhost serves API under `/api` and frontend from `frontend/dist`.
|
||||
- API base URL on frontend uses `NUXT_PUBLIC_API_BASE` (see `frontend/.env`).
|
||||
|
||||
Backend conventions
|
||||
- Use English for code identifiers/messages; keep “pont-bascule” as domain term.
|
||||
- API Platform operations are defined on Doctrine entities.
|
||||
- Reception entity is in `src/Entity/Reception.php`, with custom weigh endpoint `/receptions/weigh`.
|
||||
- Reception fields: `date_reception`, `license_plate`, `current_step` (default 0), `is_valid` (default false).
|
||||
- `date_reception` is set by the UI, stored as `DateTimeImmutable`, serialized as `Y-m-d`.
|
||||
- Weight entity (`src/Entity/Weight.php`) is 1–N with Reception, each row stores `type` (`gross` or `tare`), `dsd`, `weight`, `weighed_at` (all nullable except `type`).
|
||||
- Weigh endpoint `/receptions/weigh` returns `PontBasculeReading` with `dsd`, `weight`, `weighedAt` (formatted `Y-m-d`).
|
||||
- Custom exception: `App\Exception\PontBasculeException` with French messages, mapped to 500 in provider.
|
||||
- Parsing of pont-bascule payload is in `src/Service/PontBasculePayloadDecoder.php`.
|
||||
- `config/reference.php` is auto-generated; keep it.
|
||||
|
||||
Frontend conventions
|
||||
- Nuxt SSR disabled; Tailwind used.
|
||||
- Layout in `frontend/layouts/default.vue`: max width `1050px`, header full width.
|
||||
- Tailwind custom color palette is `primary` (e.g. `bg-primary-500`).
|
||||
- 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`.
|
||||
- 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).
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -13,6 +13,10 @@ Ajouter dans le fichier .env
|
||||
- JWT_PUBLIC_KEY
|
||||
- JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
|
||||
- COOKIE_SECURE=0 (en dev 0 et en prod 1)
|
||||
- EDNOTIF_EXPLOITATION_CODE
|
||||
- EDNOTIF_EXPLOITATION_NUMERO
|
||||
- EDNOTIF_LOGIN
|
||||
- EDNOTIF_PASSWORD
|
||||
|
||||
Ajouter dans le fichier .env du frontend
|
||||
- NUXT_PUBLIC_API_BASE
|
||||
@@ -20,7 +24,49 @@ Ajouter dans le fichier .env du frontend
|
||||
### Added
|
||||
* [#203] Réceptions — Parcours de pesée multi-étapes (début)
|
||||
* [#202] Authentification — Connexion utilisateur (JWT)
|
||||
* Ajout du bundle malio/ednotif-bundle
|
||||
* Ajout de composant UI
|
||||
* Finalisation de la partie réception de marchandise
|
||||
* [#267] Lister les réceptions en attente
|
||||
* [#268] Lister les réceptions terminées
|
||||
* [#316] Admin liste des transporteurs
|
||||
* [#312] Creation administration listing fournisseurs
|
||||
* [#315] Creation page admin utilisateur
|
||||
* [#317] Admin modification creation transporteur
|
||||
* [#318] Affichage modification reception terminée
|
||||
* [#320] Affichage modification reception terminée suite
|
||||
* [#271] Créer une nouvelle expédition (étape 1)
|
||||
* [#272] Créer une nouvelle expédition (étape 2)
|
||||
* [#273] Créer une nouvelle expédition (étape 3)
|
||||
* [#256] Créer une nouvelle réception (étape 3 - bovin)
|
||||
* [#314] Création d'une page d'administration : listing des utilisateurs
|
||||
* [#313] Admin modification creation fournisseur
|
||||
* [#275] Lister les expéditions en attente
|
||||
* [#276] Lister les expéditions terminées
|
||||
* [#324] Creation page admin listing clients
|
||||
* [#326] Admin modification creation client
|
||||
* [#325] Correction diverses
|
||||
* fix layout admin
|
||||
* Creation page admin listing bovins
|
||||
* Creation page admin ajout/modification bovins
|
||||
* [#331] Mettre à jour l'entité Shipment et bovin_shipment
|
||||
* [#278] Plan du site
|
||||
* [#334] Correctifs
|
||||
* [#332] Refonte écran réception terminée
|
||||
* [#327] afficher/modifier écran expédition terminée
|
||||
* [#352] modification front admin fournisseur
|
||||
* [#355] modification front admin transporteur
|
||||
* [#356] front page admin bovin
|
||||
* [#353] modification front admin client
|
||||
* [#353] modification front admin utilisateur
|
||||
* [#FER-11] Corriger le problème de bearer token
|
||||
* [#FER-12] Ajouter un blocage des utilisateurs
|
||||
* [#FER-13] Faire des recherches sur le scanner des bêtes
|
||||
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
|
||||
* [#FER-17] Ecran d'ajout de bovin
|
||||
* [#FER-18] Mise à jour du tableau d'arrivage
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
169
CLAUDE.md
Normal file
169
CLAUDE.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend:** Symfony 8 + API Platform 4 (PHP 8.4)
|
||||
- **Frontend:** Nuxt 4 (Vue 3, Pinia, Tailwind, Zod) in `frontend/`
|
||||
- **Infra:** Docker (PHP-FPM + Nginx), Apache vhost serves API sous `/api` et frontend depuis `frontend/dist`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
make start # Démarrer les containers
|
||||
make stop # Arrêter les containers
|
||||
make restart # Redémarrer les containers
|
||||
make shell # Shell dans le container PHP
|
||||
|
||||
# Install complet
|
||||
make install # composer install + migrations + build frontend
|
||||
|
||||
# Backend
|
||||
make composer-install # Installer dépendances PHP
|
||||
make migration-migrate # Lancer les migrations
|
||||
make fixtures # Charger les fixtures
|
||||
make cache-clear # Vider le cache Symfony
|
||||
make test # Lancer les tests PHPUnit
|
||||
make test FILES=tests/path/to/TestFile.php # Test spécifique
|
||||
make php-cs-fixer-allow-risky FILES=src/... # Fixer le style
|
||||
|
||||
# Frontend
|
||||
make build-nuxtJS # npm install + build:dist (dans le container)
|
||||
make dev-nuxt # Serveur dev Nuxt (dans le container)
|
||||
# Ou directement dans frontend/ :
|
||||
cd frontend && npm run dev # Dev server (port 3000)
|
||||
cd frontend && npm run build:dist # Build production
|
||||
|
||||
# Base de données
|
||||
make db-reset # ⚠️ Supprime et recrée la BDD + migrations + fixtures
|
||||
```
|
||||
|
||||
## Architecture backend
|
||||
|
||||
```
|
||||
src/
|
||||
├── ApiResource/ # Ressources API Platform custom
|
||||
├── Command/ # Commandes Symfony (dont app:seed)
|
||||
├── DataFixtures/ # Fixtures Doctrine
|
||||
├── Dto/ # DTOs (ex: PontBasculeReading)
|
||||
├── Entity/ # Entités Doctrine (= ressources API Platform)
|
||||
├── Exception/ # Exceptions custom (PontBasculeException)
|
||||
├── Kernel.php
|
||||
├── Service/ # Services métier (PontBasculePayloadDecoder…)
|
||||
└── State/ # State providers/processors API Platform
|
||||
```
|
||||
|
||||
## Architecture frontend
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── components/
|
||||
│ ├── ui/ # Composants réutilisables, auto-importés avec préfixe Ui (ex: UiLoadingDots)
|
||||
│ ├── reception/ # Composants métier réception
|
||||
│ ├── shipment/ # Composants métier expédition
|
||||
│ ├── workflow/ # Composants partagés réception/expédition (workflow-weight, workflow-waiting-list, workflow-liot-fields)
|
||||
│ └── commun/ # Composants communs (update-weight)
|
||||
├── composables/ # useApi, useWeighing, usePdfPrinter, useAppVersion, useLiotHandling, useFormDataLoading, useAddressSync, useWorkflowSteps
|
||||
│ └── steps/ # useWeighingStep (logique étape pesée)
|
||||
├── config/ # reception.config.ts, shipment.config.ts (WorkflowConfig)
|
||||
├── types/ # workflow.ts (interfaces partagées WorkflowEntity, WorkflowConfig, StepDefinition)
|
||||
├── services/ # Couche service avec DTOs typés dans services/dto/
|
||||
│ └── workflow-service.ts # Factory service API (createWorkflowService)
|
||||
├── stores/ # Pinia stores (reception, shipment, auth)
|
||||
│ └── workflow-store.ts # Factory store (useWorkflowStoreLogic)
|
||||
├── pages/ # Pages Nuxt (file-based routing)
|
||||
├── layouts/ # Layout default : max-width 1050px
|
||||
├── i18n/locales/ # Traductions (défaut: fr)
|
||||
├── utils/ # Constants, zod-errors helpers
|
||||
└── assets/css/ # Tailwind config, main.css (font Helvetica)
|
||||
```
|
||||
|
||||
## Conventions backend
|
||||
|
||||
- Code en anglais ; "pont-bascule" est un terme métier conservé tel quel.
|
||||
- Les opérations API Platform sont définies directement sur les entités Doctrine.
|
||||
- Pas de classes Repository custom : utiliser `EntityManagerInterface` avec les repos par défaut.
|
||||
- `config/reference.php` est auto-généré — ne pas modifier à la main.
|
||||
- Endpoints toujours au pluriel (convention API Platform).
|
||||
- Ne jamais créer de GET qui crée des ressources : utiliser POST + PATCH.
|
||||
- Les noms de `Supplier`, `Customer` et `Carrier` sont automatiquement mis en majuscule via `mb_strtoupper` dans `setName()`.
|
||||
|
||||
## Conventions frontend
|
||||
|
||||
- SSR désactivé. Tailwind avec palette custom `primary` (ex: `bg-primary-500`).
|
||||
- `useApi` (`composables/useApi.ts`) : méthodes `get/post/put/patch/delete` avec content-types par défaut.
|
||||
- Toasts personnalisables via `toastErrorMessage`/`toastSuccessMessage` ou clés i18n `toastErrorKey`/`toastSuccessKey`.
|
||||
- Utilise `useNuxtApp().$i18n` (pas `useI18n`) pour fonctionner hors setup.
|
||||
- Validation formulaires avec Zod ; helpers dans `utils/zod-errors.ts`.
|
||||
- Nav active : `NuxtLink` avec slot `custom`.
|
||||
- PDFs : `usePdfPrinter` (receipt réception, rapport poids cases).
|
||||
|
||||
### Validation required & erreurs visuelles
|
||||
- Les champs `required` utilisent l'attribut HTML natif forwardé via `v-bind="attrs"` dans les composants UI.
|
||||
- La bordure rouge n'apparaît qu'après soumission grâce à la classe CSS `submitted` ajoutée sur le `<form>` au clic sur le bouton Valider (`@click="submitted = true"`).
|
||||
- Règles CSS globales dans `main.css` : `.submitted :invalid` (bordure + texte rouge), `.submitted :has(:invalid) > label` et `.submitted label:has(:invalid)` (labels rouges).
|
||||
- Pour les validations manuelles (checkboxes, radio groups), les messages d'erreur utilisent `invisible` (pas `hidden`) pour garder l'espace réservé et éviter les décalages de layout.
|
||||
- Les dates de l'API sont renvoyées au format `Y-m-d H:i` ; les formulaires utilisent `.slice(0, 10)` pour extraire la date seule (compatible `<input type="date">`).
|
||||
|
||||
### Workflow réception/expédition (mutualisé)
|
||||
- Factory service `createWorkflowService` et factory store `useWorkflowStoreLogic` pour éviter la duplication.
|
||||
- Composables partagés : `useLiotHandling` (logique LIOT), `useFormDataLoading` (users, trucks, carriers), `useAddressSync` (sync adresse fournisseur/client).
|
||||
- `useWeighing` : une seule fonction paramétrée pour réception et expédition (remplace `useWeighing` + `useWeighingShipment`).
|
||||
- Configs workflow dans `config/reception.config.ts` et `config/shipment.config.ts` (étapes, labels pesée, filename PDF).
|
||||
- `WorkflowWeight` composant partagé pour les étapes de pesée (remplace `reception-weight.vue` et `shipment-weight.vue`).
|
||||
- `WorkflowWaitingList` composant partagé pour les listes en attente, avec support colonnes dynamiques, slot `actions`, et prop `showActions`.
|
||||
|
||||
## Domaine métier clé
|
||||
|
||||
### Réception (pesée pont-bascule)
|
||||
- Entité principale `Reception` : `date_reception` (DateTimeImmutable, format lecture `Y-m-d H:i`, écriture `Y-m-d`), `identification_number` (auto `N-BR-####`), `current_step` (défaut 0), `is_valid` (défaut false).
|
||||
- `Weight` (1-N avec Reception, cascade remove + orphanRemoval) : `type` (`gross`/`tare`), `dsd`, `weight`, `weighed_at`.
|
||||
- Endpoint pesée : `/receptions/weigh` → `PontBasculeReading` (dsd, weight, weighedAt).
|
||||
- Endpoint suppression : `DELETE /receptions/{id}` — supprime en cascade weights, pelletBuildings, bovines.
|
||||
- Parsing payload pont-bascule : `Service/PontBasculePayloadDecoder.php`.
|
||||
- Exception : `PontBasculeException` (messages en français, mappée 500).
|
||||
- Store Pinia `reception.ts` = source de vérité pour la réception en cours.
|
||||
- UI multi-étapes dans `pages/reception/[[id]].vue` basée sur `currentStep`.
|
||||
- `PrePersist` : injecte l'heure courante sur `receptionDate` à la création ; `setReceptionDate` préserve l'heure existante au PATCH.
|
||||
|
||||
### Expédition
|
||||
- Entité `Shipment` : même pattern que Reception, `shipment_date` (format lecture `Y-m-d H:i`, écriture `Y-m-d`).
|
||||
- Endpoint suppression : `DELETE /shipments/{id}`.
|
||||
- `PrePersist` : injecte l'heure courante sur `shipmentDate` ; `setShipmentDate` préserve l'heure au PATCH.
|
||||
|
||||
### LIOT (transport)
|
||||
- Si carrier code = `LIOT` : afficher sélecteurs driver + vehicle, masquer saisie plaque manuelle.
|
||||
- Liste véhicules filtrée par type de camion et transporteur.
|
||||
- Le véhicule sélectionné alimente `license_plate`.
|
||||
- Logique mutualisée dans `composables/useLiotHandling.ts`.
|
||||
|
||||
### Bovins & infrastructure
|
||||
- `Bovine` : `nationalNumber` (unique), `receivedWeight`, `arrivalDate`, `buildingCase` (ManyToOne).
|
||||
- `BuildingCase` a `bovines` (OneToMany).
|
||||
- Rapport PDF cases : `GET /building_cases/{id}/weights-report` → template Twig, projection depuis `arrivalDate`, gain journalier fixe `1.3 kg/jour`.
|
||||
|
||||
### Scanner boucles auriculaires
|
||||
- Page dédiée `/scan` : scan de codes-barres Code 39/128 (boucles auriculaires bovines) depuis un téléphone Android via Chrome.
|
||||
- Utilise l'API native `BarcodeDetector` (Shape Detection API, Chrome Android 83+) — pas de lib JS, décodage hardware quasi-instantané.
|
||||
- **Non supporté sur iOS** (tous les navigateurs iOS utilisent WebKit, qui n'implémente pas `BarcodeDetector`).
|
||||
- Les 4 premiers caractères du code-barres sont retirés avant enregistrement (`rawValue.slice(4)`).
|
||||
- Composable `useBarcodeScanner` : caméra arrière, anti-doublon 2s, vibration au scan.
|
||||
- Le bovin est créé via `POST /bovines` avec `Content-Type: application/ld+json` (nécessaire pour la résolution d'IRI de `buildingCase`).
|
||||
- Sélection bâtiment → case (filtrées dynamiquement) avant de scanner.
|
||||
|
||||
### Données de référence
|
||||
- `ReceptionType`, `MerchandiseType`, `PelletType`, `Building`, `Supplier` (avec `Address` via join table, `createdBy` → User), `Customer` (avec `Address` via join table, `createdBy` → User), `Truck`, `Carrier`, `Driver`, `Vehicle`.
|
||||
- `Address` : champ `label` nullable (déprécié, retiré du front et du `address:write`), expose `fullAddress` via getter. `countryCode` par défaut `FR` côté front.
|
||||
|
||||
### Seed & fixtures
|
||||
- Commande `app:seed` : seed infrastructure (statut, building_layout, building_case, building_case_position) puis bovins.
|
||||
- Utilise des flush intermédiaires pour que les queries find fonctionnent sur les records fraîchement créés.
|
||||
- `upsertAddress` cherche par `street|postalCode` (plus par `label`).
|
||||
- Fixtures : `BuildingInfrastructureFixtures` + `BovineFixtures` (via dépendances `AppFixtures`).
|
||||
|
||||
## Environnement
|
||||
|
||||
- API base dev : `http://localhost:8080/api` (via `NUXT_PUBLIC_API_BASE` dans `frontend/.env`)
|
||||
- CORS : Nelmio, configurable via `CORS_ALLOW_ORIGIN` dans `.env`
|
||||
- Locale par défaut : `fr` — traductions dans `frontend/i18n/locales/fr.json`
|
||||
- Docker env : `docker/.env.docker` (défaut) avec override possible via `docker/.env.docker.local`
|
||||
@@ -68,10 +68,21 @@
|
||||
|
||||
1. Créer un tag sur `develop` (auto-tag `v0.0.X`)
|
||||
2. Attendre que la release Gitea soit publiée
|
||||
3. Déployer la release
|
||||
3. (Une seule fois) Donner les droits d'écriture à PHP sur `var/` via ACL
|
||||
```bash
|
||||
sudo DEPLOY_OWNER=malio /usr/local/bin/deploy-ferme v0.0.X
|
||||
sudo apt update
|
||||
sudo apt install -y acl
|
||||
sudo setfacl -R -m u:malio:rwx,g:www-data:rwx /var/www/ferme/var
|
||||
sudo setfacl -R -m d:u:malio:rwx,d:g:www-data:rwx /var/www/ferme/var
|
||||
```
|
||||
4. Déployer la release
|
||||
```bash
|
||||
/usr/local/bin/deploy-ferme vX.Y.Z
|
||||
```
|
||||
Notes :
|
||||
- Lancer le déploiement en tant que `malio` (ou `sudo -u malio`) pour éviter de casser les droits.
|
||||
- Le script applique `umask 002` pour garder les fichiers group-writable (`www-data`).
|
||||
- Le script force des droits d'exécution minimaux sur `/var/www` et `/var/www/ferme` pour éviter un blocage Nginx.
|
||||
|
||||
### Vérifications
|
||||
- Front : `http://ferme.malio-dev.fr/`
|
||||
|
||||
92
README.md
92
README.md
@@ -1,68 +1,87 @@
|
||||
# Projet Ferme
|
||||
# Projet Ferme t
|
||||
|
||||
## Installation du projet
|
||||
|
||||
### Windows
|
||||
|
||||
Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm.
|
||||
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows)
|
||||
|
||||
### Linux
|
||||
|
||||
Pour linux, il faut installer docker et nvm.
|
||||
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux)
|
||||
|
||||
### Installation du projet
|
||||
|
||||
Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes
|
||||
|
||||
```bash
|
||||
make start
|
||||
make install
|
||||
```
|
||||
|
||||
Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible.
|
||||
|
||||
### Configuration global
|
||||
|
||||
Pour les variables d'environnement, il faut demander un .env.local pour le backend et un .env pour le frontend à votre collègue.
|
||||
|
||||
Vérifier que dans le .env.local, vous avez :
|
||||
* APP_SECRET (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));" et doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens)
|
||||
* DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
* PONT_BASCULE_BYPASS (doit être à true en dev)
|
||||
* PONT_BASCULE_URL
|
||||
* JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
|
||||
* JWT_PUBLIC_KEY
|
||||
* JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
|
||||
* COOKIE_SECURE=0 (en dev 0 et en prod 1)
|
||||
|
||||
- APP_SECRET (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));" et doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens)
|
||||
- DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
- PONT_BASCULE_BYPASS (doit être à true en dev)
|
||||
- PONT_BASCULE_URL
|
||||
- JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
|
||||
- JWT_PUBLIC_KEY
|
||||
- JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
|
||||
- COOKIE_SECURE=0 (en dev 0 et en prod 1. Si c'est du http, laisser en 0)
|
||||
|
||||
Vérifier que dans le .env du dossier frontend, vous avez :
|
||||
* NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
|
||||
|
||||
- NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
|
||||
|
||||
### Configuration xdebug
|
||||
|
||||
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
|
||||
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
|
||||
* Name : ferme-docker
|
||||
* Host : localhost
|
||||
* Port : 8080
|
||||
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
|
||||
|
||||
- Name : ferme-docker
|
||||
- Host : localhost
|
||||
- Port : 8080
|
||||
- Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
|
||||
|
||||
Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local
|
||||
|
||||
## Utilisation du projet
|
||||
|
||||
### Backend
|
||||
|
||||
L'api est disponible sur http://localhost:8080/api
|
||||
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
|
||||
Vous pouvez modifier le port si nécessaire.
|
||||
|
||||
La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter.
|
||||
C'est un bdd local dans le docker.
|
||||
|
||||
### Frontend
|
||||
|
||||
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
|
||||
|
||||
```bash
|
||||
make dev-nuxt
|
||||
```
|
||||
|
||||
Le front sera accessible sur http://localhost:3000
|
||||
|
||||
### Authentification
|
||||
|
||||
Ce projet utilise l'authentification JWT avec un cookie httpOnly (LexikJWTAuthenticationBundle).
|
||||
Le frontend ne lit jamais directement le token, le navigateur envoie automatiquement le cookie.
|
||||
|
||||
### Login flow
|
||||
|
||||
- Frontend envoie les identifiants à:
|
||||
- `POST /api/login_check`
|
||||
- Backend returns:
|
||||
@@ -71,52 +90,91 @@ Le frontend ne lit jamais directement le token, le navigateur envoie automatique
|
||||
- Le cookie est automatiquement envoyé pour les futures requêtes.
|
||||
- La déconnexion utilise `POST /api/logout` et redirige vers `/login`.
|
||||
|
||||
### Fixtures
|
||||
|
||||
Pour lancer les fixtures (Attention sa purge la bdd complètement)
|
||||
|
||||
```bash
|
||||
php bin/console doctrine:fixtures:load
|
||||
```
|
||||
|
||||
Attention cette commande est dangereuse, à utiliser que pour les débuts de la prod ou en recette.
|
||||
Dans un premier temps pour remplir les listes, vous pouvez lancer la commande symfony
|
||||
|
||||
```bash
|
||||
php bin/console app:seed
|
||||
```
|
||||
|
||||
La commande va faire une update ou une création en fonction des data existante.
|
||||
|
||||
## Livraison en recette
|
||||
|
||||
### Préparatifs
|
||||
|
||||
Avant de déployer, il faut penser à ajouter les variables d'env s'il y a des changements/modifications.
|
||||
Le .env se trouve /var/www/ferme/.env
|
||||
|
||||
Le script de livraison est version dans le repo dans script/deploy-release.sh <br>
|
||||
Sur la machine, il est disponible dans /usr/local/bin/deploy-ferme <br>
|
||||
Pour le modifier, il faut copier le contenu du deploy-release.sh dans le deploy-ferme
|
||||
|
||||
### Livraison
|
||||
|
||||
Sur le serveur de recette, il suffit d'utiliser cette commande pour livrer
|
||||
|
||||
```bash
|
||||
sudo DEPLOY_OWNER=malio /usr/local/bin/deploy-ferme vX.X.X
|
||||
/usr/local/bin/deploy-ferme vX.Y.Z
|
||||
```
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
Pour restart le container
|
||||
|
||||
```bash
|
||||
make restart
|
||||
```
|
||||
|
||||
Pour lancer les TU
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Pour accéder au container et lance des commandes
|
||||
|
||||
```bash
|
||||
make shell
|
||||
```
|
||||
|
||||
Pour clear le cache Symfony
|
||||
|
||||
```bash
|
||||
make cache-clear
|
||||
```
|
||||
|
||||
Faire une migration
|
||||
|
||||
```bash
|
||||
make migration-migrate
|
||||
```
|
||||
|
||||
Pour générer un password pour un user
|
||||
|
||||
```bash
|
||||
make shell
|
||||
php bin/console security:hash-password
|
||||
```
|
||||
|
||||
Sélectionner entity User, taper sont mdp, le copier et l'ajouter dans l'insert de bdd suivant :
|
||||
|
||||
```sql
|
||||
INSERT INTO "user" (username, roles, password)
|
||||
VALUES ('Mon user', '["ROLE_USER"]', 'Mon mdp hashé');
|
||||
```
|
||||
|
||||
## Gestion des logs
|
||||
|
||||
Pour suivre les logs en temps réel :
|
||||
* tail -f var/log/dev.log
|
||||
* tail -f var/log/prod.log
|
||||
|
||||
- tail -f var/log/dev.log
|
||||
- tail -f var/log/prod.log
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"doctrine/orm": "^3.6",
|
||||
"dompdf/dompdf": "^3.1",
|
||||
"lexik/jwt-authentication-bundle": "*",
|
||||
"malio/ednotif-bundle": ">=0.0.6",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
@@ -87,9 +88,19 @@
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.92",
|
||||
"phpunit/phpunit": "^12.5",
|
||||
"symfony/browser-kit": "8.0.*",
|
||||
"symfony/css-selector": "8.0.*"
|
||||
"symfony/css-selector": "8.0.*",
|
||||
"symfony/maker-bundle": "^1.65",
|
||||
"symfony/stopwatch": "8.0.*",
|
||||
"symfony/web-profiler-bundle": "8.0.*"
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://gitea.malio.fr/MALIO-DEV/ednotif-bundle"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
999
composer.lock
generated
999
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
|
||||
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
||||
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||
use Malio\EdnotifBundle\EdnotifBundle;
|
||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Bundle\MakerBundle\MakerBundle;
|
||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||
use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle;
|
||||
|
||||
return [
|
||||
FrameworkBundle::class => ['all' => true],
|
||||
@@ -22,4 +26,8 @@ return [
|
||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
ApiPlatformBundle::class => ['all' => true],
|
||||
MonologBundle::class => ['all' => true],
|
||||
EdnotifBundle::class => ['all' => true],
|
||||
WebProfilerBundle::class => ['dev' => true],
|
||||
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
MakerBundle::class => ['dev' => true],
|
||||
];
|
||||
|
||||
@@ -5,6 +5,8 @@ api_platform:
|
||||
stateless: true
|
||||
cache_headers:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
pagination_client_items_per_page: true
|
||||
pagination_maximum_items_per_page: 100
|
||||
formats:
|
||||
json: ['application/json']
|
||||
jsonld: ['application/ld+json']
|
||||
|
||||
13
config/packages/ednotif.yaml
Normal file
13
config/packages/ednotif.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
ednotif:
|
||||
guichet_wsdl: 'https://ws-reswel-elv.equade.fr/wsguichet/WsGuichet?wsdl'
|
||||
metier_wsdl: 'https://ws-ednotif.equade.fr/wsIpBNotif/wsIpBNotif?wsdl'
|
||||
exploitation_code: '%env(string:EDNOTIF_EXPLOITATION_CODE)%'
|
||||
exploitation_number: '%env(string:EDNOTIF_EXPLOITATION_NUMERO)%'
|
||||
exploitation_country_code: 'FR'
|
||||
login: '%env(string:EDNOTIF_LOGIN)%'
|
||||
password: '%env(string:EDNOTIF_PASSWORD)%'
|
||||
token_ttl_seconds: 900
|
||||
soap_options:
|
||||
trace: false
|
||||
exceptions: true
|
||||
connection_timeout: 15
|
||||
@@ -20,7 +20,7 @@ when@prod:
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: info
|
||||
level: debug
|
||||
channels: ["!deprecation"]
|
||||
deprecation:
|
||||
type: stream
|
||||
|
||||
@@ -20,6 +20,7 @@ security:
|
||||
pattern: ^/login_check
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Security\UserChecker
|
||||
json_login:
|
||||
check_path: /login_check
|
||||
username_path: username
|
||||
@@ -30,6 +31,7 @@ security:
|
||||
pattern: ^/
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Security\UserChecker
|
||||
jwt: ~
|
||||
logout:
|
||||
path: /api/logout
|
||||
@@ -53,6 +55,8 @@ security:
|
||||
- { path: ^/api/users, roles: PUBLIC_ACCESS, methods: [GET] }
|
||||
# Doc API (swagger) en public
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
# Version de l'application en public
|
||||
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [GET] }
|
||||
# Tout le reste nécessite un JWT
|
||||
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
|
||||
7
config/packages/web_profiler.yaml
Normal file
7
config/packages/web_profiler.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
when@dev:
|
||||
web_profiler:
|
||||
toolbar: true
|
||||
|
||||
framework:
|
||||
profiler:
|
||||
collect_serializer_data: true
|
||||
1518
config/reference.php
1518
config/reference.php
File diff suppressed because it is too large
Load Diff
8
config/routes/web_profiler.yaml
Normal file
8
config/routes/web_profiler.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
when@dev:
|
||||
web_profiler_wdt:
|
||||
resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
|
||||
prefix: /_wdt
|
||||
|
||||
web_profiler_profiler:
|
||||
resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
|
||||
prefix: /_profiler
|
||||
@@ -8,6 +8,9 @@
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
|
||||
imports:
|
||||
- { resource: version.yaml }
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
_defaults:
|
||||
|
||||
2
config/version.yaml
Normal file
2
config/version.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.0.87'
|
||||
@@ -14,6 +14,8 @@ services:
|
||||
XDEBUG_CLIENT_HOST: ${XDEBUG_CLIENT_HOST:-host.docker.internal}
|
||||
XDEBUG_CONFIG: client_host=${XDEBUG_CLIENT_HOST:-host.docker.internal} client_port=9003
|
||||
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
COMPOSER_HOME: /tmp/composer
|
||||
COMPOSER_CACHE_DIR: /tmp/composer/cache
|
||||
volumes:
|
||||
- ./:/var/www/html
|
||||
- ~/.cache:/var/www/.cache # Pour la cache de composer
|
||||
|
||||
@@ -7,3 +7,5 @@ POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=root
|
||||
POSTGRES_PORT=5432
|
||||
XDEBUG_CLIENT_HOST=host.docker.internal
|
||||
CURRENT_UID=1004
|
||||
CURRENT_GID=1004
|
||||
|
||||
@@ -10,6 +10,16 @@ server {
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /_wdt/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /_profiler/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /bundles/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri =404;
|
||||
|
||||
@@ -102,6 +102,11 @@ RUN docker-php-ext-enable opcache
|
||||
RUN rm -rf /var/cache/apk/* && rm -rf /tmp/* && \
|
||||
curl --insecure https://getcomposer.org/composer.phar -o /usr/bin/composer && chmod +x /usr/bin/composer
|
||||
|
||||
# cache Composer pour www-data
|
||||
RUN mkdir -p /var/www/.composer/cache/vcs \
|
||||
&& chown -R www-data:www-data /var/www/.composer
|
||||
ENV COMPOSER_HOME=/var/www/.composer
|
||||
|
||||
# Création de la structure du projet
|
||||
RUN mkdir /var/www/html/LOG
|
||||
|
||||
|
||||
@@ -3,3 +3,11 @@
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { load } = useAppVersion()
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
23
frontend/assets/css/main.css
Normal file
23
frontend/assets/css/main.css
Normal file
@@ -0,0 +1,23 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
149
frontend/components/address.vue
Normal file
149
frontend/components/address.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<form :class="{ submitted }" @submit.prevent="validateForm">
|
||||
<div class="flex items-center mb-11 justify-between relative">
|
||||
<div class="flex flex-row absolute -left-[60px] ">
|
||||
<Icon @click="goBack" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
|
||||
</div>
|
||||
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
||||
{{ props.address ? "Modification d'une adresse" : "Ajout d'une adresse" }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-y-16 gap-x-[200px] mb-16">
|
||||
<UiTextInput id="address-street" v-model="form.street" label="Rue" required />
|
||||
<UiTextInput id="address-street2" v-model="form.street2" label="Complément" />
|
||||
<UiTextInput id="address-postalCode" v-model="form.postalCode" label="Code postal" required />
|
||||
<UiSelect
|
||||
id="address-city"
|
||||
v-model="form.city"
|
||||
label="Ville"
|
||||
:options="communeOptions"
|
||||
:loading="isLoadingCities"
|
||||
:disabled="communes.length === 0"
|
||||
required
|
||||
/>
|
||||
<UiTextInput id="address-country" v-model="form.countryCode" label="Pays (code)" />
|
||||
</div>
|
||||
<div class="flex justify-center items-center">
|
||||
<UiButton
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
@click="submitted = true"
|
||||
>
|
||||
Valider
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AddressPayload } from "~/services/address"
|
||||
import { getCommunesByPostalCode, type CommuneData } from "~/services/geo"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
type?: "supplier" | "customer",
|
||||
address?: AddressPayload | null
|
||||
}>()
|
||||
|
||||
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 => ({
|
||||
street: "",
|
||||
street2: null,
|
||||
postalCode: "",
|
||||
city: "",
|
||||
countryCode: "FR",
|
||||
})
|
||||
|
||||
const form = reactive<AddressPayload>(emptyForm())
|
||||
|
||||
const backPath = computed(() => {
|
||||
if (props.type === "customer") {
|
||||
const customerId = Number(route.query.customerId)
|
||||
return Number.isFinite(customerId) && customerId > 0
|
||||
? `/admin/customer/${customerId}`
|
||||
: "/admin/customer/customer-list"
|
||||
}
|
||||
|
||||
const supplierId = Number(route.query.supplierId)
|
||||
return Number.isFinite(supplierId) && supplierId > 0
|
||||
? `/admin/supplier/${supplierId}`
|
||||
: "/admin/supplier/supplier-list"
|
||||
})
|
||||
|
||||
const hydrateForm = (address?: AddressPayload | null) => {
|
||||
const data = address ?? emptyForm()
|
||||
form.street = data.street ?? ""
|
||||
form.street2 = data.street2 ?? null
|
||||
form.postalCode = data.postalCode ?? ""
|
||||
form.city = data.city ?? ""
|
||||
form.countryCode = data.countryCode || "FR"
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.address,
|
||||
(addr) => {
|
||||
hydrateForm(addr)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(
|
||||
() => form.postalCode,
|
||||
(cp) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
|
||||
if (!cp || cp.length < 5) {
|
||||
communes.value = []
|
||||
form.city = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (cp.length === 5) {
|
||||
debounceTimer = setTimeout(async () => {
|
||||
isLoadingCities.value = true
|
||||
const previousCity = form.city
|
||||
try {
|
||||
communes.value = await getCommunesByPostalCode(cp)
|
||||
|
||||
if (communes.value.length === 1) {
|
||||
form.city = communes.value[0].nom
|
||||
} else if (communes.value.some(c => c.nom === previousCity)) {
|
||||
form.city = previousCity
|
||||
} else {
|
||||
form.city = ''
|
||||
}
|
||||
} finally {
|
||||
isLoadingCities.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const validateForm = () => {
|
||||
if (isLoading.value) return
|
||||
emit("validate", {...form})
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push(backPath.value)
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'validate', form: AddressPayload): void
|
||||
}>()
|
||||
</script>
|
||||
29
frontend/components/card-link.vue
Normal file
29
frontend/components/card-link.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="link">
|
||||
<div class="w-[300px] h-[216px] border border-primary-700 rounded-lg p-6 flex flex-col justify-between gap-4">
|
||||
<div class="flex justify-between">
|
||||
<div class="rounded-full w-[80px] h-[80px] bg-[#D9D9D9] flex justify-center items-center">
|
||||
<Icon :name="iconName" class="!text-primary-700" size="44" />
|
||||
</div>
|
||||
<div>
|
||||
<Icon name="mdi:plus" style="color: black" size="44" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="uppercase font-bold">
|
||||
<p class="text-3xl text-primary-700">
|
||||
<slot name="label">{{ label }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
link: string
|
||||
iconName: string
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
65
frontend/components/commun/update-weight.vue
Normal file
65
frontend/components/commun/update-weight.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<form>
|
||||
<div class="grid grid-cols-3 gap-x-40 gap-y-8 mb-8">
|
||||
<UiNumberInput
|
||||
:key="localWeight.type"
|
||||
:label="'POIDS'"
|
||||
labelClass="font-bold uppercase text-xl "
|
||||
v-model="localWeight.weight"
|
||||
:disabled="!isAdmin"
|
||||
:min="0"
|
||||
wrapper-class="flex-col"
|
||||
required
|
||||
/>
|
||||
|
||||
<UiDateInput
|
||||
label="Date de pesée"
|
||||
v-model="localWeight.weighedAt"
|
||||
:disabled="!isAdmin"
|
||||
required
|
||||
/>
|
||||
|
||||
<UiNumberInput
|
||||
label="Dsd"
|
||||
class="col-start-2"
|
||||
labelClass="font-bold uppercase"
|
||||
v-model="localWeight.dsd"
|
||||
:disabled="!isAdmin"
|
||||
wrapper-class="flex-col"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {WeightEntryData} from '~/services/dto/weight-data'
|
||||
import {reactive, watch} from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: WeightEntryData
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: WeightEntryData): void
|
||||
}>()
|
||||
|
||||
const localWeight = reactive<WeightEntryData>({...props.modelValue})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
Object.assign(localWeight, value)
|
||||
},
|
||||
{deep: true}
|
||||
)
|
||||
|
||||
watch(
|
||||
localWeight,
|
||||
(value) => {
|
||||
emit('update:modelValue', {...value})
|
||||
},
|
||||
{deep: true}
|
||||
)
|
||||
</script>
|
||||
202
frontend/components/reception/reception-bovine-received.vue
Normal file
202
frontend/components/reception/reception-bovine-received.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<form
|
||||
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"
|
||||
class="flex flex-col gap-16"
|
||||
@submit.prevent="goNext"
|
||||
>
|
||||
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1>
|
||||
<div
|
||||
class="flex flex-row gap-8 items-center w-full">
|
||||
<div
|
||||
v-for="type in bovineType"
|
||||
:key="type.id"
|
||||
class="mt-8 flex flex-row mb-2 w-full">
|
||||
<UiNumberInput
|
||||
:id="type.id"
|
||||
:label="type.label"
|
||||
:code="type.code"
|
||||
v-model="bovineQuantities[String(type.id)]"
|
||||
:placeholder="0"
|
||||
:min="0"
|
||||
:max="10"
|
||||
class="max-w-[150px]"
|
||||
wrapper-class="gap-3"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-8 flex flex-row mb-2 gap-6">
|
||||
<UiNumberInput
|
||||
label="Autres"
|
||||
v-model="otherQuantity"
|
||||
class="max-w-[80px]"
|
||||
wrapper-class="gap-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-red-500 text-sm" :class="showBovineError ? '' : 'invisible'">
|
||||
Veuillez saisir au moins une race bovine.
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<UiButton
|
||||
type="submit"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||
>Valider
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
|
||||
import {getBovineTypeList} from "~/services/bovine-type";
|
||||
import {RECEPTION_TYPE_CODES} from "~/utils/constants";
|
||||
import {useReceptionStore} from '~/stores/reception'
|
||||
import {
|
||||
createReceptionBovine,
|
||||
deleteReceptionBovine,
|
||||
getReceptionBovineList,
|
||||
updateReceptionBovine
|
||||
} from "~/services/reception-bovine";
|
||||
import {computed, onMounted, reactive, ref, watch} from "vue";
|
||||
|
||||
const toast = useToast()
|
||||
const isLoadingBovineType = ref(false)
|
||||
const bovineType = ref<BovineTypeData[]>([])
|
||||
const receptionStore = useReceptionStore()
|
||||
const showBovineError = ref(false)
|
||||
const bovineQuantities = reactive<Record<string, number | null>>({})
|
||||
const otherQuantity = ref<number | null>(0)
|
||||
const receptionId = computed(() => receptionStore.current?.id ?? null)
|
||||
const receptionIri = computed(() =>
|
||||
receptionId.value ? `/api/receptions/${receptionId.value}` : null
|
||||
)
|
||||
const totalBovines = computed(() => {
|
||||
const base = Object.values(bovineQuantities).reduce((sum, value) => {
|
||||
return sum + (value ?? 0)
|
||||
}, 0)
|
||||
return base + (otherQuantity.value ?? 0)
|
||||
})
|
||||
|
||||
const loadBovineType = async () => {
|
||||
isLoadingBovineType.value = true
|
||||
try {
|
||||
bovineType.value = await getBovineTypeList()
|
||||
} finally {
|
||||
isLoadingBovineType.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBovineType()
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => receptionId.value, () => bovineType.value],
|
||||
async ([id, types]) => {
|
||||
if (!id || !receptionIri.value || types.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectionMap: Record<string, number | null> = {}
|
||||
for (const type of 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 = receptionStore.current?.bovineDetail
|
||||
const parsedOther =
|
||||
typeof existingOther === 'string' && existingOther.trim() !== ''
|
||||
? Number(existingOther)
|
||||
: 0
|
||||
otherQuantity.value = Number.isFinite(parsedOther) ? parsedOther : 0
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
|
||||
async function syncBovineSelections(receptionIri: string) {
|
||||
const existing = await getReceptionBovineList(receptionIri)
|
||||
const existingMap = new Map<string, { id: number; quantity: number | null }>()
|
||||
|
||||
for (const selection of existing) {
|
||||
const bovineTypeId = String(selection.bovineType.id)
|
||||
existingMap.set(bovineTypeId, {
|
||||
id: selection.id,
|
||||
quantity: selection.quantity ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
// Supprime les entrées supprimées ou modifiées
|
||||
for (const [bovineTypeId, entry] of existingMap.entries()) {
|
||||
const selectedQuantity = bovineQuantities[bovineTypeId] ?? 0
|
||||
if (!selectedQuantity) {
|
||||
await deleteReceptionBovine(entry.id)
|
||||
existingMap.delete(bovineTypeId)
|
||||
continue
|
||||
}
|
||||
|
||||
if (selectedQuantity !== entry.quantity) {
|
||||
await updateReceptionBovine(entry.id, {quantity: selectedQuantity})
|
||||
existingMap.set(bovineTypeId, {
|
||||
id: entry.id,
|
||||
quantity: selectedQuantity
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Crée les entrées manquantes
|
||||
for (const [bovineTypeId, quantity] of Object.entries(bovineQuantities)) {
|
||||
if (!quantity) {
|
||||
continue
|
||||
}
|
||||
if (existingMap.has(bovineTypeId)) {
|
||||
// Déjà à jour
|
||||
continue
|
||||
}
|
||||
await createReceptionBovine({
|
||||
reception: receptionIri,
|
||||
bovineType: `/api/bovine_types/${bovineTypeId}`,
|
||||
quantity
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function goNext() {
|
||||
if (!receptionStore.current || !receptionIri.value) {
|
||||
return
|
||||
}
|
||||
|
||||
showBovineError.value = false
|
||||
|
||||
if (totalBovines.value === 0) {
|
||||
showBovineError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (totalBovines.value > 52) {
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: ('Le total des bovins ne peut pas dépasser 52.')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextStep = receptionStore.current.currentStep + 1
|
||||
await syncBovineSelections(receptionIri.value)
|
||||
|
||||
await receptionStore.updateReception(receptionStore.current.id, {
|
||||
merchandiseType: null,
|
||||
merchandiseDetail: null,
|
||||
bovineDetail: otherQuantity.value ? String(otherQuantity.value) : null,
|
||||
currentStep: nextStep
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,67 +1,284 @@
|
||||
<template>
|
||||
<form @submit.prevent="validate">
|
||||
<div class="grid grid-cols-1 items-start gap-8 mb-16">
|
||||
<h1 class="font-bold text-5xl uppercase">Réception</h1>
|
||||
<div>
|
||||
<form ref="formRef" :class="{ submitted }" @submit.prevent="validate">
|
||||
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
|
||||
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Réception</h1>
|
||||
<UiSelect
|
||||
id="reception-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="reception-date"
|
||||
v-model="form.receptionDate"
|
||||
label="Date de réception"
|
||||
wrapper-class="col-start-1 row-start-3"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
id="reception-type"
|
||||
v-model="form.receptionTypeId"
|
||||
label="Type de réception"
|
||||
:options="receptionTypes.map((type) => ({
|
||||
value: String(type.id),
|
||||
label: type.label
|
||||
}))"
|
||||
wrapper-class="col-start-1 row-start-4"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
id="reception-supplier"
|
||||
v-model="form.supplierId"
|
||||
label="Fournisseur"
|
||||
:options="suppliers.map((supplier) => ({
|
||||
value: String(supplier.id),
|
||||
label: supplier.name
|
||||
}))"
|
||||
:loading="isLoadingSuppliers"
|
||||
wrapper-class="col-start-1 row-start-5"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
id="reception-address"
|
||||
v-model="form.addressId"
|
||||
label="Adresse"
|
||||
:options="addressOptions"
|
||||
:disabled="isLoadingSuppliers || ownerAddresses.length === 0"
|
||||
wrapper-class="col-start-2 row-start-1"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
id="reception-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="reception-carrier"
|
||||
v-model="form.carrierId"
|
||||
label="Transporteur"
|
||||
:options="carriers.map((carrier) => ({
|
||||
value: String(carrier.id),
|
||||
label: carrier.name
|
||||
}))"
|
||||
:loading="isLoadingCarriers"
|
||||
select-class="h-[34px]"
|
||||
wrapper-class="col-start-2 row-start-3"
|
||||
required
|
||||
/>
|
||||
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
|
||||
<UiLicensePlateInput
|
||||
v-model="form.licensePlate"
|
||||
v-model:allowAny="allowAnyLicensePlate"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label for="reception-date" class="font-bold uppercase text-xl mb-4">Date de reception</label>
|
||||
<input
|
||||
id="reception-date"
|
||||
v-model="form.receptionDate"
|
||||
type="date"
|
||||
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase"
|
||||
<UiSelect
|
||||
v-if="isLiotCarrier"
|
||||
id="reception-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 h-[64px]"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
id="reception-driver"
|
||||
v-model="form.driverId"
|
||||
label="Nom du chauffeur si LIOT"
|
||||
:options="filteredDrivers.map((driver) => ({
|
||||
value: String(driver.id),
|
||||
label: driver.name
|
||||
}))"
|
||||
:loading="isLoadingDrivers"
|
||||
v-if="isLiotCarrier"
|
||||
wrapper-class="col-start-2 row-start-5"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
<UiButton
|
||||
type="submit"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||
>Peser
|
||||
</button>
|
||||
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||
@click="submitted = true"
|
||||
>Valider
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useReceptionStore } from '~/stores/reception'
|
||||
|
||||
type ReceptionFormData = {
|
||||
licensePlate: string
|
||||
receptionDate: string
|
||||
}
|
||||
import { useFormDataLoading } from '~/composables/useFormDataLoading'
|
||||
import { useLiotHandling } from '~/composables/useLiotHandling'
|
||||
import { useAddressSync } from '~/composables/useAddressSync'
|
||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||
import { getReceptionTypeList } from '~/services/reception-type'
|
||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||
import { getSupplierList } from '~/services/supplier'
|
||||
import { RECEPTION_TYPE_CODES } from '~/utils/constants'
|
||||
import { deleteReceptionBovine, getReceptionBovineList } from '~/services/reception-bovine'
|
||||
import type { ReceptionFormData } from '~/services/dto/reception-data'
|
||||
|
||||
const router = useRouter()
|
||||
const receptionStore = useReceptionStore()
|
||||
const isHydrating = ref(false)
|
||||
const submitted = ref(false)
|
||||
const formRef = ref<HTMLFormElement | null>(null)
|
||||
|
||||
const form = reactive<ReceptionFormData>({
|
||||
licensePlate: '',
|
||||
receptionDate: new Date().toISOString().slice(0, 10)
|
||||
receptionDate: new Date().toISOString().slice(0, 10),
|
||||
receptionTypeId: '',
|
||||
userId: '',
|
||||
supplierId: '',
|
||||
addressId: '',
|
||||
truckId: '',
|
||||
carrierId: '',
|
||||
driverId: '',
|
||||
vehicleId: ''
|
||||
})
|
||||
const allowAnyLicensePlate = ref(false)
|
||||
|
||||
const receptionTypes = ref<ReceptionTypeData[]>([])
|
||||
const suppliers = ref<SupplierData[]>([])
|
||||
const isLoadingSuppliers = ref(false)
|
||||
|
||||
const { users, trucks, carriers, isLoadingUsers, isLoadingTrucks, isLoadingCarriers, loadCommonData } =
|
||||
useFormDataLoading(form)
|
||||
|
||||
const {
|
||||
isLiotCarrier, filteredDrivers, filteredVehicles,
|
||||
isLoadingDrivers, isLoadingVehicles, allowAnyLicensePlate,
|
||||
loadDrivers, loadVehicles
|
||||
} = useLiotHandling(form, carriers, isHydrating)
|
||||
|
||||
const supplierIdRef = computed(() => form.supplierId)
|
||||
const { ownerAddresses, addressOptions } = useAddressSync(form, supplierIdRef, suppliers)
|
||||
|
||||
const selectedReceptionType = computed(() =>
|
||||
receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null
|
||||
)
|
||||
|
||||
const clearReceptionBovines = async (receptionIri: string) => {
|
||||
const existing = await getReceptionBovineList(receptionIri)
|
||||
for (const selection of existing) {
|
||||
await deleteReceptionBovine(selection.id)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSuppliers = async () => {
|
||||
isLoadingSuppliers.value = true
|
||||
try {
|
||||
suppliers.value = await getSupplierList()
|
||||
} finally {
|
||||
isLoadingSuppliers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => receptionStore.current,
|
||||
(reception) => {
|
||||
isHydrating.value = true
|
||||
form.licensePlate = reception?.licensePlate ?? ''
|
||||
form.receptionDate = reception?.receptionDate ?? new Date().toISOString().slice(0, 10)
|
||||
form.receptionDate = reception?.receptionDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)
|
||||
form.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 }
|
||||
)
|
||||
|
||||
async function validate() {
|
||||
onMounted(async () => {
|
||||
receptionTypes.value = await getReceptionTypeList()
|
||||
await loadSuppliers()
|
||||
await loadCommonData()
|
||||
await loadDrivers()
|
||||
await loadVehicles()
|
||||
})
|
||||
|
||||
const buildPayload = () => {
|
||||
const normalizedLicensePlate = form.licensePlate.trim()
|
||||
const normalizedReceptionDate = form.receptionDate.trim()
|
||||
const normalizedReceptionTypeId = form.receptionTypeId.trim()
|
||||
const normalizedUserId = form.userId.trim()
|
||||
const normalizedSupplierId = form.supplierId.trim()
|
||||
const normalizedAddressId = form.addressId.trim()
|
||||
const normalizedTruckId = form.truckId.trim()
|
||||
const normalizedCarrierId = form.carrierId.trim()
|
||||
const normalizedDriverId = form.driverId.trim()
|
||||
|
||||
const receptionTypeIri = normalizedReceptionTypeId ? `/api/reception_types/${normalizedReceptionTypeId}` : null
|
||||
const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
|
||||
const supplierIri = normalizedSupplierId ? `/api/suppliers/${normalizedSupplierId}` : null
|
||||
const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
|
||||
const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
|
||||
const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
|
||||
const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
|
||||
|
||||
return {
|
||||
licensePlate: normalizedLicensePlate,
|
||||
receptionDate: normalizedReceptionDate,
|
||||
receptionType: receptionTypeIri,
|
||||
user: userIri,
|
||||
supplier: supplierIri,
|
||||
address: addressIri,
|
||||
truck: truckIri,
|
||||
carrier: carrierIri,
|
||||
...(isLiotCarrier.value && driverIri ? { driver: driverIri } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const saveDraft = async () => {
|
||||
const payload = buildPayload()
|
||||
if (!receptionStore.current) {
|
||||
await receptionStore.createReception({
|
||||
currentStep: 0,
|
||||
...payload
|
||||
})
|
||||
return
|
||||
}
|
||||
await receptionStore.updateReception(receptionStore.current.id, {
|
||||
currentStep: receptionStore.current.currentStep,
|
||||
...payload
|
||||
})
|
||||
}
|
||||
|
||||
const validateFields = () => {
|
||||
submitted.value = true
|
||||
return formRef.value?.reportValidity() ?? false
|
||||
}
|
||||
|
||||
defineExpose({ saveDraft, validateFields })
|
||||
|
||||
async function validate() {
|
||||
const payload = buildPayload()
|
||||
|
||||
if (!receptionStore.current) {
|
||||
const created = await receptionStore.createReception({
|
||||
currentStep: 1,
|
||||
licensePlate: normalizedLicensePlate,
|
||||
receptionDate: normalizedReceptionDate
|
||||
...payload
|
||||
})
|
||||
if (created) {
|
||||
await router.push(`/reception/${created.id}`)
|
||||
@@ -69,12 +286,20 @@ async function validate() {
|
||||
return
|
||||
}
|
||||
|
||||
const previousTypeCode = receptionStore.current.receptionType?.code ?? null
|
||||
const nextTypeCode = selectedReceptionType.value?.code ?? null
|
||||
const receptionIri = `/api/receptions/${receptionStore.current.id}`
|
||||
|
||||
if (
|
||||
previousTypeCode === RECEPTION_TYPE_CODES.BOVINS &&
|
||||
nextTypeCode === RECEPTION_TYPE_CODES.MERCHANDISES
|
||||
) {
|
||||
await clearReceptionBovines(receptionIri)
|
||||
}
|
||||
const nextStep = receptionStore.current.currentStep + 1
|
||||
await receptionStore.updateReception(receptionStore.current.id, {
|
||||
currentStep: nextStep,
|
||||
licensePlate: normalizedLicensePlate,
|
||||
receptionDate: normalizedReceptionDate
|
||||
...payload
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
288
frontend/components/reception/reception-product-received.vue
Normal file
288
frontend/components/reception/reception-product-received.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<form :class="['flex flex-col items-center gap-16', { submitted }]" @submit.prevent="goNext">
|
||||
<div
|
||||
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"
|
||||
class="flex flex-col gap-16 items-center w-full">
|
||||
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des marchandises réceptionnnées</h1>
|
||||
<UiSelect
|
||||
id="merchandise-type"
|
||||
v-model="selectedMerchandiseTypeId"
|
||||
label="Type de marchandises"
|
||||
:options="merchandiseTypes.map((type) => ({ value: String(type.id), label: type.label }))"
|
||||
wrapper-class="w-[550px]"
|
||||
required
|
||||
/>
|
||||
<div
|
||||
v-if="selectedMerchandiseTypeId && isAutres"
|
||||
class="flex flex-col w-full max-w-[550px]"
|
||||
>
|
||||
<UiTextInput
|
||||
id="merchandise-detail"
|
||||
v-model="merchandiseDetail"
|
||||
label="Préciser"
|
||||
placeholder="Précisions complémentaires"
|
||||
:maxlength="255"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedMerchandiseTypeId && !isGranule"
|
||||
class="flex flex-col gap-4 w-[550px]"
|
||||
>
|
||||
<div class="flex gap-4 justify-between">
|
||||
<div
|
||||
v-for="building in buildings"
|
||||
:key="building.id"
|
||||
>
|
||||
<UiCheckbox
|
||||
v-model="selectedBuildingIds"
|
||||
:value="String(building.id)"
|
||||
:label="building.label"
|
||||
label-class="text-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-red-500 text-sm" :class="showBuildingError ? '' : 'invisible'">
|
||||
Veuillez sélectionner au moins un bâtiment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedMerchandiseTypeId && isGranule"
|
||||
class="flex flex-col gap-10 w-full max-w-[1100px]"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-10 md:grid-cols-4">
|
||||
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4">
|
||||
<p class="font-bold uppercase text-primary-500">{{ type.label }}</p>
|
||||
<div
|
||||
v-for="building in buildings"
|
||||
:key="building.id"
|
||||
class="flex items-center gap-2 text-lg pl-[2px]"
|
||||
>
|
||||
<UiCheckbox
|
||||
v-model="selectedPelletBuildingIds[String(type.id)]"
|
||||
:value="String(building.id)"
|
||||
:label="building.label"
|
||||
label-class="text-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-red-500 text-sm" :class="showBuildingError ? '' : 'invisible'">
|
||||
Veuillez sélectionner au moins un bâtiment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<UiButton
|
||||
type="submit"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||
@click="submitted = true"
|
||||
>Valider
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, ref} from 'vue'
|
||||
import {getBuildingList} from '~/services/building'
|
||||
import {getMerchandiseTypeList} from '~/services/merchandise-type'
|
||||
import type {MerchandiseTypeData} from '~/services/dto/merchandise-type-data'
|
||||
import type {BuildingData} from '~/services/dto/building-data'
|
||||
import type {PelletTypeData} from '~/services/dto/pellet-type-data'
|
||||
import {getPelletTypeList} from '~/services/pellet-type'
|
||||
import {
|
||||
createReceptionPelletBuilding,
|
||||
deleteReceptionPelletBuilding,
|
||||
getReceptionPelletBuildingList
|
||||
} from '~/services/reception-pellet-building'
|
||||
import {useReceptionStore} from '~/stores/reception'
|
||||
import {MERCHANDISE_TYPE_CODES, RECEPTION_TYPE_CODES} from '~/utils/constants'
|
||||
import ReceptionBovineReceived from "~/components/reception/reception-bovine-received.vue";
|
||||
|
||||
const receptionStore = useReceptionStore()
|
||||
const merchandiseTypes = ref<MerchandiseTypeData[]>([])
|
||||
const buildings = ref<BuildingData[]>([])
|
||||
const pelletTypes = ref<PelletTypeData[]>([])
|
||||
const selectedMerchandiseTypeId = ref('')
|
||||
const selectedBuildingIds = ref<string[]>([])
|
||||
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
|
||||
const merchandiseDetail = ref('')
|
||||
const submitted = ref(false)
|
||||
const showBuildingError = ref(false)
|
||||
const showPelletBuildingError = ref(false)
|
||||
|
||||
// Extrait l'ID d'une relation depuis un IRI ou un objet complet.
|
||||
const getRelationId = (value: unknown): string | null => {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const match = value.match(/\/(\d+)$/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && 'id' in value) {
|
||||
const record = value as { id?: number | string }
|
||||
if (typeof record.id === 'number') {
|
||||
return String(record.id)
|
||||
}
|
||||
if (typeof record.id === 'string') {
|
||||
return record.id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Type de marchandise sélectionné dans le select
|
||||
const selectedMerchandiseType = computed(() =>
|
||||
merchandiseTypes.value.find((type) => String(type.id) === selectedMerchandiseTypeId.value)
|
||||
)
|
||||
// Indique si le type est "Granulé"
|
||||
const isGranule = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.GRANULE)
|
||||
// Indique si le type est "Autres"
|
||||
const isAutres = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES)
|
||||
|
||||
// Charge les référentiels et hydrate le formulaire depuis la réception
|
||||
onMounted(async () => {
|
||||
const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([
|
||||
getMerchandiseTypeList(),
|
||||
getBuildingList(),
|
||||
getPelletTypeList()
|
||||
])
|
||||
merchandiseTypes.value = merchandiseTypeList
|
||||
buildings.value = buildingList
|
||||
pelletTypes.value = pelletTypeList
|
||||
|
||||
const currentId = receptionStore.current?.merchandiseType?.id
|
||||
if (currentId) {
|
||||
selectedMerchandiseTypeId.value = String(currentId)
|
||||
}
|
||||
merchandiseDetail.value = receptionStore.current?.merchandiseDetail ?? ''
|
||||
|
||||
selectedBuildingIds.value =
|
||||
receptionStore.current?.buildings?.map((building) => String(building.id)) ?? []
|
||||
|
||||
const existingPelletSelections = receptionStore.current?.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 goNext() {
|
||||
if (!receptionStore.current) {
|
||||
return
|
||||
}
|
||||
|
||||
showBuildingError.value = false
|
||||
showPelletBuildingError.value = false
|
||||
|
||||
if (!isGranule.value && !isAutres.value && selectedBuildingIds.value.length === 0) {
|
||||
showBuildingError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (isGranule.value) {
|
||||
const hasAnyPelletBuilding = Object.values(selectedPelletBuildingIds.value)
|
||||
.some((ids) => ids.length > 0)
|
||||
if (!hasAnyPelletBuilding) {
|
||||
showPelletBuildingError.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const nextStep = receptionStore.current.currentStep + 1
|
||||
const receptionIri = `/api/receptions/${receptionStore.current.id}`
|
||||
|
||||
await receptionStore.updateReception(receptionStore.current.id, {
|
||||
merchandiseType: selectedMerchandiseTypeId.value
|
||||
? `/api/merchandise_types/${selectedMerchandiseTypeId.value}`
|
||||
: null,
|
||||
merchandiseDetail: isAutres.value ? merchandiseDetail.value.trim() : null,
|
||||
buildings: isGranule.value
|
||||
? []
|
||||
: selectedBuildingIds.value.map((id) => `/api/buildings/${id}`),
|
||||
bovineDetail: null,
|
||||
bovinesTypes: null,
|
||||
currentStep: nextStep
|
||||
})
|
||||
|
||||
if (isGranule.value) {
|
||||
await syncPelletSelections(receptionIri)
|
||||
} else {
|
||||
await clearPelletSelections(receptionIri)
|
||||
}
|
||||
}
|
||||
|
||||
// Supprime toutes les associations granulés/bâtiments existantes
|
||||
async function clearPelletSelections(receptionIri: string) {
|
||||
const existing = await getReceptionPelletBuildingList(receptionIri)
|
||||
for (const selection of existing) {
|
||||
await deleteReceptionPelletBuilding(selection.id)
|
||||
}
|
||||
}
|
||||
// Synchronise les associations granulés/bâtiments avec l'état du formulaire
|
||||
async function syncPelletSelections(receptionIri: string) {
|
||||
const existing = await getReceptionPelletBuildingList(receptionIri)
|
||||
const existingMap = new Map<string, number>()
|
||||
for (const selection of existing) {
|
||||
// Construit la table de correspondance avec des IDs normalisés pour éviter les doublons.
|
||||
const pelletTypeId = getRelationId(selection.pelletType)
|
||||
const buildingId = getRelationId(selection.building)
|
||||
if (!pelletTypeId || !buildingId) {
|
||||
continue
|
||||
}
|
||||
const key = `${pelletTypeId}:${buildingId}`
|
||||
existingMap.set(key, selection.id)
|
||||
}
|
||||
|
||||
const desiredEntries: Array<{ pelletTypeId: string; buildingId: string }> = []
|
||||
for (const [pelletTypeId, buildingIds] of Object.entries(selectedPelletBuildingIds.value)) {
|
||||
for (const buildingId of buildingIds) {
|
||||
desiredEntries.push({pelletTypeId, buildingId})
|
||||
}
|
||||
}
|
||||
|
||||
const desiredKeys = new Set(desiredEntries.map(
|
||||
(entry) => `${entry.pelletTypeId}:${entry.buildingId}`
|
||||
))
|
||||
|
||||
for (const [key, id] of existingMap.entries()) {
|
||||
if (!desiredKeys.has(key)) {
|
||||
await deleteReceptionPelletBuilding(id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of desiredEntries) {
|
||||
const key = `${entry.pelletTypeId}:${entry.buildingId}`
|
||||
if (!existingMap.has(key)) {
|
||||
await createReceptionPelletBuilding({
|
||||
reception: receptionIri,
|
||||
pelletType: `/api/pellet_types/${entry.pelletTypeId}`,
|
||||
building: `/api/buildings/${entry.buildingId}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,30 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center mt-[164px] gap-32">
|
||||
<div class="flex gap-8 items-center justify-center">
|
||||
<!--@TODO Prendre en compte que l'on peut aussi décharger de la marchandise-->
|
||||
<h1 class="text-4xl uppercase font-bold">Décharger les bêtes</h1>
|
||||
<UiLoadingDots />
|
||||
</div>
|
||||
<button
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
||||
@click="goNext"
|
||||
>Peser</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useReceptionStore } from '~/stores/reception'
|
||||
|
||||
const receptionStore = useReceptionStore()
|
||||
|
||||
async function goNext() {
|
||||
if (!receptionStore.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextStep = receptionStore.current.currentStep + 1
|
||||
await receptionStore.updateReception(receptionStore.current.id, {
|
||||
currentStep: nextStep
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,104 +0,0 @@
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<div class="flex flex-col items-center w-[660px]">
|
||||
<h1 class="font-bold text-5xl uppercase">{{ title }}</h1>
|
||||
<!--@TODO Voir comment faire pour savoir si le pont-bascule et bien connecté + ajouter un icon comme sur la maquette-->
|
||||
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
|
||||
<div
|
||||
v-if="showLoadingBox"
|
||||
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
|
||||
<UiLoadingDots />
|
||||
</div>
|
||||
<div v-else-if="displayWeight !== null" class="w-full">
|
||||
<div
|
||||
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl">
|
||||
{{ displayWeight }} kg
|
||||
</div>
|
||||
<!-- <div class="grid grid-cols-2 border border-black text-center">-->
|
||||
<!-- <p class="border-r border-black py-3 text-4xl font-bold">DSD</p>-->
|
||||
<!-- <p class="py-3 text-4xl">{{ displayDsd }}</p>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center mt-[54px]">
|
||||
<button
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
||||
@click="fetchWeight"
|
||||
>{{ displayWeight !== null ? 'refaire une pesee' : 'peser' }}</button>
|
||||
<button
|
||||
v-if="displayWeight !== null && !showGenerateReceipt"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
|
||||
@click="saveWeight"
|
||||
>Valider la pesée</button>
|
||||
<button
|
||||
v-if="showGenerateReceipt"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
|
||||
@click="printReceipt"
|
||||
>Générer le bon</button>
|
||||
</div>
|
||||
<UiPdfPrinter ref="pdfPrinter" />
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useWeighing } from '~/composables/useWeighing'
|
||||
import { useReceptionStore } from '~/stores/reception'
|
||||
|
||||
const props = defineProps<{
|
||||
mode: 'gross' | 'tare'
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const receptionStore = useReceptionStore()
|
||||
const { current: storeReception } = storeToRefs(receptionStore)
|
||||
type PdfPrinterHandle = {
|
||||
print: (url: string) => Promise<void>
|
||||
}
|
||||
// Ref sur le composant d'impression pour déclencher le print() du PDF.
|
||||
const pdfPrinter = ref<PdfPrinterHandle | null>(null)
|
||||
const {
|
||||
displayWeight,
|
||||
displayDsd,
|
||||
title,
|
||||
showLoadingBox,
|
||||
fetchWeight,
|
||||
saveWeight
|
||||
} = useWeighing({
|
||||
mode: props.mode,
|
||||
reception: storeReception,
|
||||
updateReception: receptionStore.updateReception,
|
||||
loadReception: receptionStore.loadReception
|
||||
})
|
||||
const showGenerateReceipt = computed(
|
||||
() => props.mode === 'tare' && displayWeight.value !== null
|
||||
)
|
||||
|
||||
const printReceipt = async () => {
|
||||
if (!import.meta.client || !receptionStore.current || !pdfPrinter.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await saveWeight()
|
||||
await pdfPrinter.value.print(`/receptions/${receptionStore.current.id}/receipt`)
|
||||
|
||||
// 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('/')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWeight()
|
||||
})
|
||||
</script>
|
||||
161
frontend/components/reception/update-bovin.vue
Normal file
161
frontend/components/reception/update-bovin.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<form>
|
||||
<div class="flex flex-row justify-between gap-x-12 font-bold uppercase mb-8">
|
||||
<div
|
||||
v-for="type in bovineTypes"
|
||||
:key="type.id"
|
||||
>
|
||||
<UiNumberInput
|
||||
:label="type.label"
|
||||
:code="type.code"
|
||||
v-model="localQuantities[String(type.id)]"
|
||||
:disabled="!isAdmin"
|
||||
:placeholder="0"
|
||||
:min="0"
|
||||
:max="10"
|
||||
wrapperClass="w-44 flex-col"
|
||||
inputClass="font-medium"
|
||||
/>
|
||||
</div>
|
||||
<UiNumberInput
|
||||
label="Autres"
|
||||
v-model="localOtherQuantity"
|
||||
:disabled="!isAdmin"
|
||||
wrapperClass="w-44 flex-col"
|
||||
inputClass="font-medium"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { getBovineTypeList } from '~/services/bovine-type'
|
||||
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
|
||||
import type { ReceptionBovineTypeData } from '~/services/dto/reception-bovine-data'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ReceptionBovineTypeData[]
|
||||
otherQuantity: number | null
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: ReceptionBovineTypeData[]): void
|
||||
(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
|
||||
}
|
||||
)
|
||||
|
||||
watch(localOtherQuantity, (value) => {
|
||||
if (isSyncing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = value ?? 0
|
||||
emit('update:otherQuantity', next)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
// Hydratation locale uniquement quand le parent change.
|
||||
syncLocalFromProps()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
localQuantities,
|
||||
() => {
|
||||
if (isSyncing.value) {
|
||||
return
|
||||
}
|
||||
// N'émet que si les quantités diffèrent réellement du parent.
|
||||
const nextEntries = buildEntriesFromLocal()
|
||||
if (!entriesEqualByTypeAndQuantity(nextEntries, props.modelValue)) {
|
||||
emit('update:modelValue', nextEntries)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
bovineTypes.value = await getBovineTypeList()
|
||||
syncLocalFromProps()
|
||||
})
|
||||
</script>
|
||||
265
frontend/components/reception/update-merchandise.vue
Normal file
265
frontend/components/reception/update-merchandise.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<form>
|
||||
<div class="flex flex-col">
|
||||
<div class="w-full relative grid grid-cols-[1fr_200px]">
|
||||
<UiRadioGroup
|
||||
id="merchandise-type"
|
||||
v-model="selectedMerchandiseTypeId"
|
||||
label="Type de marchandises"
|
||||
:options="merchandiseTypes.map((type) => ({
|
||||
value: String(type.id),
|
||||
label: type.label
|
||||
}))"
|
||||
input-class="accent-primary-700 focus:ring-primary-700"
|
||||
option-label-class="uppercase"
|
||||
wrapper-class="w-full uppercase"
|
||||
group-class="grid grid-cols-4 mt-9 mb-7"
|
||||
:disabled="!isAdmin"
|
||||
/>
|
||||
<UiTextInput
|
||||
v-if="isAutres"
|
||||
id="merchandise-detail"
|
||||
:disabled="!isAdmin"
|
||||
v-model="merchandiseDetail"
|
||||
placeholder="Préciser"
|
||||
:maxlength="255"
|
||||
wrapper-class="w-[200px] mt-12 mb-7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedMerchandiseTypeId && !isGranule"
|
||||
class="w-full grid grid-cols-[1fr_200px]"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-6"
|
||||
>
|
||||
<div
|
||||
v-for="building in buildings"
|
||||
:key="building.id"
|
||||
>
|
||||
<UiCheckbox
|
||||
v-model="selectedBuildingIds"
|
||||
:value="String(building.id)"
|
||||
:label="building.label"
|
||||
:disabled="!isAdmin"
|
||||
input-class="accent-primary-700 focus:ring-primary-700"
|
||||
label-class="uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedMerchandiseTypeId && isGranule"
|
||||
class="grid grid-cols-[1fr_200px] w-full col-start-2 row-start-1"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-6 justify-between">
|
||||
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4">
|
||||
<p class="mb-1 font-medium uppercase">{{ type.label }}</p>
|
||||
<div
|
||||
v-for="building in buildings"
|
||||
:key="building.id"
|
||||
class="flex text-lg"
|
||||
>
|
||||
<UiCheckbox
|
||||
v-model="selectedPelletBuildingIds[String(type.id)]"
|
||||
:value="String(building.id)"
|
||||
:label="building.label"
|
||||
:disabled="!isAdmin"
|
||||
input-class="accent-primary-700 focus:ring-primary-700"
|
||||
label-class="text-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import type { BuildingData } from '~/services/dto/building-data'
|
||||
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
|
||||
import type { PelletTypeData } from '~/services/dto/pellet-type-data'
|
||||
import type { MerchandiseEntryData } from '~/services/dto/reception-data'
|
||||
import { getBuildingList } from '~/services/building'
|
||||
import { getMerchandiseTypeList } from '~/services/merchandise-type'
|
||||
import { getPelletTypeList } from '~/services/pellet-type'
|
||||
import { MERCHANDISE_TYPE_CODES } from '~/utils/constants'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: MerchandiseEntryData
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: MerchandiseEntryData): void
|
||||
}>()
|
||||
|
||||
const merchandiseTypes = ref<MerchandiseTypeData[]>([])
|
||||
const buildings = ref<BuildingData[]>([])
|
||||
const pelletTypes = ref<PelletTypeData[]>([])
|
||||
|
||||
const selectedMerchandiseTypeId = ref('')
|
||||
const selectedBuildingIds = ref<string[]>([])
|
||||
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
|
||||
const merchandiseDetail = ref('')
|
||||
// Verrou de synchro pour empêcher les aller-retours infinis entre parent et composant.
|
||||
const isSyncing = ref(false)
|
||||
const isReady = ref(false)
|
||||
|
||||
const selectedMerchandiseType = computed(() =>
|
||||
merchandiseTypes.value.find((type) => String(type.id) === selectedMerchandiseTypeId.value) ?? null
|
||||
)
|
||||
const isGranule = computed(
|
||||
() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.GRANULE
|
||||
)
|
||||
const isAutres = computed(
|
||||
() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES
|
||||
)
|
||||
|
||||
function clonePelletSelections(value: Record<string, string[]>) {
|
||||
const clone: Record<string, string[]> = {}
|
||||
for (const [key, buildingIds] of Object.entries(value)) {
|
||||
clone[key] = [...buildingIds]
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
function sorted(values: string[]): string[] {
|
||||
return [...values].sort()
|
||||
}
|
||||
|
||||
function normalizeModel(value: MerchandiseEntryData): MerchandiseEntryData {
|
||||
// Normalisation stable pour comparer deux modèles sans faux positifs (ordre des tableaux).
|
||||
const pellet: Record<string, string[]> = {}
|
||||
const pelletKeys = Object.keys(value.selectedPelletBuildingIds ?? {}).sort()
|
||||
for (const key of pelletKeys) {
|
||||
pellet[key] = sorted(value.selectedPelletBuildingIds[key] ?? [])
|
||||
}
|
||||
|
||||
return {
|
||||
merchandiseTypeId: value.merchandiseTypeId ?? '',
|
||||
merchandiseDetail: value.merchandiseDetail ?? '',
|
||||
selectedBuildingIds: sorted(value.selectedBuildingIds ?? []),
|
||||
selectedPelletBuildingIds: pellet
|
||||
}
|
||||
}
|
||||
|
||||
function buildCurrentModel(): MerchandiseEntryData {
|
||||
return {
|
||||
merchandiseTypeId: selectedMerchandiseTypeId.value,
|
||||
merchandiseDetail: merchandiseDetail.value,
|
||||
selectedBuildingIds: [...selectedBuildingIds.value],
|
||||
selectedPelletBuildingIds: clonePelletSelections(selectedPelletBuildingIds.value)
|
||||
}
|
||||
}
|
||||
|
||||
function isSameModel(left: MerchandiseEntryData, right: MerchandiseEntryData): boolean {
|
||||
return JSON.stringify(normalizeModel(left)) === JSON.stringify(normalizeModel(right))
|
||||
}
|
||||
|
||||
function ensurePelletKeys() {
|
||||
for (const pelletType of pelletTypes.value) {
|
||||
const key = String(pelletType.id)
|
||||
if (!selectedPelletBuildingIds.value[key]) {
|
||||
selectedPelletBuildingIds.value[key] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateFromModelValue(value: MerchandiseEntryData) {
|
||||
isSyncing.value = true
|
||||
try {
|
||||
selectedMerchandiseTypeId.value = value.merchandiseTypeId ?? ''
|
||||
merchandiseDetail.value = value.merchandiseDetail ?? ''
|
||||
selectedBuildingIds.value = [...(value.selectedBuildingIds ?? [])]
|
||||
selectedPelletBuildingIds.value = clonePelletSelections(
|
||||
value.selectedPelletBuildingIds ?? {}
|
||||
)
|
||||
ensurePelletKeys()
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeLocalState() {
|
||||
if (isGranule.value) {
|
||||
if (selectedBuildingIds.value.length > 0) {
|
||||
selectedBuildingIds.value = []
|
||||
}
|
||||
} else {
|
||||
for (const key of Object.keys(selectedPelletBuildingIds.value)) {
|
||||
if (selectedPelletBuildingIds.value[key].length > 0) {
|
||||
selectedPelletBuildingIds.value[key] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAutres.value && merchandiseDetail.value !== '') {
|
||||
merchandiseDetail.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function emitCurrentModel() {
|
||||
const currentModel = buildCurrentModel()
|
||||
// Ne pas réémettre si rien n'a changé côté métier.
|
||||
if (isSameModel(currentModel, props.modelValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:modelValue', currentModel)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const currentModel = buildCurrentModel()
|
||||
// Si local == parent, on ignore pour éviter la boucle de réhydratation.
|
||||
if (isSameModel(currentModel, value)) {
|
||||
return
|
||||
}
|
||||
hydrateFromModelValue(value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
[selectedMerchandiseTypeId, selectedBuildingIds, selectedPelletBuildingIds, merchandiseDetail],
|
||||
() => {
|
||||
if (isSyncing.value || !isReady.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const beforeSanitize = buildCurrentModel()
|
||||
isSyncing.value = true
|
||||
// Applique les règles métier (granulé / autres) avant émission.
|
||||
sanitizeLocalState()
|
||||
isSyncing.value = false
|
||||
|
||||
const afterSanitize = buildCurrentModel()
|
||||
// Si la sanitation a modifié l'état, on laisse le watcher repasser proprement.
|
||||
if (!isSameModel(beforeSanitize, afterSanitize)) {
|
||||
return
|
||||
}
|
||||
|
||||
emitCurrentModel()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([
|
||||
getMerchandiseTypeList(),
|
||||
getBuildingList(),
|
||||
getPelletTypeList()
|
||||
])
|
||||
merchandiseTypes.value = merchandiseTypeList
|
||||
buildings.value = buildingList
|
||||
pelletTypes.value = pelletTypeList
|
||||
|
||||
hydrateFromModelValue(props.modelValue)
|
||||
isReady.value = true
|
||||
})
|
||||
</script>
|
||||
317
frontend/components/shipment/shipment-form.vue
Normal file
317
frontend/components/shipment/shipment-form.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<form ref="formRef" :class="{ submitted }" @submit.prevent="validate">
|
||||
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
|
||||
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Expédition</h1>
|
||||
<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 du jour"
|
||||
wrapper-class="col-start-1 row-start-3"
|
||||
required
|
||||
/>
|
||||
<div class="col-start-1 row-start-4 h-[64px]">
|
||||
<div class="flex w-full items-end gap-[104px]">
|
||||
<UiRadioGroup
|
||||
id="shipment-type"
|
||||
name="shipment-type"
|
||||
label="Type d'expédition bovine"
|
||||
input-class="accent-primary-700 focus:ring-primary-700"
|
||||
wrapper-class=""
|
||||
group-class="flex flex-row gap-[104px] w-[160px_160px] h-[32px]"
|
||||
v-model="selectedShipmentTypeId"
|
||||
:options="bovineShipment.map((type) => ({
|
||||
value: String(type.id),
|
||||
label: type.label
|
||||
}))"
|
||||
required
|
||||
/>
|
||||
<UiNumberInput
|
||||
id="shipment-type-quantity"
|
||||
v-model="shipmentQuantity"
|
||||
:placeholder="0"
|
||||
:min="0"
|
||||
:max="1200"
|
||||
:disabled="!selectedShipmentTypeId"
|
||||
/>
|
||||
</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="addressOptions"
|
||||
:disabled="isLoadingCustomers || ownerAddresses.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
|
||||
/>
|
||||
<UiSelect
|
||||
id="shipment-driver"
|
||||
v-model="form.driverId"
|
||||
label="Nom du chauffeur si LIOT"
|
||||
:options="filteredDrivers.map((driver) => ({
|
||||
value: String(driver.id),
|
||||
label: driver.name
|
||||
}))"
|
||||
:loading="isLoadingDrivers"
|
||||
wrapper-class="col-start-2 row-start-5"
|
||||
v-if="isLiotCarrier"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<UiButton
|
||||
type="submit"
|
||||
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||
@click="submitted = true"
|
||||
>Valider
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFormDataLoading } from '~/composables/useFormDataLoading'
|
||||
import { useLiotHandling } from '~/composables/useLiotHandling'
|
||||
import { useAddressSync } from '~/composables/useAddressSync'
|
||||
import type { CustomerData } from '~/services/dto/customer-data'
|
||||
import { getCustomerList } from '~/services/customer'
|
||||
import type { ShipmentFormData } from '~/services/dto/shipment-data'
|
||||
import { useShipmentStore } from '~/stores/shipment'
|
||||
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||
import { getShipmentTypeList } from '~/services/shipment-type'
|
||||
|
||||
const router = useRouter()
|
||||
const shipmentStore = useShipmentStore()
|
||||
const isHydrating = ref(false)
|
||||
const submitted = ref(false)
|
||||
const formRef = ref<HTMLFormElement | null>(null)
|
||||
|
||||
const form = reactive<ShipmentFormData>({
|
||||
userId: '',
|
||||
shipmentDate: new Date().toISOString().slice(0, 10),
|
||||
customerId: '',
|
||||
addressId: '',
|
||||
truckId: '',
|
||||
carrierId: '',
|
||||
driverId: '',
|
||||
vehicleId: '',
|
||||
licensePlate: '',
|
||||
})
|
||||
|
||||
const customers = ref<CustomerData[]>([])
|
||||
const isLoadingCustomers = ref(false)
|
||||
const bovineShipment = ref<ShipmentTypeData[]>([])
|
||||
const selectedShipmentTypeId = ref('')
|
||||
const shipmentQuantity = ref<number | null>(0)
|
||||
|
||||
const { users, trucks, carriers, isLoadingUsers, isLoadingTrucks, isLoadingCarriers, loadCommonData } =
|
||||
useFormDataLoading(form)
|
||||
|
||||
const {
|
||||
isLiotCarrier, filteredDrivers, filteredVehicles,
|
||||
isLoadingDrivers, isLoadingVehicles, allowAnyLicensePlate,
|
||||
loadDrivers, loadVehicles
|
||||
} = useLiotHandling(form, carriers, isHydrating)
|
||||
|
||||
const customerIdRef = computed(() => form.customerId)
|
||||
const { ownerAddresses, addressOptions } = useAddressSync(form, customerIdRef, customers)
|
||||
|
||||
const loadCustomers = async () => {
|
||||
isLoadingCustomers.value = true
|
||||
try {
|
||||
customers.value = await getCustomerList()
|
||||
} finally {
|
||||
isLoadingCustomers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => shipmentStore.current,
|
||||
(shipment) => {
|
||||
isHydrating.value = true
|
||||
form.licensePlate = shipment?.licensePlate ?? ''
|
||||
form.shipmentDate = shipment?.shipmentDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)
|
||||
form.userId = shipment?.user?.id ? String(shipment.user.id) : form.userId
|
||||
form.customerId = shipment?.customer?.id ? String(shipment.customer.id) : ''
|
||||
form.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
|
||||
isHydrating.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Extra watcher for LIOT defaults after hydration
|
||||
watch(
|
||||
() => isHydrating.value,
|
||||
(value) => {
|
||||
if (!value && isLiotCarrier.value) {
|
||||
if (filteredDrivers.value.length === 1 && !form.driverId) {
|
||||
form.driverId = String(filteredDrivers.value[0].id)
|
||||
}
|
||||
if (filteredVehicles.value.length === 1 && !form.vehicleId) {
|
||||
form.vehicleId = String(filteredVehicles.value[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
bovineShipment.value = await getShipmentTypeList()
|
||||
await loadCustomers()
|
||||
await loadCommonData()
|
||||
await loadVehicles()
|
||||
await loadDrivers()
|
||||
})
|
||||
|
||||
const 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 customerIri = normalizedCustomerId ? `/api/customers/${normalizedCustomerId}` : null
|
||||
const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
|
||||
const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
|
||||
const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
|
||||
const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
|
||||
const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
|
||||
const normalizedShipmentTypeId = selectedShipmentTypeId.value.trim()
|
||||
const shipmentTypeIri = normalizedShipmentTypeId ? `/api/shipment_types/${normalizedShipmentTypeId}` : null
|
||||
|
||||
const rawQuantity = Number(shipmentQuantity.value ?? 0)
|
||||
const normalizedQuantity = Number.isFinite(rawQuantity) ? Math.max(0, Math.trunc(rawQuantity)) : 0
|
||||
|
||||
return {
|
||||
licensePlate: normalizedLicensePlate,
|
||||
shipmentDate: normalizedShipmentDate,
|
||||
customer: customerIri,
|
||||
truck: truckIri,
|
||||
carrier: carrierIri,
|
||||
driver: driverIri,
|
||||
user: userIri,
|
||||
address: addressIri,
|
||||
shipmentType: shipmentTypeIri,
|
||||
nbBovinSend: normalizedQuantity,
|
||||
}
|
||||
}
|
||||
|
||||
const saveDraft = async () => {
|
||||
const payload = buildPayload()
|
||||
if (!shipmentStore.current) {
|
||||
await shipmentStore.createShipment({
|
||||
currentStep: 0,
|
||||
...payload
|
||||
})
|
||||
return
|
||||
}
|
||||
await shipmentStore.updateShipment(shipmentStore.current.id, {
|
||||
currentStep: shipmentStore.current.currentStep,
|
||||
...payload
|
||||
})
|
||||
}
|
||||
|
||||
const validateFields = () => {
|
||||
submitted.value = true
|
||||
return formRef.value?.reportValidity() ?? false
|
||||
}
|
||||
|
||||
defineExpose({ saveDraft, validateFields })
|
||||
|
||||
const validate = async () => {
|
||||
const payload = buildPayload()
|
||||
if (!shipmentStore.current) {
|
||||
const created = await shipmentStore.createShipment({
|
||||
currentStep: 1,
|
||||
...payload
|
||||
})
|
||||
if (created) {
|
||||
await shipmentStore.loadShipment(created.id)
|
||||
await router.push(`/shipment/${created.id}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
const nextStep = shipmentStore.current.currentStep + 1
|
||||
await shipmentStore.updateShipment(shipmentStore.current.id, {
|
||||
currentStep: nextStep,
|
||||
...payload
|
||||
})
|
||||
await shipmentStore.loadShipment(shipmentStore.current.id)
|
||||
}
|
||||
</script>
|
||||
26
frontend/components/shipment/shipment-loading.vue
Normal file
26
frontend/components/shipment/shipment-loading.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-[150px]">
|
||||
<h1 class="font-bold text-5xl uppercase text-primary-500">Chargement des bovins</h1>
|
||||
<div
|
||||
class="w-full flex flex-col items-center justify-center">
|
||||
<UiLoadingDots />
|
||||
</div>
|
||||
<UiButton
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
|
||||
@click="goNext"
|
||||
>Peser</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useShipmentStore} from "~/stores/shipment";
|
||||
|
||||
const shipmentStore = useShipmentStore()
|
||||
|
||||
const goNext = async () => {
|
||||
const nextStep = shipmentStore.current.currentStep + 1
|
||||
await shipmentStore.updateShipment(shipmentStore.current.id, {
|
||||
currentStep: nextStep
|
||||
})
|
||||
}
|
||||
</script>
|
||||
39
frontend/components/ui/UiButton.vue
Normal file
39
frontend/components/ui/UiButton.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<component
|
||||
:is="'button'"
|
||||
:type="type"
|
||||
:disabled="isDisabled"
|
||||
class="inline-flex min-w-[194px] items-center justify-center rounded-md"
|
||||
:class="[
|
||||
isDisabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
|
||||
buttonClass
|
||||
]"
|
||||
v-bind="attrs"
|
||||
>
|
||||
<slot v-if="!loading" />
|
||||
<UiLoadingDots v-else />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, useAttrs} from 'vue'
|
||||
|
||||
defineOptions({inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
buttonClass?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
loading: false,
|
||||
buttonClass: ''
|
||||
}
|
||||
)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const isDisabled = computed(() => props.disabled || props.loading)
|
||||
</script>
|
||||
76
frontend/components/ui/UiCheckbox.vue
Normal file
76
frontend/components/ui/UiCheckbox.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<label
|
||||
class="flex items-center gap-2 cursor-pointer text-primary-700"
|
||||
:class="labelClass"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="checked"
|
||||
:disabled="disabled"
|
||||
:class="['h-4 w-4 cursor-pointer text-primary-500', inputClass]"
|
||||
@change="onChange"
|
||||
>
|
||||
<span v-if="label">{{ label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
type CheckboxValue = string | number
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean | CheckboxValue[]
|
||||
value?: CheckboxValue
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
inputClass?: string
|
||||
}>(),
|
||||
{
|
||||
value: undefined,
|
||||
label: '',
|
||||
disabled: false,
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
inputClass: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean | CheckboxValue[]): void
|
||||
}>()
|
||||
|
||||
const checked = computed(() => {
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
if (props.value === undefined) {
|
||||
return false
|
||||
}
|
||||
return props.modelValue.includes(props.value)
|
||||
}
|
||||
return Boolean(props.modelValue)
|
||||
})
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
if (props.value === undefined) {
|
||||
emit('update:modelValue', props.modelValue)
|
||||
return
|
||||
}
|
||||
const next = new Set(props.modelValue)
|
||||
if (target.checked) {
|
||||
next.add(props.value)
|
||||
} else {
|
||||
next.delete(props.value)
|
||||
}
|
||||
emit('update:modelValue', Array.from(next))
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', target.checked)
|
||||
}
|
||||
</script>
|
||||
233
frontend/components/ui/UiDataTable.vue
Normal file
233
frontend/components/ui/UiDataTable.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="relative border border-slate-200">
|
||||
<div
|
||||
class="grid items-center gap-6 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
|
||||
:style="{ gridTemplateColumns: gridCols }"
|
||||
>
|
||||
<div v-for="col in columns" :key="col.key" class="min-w-0">
|
||||
<slot :name="`header-${col.key}`" :column="col">{{ col.label }}</slot>
|
||||
</div>
|
||||
<div v-if="showActions" class="min-w-0">
|
||||
<slot name="header-actions">Actions</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="dimRows ? 'opacity-50 transition-opacity' : ''" :aria-busy="loading || undefined">
|
||||
<template v-if="paginatedItems.length">
|
||||
<div
|
||||
v-for="(item, index) in paginatedItems"
|
||||
:key="item.id ?? index"
|
||||
class="grid gap-6 px-4 py-3 text-sm border-t border-slate-200"
|
||||
:class="rowClickable ? 'hover:bg-slate-50 cursor-pointer' : ''"
|
||||
:style="{ gridTemplateColumns: gridCols }"
|
||||
:role="rowClickable ? 'button' : undefined"
|
||||
:tabindex="rowClickable ? 0 : undefined"
|
||||
@click="onRowClick(item)"
|
||||
@keydown.enter="onRowClick(item)"
|
||||
@keydown.space.prevent="onRowClick(item)"
|
||||
>
|
||||
<div v-for="col in columns" :key="col.key" class="min-w-0 truncate">
|
||||
<slot :name="`cell-${col.key}`" :item="item" :column="col">
|
||||
{{ getNestedValue(item, col.key) }}
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="showActions" @click.stop>
|
||||
<slot name="actions" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="loading"
|
||||
class="flex items-center justify-center border-t border-slate-200 px-4 py-8 text-primary-500"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<UiLoadingDots />
|
||||
<span class="sr-only">Chargement…</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="border-t border-slate-200 px-4 py-8 text-center text-sm text-slate-500"
|
||||
>
|
||||
<slot name="empty">{{ emptyMessage }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="dimRows"
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="rounded bg-white/80 px-4 py-2 text-primary-500 shadow">
|
||||
<UiLoadingDots />
|
||||
<span class="sr-only">Chargement…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > 0" class="flex justify-between pt-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label :for="perPageId" class="whitespace-nowrap text-sm text-slate-700">Lignes :</label>
|
||||
<select
|
||||
:id="perPageId"
|
||||
:value="currentPerPage"
|
||||
class="h-10 rounded border border-slate-300 bg-white px-2 text-sm text-primary-700"
|
||||
@change="onPerPageChange(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="n in perPageOptions" :key="n" :value="n">{{ n }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Pagination" class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-3 text-sm text-primary-500 hover:underline disabled:cursor-not-allowed disabled:text-slate-400 disabled:no-underline"
|
||||
:disabled="currentPage <= 1"
|
||||
aria-label="Page précédente"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<template v-for="(entry, i) in visiblePages" :key="`${typeof entry}-${entry}-${i}`">
|
||||
<span
|
||||
v-if="entry === '...'"
|
||||
class="px-1 text-sm text-slate-400"
|
||||
aria-hidden="true"
|
||||
>…</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
|
||||
:class="entry === currentPage
|
||||
? 'bg-primary-500 font-semibold text-white'
|
||||
: 'text-slate-700 hover:bg-slate-100'"
|
||||
:aria-current="entry === currentPage ? 'page' : undefined"
|
||||
@click="goToPage(entry)"
|
||||
>
|
||||
{{ entry }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-3 text-sm text-primary-500 hover:underline disabled:cursor-not-allowed disabled:text-slate-400 disabled:no-underline"
|
||||
:disabled="currentPage >= totalPages"
|
||||
aria-label="Page suivante"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import { computed, useId } from 'vue'
|
||||
|
||||
interface Column {
|
||||
key: string
|
||||
label: string
|
||||
width?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
columns: Column[]
|
||||
items: T[]
|
||||
totalItems?: number
|
||||
page?: number
|
||||
perPage?: number
|
||||
perPageOptions?: number[]
|
||||
rowClickable?: boolean
|
||||
showActions?: boolean
|
||||
emptyMessage?: string
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
totalItems: undefined,
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
perPageOptions: () => [10, 25, 50],
|
||||
rowClickable: false,
|
||||
showActions: false,
|
||||
emptyMessage: 'Aucune donnée',
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:page', value: number): void
|
||||
(e: 'update:perPage', value: number): void
|
||||
(e: 'row-click', item: T): void
|
||||
}>()
|
||||
|
||||
const perPageId = useId()
|
||||
|
||||
const currentPage = computed(() => props.page)
|
||||
const currentPerPage = computed(() => props.perPage)
|
||||
|
||||
const isServerSide = computed(() => props.totalItems !== undefined)
|
||||
const total = computed(() => props.totalItems ?? props.items.length)
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(total.value / currentPerPage.value))
|
||||
)
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
if (isServerSide.value) return props.items
|
||||
const start = (currentPage.value - 1) * currentPerPage.value
|
||||
return props.items.slice(start, start + currentPerPage.value)
|
||||
})
|
||||
|
||||
const gridCols = computed(() => {
|
||||
const dataCols = props.columns.map(c => c.width ?? '1fr').join(' ')
|
||||
return props.showActions ? `${dataCols} 60px` : dataCols
|
||||
})
|
||||
|
||||
const dimRows = computed(() => props.loading && paginatedItems.value.length > 0)
|
||||
|
||||
const visiblePages = computed<(number | '...')[]>(() => {
|
||||
const tp = totalPages.value
|
||||
const cp = currentPage.value
|
||||
|
||||
if (tp <= 5) {
|
||||
return Array.from({ length: tp }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
const pages: (number | '...')[] = []
|
||||
pages.push(1)
|
||||
|
||||
if (cp > 3) pages.push('...')
|
||||
|
||||
const start = Math.max(2, cp - 1)
|
||||
const end = Math.min(tp - 1, cp + 1)
|
||||
for (let i = start; i <= end; i++) pages.push(i)
|
||||
|
||||
if (cp < tp - 2) pages.push('...')
|
||||
|
||||
if (tp > 1) pages.push(tp)
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const goToPage = (n: number) => {
|
||||
if (n < 1 || n > totalPages.value || n === currentPage.value) return
|
||||
emit('update:page', n)
|
||||
}
|
||||
|
||||
const onPerPageChange = (value: string) => {
|
||||
emit('update:perPage', Number(value))
|
||||
emit('update:page', 1)
|
||||
}
|
||||
|
||||
const onRowClick = (item: T) => {
|
||||
if (!props.rowClickable) return
|
||||
emit('row-click', item)
|
||||
}
|
||||
|
||||
const getNestedValue = (obj: any, path: string): string => {
|
||||
const value = path.split('.').reduce((acc, key) => acc?.[key], obj)
|
||||
return value ?? '—'
|
||||
}
|
||||
</script>
|
||||
70
frontend/components/ui/UiDateInput.vue
Normal file
70
frontend/components/ui/UiDateInput.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<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"
|
||||
type="date"
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="disabled"
|
||||
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="[
|
||||
sizeClass,
|
||||
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
inputClass
|
||||
]"
|
||||
@input="onInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
label?: string
|
||||
modelValue: string | null | undefined
|
||||
disabled?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
inputClass?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
inputClass: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const isEmpty = computed(() => !props.modelValue)
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'compact'
|
||||
? 'text-sm h-8 font-normal normal-case tracking-normal'
|
||||
: 'text-xl py-[6px] uppercase h-[34px]'
|
||||
)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
</script>
|
||||
120
frontend/components/ui/UiNumberInput.vue
Normal file
120
frontend/components/ui/UiNumberInput.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
// flex row passer en class wraper class flex col ainsi que le wfull 34
|
||||
<template>
|
||||
<div :class="['flex', wrapperClass]">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
class="text-xl flex items-center gap-2 text-primary-700"
|
||||
:class="labelClass"
|
||||
>
|
||||
<span
|
||||
v-if="label">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="code"
|
||||
class="text-neutral-600">
|
||||
({{ code }})
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
:id="id"
|
||||
type="number"
|
||||
:value="modelValue ?? ''"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
v-bind="attrs"
|
||||
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
|
||||
:class="[
|
||||
isEmpty ? 'text-neutral-400' : 'text-black',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-text',
|
||||
inputClass
|
||||
]"
|
||||
@keydown="onKeydown"
|
||||
@input="onInput"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, useAttrs} from 'vue'
|
||||
|
||||
defineOptions({inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
label?: string
|
||||
code?: string
|
||||
modelValue: number | string | null | undefined
|
||||
min?: number | string
|
||||
max?: number | string
|
||||
step?: number | string
|
||||
disabled?: boolean
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
inputClass?: string
|
||||
}>(),
|
||||
{
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
step: undefined,
|
||||
disabled: false,
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
inputClass: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: number | null): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const isEmpty = computed(() => props.modelValue === null || props.modelValue === undefined || props.modelValue === '')
|
||||
|
||||
const toNumberOrNull = (value: number | string | undefined) => {
|
||||
if (value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.value === '') {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const parsed = Number(target.value)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
|
||||
const min = toNumberOrNull(props.min)
|
||||
const max = toNumberOrNull(props.max)
|
||||
|
||||
let numeric = parsed
|
||||
if (min !== null) {
|
||||
numeric = Math.max(min, numeric)
|
||||
} else {
|
||||
numeric = Math.max(0, numeric)
|
||||
}
|
||||
if (max !== null) {
|
||||
numeric = Math.min(max, numeric)
|
||||
}
|
||||
|
||||
target.value = String(numeric)
|
||||
emit('update:modelValue', numeric)
|
||||
}
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === '-' || event.key === 'e' || event.key === 'E') {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
93
frontend/components/ui/UiRadioGroup.vue
Normal file
93
frontend/components/ui/UiRadioGroup.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div :class="['flex flex-col', wrapperClass]">
|
||||
<label
|
||||
v-if="label"
|
||||
class="font-bold uppercase text-xl text-primary-700"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<div
|
||||
role="radiogroup"
|
||||
:aria-label="label || id || 'radio-group'"
|
||||
:class="['flex items-center gap-6 mt-1', groupClass]"
|
||||
>
|
||||
<label
|
||||
v-for="option in options"
|
||||
:key="String(option.value)"
|
||||
:for="`${id || 'radio'}-${option.value}`"
|
||||
class="flex items-center gap-2 text-primary-700"
|
||||
:class="itemClass"
|
||||
>
|
||||
<input
|
||||
:id="`${id || 'radio'}-${option.value}`"
|
||||
type="radio"
|
||||
:name="name || id || 'radio-group'"
|
||||
:value="String(option.value)"
|
||||
:checked="String(modelValue ?? '') === String(option.value)"
|
||||
:disabled="disabled"
|
||||
v-bind="attrs"
|
||||
class="h-4 w-4 border-primary-700/50 text-primary-700 focus:ring-primary-700"
|
||||
:class="[
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
inputClass
|
||||
]"
|
||||
@change="onChange"
|
||||
>
|
||||
<span class="text-xl" :class="optionLabelClass">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
type RadioOption = {
|
||||
value: string | number
|
||||
label: string
|
||||
}
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
modelValue: string | number | null | undefined
|
||||
options: RadioOption[]
|
||||
disabled?: boolean
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
itemClass?: string
|
||||
inputClass?: string
|
||||
optionLabelClass?: string
|
||||
}>(),
|
||||
{
|
||||
name: '',
|
||||
label: '',
|
||||
disabled: false,
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
itemClass: '',
|
||||
inputClass: '',
|
||||
optionLabelClass: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
</script>
|
||||
93
frontend/components/ui/UiSelect.vue
Normal file
93
frontend/components/ui/UiSelect.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<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>
|
||||
<select
|
||||
:id="id"
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="disabled || loading"
|
||||
v-bind="attrs"
|
||||
class="w-full min-w-0 border-b border-primary-700 justify-self-start text-primary-700 bg-transparent"
|
||||
:class="[
|
||||
sizeClass,
|
||||
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||
disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
selectClass
|
||||
]"
|
||||
@change="onChange"
|
||||
>
|
||||
<option value="" class="text-neutral-400">
|
||||
{{ placeholderText }}
|
||||
</option>
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="text-primary-700"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
type SelectOption = {
|
||||
value: string | number
|
||||
label: string
|
||||
}
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
modelValue: string | number | null | undefined
|
||||
options: SelectOption[]
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
selectClass?: string
|
||||
}>(),
|
||||
{
|
||||
placeholder: 'Sélectionner',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
selectClass: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const isEmpty = computed(() => props.modelValue === '' || props.modelValue === null || props.modelValue === undefined)
|
||||
const placeholderText = computed(() => props.placeholder || 'Sélectionner')
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'compact'
|
||||
? 'text-sm h-8 font-normal normal-case tracking-normal'
|
||||
: 'text-xl py-[6px]'
|
||||
)
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
</script>
|
||||
76
frontend/components/ui/UiTextInput.vue
Normal file
76
frontend/components/ui/UiTextInput.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<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"
|
||||
type="text"
|
||||
:value="modelValue ?? ''"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxlength"
|
||||
: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 { computed, useAttrs } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
label?: string
|
||||
modelValue: string | null | undefined
|
||||
placeholder?: string
|
||||
maxlength?: number | string
|
||||
disabled?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
inputClass?: string
|
||||
}>(),
|
||||
{
|
||||
placeholder: '',
|
||||
maxlength: undefined,
|
||||
disabled: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
inputClass: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const isEmpty = computed(() => !props.modelValue)
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'compact'
|
||||
? 'text-sm h-8 font-normal normal-case tracking-normal'
|
||||
: 'text-xl py-[6px]'
|
||||
)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<label :for="inputId" class="font-bold uppercase text-xl mb-4">{{ label }}</label>
|
||||
<label :for="inputId" class="font-bold uppercase text-xl text-primary-500">{{ label }}</label>
|
||||
<div class="flex items-end gap-8">
|
||||
<input
|
||||
:id="inputId"
|
||||
:value="modelValue"
|
||||
@@ -8,19 +9,20 @@
|
||||
type="text"
|
||||
:maxlength="maxLength"
|
||||
:placeholder="placeholderText"
|
||||
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase"
|
||||
:required="required"
|
||||
class="border-b border-primary-700 flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<label :for="checkboxId" class="mt-3 flex items-center gap-3 text-sm">
|
||||
<input
|
||||
<UiCheckbox
|
||||
:id="checkboxId"
|
||||
:checked="allowAny"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-primary-500"
|
||||
@change="toggleAllowAny"
|
||||
:model-value="allowAny"
|
||||
label="Autoriser un format libre"
|
||||
wrapper-class="ml-auto"
|
||||
label-class="gap-3 whitespace-nowrap text-sm"
|
||||
input-class="h-4 w-4 accent-primary-500"
|
||||
@update:modelValue="handleAllowAnyChange"
|
||||
/>
|
||||
Autoriser un format libre
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,12 +33,14 @@ type Props = {
|
||||
allowAny?: boolean
|
||||
label?: string
|
||||
id?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
allowAny: false,
|
||||
label: 'Immatriculation',
|
||||
id: 'license-plate'
|
||||
id: 'license-plate',
|
||||
required: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -78,13 +82,7 @@ const handleInput = (event: Event) => {
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const toggleAllowAny = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextValue = target.checked
|
||||
const handleAllowAnyChange = (nextValue: boolean) => {
|
||||
emit('update:allowAny', nextValue)
|
||||
if (!nextValue) {
|
||||
emit('update:modelValue', props.modelValue)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<iframe ref="printFrame" class="hidden" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { usePdfPrinter } from '~/composables/usePdfPrinter'
|
||||
|
||||
const printFrame = ref<HTMLIFrameElement | null>(null)
|
||||
const { printPdf } = usePdfPrinter()
|
||||
|
||||
// Expose une methode simple pour imprimer un PDF depuis les ecrans.
|
||||
const print = async (url: string): Promise<void> => {
|
||||
return printPdf(url, printFrame)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
print
|
||||
})
|
||||
</script>
|
||||
84
frontend/components/ui/stepper.vue
Normal file
84
frontend/components/ui/stepper.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<div class="relative h-[18px] text-[16px] uppercase font-bold text-black mb-3">
|
||||
<div
|
||||
v-for="(label, index) in labels"
|
||||
:key="label"
|
||||
class="absolute top-0 whitespace-nowrap text-primary-500"
|
||||
:class="labelClass(index)"
|
||||
:style="positionStyle(index)"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-[22px]">
|
||||
<div class="absolute left-0 right-0 top-1/2 h-[2px] -translate-y-1/2 bg-black"></div>
|
||||
<div
|
||||
v-for="(_, index) in labels"
|
||||
:key="index"
|
||||
class="absolute top-1/2 h-[22px] w-[22px] -translate-y-1/2 rounded-full border border-black"
|
||||
:class="[
|
||||
dotClass(index),
|
||||
isActive(index) ? 'bg-black' : 'bg-white',
|
||||
isClickable(index) ? 'cursor-pointer' : 'cursor-not-allowed'
|
||||
]"
|
||||
:style="positionStyle(index)"
|
||||
@click="handleClick(index)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type Props = {
|
||||
labels: string[]
|
||||
currentStep: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', step: number): void
|
||||
}>()
|
||||
|
||||
const stepCount = computed(() => Math.max(props.labels.length, 1))
|
||||
|
||||
const positionStyle = (index: number) => {
|
||||
if (stepCount.value <= 1) {
|
||||
return { left: '0%' }
|
||||
}
|
||||
if (index === 0) {
|
||||
return { left: '0%' }
|
||||
}
|
||||
if (index === stepCount.value - 1) {
|
||||
return { right: '0%' }
|
||||
}
|
||||
const percent = (index / (stepCount.value - 1)) * 100
|
||||
return { left: `${percent}%` }
|
||||
}
|
||||
|
||||
const isMiddle = (index: number) => index > 0 && index < stepCount.value - 1
|
||||
const isLast = (index: number) => index === stepCount.value - 1
|
||||
|
||||
const dotClass = (index: number) => (isMiddle(index) ? '-translate-x-1/2' : '')
|
||||
|
||||
const labelClass = (index: number) => {
|
||||
if (isLast(index)) {
|
||||
return 'text-right'
|
||||
}
|
||||
if (isMiddle(index)) {
|
||||
return 'text-center -translate-x-1/2'
|
||||
}
|
||||
return 'text-left'
|
||||
}
|
||||
|
||||
const isActive = (index: number) => index === props.currentStep
|
||||
|
||||
const isClickable = (index: number) => index <= props.currentStep
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
if (!isClickable(index)) {
|
||||
return
|
||||
}
|
||||
emit('select', index)
|
||||
}
|
||||
</script>
|
||||
57
frontend/components/workflow/workflow-liot-fields.vue
Normal file
57
frontend/components/workflow/workflow-liot-fields.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<template v-if="!isLiotCarrier">
|
||||
<div :class="wrapperClass">
|
||||
<UiLicensePlateInput
|
||||
v-model="form.licensePlate"
|
||||
v-model:allowAny="allowAnyLicensePlate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="isLiotCarrier">
|
||||
<UiSelect
|
||||
:id="`${idPrefix}-vehicle`"
|
||||
v-model="form.vehicleId"
|
||||
label="Immatriculation"
|
||||
:options="filteredVehicles.map((vehicle) => ({
|
||||
value: String(vehicle.id),
|
||||
label: vehicle.plate
|
||||
}))"
|
||||
:loading="isLoadingVehicles"
|
||||
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
|
||||
:wrapper-class="wrapperClass"
|
||||
/>
|
||||
<UiSelect
|
||||
:id="`${idPrefix}-driver`"
|
||||
v-model="form.driverId"
|
||||
label="Nom du chauffeur si LIOT"
|
||||
:options="filteredDrivers.map((driver) => ({
|
||||
value: String(driver.id),
|
||||
label: driver.name
|
||||
}))"
|
||||
:loading="isLoadingDrivers"
|
||||
:wrapper-class="driverWrapperClass"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DriverData } from '~/services/dto/driver-data'
|
||||
import type { VehicleData } from '~/services/dto/vehicle-data'
|
||||
|
||||
defineProps<{
|
||||
idPrefix: string
|
||||
form: { licensePlate: string; vehicleId: string; driverId: string }
|
||||
isLiotCarrier: boolean
|
||||
allowAnyLicensePlate: boolean
|
||||
filteredVehicles: VehicleData[]
|
||||
filteredDrivers: DriverData[]
|
||||
isLoadingVehicles: boolean
|
||||
isLoadingDrivers: boolean
|
||||
wrapperClass?: string
|
||||
driverWrapperClass?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:allowAnyLicensePlate': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
72
frontend/components/workflow/workflow-waiting-list.vue
Normal file
72
frontend/components/workflow/workflow-waiting-list.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-10">
|
||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">{{ title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
<div class="mt-6 border border-slate-200 mb-16">
|
||||
<div
|
||||
class="grid gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
|
||||
:style="{ gridTemplateColumns: gridCols }"
|
||||
>
|
||||
<div v-for="col in columns" :key="col.key">{{ col.label }}</div>
|
||||
<div v-if="showActions">Actions</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="grid gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
|
||||
:style="{ gridTemplateColumns: gridCols }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goToItem(item.id)"
|
||||
@keydown.enter="goToItem(item.id)"
|
||||
>
|
||||
<div v-for="col in columns" :key="col.key">
|
||||
<slot :name="`cell-${col.key}`" :item="item">
|
||||
{{ getNestedValue(item, col.key) }}
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="showActions" @click.stop>
|
||||
<slot name="actions" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Column {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
columns: Column[]
|
||||
items: any[]
|
||||
routePrefix: string
|
||||
showActions?: boolean
|
||||
}>(), {
|
||||
showActions: false
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const gridCols = computed(() => {
|
||||
const dataCols = props.columns.map(() => '1fr').join(' ')
|
||||
return props.showActions ? `${dataCols} 60px` : dataCols
|
||||
})
|
||||
|
||||
const goToItem = (id: number) => {
|
||||
router.push(`${props.routePrefix}/${id}`)
|
||||
}
|
||||
|
||||
const getNestedValue = (obj: any, path: string): string => {
|
||||
const value = path.split('.').reduce((acc, key) => acc?.[key], obj)
|
||||
return value || '—'
|
||||
}
|
||||
</script>
|
||||
81
frontend/components/workflow/workflow-weight.vue
Normal file
81
frontend/components/workflow/workflow-weight.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<div class="flex flex-col items-center w-[660px]">
|
||||
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
|
||||
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
|
||||
<div
|
||||
v-if="!displayWeight"
|
||||
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
|
||||
<UiLoadingDots />
|
||||
</div>
|
||||
<div v-else class="w-full">
|
||||
<div
|
||||
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl text-primary-500">
|
||||
{{ displayWeight }} kg
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center mt-[54px]">
|
||||
<UiButton
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
||||
@click="fetchWeight"
|
||||
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
|
||||
<UiButton
|
||||
v-if="displayWeight !== null && !showGenerateReceipt"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
|
||||
@click="saveWeight"
|
||||
>Valider la pesée</UiButton>
|
||||
<UiButton
|
||||
v-if="showGenerateReceipt"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
|
||||
@click="printReceipt"
|
||||
>Générer le bon</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue'
|
||||
import { useWeighingStep } from '~/composables/steps/useWeighingStep'
|
||||
import type { WeightData } from '~/services/dto/weight-data'
|
||||
|
||||
const props = defineProps<{
|
||||
mode: 'gross' | 'tare'
|
||||
entityName: 'reception' | 'shipment'
|
||||
apiResource: string
|
||||
titleLabel: string
|
||||
isFinal: boolean
|
||||
entity: any
|
||||
getWeightFromScale: () => Promise<WeightData>
|
||||
updateEntity: (id: number, payload: any) => Promise<any>
|
||||
loadEntity: (id: number) => Promise<any>
|
||||
clearEntity: () => void
|
||||
buildReceiptFilename: (entity: any) => string
|
||||
}>()
|
||||
|
||||
const entityRef = toRef(props, 'entity')
|
||||
|
||||
const {
|
||||
displayWeight,
|
||||
title,
|
||||
fetchWeight,
|
||||
saveWeight,
|
||||
saveWeightDraft,
|
||||
showGenerateReceipt,
|
||||
printReceipt
|
||||
} = useWeighingStep({
|
||||
mode: props.mode,
|
||||
entity: entityRef,
|
||||
entityName: props.entityName,
|
||||
apiResource: props.apiResource,
|
||||
titleLabel: props.titleLabel,
|
||||
isFinal: props.isFinal,
|
||||
getWeightFromScale: props.getWeightFromScale,
|
||||
updateEntity: props.updateEntity,
|
||||
loadEntity: props.loadEntity,
|
||||
clearEntity: props.clearEntity,
|
||||
buildReceiptFilename: props.buildReceiptFilename
|
||||
})
|
||||
|
||||
defineExpose({ saveWeightDraft })
|
||||
</script>
|
||||
80
frontend/composables/steps/useWeighingStep.ts
Normal file
80
frontend/composables/steps/useWeighingStep.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { useWeighing } from '~/composables/useWeighing'
|
||||
import { usePdfPrinter } from '~/composables/usePdfPrinter'
|
||||
import type { WeightData } from '~/services/dto/weight-data'
|
||||
|
||||
interface UseWeighingStepOptions {
|
||||
mode: 'gross' | 'tare'
|
||||
entity: Ref<any>
|
||||
entityName: 'reception' | 'shipment'
|
||||
apiResource: string
|
||||
titleLabel: string
|
||||
isFinal: boolean
|
||||
getWeightFromScale: () => Promise<WeightData>
|
||||
updateEntity: (id: number, payload: any) => Promise<any>
|
||||
loadEntity: (id: number) => Promise<any>
|
||||
clearEntity: () => void
|
||||
buildReceiptFilename: (entity: any) => string
|
||||
}
|
||||
|
||||
export const useWeighingStep = (options: UseWeighingStepOptions) => {
|
||||
const router = useRouter()
|
||||
const { printPdf } = usePdfPrinter()
|
||||
|
||||
const {
|
||||
weightData,
|
||||
currentWeightEntry,
|
||||
displayWeight,
|
||||
displayDsd,
|
||||
title,
|
||||
showLoadingBox,
|
||||
fetchWeight,
|
||||
saveWeight,
|
||||
saveWeightDraft
|
||||
} = useWeighing({
|
||||
mode: options.mode,
|
||||
entity: options.entity,
|
||||
entityName: options.entityName,
|
||||
apiResource: options.apiResource,
|
||||
titleLabel: options.titleLabel,
|
||||
isFinal: options.isFinal,
|
||||
getWeightFromScale: options.getWeightFromScale,
|
||||
updateEntity: options.updateEntity,
|
||||
loadEntity: options.loadEntity
|
||||
})
|
||||
|
||||
const showGenerateReceipt = computed(
|
||||
() => options.isFinal && displayWeight.value !== null
|
||||
)
|
||||
|
||||
const printReceipt = async () => {
|
||||
if (!import.meta.client || !options.entity.value) return
|
||||
|
||||
await saveWeight()
|
||||
const entity = options.entity.value
|
||||
const filename = options.buildReceiptFilename(entity)
|
||||
await printPdf(`/${options.apiResource}/${entity.id}/receipt`, filename)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
|
||||
const result = await options.updateEntity(entity.id, { isValid: true })
|
||||
if (!result) return
|
||||
|
||||
options.clearEntity()
|
||||
await router.push('/')
|
||||
}
|
||||
|
||||
return {
|
||||
weightData,
|
||||
currentWeightEntry,
|
||||
displayWeight,
|
||||
displayDsd,
|
||||
title,
|
||||
showLoadingBox,
|
||||
fetchWeight,
|
||||
saveWeight,
|
||||
saveWeightDraft,
|
||||
showGenerateReceipt,
|
||||
printReceipt
|
||||
}
|
||||
}
|
||||
54
frontend/composables/useAddressSync.ts
Normal file
54
frontend/composables/useAddressSync.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { AddressData } from '~/services/dto/address-data'
|
||||
|
||||
interface AddressOwner {
|
||||
id: number
|
||||
addresses?: AddressData[]
|
||||
}
|
||||
|
||||
export const useAddressSync = (
|
||||
form: { addressId: string },
|
||||
ownerId: Ref<string>,
|
||||
owners: Ref<AddressOwner[]>
|
||||
) => {
|
||||
const ownerAddresses = computed<AddressData[]>(() => {
|
||||
const id = Number(ownerId.value)
|
||||
if (!Number.isFinite(id)) return []
|
||||
return owners.value.find((owner) => owner.id === id)?.addresses ?? []
|
||||
})
|
||||
|
||||
const addressOptions = computed(() =>
|
||||
ownerAddresses.value.map((address) => ({
|
||||
value: String(address.id),
|
||||
label: address.fullAddress
|
||||
}))
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [ownerId.value, form.addressId, owners.value],
|
||||
() => {
|
||||
if (!ownerId.value) {
|
||||
form.addressId = ''
|
||||
return
|
||||
}
|
||||
if (!form.addressId && ownerAddresses.value.length === 1) {
|
||||
form.addressId = String(ownerAddresses.value[0].id)
|
||||
return
|
||||
}
|
||||
if (!form.addressId) return
|
||||
const matches = ownerAddresses.value.some(
|
||||
(address) => String(address.id) === form.addressId
|
||||
)
|
||||
if (!matches) {
|
||||
if (ownerAddresses.value.length === 1) {
|
||||
form.addressId = String(ownerAddresses.value[0].id)
|
||||
} else {
|
||||
form.addressId = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return { ownerAddresses, addressOptions }
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export const useApi = (): ApiClient => {
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
const nuxtApp = useNuxtApp()
|
||||
let isHandlingUnauthorized = false
|
||||
const i18n = nuxtApp.$i18n as
|
||||
| {
|
||||
t: (key: string) => string
|
||||
@@ -95,7 +96,23 @@ export const useApi = (): ApiClient => {
|
||||
})
|
||||
}
|
||||
},
|
||||
onResponseError({ response, error, options }) {
|
||||
async onResponseError({ response, error, options }) {
|
||||
if (response?.status === 401) {
|
||||
const requestUrl = typeof options?.url === 'string' ? options.url : ''
|
||||
if (!requestUrl.includes('login_check') && !requestUrl.includes('logout')) {
|
||||
if (!isHandlingUnauthorized) {
|
||||
isHandlingUnauthorized = true
|
||||
auth.clearSession()
|
||||
const route = useRoute()
|
||||
if (route.path !== '/login') {
|
||||
await navigateTo('/login')
|
||||
}
|
||||
isHandlingUnauthorized = false
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const apiOptions = options as ApiFetchOptions<'json'>
|
||||
if (apiOptions?.toast === false) {
|
||||
return
|
||||
|
||||
17
frontend/composables/useAppVersion.ts
Normal file
17
frontend/composables/useAppVersion.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const useAppVersion = () => {
|
||||
const api = useApi()
|
||||
const version = useState<string | null>('app-version', () => null)
|
||||
|
||||
const load = async () => {
|
||||
if (version.value) {
|
||||
return version.value
|
||||
}
|
||||
const response = await api.get<{ version: string }>('version', {}, {
|
||||
toast: false
|
||||
})
|
||||
version.value = response.version
|
||||
return version.value
|
||||
}
|
||||
|
||||
return { version, load }
|
||||
}
|
||||
113
frontend/composables/useBarcodeScanner.ts
Normal file
113
frontend/composables/useBarcodeScanner.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
BarcodeDetector: new (options?: { formats: string[] }) => {
|
||||
detect(source: HTMLVideoElement | ImageBitmapSource): Promise<{ rawValue: string }[]>
|
||||
}
|
||||
}
|
||||
const BarcodeDetector: Window['BarcodeDetector'] | undefined
|
||||
}
|
||||
|
||||
export function useBarcodeScanner(onDetected: (code: string) => void) {
|
||||
const isSupported = ref('BarcodeDetector' in globalThis)
|
||||
const isScanning = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let detector: InstanceType<Window['BarcodeDetector']> | null = null
|
||||
let stream: MediaStream | null = null
|
||||
let animationFrameId: number | null = null
|
||||
let lastDetectedCode = ''
|
||||
let lastDetectedTime = 0
|
||||
|
||||
const COOLDOWN_MS = 2000
|
||||
|
||||
async function start(videoElement: HTMLVideoElement) {
|
||||
if (!isSupported.value) {
|
||||
error.value = 'BarcodeDetector non supporté. Utilisez Chrome sur Android.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
detector = new BarcodeDetector({ formats: ['code_39', 'code_128'] })
|
||||
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
}
|
||||
})
|
||||
|
||||
videoElement.srcObject = stream
|
||||
await videoElement.play()
|
||||
isScanning.value = true
|
||||
error.value = null
|
||||
|
||||
scanLoop(videoElement)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur lors du démarrage de la caméra'
|
||||
isScanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scanLoop(videoElement: HTMLVideoElement) {
|
||||
if (!isScanning.value || !detector) return
|
||||
|
||||
animationFrameId = requestAnimationFrame(async () => {
|
||||
try {
|
||||
if (videoElement.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) {
|
||||
const barcodes = await detector!.detect(videoElement)
|
||||
|
||||
if (barcodes.length > 0) {
|
||||
const code = barcodes[0].rawValue.slice(4)
|
||||
const now = Date.now()
|
||||
|
||||
if (code !== lastDetectedCode || now - lastDetectedTime > COOLDOWN_MS) {
|
||||
lastDetectedCode = code
|
||||
lastDetectedTime = now
|
||||
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(100)
|
||||
}
|
||||
|
||||
onDetected(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Detection error on single frame, continue
|
||||
}
|
||||
|
||||
scanLoop(videoElement)
|
||||
})
|
||||
}
|
||||
|
||||
function stop() {
|
||||
isScanning.value = false
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
stream = null
|
||||
}
|
||||
|
||||
detector = null
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isScanning,
|
||||
error,
|
||||
start,
|
||||
stop
|
||||
}
|
||||
}
|
||||
102
frontend/composables/useDataTableServerState.ts
Normal file
102
frontend/composables/useDataTableServerState.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
type FilterValue = string | number | boolean | null
|
||||
|
||||
export interface UseDataTableServerStateOptions {
|
||||
initialPerPage?: number
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export function useDataTableServerState<T = Record<string, unknown>>(
|
||||
endpoint: string,
|
||||
initialFilters: Record<string, FilterValue> = {},
|
||||
options: UseDataTableServerStateOptions = {}
|
||||
) {
|
||||
const api = useApi()
|
||||
|
||||
const debounceMs = options.debounceMs ?? 300
|
||||
const initialPerPage = options.initialPerPage ?? 10
|
||||
|
||||
const items = ref<T[]>([]) as { value: T[] }
|
||||
const totalItems = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = ref(initialPerPage)
|
||||
const filters = ref<Record<string, FilterValue>>({ ...initialFilters })
|
||||
const loading = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requestToken = 0
|
||||
|
||||
const buildQueryParams = (): Record<string, string | number | boolean> => {
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
page: page.value,
|
||||
itemsPerPage: perPage.value
|
||||
}
|
||||
for (const [key, value] of Object.entries(filters.value)) {
|
||||
if (value === '' || value === null) continue
|
||||
params[key] = value as string | number | boolean
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
const fetchItems = async (): Promise<void> => {
|
||||
const currentToken = ++requestToken
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: T[]; totalItems: number }>(
|
||||
endpoint,
|
||||
buildQueryParams(),
|
||||
{
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' }
|
||||
}
|
||||
)
|
||||
if (currentToken !== requestToken) return
|
||||
items.value = data?.member ?? []
|
||||
totalItems.value = data?.totalItems ?? 0
|
||||
} finally {
|
||||
if (currentToken === requestToken) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reload = (): void => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
void fetchItems()
|
||||
}
|
||||
|
||||
const scheduleReload = (): void => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
void fetchItems()
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
watch([page, perPage], () => {
|
||||
reload()
|
||||
})
|
||||
|
||||
watch(filters, () => {
|
||||
if (page.value !== 1) {
|
||||
page.value = 1
|
||||
return
|
||||
}
|
||||
scheduleReload()
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
items,
|
||||
totalItems,
|
||||
page,
|
||||
perPage,
|
||||
filters,
|
||||
loading,
|
||||
reload
|
||||
}
|
||||
}
|
||||
73
frontend/composables/useFormDataLoading.ts
Normal file
73
frontend/composables/useFormDataLoading.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { TruckData } from '~/services/dto/truck-data'
|
||||
import type { CarrierData } from '~/services/dto/carrier-data'
|
||||
import { getUsers } from '~/services/auth'
|
||||
import { getTruckList } from '~/services/truck'
|
||||
import { getCarrierList } from '~/services/carrier'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export const useFormDataLoading = (form: { userId: string }) => {
|
||||
const users = ref<UserData[]>([])
|
||||
const trucks = ref<TruckData[]>([])
|
||||
const carriers = ref<CarrierData[]>([])
|
||||
const isLoadingUsers = ref(false)
|
||||
const isLoadingTrucks = ref(false)
|
||||
const isLoadingCarriers = ref(false)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loadUsers = async () => {
|
||||
isLoadingUsers.value = true
|
||||
try {
|
||||
users.value = await getUsers()
|
||||
} finally {
|
||||
isLoadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadTrucks = async () => {
|
||||
isLoadingTrucks.value = true
|
||||
try {
|
||||
trucks.value = await getTruckList()
|
||||
} finally {
|
||||
isLoadingTrucks.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadCarriers = async () => {
|
||||
isLoadingCarriers.value = true
|
||||
try {
|
||||
carriers.value = await getCarrierList()
|
||||
} finally {
|
||||
isLoadingCarriers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDefaultUser = () => {
|
||||
if (form.userId) return
|
||||
if (authStore.user?.id) {
|
||||
form.userId = String(authStore.user.id)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCommonData = async () => {
|
||||
await loadUsers()
|
||||
await loadTrucks()
|
||||
await loadCarriers()
|
||||
await authStore.ensureSession()
|
||||
setDefaultUser()
|
||||
}
|
||||
|
||||
return {
|
||||
users,
|
||||
trucks,
|
||||
carriers,
|
||||
isLoadingUsers,
|
||||
isLoadingTrucks,
|
||||
isLoadingCarriers,
|
||||
loadUsers,
|
||||
loadTrucks,
|
||||
loadCarriers,
|
||||
setDefaultUser,
|
||||
loadCommonData
|
||||
}
|
||||
}
|
||||
153
frontend/composables/useLiotHandling.ts
Normal file
153
frontend/composables/useLiotHandling.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { CarrierData } from '~/services/dto/carrier-data'
|
||||
import type { DriverData } from '~/services/dto/driver-data'
|
||||
import type { VehicleData } from '~/services/dto/vehicle-data'
|
||||
import { getDriverList } from '~/services/driver'
|
||||
import { getVehicleList } from '~/services/vehicle'
|
||||
import { SUPPLIER_CODE } from '~/utils/constants'
|
||||
|
||||
interface LiotForm {
|
||||
carrierId: string
|
||||
truckId: string
|
||||
driverId: string
|
||||
vehicleId: string
|
||||
licensePlate: string
|
||||
}
|
||||
|
||||
export const useLiotHandling = (
|
||||
form: LiotForm,
|
||||
carriers: Ref<CarrierData[]>,
|
||||
isHydrating: Ref<boolean>
|
||||
) => {
|
||||
const drivers = ref<DriverData[]>([])
|
||||
const vehicles = ref<VehicleData[]>([])
|
||||
const isLoadingDrivers = ref(false)
|
||||
const isLoadingVehicles = ref(false)
|
||||
const allowAnyLicensePlate = ref(false)
|
||||
|
||||
const selectedCarrier = computed(() =>
|
||||
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
|
||||
)
|
||||
|
||||
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
|
||||
|
||||
const filteredDrivers = computed<DriverData[]>(() => {
|
||||
if (!form.carrierId) return []
|
||||
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
|
||||
})
|
||||
|
||||
const filteredVehicles = computed<VehicleData[]>(() => {
|
||||
if (!form.carrierId) return []
|
||||
return vehicles.value.filter(
|
||||
(vehicle) =>
|
||||
String(vehicle.carrier?.id) === form.carrierId &&
|
||||
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
|
||||
)
|
||||
})
|
||||
|
||||
const loadDrivers = async () => {
|
||||
isLoadingDrivers.value = true
|
||||
try {
|
||||
drivers.value = await getDriverList()
|
||||
} finally {
|
||||
isLoadingDrivers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadVehicles = async () => {
|
||||
isLoadingVehicles.value = true
|
||||
try {
|
||||
vehicles.value = await getVehicleList()
|
||||
} finally {
|
||||
isLoadingVehicles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select driver/vehicle when carrier changes
|
||||
watch(
|
||||
() => form.carrierId,
|
||||
() => {
|
||||
if (isHydrating.value) return
|
||||
if (!form.carrierId) {
|
||||
form.driverId = ''
|
||||
form.vehicleId = ''
|
||||
return
|
||||
}
|
||||
if (!isLiotCarrier.value) {
|
||||
form.driverId = ''
|
||||
form.vehicleId = ''
|
||||
return
|
||||
}
|
||||
if (filteredDrivers.value.length === 1) {
|
||||
form.driverId = String(filteredDrivers.value[0].id)
|
||||
}
|
||||
if (filteredVehicles.value.length === 1) {
|
||||
form.vehicleId = String(filteredVehicles.value[0].id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Validate/auto-select vehicle when truck/carrier changes
|
||||
watch(
|
||||
() => [form.truckId, form.carrierId, vehicles.value],
|
||||
() => {
|
||||
if (!isLiotCarrier.value) return
|
||||
if (filteredVehicles.value.length === 1) {
|
||||
form.vehicleId = String(filteredVehicles.value[0].id)
|
||||
return
|
||||
}
|
||||
if (!form.vehicleId) return
|
||||
const matches = filteredVehicles.value.some(
|
||||
(vehicle) => String(vehicle.id) === form.vehicleId
|
||||
)
|
||||
if (!matches) {
|
||||
form.vehicleId = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Sync license plate from selected vehicle
|
||||
watch(
|
||||
() => [form.vehicleId, form.carrierId, vehicles.value],
|
||||
() => {
|
||||
if (!isLiotCarrier.value) return
|
||||
if (isHydrating.value) return
|
||||
const selected = filteredVehicles.value.find(
|
||||
(vehicle) => String(vehicle.id) === form.vehicleId
|
||||
)
|
||||
if (selected) {
|
||||
form.licensePlate = selected.plate
|
||||
allowAnyLicensePlate.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Auto-select vehicle from license plate
|
||||
watch(
|
||||
() => [form.licensePlate, form.carrierId, vehicles.value],
|
||||
() => {
|
||||
if (!isLiotCarrier.value || form.vehicleId) return
|
||||
const match = filteredVehicles.value.find(
|
||||
(vehicle) => vehicle.plate === form.licensePlate
|
||||
)
|
||||
if (match) {
|
||||
form.vehicleId = String(match.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
drivers,
|
||||
vehicles,
|
||||
isLoadingDrivers,
|
||||
isLoadingVehicles,
|
||||
allowAnyLicensePlate,
|
||||
isLiotCarrier,
|
||||
filteredDrivers,
|
||||
filteredVehicles,
|
||||
loadDrivers,
|
||||
loadVehicles
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,26 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
type PrintFrameRef = Ref<HTMLIFrameElement | null>
|
||||
|
||||
export const usePdfPrinter = () => {
|
||||
const api = useApi()
|
||||
|
||||
const printPdf = async (url: string, frameRef: PrintFrameRef): Promise<void> => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const frame = frameRef.value
|
||||
if (!frame) {
|
||||
return
|
||||
}
|
||||
|
||||
// On charge le PDF en blob pour rester en same-origin dans l'iframe.
|
||||
const printPdf = async (url: string, filename = 'document.pdf'): Promise<void> => {
|
||||
const blob = await api.getBlob(url)
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
const tryPrint = () => {
|
||||
frame.contentWindow?.focus()
|
||||
frame.contentWindow?.print()
|
||||
}
|
||||
const pdfBlob = blob.type === 'application/pdf'
|
||||
? blob
|
||||
: new Blob([blob], { type: 'application/pdf' })
|
||||
|
||||
frame.onload = () => {
|
||||
tryPrint()
|
||||
// On libere l'URL blob apres l'impression.
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000)
|
||||
}
|
||||
frame.src = blobUrl
|
||||
setTimeout(tryPrint, 1200)
|
||||
const blobUrl = URL.createObjectURL(pdfBlob)
|
||||
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = filename
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
// L'ouverture dans un nouvel onglet déclenche un 2e PDF sans le nom personnalisé.
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,60 +1,66 @@
|
||||
import type {Ref} from 'vue'
|
||||
import {computed, ref} from 'vue'
|
||||
import type {ReceptionData, WeightEntryData} from '~/services/dto/reception-data'
|
||||
import type {WeightData} from '~/services/dto/weight-data'
|
||||
import {getWeight} from '~/services/reception'
|
||||
import {createWeight, updateWeight} from '~/services/weight'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { WeightEntryData } from '~/services/dto/reception-data'
|
||||
import type { WeightData } from '~/services/dto/weight-data'
|
||||
import { createWeight, updateWeight } from '~/services/weight'
|
||||
|
||||
export type WeighingMode = 'gross' | 'tare'
|
||||
|
||||
type UseWeighingOptions = {
|
||||
export interface UseWeighingOptions {
|
||||
mode: WeighingMode
|
||||
reception: Ref<ReceptionData | null>
|
||||
updateReception: (id: number, payload: Partial<ReceptionData>) => Promise<ReceptionData | null>
|
||||
loadReception?: (id: number) => Promise<ReceptionData | null>
|
||||
entity: Ref<{ id: number; currentStep: number; isValid: boolean; weights?: WeightEntryData[] | null } | null>
|
||||
entityName: 'reception' | 'shipment'
|
||||
apiResource: string
|
||||
titleLabel: string
|
||||
isFinal?: boolean
|
||||
getWeightFromScale: () => Promise<WeightData>
|
||||
updateEntity: (id: number, payload: any) => Promise<any>
|
||||
loadEntity?: (id: number) => Promise<any>
|
||||
}
|
||||
|
||||
export const useWeighing = ({
|
||||
mode,
|
||||
reception,
|
||||
updateReception,
|
||||
loadReception
|
||||
entity,
|
||||
entityName,
|
||||
apiResource,
|
||||
titleLabel,
|
||||
isFinal = false,
|
||||
getWeightFromScale,
|
||||
updateEntity,
|
||||
loadEntity
|
||||
}: UseWeighingOptions) => {
|
||||
const weightData = ref<WeightData | null>(null)
|
||||
const isFetching = ref(false)
|
||||
|
||||
const currentWeightEntry = computed<WeightEntryData | null>(() => {
|
||||
const weights = reception.value?.weights ?? []
|
||||
const weights = entity.value?.weights ?? []
|
||||
return weights.find((entry) => entry.type === mode) ?? null
|
||||
})
|
||||
|
||||
const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null)
|
||||
const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-')
|
||||
const title = computed(() => (mode === 'gross' ? 'Pesée à plein' : 'Pesée à vide'))
|
||||
const showLoadingBox = computed(
|
||||
() => isFetching.value || (displayWeight.value === null && currentWeightEntry.value === null)
|
||||
)
|
||||
const title = computed(() => titleLabel)
|
||||
const showLoadingBox = computed(() => isFetching.value)
|
||||
|
||||
const fetchWeight = async () => {
|
||||
isFetching.value = true
|
||||
weightData.value = await getWeight().finally(() => {
|
||||
weightData.value = await getWeightFromScale().finally(() => {
|
||||
isFetching.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const saveWeight = async () => {
|
||||
if (!reception.value) {
|
||||
return
|
||||
}
|
||||
if (!entity.value) return
|
||||
|
||||
const existingEntry = currentWeightEntry.value
|
||||
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
|
||||
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
|
||||
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
|
||||
|
||||
if (baseWeight === null) {
|
||||
return
|
||||
}
|
||||
if (baseWeight === null) return
|
||||
|
||||
const relationPayload: Record<string, string> = {}
|
||||
relationPayload[entityName] = `/api/${apiResource}/${entity.value.id}`
|
||||
|
||||
if (existingEntry?.id) {
|
||||
await updateWeight(existingEntry.id, {
|
||||
@@ -65,7 +71,7 @@ export const useWeighing = ({
|
||||
})
|
||||
} else {
|
||||
await createWeight({
|
||||
reception: `api/receptions/${reception.value.id}`,
|
||||
...relationPayload,
|
||||
type: mode,
|
||||
dsd: baseDsd,
|
||||
weight: baseWeight,
|
||||
@@ -73,16 +79,48 @@ export const useWeighing = ({
|
||||
})
|
||||
}
|
||||
|
||||
const nextStep = mode === 'tare'
|
||||
? reception.value.currentStep
|
||||
: reception.value.currentStep + 1
|
||||
await updateReception(reception.value.id, {
|
||||
const nextStep = isFinal
|
||||
? entity.value.currentStep
|
||||
: entity.value.currentStep + 1
|
||||
await updateEntity(entity.value.id, {
|
||||
currentStep: nextStep,
|
||||
isValid: reception.value.isValid
|
||||
isValid: entity.value.isValid
|
||||
})
|
||||
|
||||
if (loadReception) {
|
||||
await loadReception(reception.value.id)
|
||||
if (loadEntity) {
|
||||
await loadEntity(entity.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
const saveWeightDraft = async () => {
|
||||
if (!entity.value) return
|
||||
if (!weightData.value && !currentWeightEntry.value) return
|
||||
|
||||
const existingEntry = currentWeightEntry.value
|
||||
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
|
||||
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
|
||||
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
|
||||
|
||||
if (baseWeight === null) return
|
||||
|
||||
const relationPayload: Record<string, string> = {}
|
||||
relationPayload[entityName] = `/api/${apiResource}/${entity.value.id}`
|
||||
|
||||
if (existingEntry?.id) {
|
||||
await updateWeight(existingEntry.id, {
|
||||
type: mode,
|
||||
dsd: baseDsd,
|
||||
weight: baseWeight,
|
||||
weighedAt: baseWeighedAt
|
||||
})
|
||||
} else {
|
||||
await createWeight({
|
||||
...relationPayload,
|
||||
type: mode,
|
||||
dsd: baseDsd,
|
||||
weight: baseWeight,
|
||||
weighedAt: baseWeighedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +132,34 @@ export const useWeighing = ({
|
||||
title,
|
||||
showLoadingBox,
|
||||
fetchWeight,
|
||||
saveWeight
|
||||
saveWeight,
|
||||
saveWeightDraft
|
||||
}
|
||||
}
|
||||
|
||||
// Backward-compatible aliases
|
||||
export const useWeighingShipment = ({
|
||||
modeShipment,
|
||||
shipment,
|
||||
updateShipment,
|
||||
loadShipment
|
||||
}: {
|
||||
modeShipment: WeighingMode
|
||||
shipment: Ref<any>
|
||||
updateShipment: (id: number, payload: any) => Promise<any>
|
||||
loadShipment?: (id: number) => Promise<any>
|
||||
}) => {
|
||||
return useWeighing({
|
||||
mode: modeShipment,
|
||||
entity: shipment,
|
||||
entityName: 'shipment',
|
||||
apiResource: 'shipments',
|
||||
titleLabel: modeShipment === 'gross' ? 'Pesée à plein' : 'Pesée à vide',
|
||||
getWeightFromScale: async () => {
|
||||
const { getWeightShipment } = await import('~/services/shipment')
|
||||
return getWeightShipment()
|
||||
},
|
||||
updateEntity: updateShipment,
|
||||
loadEntity: loadShipment
|
||||
})
|
||||
}
|
||||
|
||||
84
frontend/composables/useWorkflowSteps.ts
Normal file
84
frontend/composables/useWorkflowSteps.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { WorkflowConfig } from '~/types/workflow'
|
||||
|
||||
interface WorkflowStore {
|
||||
current: any
|
||||
isLoading: boolean
|
||||
clearCurrent: () => void
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const useWorkflowSteps = (config: WorkflowConfig, store: WorkflowStore) => {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const stepLabels = config.steps.map(s => s.label)
|
||||
|
||||
const currentStep = computed(() => store.current?.currentStep ?? 0)
|
||||
const entity = computed(() => store.current)
|
||||
|
||||
const loadMethod = `load${config.entityName.charAt(0).toUpperCase() + config.entityName.slice(1)}`
|
||||
const updateMethod = `update${config.entityName.charAt(0).toUpperCase() + config.entityName.slice(1)}`
|
||||
|
||||
const resolveId = (param: unknown) => {
|
||||
const idStr = Array.isArray(param) ? param[0] : param
|
||||
if (!idStr) return null
|
||||
const id = Number(idStr)
|
||||
return Number.isFinite(id) ? id : null
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (param) => {
|
||||
const id = resolveId(param)
|
||||
if (id === null) {
|
||||
store.clearCurrent()
|
||||
return
|
||||
}
|
||||
await store[loadMethod](id)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
|
||||
const saveAndHold = async () => {
|
||||
if (!store.current) {
|
||||
await router.push('/')
|
||||
return
|
||||
}
|
||||
const datePayload: Record<string, any> = {}
|
||||
const rawDate = store.current[config.dateField]
|
||||
datePayload[config.dateField] = rawDate ? rawDate.slice(0, 10) : rawDate
|
||||
await store[updateMethod](store.current.id, {
|
||||
currentStep: store.current.currentStep,
|
||||
licensePlate: store.current.licensePlate,
|
||||
...datePayload
|
||||
})
|
||||
await router.push('/')
|
||||
}
|
||||
|
||||
const handleStepSelect = async (step: number) => {
|
||||
if (!store.current) return
|
||||
if (step === store.current.currentStep) return
|
||||
await store[updateMethod](store.current.id, { currentStep: step })
|
||||
await store[loadMethod](store.current.id)
|
||||
}
|
||||
|
||||
const advanceStep = async () => {
|
||||
if (!store.current) return
|
||||
const nextStep = store.current.currentStep + 1
|
||||
await store[updateMethod](store.current.id, { currentStep: nextStep })
|
||||
await store[loadMethod](store.current.id)
|
||||
}
|
||||
|
||||
return {
|
||||
stepLabels,
|
||||
currentStep,
|
||||
entity,
|
||||
init,
|
||||
saveAndHold,
|
||||
handleStepSelect,
|
||||
advanceStep
|
||||
}
|
||||
}
|
||||
25
frontend/config/reception.config.ts
Normal file
25
frontend/config/reception.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { WorkflowConfig, WorkflowEntity } from '~/types/workflow'
|
||||
|
||||
export const receptionConfig: WorkflowConfig = {
|
||||
entityName: 'reception',
|
||||
apiResource: 'receptions',
|
||||
steps: [
|
||||
{ label: 'Réception' },
|
||||
{ label: 'Pesée à plein', weighingMode: 'gross' },
|
||||
{ label: 'Sélection réception' },
|
||||
{ label: 'Pesée à vide', weighingMode: 'tare', isFinal: true }
|
||||
],
|
||||
weighingLabels: {
|
||||
gross: 'Pesée à plein',
|
||||
tare: 'Pesée à vide'
|
||||
},
|
||||
buildReceiptFilename: (entity: WorkflowEntity) => {
|
||||
const rec = entity as any
|
||||
return `${rec.identificationNumber ?? rec.id}_${rec.supplier?.name ?? 'fournisseur'}_${rec.licensePlate ?? 'immat'}.pdf`
|
||||
},
|
||||
routePrefix: '/reception',
|
||||
toastPrefix: 'reception',
|
||||
dateField: 'receptionDate'
|
||||
}
|
||||
|
||||
export const RECEPTION_STEP_LABELS = receptionConfig.steps.map(s => s.label)
|
||||
25
frontend/config/shipment.config.ts
Normal file
25
frontend/config/shipment.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { WorkflowConfig, WorkflowEntity } from '~/types/workflow'
|
||||
|
||||
export const shipmentConfig: WorkflowConfig = {
|
||||
entityName: 'shipment',
|
||||
apiResource: 'shipments',
|
||||
steps: [
|
||||
{ label: 'Expédition' },
|
||||
{ label: 'Pesée à vide', weighingMode: 'tare' },
|
||||
{ label: 'Chargement' },
|
||||
{ label: 'Pesée à plein', weighingMode: 'gross', isFinal: true }
|
||||
],
|
||||
weighingLabels: {
|
||||
gross: 'Pesée à plein',
|
||||
tare: 'Pesée à vide'
|
||||
},
|
||||
buildReceiptFilename: (entity: WorkflowEntity) => {
|
||||
const ship = entity as any
|
||||
return `${ship.identificationNumber ?? ship.id}_${ship.customer?.label ?? 'client'}_${ship.licensePlate ?? 'immat'}.pdf`
|
||||
},
|
||||
routePrefix: '/shipment',
|
||||
toastPrefix: 'shipment',
|
||||
dateField: 'shipmentDate'
|
||||
}
|
||||
|
||||
export const SHIPMENT_STEP_LABELS = shipmentConfig.steps.map(s => s.label)
|
||||
@@ -12,21 +12,148 @@
|
||||
"fetch": "Impossible de récupérer la réception.",
|
||||
"create": "Impossible de créer la réception.",
|
||||
"update": "Impossible de mettre à jour la réception.",
|
||||
"delete": "Impossible de supprimer la réception.",
|
||||
"weight": "Impossible de récupérer la pesée."
|
||||
},
|
||||
"weight": {
|
||||
"update": "Impossible de mettre à jour la pesée"
|
||||
},
|
||||
"shipment": {
|
||||
"list": "Impossible de récupérer la liste des éxpeditions.",
|
||||
"fetch": "Impossible de récupérer l'éxpeditions.",
|
||||
"create": "Impossible de créer l'éxpeditions.",
|
||||
"update": "Impossible de mettre à jour l'éxpeditions.",
|
||||
"delete": "Impossible de supprimer l'expédition.",
|
||||
"weigh": "Impossible de récupérer la pesée."
|
||||
},
|
||||
"shipmentBovine": {
|
||||
"list": "Impossible de récupérer la liste des bovins de l'éxpedition.",
|
||||
"create": "Impossible d'enregistrer le bovin.",
|
||||
"delete": "Impossible de supprimer le bovin.",
|
||||
"update": "Impossible de mettre à jour le bovin."
|
||||
},
|
||||
"shipmentType": {
|
||||
"list": "Impossible de récupérer la liste des types d'éxpedition."
|
||||
},
|
||||
"receptionType": {
|
||||
"list": "Impossible de récupérer la liste des types de réception."
|
||||
},
|
||||
"merchandiseType": {
|
||||
"list": "Impossible de récupérer la liste des types de marchandises."
|
||||
},
|
||||
"building": {
|
||||
"list": "Impossible de récupérer la liste des bâtiments."
|
||||
},
|
||||
"pelletType": {
|
||||
"list": "Impossible de récupérer la liste des types de granulés."
|
||||
},
|
||||
"receptionPelletBuilding": {
|
||||
"list": "Impossible de récupérer la liste des dépôts de granulés.",
|
||||
"create": "Impossible d'enregistrer le dépôt de granulés.",
|
||||
"delete": "Impossible de supprimer le dépôt de granulés."
|
||||
},
|
||||
"receptionBovine": {
|
||||
"list": "Impossible de récupérer la liste des bovins de la réception.",
|
||||
"create": "Impossible d'enregistrer le bovin.",
|
||||
"delete": "Impossible de supprimer le bovin."
|
||||
},
|
||||
"supplier": {
|
||||
"list": "Impossible de récupérer la liste des fournisseurs.",
|
||||
"fetch": "Impossible de récupérer le fournisseur.",
|
||||
"create": "Impossible de créer le fournisseur.",
|
||||
"update": "Impossible de mettre à jour le fournisseur.",
|
||||
"nameRequired": "Le nom du fournisseur est obligatoire."
|
||||
},
|
||||
"address": {
|
||||
"fetch": "Impossible de récupérer l'adresse.",
|
||||
"create": "Impossible de créer l'adresse.",
|
||||
"update": "Impossible de mettre à jour l'adresse.",
|
||||
"entityNotFound": "Entité introuvable.",
|
||||
"streetRequired": "La rue est obligatoire.",
|
||||
"postalCodeRequired": "Le code postal est obligatoire.",
|
||||
"cityRequired": "La ville est obligatoire.",
|
||||
"countryCodeInvalid": "Le pays doit être un code ISO2 (2 lettres)."
|
||||
},
|
||||
"customer": {
|
||||
"list": "Impossible de récupérer la liste des clients.",
|
||||
"fetch": "Impossible de récupérer le client.",
|
||||
"create": "Impossible de créer le client.",
|
||||
"update": "Impossible de mettre à jour le client."
|
||||
},
|
||||
"truck": {
|
||||
"list": "Impossible de récupérer la liste des camions."
|
||||
},
|
||||
"bovin": {
|
||||
"list": "Impossible de récupérer la liste des races de bovins.",
|
||||
"fetch": "Impossible de récupérer le type bovin.",
|
||||
"create": "Impossible de créer le type bovin.",
|
||||
"update": "Impossible de mettre à jour le type bovin."
|
||||
},
|
||||
"bovine": {
|
||||
"create": "Impossible d'enregistrer le bovin."
|
||||
},
|
||||
"carrier": {
|
||||
"list": "Impossible de récupérer la liste des transporteurs.",
|
||||
"fetch": "Impossible de récupérer les données du transporteur",
|
||||
"update": "Impossible de mettre à jour le transporteur",
|
||||
"create": "Impossible de créer le transporteur"
|
||||
},
|
||||
"driver": {
|
||||
"list": "Impossible de récupérer la liste des chauffeurs."
|
||||
},
|
||||
"vehicle": {
|
||||
"list": "Impossible de récupérer la liste des immatriculations."
|
||||
},
|
||||
"auth": {
|
||||
"login": "Identifiants invalides.",
|
||||
"users": "Impossible de récupérer les utilisateurs.",
|
||||
"logout": "Impossible de se déconnecter."
|
||||
"logout": "Impossible de se déconnecter.",
|
||||
"update": "Impossible de mettre à jour l'utilisateur.",
|
||||
"create": "Impossible de créer l'utilisateur."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"reception": {
|
||||
"update": "Réception mise à jour avec succès."
|
||||
"create": "Réception créée avec succès",
|
||||
"update": "Réception mise à jour avec succès.",
|
||||
"delete": "Réception supprimée avec succès."
|
||||
},
|
||||
"shipment": {
|
||||
"create": "Éxpedition créée avec succès",
|
||||
"update": "Éxpedition mise à jour avec succès.",
|
||||
"delete": "Expédition supprimée avec succès."
|
||||
},
|
||||
"supplier": {
|
||||
"create": "Fournisseur créé avec succès.",
|
||||
"update": "Fournisseur mis à jour avec succès."
|
||||
},
|
||||
"customer": {
|
||||
"create": "Client créé avec succès.",
|
||||
"update": "Client mis à jour avec succès."
|
||||
},
|
||||
"address": {
|
||||
"create": "Adresse créée avec succès.",
|
||||
"update": "Adresse mise à jour avec succès."
|
||||
},
|
||||
"auth": {
|
||||
"update": "Utilisateur mis à jour avec succès.",
|
||||
"create": "Utilisateur créé avec succès.",
|
||||
"login": "Connexion réussie.",
|
||||
"logout": "Déconnexion réussie."
|
||||
},
|
||||
"carrier": {
|
||||
"update": "Transporteur mis à jour",
|
||||
"create": "Transporteur créé"
|
||||
},
|
||||
"bovin": {
|
||||
"update": "Type bovin mis à jour avec succès.",
|
||||
"create": "Type bovin créé avec succès."
|
||||
},
|
||||
"bovine": {
|
||||
"create": "Bovin enregistré avec succès."
|
||||
},
|
||||
"weight": {
|
||||
"update": "Pesée mis à jour"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,289 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-white text-neutral-900">
|
||||
<header class="w-full border-b border-neutral-200 bg-primary-500">
|
||||
<div class="flex w-full items-center px-6 py-4">
|
||||
<NuxtLink to="/" class="flex items-center gap-3">
|
||||
<span
|
||||
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
|
||||
<div class="min-h-screen text-neutral-900 flex flex-col">
|
||||
<!-- HEADER -->
|
||||
<header class="w-full bg-primary-500 py-5 px-6">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<!-- Burger (mobile) -->
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center text-3xl text-white md:hidden"
|
||||
aria-label="Ouvrir le menu"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<span aria-hidden="true" class="flex items-center">
|
||||
<Icon name="mdi:menu" size="44"/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Logo -->
|
||||
<NuxtLink to="/" class="shrink-0">
|
||||
<span class="flex items-center justify-center bg-white text-xl font-bold uppercase px-6 py-4">
|
||||
LOGO
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<nav class="mx-8 flex flex-1 gap-8 text-2xl font-bold uppercase text-white">
|
||||
<NuxtLink to="/" custom v-slot="{ href, navigate, isExactActive }">
|
||||
|
||||
<!-- NAV centré (desktop) -->
|
||||
<nav
|
||||
class="hidden md:flex flex-1 items-center justify-center gap-8 text-xl font-bold uppercase text-white"
|
||||
>
|
||||
<NuxtLink to="/" custom v-slot="{ href, navigate }">
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
:class="isExactActive ? 'opacity-100' : 'opacity-50'"
|
||||
:class="route.path === '/'
|
||||
? 'opacity-100'
|
||||
: 'opacity-65 hover:opacity-100 transition'"
|
||||
>
|
||||
Accueil
|
||||
</a>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/reception" custom v-slot="{ href, navigate, isActive }">
|
||||
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/user/list"
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
:class="isReceptionActive ? 'opacity-100' : 'opacity-50'"
|
||||
:class="route.path.startsWith('/admin/user')
|
||||
? 'opacity-100'
|
||||
: 'opacity-65 hover:opacity-100 transition'"
|
||||
>
|
||||
Reception
|
||||
Utilisateurs
|
||||
</a>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/supplier/supplier-list"
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
:class="route.path.startsWith('/admin/supplier')
|
||||
? 'opacity-100'
|
||||
: 'opacity-65 hover:opacity-100 transition'"
|
||||
>
|
||||
Fournisseurs
|
||||
</a>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/customer/customer-list"
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
:class="route.path.startsWith('/admin/customer')
|
||||
? 'opacity-100'
|
||||
: 'opacity-65 hover:opacity-100 transition'"
|
||||
>
|
||||
Clients
|
||||
</a>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/carrier/carrier-list"
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
:class="route.path.startsWith('/admin/carrier')
|
||||
? 'opacity-100'
|
||||
: 'opacity-65 hover:opacity-100 transition'"
|
||||
>
|
||||
Transporteurs
|
||||
</a>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin/bovin/bovin-list"
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
:class="route.path.startsWith('/admin/bovin')
|
||||
? 'opacity-100'
|
||||
: 'opacity-65 hover:opacity-100 transition'"
|
||||
>
|
||||
Bovins
|
||||
</a>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/scan"
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
:class="route.path.startsWith('/scan')
|
||||
? 'opacity-100'
|
||||
: 'opacity-65 hover:opacity-100 transition'"
|
||||
>
|
||||
Scanner
|
||||
</a>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
|
||||
<div class="w-[44px] md:hidden"></div>
|
||||
|
||||
<!-- User dropdown à droite (desktop) -->
|
||||
<div v-if="auth.isAuthenticated" class="ml-auto relative hidden md:flex items-center text-white group">
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto text-xl font-bold uppercase text-white transition hover:opacity-80"
|
||||
class="inline-flex items-center py-2 -my-2 text-xl leading-none transition hover:opacity-80"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
|
||||
<span class="capitalize font-bold ml-4">{{ userDisplayName }}</span>
|
||||
<span
|
||||
class="ml-[6px] inline-flex items-center font-bold transition-transform group-hover:rotate-180 group-focus-within:rotate-180">
|
||||
<Icon name="mdi:chevron-down" size="20"/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="absolute right-0 top-full z-10 w-56 rounded-md bg-primary-500 py-2 border-neutral-300 border shadow-lg
|
||||
opacity-0 invisible pointer-events-none transition
|
||||
group-hover:opacity-100 group-hover:visible group-hover:pointer-events-auto
|
||||
group-focus-within:opacity-100 group-focus-within:visible group-focus-within:pointer-events-auto"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-4 py-2 text-left text-sm font-semibold text-white opacity-85 hover:opacity-100 transition"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay (mobile) -->
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
class="fixed inset-0 z-40 bg-black/40 md:hidden"
|
||||
@click="closeMenu"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
<!-- Drawer (mobile) -->
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="-translate-x-full"
|
||||
enter-to-class="translate-x-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="-translate-x-full"
|
||||
>
|
||||
<aside
|
||||
v-if="isMenuOpen"
|
||||
class="fixed left-0 top-0 z-50 h-full w-full bg-primary-500 px-6 pb-8 pt-6 text-white shadow-xl md:hidden"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-2xl font-bold uppercase">Menu</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-2xl"
|
||||
aria-label="Fermer le menu"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<Icon name="mdi:close" size="44"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="mt-8 flex flex-col gap-6 text-xl font-bold uppercase">
|
||||
<NuxtLink to="/admin/dashboard" @click="closeMenu">Accueil</NuxtLink>
|
||||
<NuxtLink v-if="auth.isAdmin" to="/admin/supplier/supplier-list" @click="closeMenu">
|
||||
Fournisseurs
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="auth.isAdmin" to="/admin/carrier/carrier-list" @click="closeMenu">
|
||||
Transporteurs
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="auth.isAdmin" to="/admin/user/list" @click="closeMenu">
|
||||
Utilisateurs
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="auth.isAdmin" to="/admin/customer/customer-list" @click="closeMenu">
|
||||
Clients
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/bovin-list" @click="closeMenu">
|
||||
Bovins
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/scan" @click="closeMenu">
|
||||
Scanner
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
v-if="auth.isAuthenticated"
|
||||
type="button"
|
||||
class="mt-6 text-xl font-bold uppercase"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</aside>
|
||||
</transition>
|
||||
</header>
|
||||
<main class="mx-auto w-full max-w-[1050px] px-6 pt-[90px] pb-0">
|
||||
<main class="md:mx-auto w-full md:max-w-[1280px] mt-4 md:mt-16">
|
||||
<slot/>
|
||||
</main>
|
||||
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">
|
||||
<p class="font-bold text-white text-right">v{{ version }}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import {useAuthStore} from '~/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const isReceptionActive = computed(() => route.path.startsWith('/reception'))
|
||||
const {version} = useAppVersion()
|
||||
|
||||
const isMenuOpen = ref(false)
|
||||
|
||||
const userDisplayName = computed(() => auth.user?.username ?? 'Utilisateur')
|
||||
|
||||
const closeMenu = () => {
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
|
||||
const toggleMenu = () => {
|
||||
isMenuOpen.value = !isMenuOpen.value
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await auth.logout()
|
||||
} finally {
|
||||
closeMenu()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,19 @@ export default defineNuxtConfig({
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@pinia/nuxt',
|
||||
'nuxt-toast',
|
||||
'@nuxtjs/i18n'
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxt/icon'
|
||||
],
|
||||
css: ['~/assets/css/toast.css'],
|
||||
css: ['~/assets/css/main.css', '~/assets/css/toast.css'],
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE,
|
||||
geoApiBase: ''
|
||||
}
|
||||
},
|
||||
toast: {
|
||||
settings: {
|
||||
timeout: 0,
|
||||
timeout: 10000,
|
||||
closeOnClick: true,
|
||||
progressBar: false
|
||||
}
|
||||
|
||||
77
frontend/package-lock.json
generated
77
frontend/package-lock.json
generated
@@ -7,6 +7,7 @@
|
||||
"name": "frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.1",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"izitoast": "^1.4.0",
|
||||
@@ -35,6 +36,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@antfu/install-pkg": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
||||
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"package-manager-detector": "^1.3.0",
|
||||
"tinyexec": "^1.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
@@ -1248,6 +1262,47 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/collections": {
|
||||
"version": "1.0.646",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.646.tgz",
|
||||
"integrity": "sha512-zA5Gr1MJm1SI0TjOUl7wu4kvBWXQ6Uh8ALEtqQ5ucXyUxP2M8m2bk2hfVtGykSdMlDB+Xs2AHbJ9pQqayz9WGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iconify/types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
||||
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@iconify/utils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
|
||||
"integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@antfu/install-pkg": "^1.1.0",
|
||||
"@iconify/types": "^2.0.0",
|
||||
"mlly": "^1.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/vue": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz",
|
||||
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iconify/types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/cyberalien"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/bundle-utils": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
|
||||
@@ -2268,6 +2323,28 @@
|
||||
"devtools-wizard": "cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/icon": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.1.tgz",
|
||||
"integrity": "sha512-GI840yYGuvHI0BGDQ63d6rAxGzG96jQcWrnaWIQKlyQo/7sx9PjXkSHckXUXyX1MCr9zY6U25Td6OatfY6Hklw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iconify/collections": "^1.0.641",
|
||||
"@iconify/types": "^2.0.0",
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@nuxt/devtools-kit": "^3.1.1",
|
||||
"@nuxt/kit": "^4.2.2",
|
||||
"consola": "^3.4.2",
|
||||
"local-pkg": "^1.1.2",
|
||||
"mlly": "^1.8.0",
|
||||
"ohash": "^2.0.11",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^3.10.0",
|
||||
"tinyglobby": "^0.2.15"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.2.2.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.1",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"izitoast": "^1.4.0",
|
||||
|
||||
112
frontend/pages/admin/bovin/[[id]].vue
Normal file
112
frontend/pages/admin/bovin/[[id]].vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<form :class="{ submitted }" @submit.prevent="validate">
|
||||
<div class="flex items-center justify-between relative">
|
||||
<div class="flex flex-row absolute -left-[60px]">
|
||||
<Icon
|
||||
@click="router.push('/admin/bovin/bovin-list')"
|
||||
name="gg:arrow-left-o"
|
||||
size="40"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
||||
{{ route.params.id ? 'Modifications du type bovin' : 'Ajout d\'un type bovin' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
|
||||
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" required />
|
||||
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" required />
|
||||
</div>
|
||||
<div class="flex justify-center items-center">
|
||||
<UiButton
|
||||
type="submit"
|
||||
:disabled="isLoading || isHydrating"
|
||||
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||
@click="submitted = true"
|
||||
>
|
||||
Valider
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
|
||||
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const isLoading = ref(false)
|
||||
const isHydrating = ref(false)
|
||||
const submitted = ref(false)
|
||||
const idBovin = computed(() => resolveId(route.params.id))
|
||||
const isEdit = computed(() => idBovin.value !== null)
|
||||
|
||||
function resolveId(param: unknown) {
|
||||
const idStr = Array.isArray(param) ? param[0] : param
|
||||
if (!idStr) return null
|
||||
const id = Number(idStr)
|
||||
return Number.isFinite(id) ? id : null
|
||||
}
|
||||
|
||||
const form = reactive<BovinFormData>({
|
||||
label: '',
|
||||
code: ''
|
||||
})
|
||||
|
||||
|
||||
const hydrateFromBovin = (bovin: BovineTypeData | null) => {
|
||||
if (!bovin) {
|
||||
return
|
||||
}
|
||||
isHydrating.value = true
|
||||
form.label = bovin.label ?? ''
|
||||
form.code = bovin.code ?? ''
|
||||
isHydrating.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => idBovin.value,
|
||||
async (id) => {
|
||||
if (id === null) {
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
try {
|
||||
const bovin = await getBovin(id)
|
||||
hydrateFromBovin(bovin)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
async function validate() {
|
||||
if (isLoading.value || isHydrating.value) return
|
||||
|
||||
const normalizedBovinCode = form.code.trim()
|
||||
const normalizedBovinLabel = form.label.trim()
|
||||
|
||||
|
||||
const basePayload = {
|
||||
label: normalizedBovinLabel,
|
||||
code: normalizedBovinCode
|
||||
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (isEdit.value && idBovin.value !== null) {
|
||||
await updateBovin(idBovin.value, basePayload)
|
||||
} else {
|
||||
await createBovin(basePayload)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function navigate(){
|
||||
return router.push("/admin/bovin/list")
|
||||
}
|
||||
</script>
|
||||
77
frontend/pages/admin/bovin/bovin-list.vue
Normal file
77
frontend/pages/admin/bovin/bovin-list.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des types bovins</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 mb-11">
|
||||
<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-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/bovin"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import 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}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
117
frontend/pages/admin/carrier/[[id]].vue
Normal file
117
frontend/pages/admin/carrier/[[id]].vue
Normal file
@@ -0,0 +1,117 @@
|
||||
|
||||
<template>
|
||||
<form :class="{ submitted }" @submit.prevent="validate">
|
||||
<div class="flex items-center justify-between relative">
|
||||
<div class="flex flex-row absolute -left-[60px]">
|
||||
<Icon
|
||||
@click="router.push('/admin/carrier/carrier-list')"
|
||||
name="gg:arrow-left-o"
|
||||
size="40"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
||||
{{ route.params.id ? 'Modification du transporteur' : 'Ajout d\'un transporteur' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||
@click="submitted = true"
|
||||
>
|
||||
Valider
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {createCarrier, getCarrier, updateCarrier} from "~/services/carrier";
|
||||
import type {CarrierData, CarrierFormData} from "~/services/dto/carrier-data";
|
||||
import {computed} from "vue";
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const idCarrier = computed(() => resolveId(route.params.id))
|
||||
const isLoading = ref(false)
|
||||
const isHydrating = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
const resolveId = (param: unknown) => {
|
||||
const idStr = Array.isArray(param) ? param[0] : param
|
||||
if (!idStr) return null
|
||||
const id = Number(idStr)
|
||||
return Number.isFinite(id) ? id : null
|
||||
}
|
||||
|
||||
const form = reactive<CarrierFormData>({
|
||||
code:'',
|
||||
name:''
|
||||
})
|
||||
|
||||
const hydrateFromUser = (carrier: CarrierData | null) => {
|
||||
if (!carrier) {
|
||||
return
|
||||
}
|
||||
isHydrating.value = true
|
||||
form.name = carrier.name ?? ''
|
||||
form.code = carrier.code ?? ''
|
||||
isHydrating.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => idCarrier.value,
|
||||
async (id) => {
|
||||
if (id === null) {
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
try {
|
||||
const user = await getCarrier(id)
|
||||
hydrateFromUser(user)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
async function validate() {
|
||||
|
||||
const normalizedCarrierCode = form.code.trim()
|
||||
const normalizedCarrierName = form.name.trim()
|
||||
|
||||
const basePayload = {
|
||||
name: normalizedCarrierName,
|
||||
code: normalizedCarrierCode
|
||||
|
||||
}
|
||||
|
||||
if(idCarrier.value){
|
||||
await updateCarrier(idCarrier.value, basePayload)
|
||||
return
|
||||
}else{
|
||||
await createCarrier(basePayload)
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(){
|
||||
router.push("/admin/carrier/carrier-list")
|
||||
}
|
||||
</script>
|
||||
62
frontend/pages/admin/carrier/carrier-list.vue
Normal file
62
frontend/pages/admin/carrier/carrier-list.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-7 mb-11">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToCarrier"
|
||||
>
|
||||
<template #header-name>
|
||||
<UiTextInput v-model="filters.name" placeholder="Label" size="compact" />
|
||||
</template>
|
||||
<template #header-code>
|
||||
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/carrier"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CarrierData } from '~/services/dto/carrier-data'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<CarrierData>(
|
||||
'carriers',
|
||||
{
|
||||
name: '',
|
||||
code: ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Label' },
|
||||
{ key: 'code', label: 'Code' }
|
||||
]
|
||||
|
||||
const goToCarrier = (carrier: CarrierData) => {
|
||||
router.push(`/admin/carrier/${carrier.id}`)
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
</script>
|
||||
249
frontend/pages/admin/customer/[[id]].vue
Normal file
249
frontend/pages/admin/customer/[[id]].vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<form :class="{ submitted }" @submit.prevent="validate">
|
||||
<div class="flex items-center relative">
|
||||
<div class="flex flex-row absolute -left-[60px] ">
|
||||
<Icon @click="router.push('/admin/customer/customer-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
|
||||
</div>
|
||||
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
||||
{{ customerId ? "Modification du client" : "Ajout d'un client" }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
|
||||
<UiTextInput id="customer-name" v-model="form.name" label="Nom du client" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
|
||||
<UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
|
||||
<UiTextInput id="customer-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
|
||||
</div>
|
||||
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
|
||||
<UiTextInput id="address-street" v-model="addressForm.street" label="Rue" wrapper-class="w-[280px]" required />
|
||||
<UiTextInput id="address-street2" v-model="addressForm.street2" label="Complément" wrapper-class="w-[280px]" />
|
||||
<UiTextInput id="address-country" v-model="addressForm.countryCode" label="Pays (code)" wrapper-class="w-[280px]" />
|
||||
</div>
|
||||
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
|
||||
<UiTextInput id="address-postalCode" v-model="addressForm.postalCode" label="Code postal" wrapper-class="w-[280px]" required />
|
||||
<UiSelect id="address-city" v-model="addressForm.city" label="Ville"
|
||||
:options="communeOptions" :loading="isLoadingCities"
|
||||
wrapper-class="w-[280px]" required />
|
||||
<div class="w-[280px]" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<UiButton
|
||||
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||
type="submit"
|
||||
:disabled="isLoading || !auth.isAdmin"
|
||||
@click="submitted = true"
|
||||
>
|
||||
<Icon :name="customerId ? '' : 'mdi:plus'" size="28" />
|
||||
{{ customerId ? "Valider" : "Ajouter" }}
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<template v-if="customerId">
|
||||
<div class="flex items-center justify-between mb-7">
|
||||
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du client</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto mb-11 text-primary-700">
|
||||
<table class="w-full border-collapse text-primary-700">
|
||||
<thead>
|
||||
<tr class="text-left border bg-slate-100 border-gray-200">
|
||||
<th class="py-3 px-4 text-sm uppercase">Rue</th>
|
||||
<th class="py-3 px-4 text-sm uppercase">Complément</th>
|
||||
<th class="py-3 px-4 text-sm uppercase">Code postal</th>
|
||||
<th class="py-3 px-4 text-sm uppercase">Ville</th>
|
||||
<th class="py-3 px-4 text-sm uppercase">Pays</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="form.addresses.length === 0">
|
||||
<tr>
|
||||
<td colspan="5" class="py-4 text-slate-400">
|
||||
Aucune adresse.
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr
|
||||
v-for="(address, index) in form.addresses"
|
||||
:key="address.id ?? index"
|
||||
class="border border-gray-100 hover:bg-slate-50"
|
||||
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
@click="goToEditAddress(address.id ?? null)"
|
||||
>
|
||||
<td class="py-3 px-4">{{ address.street || "—" }}</td>
|
||||
<td class="py-3 px-4">{{ address.street2 || "—" }}</td>
|
||||
<td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
|
||||
<td class="py-3 px-4">{{ address.city || "—" }}</td>
|
||||
<td class="py-3 px-4">{{ address.countryCode || "—" }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex justify-center items-center">
|
||||
<UiButton
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center text-xl gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||
:disabled="customerId === null || !auth.isAdmin"
|
||||
@click="goToAddAddress"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, reactive, ref, watch} from "vue"
|
||||
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
|
||||
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
|
||||
import {createAddress, type AddressPayload} from "~/services/address"
|
||||
import {getCommunesByPostalCode, type CommuneData} from "~/services/geo"
|
||||
import {useAuthStore} from "~/stores/auth"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const resolveId = (param: unknown) => {
|
||||
const idStr = Array.isArray(param) ? param[0] : param
|
||||
if (!idStr) return null
|
||||
const id = Number(idStr)
|
||||
return Number.isFinite(id) ? id : null
|
||||
}
|
||||
const customerId = computed(() => resolveId(route.params.id))
|
||||
const isLoading = ref(false)
|
||||
const submitted = ref(false)
|
||||
const form = reactive<CustomerFormData>({
|
||||
name: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
addresses: [],
|
||||
})
|
||||
|
||||
// Address form (creation mode only)
|
||||
const addressForm = reactive<AddressPayload>({
|
||||
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
|
||||
})
|
||||
const communes = ref<CommuneData[]>([])
|
||||
const isLoadingCities = ref(false)
|
||||
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(() => addressForm.postalCode, (cp) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
|
||||
if (cp.length === 5) {
|
||||
debounceTimer = setTimeout(async () => {
|
||||
isLoadingCities.value = true
|
||||
try {
|
||||
communes.value = await getCommunesByPostalCode(cp)
|
||||
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
|
||||
else addressForm.city = ''
|
||||
} finally { isLoadingCities.value = false }
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
|
||||
const goToAddAddress = () => {
|
||||
if (customerId.value === null || !auth.isAdmin) return
|
||||
router.push({
|
||||
path: "/admin/customer/address",
|
||||
query: {
|
||||
customerId: String(customerId.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const goToEditAddress = (addressId: number | null) => {
|
||||
if (customerId.value === null || addressId === null || !auth.isAdmin) return
|
||||
router.push({
|
||||
path: "/admin/customer/address",
|
||||
query: {
|
||||
customerId: String(customerId.value),
|
||||
addressId: String(addressId),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const hydrateFromCustomer = (customer: CustomerData | null) => {
|
||||
if (!customer) return
|
||||
form.name = customer.name ?? ""
|
||||
form.phone = customer.phone ?? ""
|
||||
form.email = customer.email ?? ""
|
||||
if (!Array.isArray(customer.addresses) || customer.addresses.length === 0) {
|
||||
form.addresses = []
|
||||
return
|
||||
}
|
||||
if (typeof customer.addresses[0] === "string") {
|
||||
form.addresses = []
|
||||
return
|
||||
}
|
||||
|
||||
form.addresses = customer.addresses.map((address) => ({
|
||||
id: address.id ?? null,
|
||||
street: address.street ?? "",
|
||||
street2: address.street2 ?? null,
|
||||
postalCode: address.postalCode ?? "",
|
||||
city: address.city ?? "",
|
||||
countryCode: address.countryCode ?? "",
|
||||
}))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => customerId.value,
|
||||
async (id) => {
|
||||
if (id === null) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
const customer = await getCustomer(id)
|
||||
hydrateFromCustomer(customer)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
|
||||
async function validate() {
|
||||
if (isLoading.value) return
|
||||
if (!auth.isAdmin) return
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const name = form.name.trim()
|
||||
const phone = form.phone?.trim() || null
|
||||
const email = form.email?.trim() || null
|
||||
|
||||
const customerPayload: CustomerPayload = {
|
||||
name,
|
||||
phone,
|
||||
email,
|
||||
}
|
||||
let targetId: number | null = null
|
||||
|
||||
if (customerId.value !== null) {
|
||||
await updateCustomer(customerId.value, customerPayload)
|
||||
targetId = customerId.value
|
||||
} else {
|
||||
const addressData = await createAddress({ ...addressForm })
|
||||
const addressIRI = `/api/addresses/${addressData.id}`
|
||||
const creationPayload = {
|
||||
...customerPayload,
|
||||
addresses: [addressIRI],
|
||||
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
|
||||
}
|
||||
const created = await createCustomer(creationPayload)
|
||||
targetId = created.id
|
||||
}
|
||||
|
||||
if (targetId !== null) {
|
||||
await router.push(`/admin/customer/${targetId}`)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
44
frontend/pages/admin/customer/address.vue
Normal file
44
frontend/pages/admin/customer/address.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<Address type="customer" :address="address" @validate="validate"/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AddressData, AddressPayload } from "~/services/address"
|
||||
import { createAddress, getAddress, updateAddress } from "~/services/address"
|
||||
import { getCustomer, updateCustomer } from "~/services/customer"
|
||||
import type { CustomerData } from "~/services/dto/customer-data"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const customerId = computed(() => Number(route.query.customerId))
|
||||
const customer = ref<CustomerData | null>(null)
|
||||
const addressId = computed(() => (route.query.addressId !== undefined ? Number(route.query.addressId) : null))
|
||||
const address = ref<AddressData | null>(null)
|
||||
|
||||
const validate = async (payload: AddressPayload) => {
|
||||
if (addressId.value !== null) {
|
||||
await updateAddress(addressId.value, payload)
|
||||
} else {
|
||||
await addAddress(payload)
|
||||
await router.push("/admin/customer/" + customerId.value)
|
||||
}
|
||||
}
|
||||
|
||||
const addAddress = async (payload: AddressPayload) => {
|
||||
const response: AddressData = await createAddress(payload)
|
||||
const addressIRI = `/api/addresses/${response.id}`
|
||||
const existingIris = (customer.value?.addresses ?? [])
|
||||
.map((item: any) => (typeof item === "string" ? item : `/api/addresses/${item.id}`))
|
||||
.filter((iri: string | null) => Boolean(iri)) as string[]
|
||||
const next = [...new Set([...existingIris, addressIRI])]
|
||||
|
||||
return await updateCustomer(customerId.value, { addresses: next })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
customer.value = await getCustomer(customerId.value)
|
||||
if (addressId.value !== null) {
|
||||
address.value = await getAddress(addressId.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
87
frontend/pages/admin/customer/customer-list.vue
Normal file
87
frontend/pages/admin/customer/customer-list.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 mb-11">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToCustomer"
|
||||
>
|
||||
<template #header-name>
|
||||
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
|
||||
</template>
|
||||
<template #header-phone>
|
||||
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
|
||||
</template>
|
||||
<template #header-email>
|
||||
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
|
||||
</template>
|
||||
<template #header-createdBy.username>
|
||||
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/customer"
|
||||
class="inline-flex items-center mb-16 justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CustomerData } from '~/services/dto/customer-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<CustomerData>(
|
||||
'customers',
|
||||
{
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
'createdBy.username': ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom' },
|
||||
{ key: 'phone', label: 'Téléphone' },
|
||||
{ key: 'email', label: 'Mail' },
|
||||
{ key: 'createdBy.username', label: 'Créé par' }
|
||||
]
|
||||
|
||||
const goToCustomer = (customer: CustomerData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/customer/${customer.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
250
frontend/pages/admin/supplier/[[id]].vue
Normal file
250
frontend/pages/admin/supplier/[[id]].vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<form :class="{ submitted }" @submit.prevent="validate">
|
||||
|
||||
<div class="flex items-center relative">
|
||||
<div class="flex flex-row absolute -left-[60px] ">
|
||||
<Icon @click="router.push('/admin/supplier/supplier-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
|
||||
</div>
|
||||
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
||||
{{ supplierId ? "Modification du fournisseur" : "Ajout d'un fournisseur" }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
|
||||
<UiTextInput id="supplier-name" v-model="form.name" label="Nom du fournisseur" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
|
||||
<UiTextInput id="supplier-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
|
||||
<UiTextInput id="supplier-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
|
||||
</div>
|
||||
<div v-if="!supplierId" class="flex flex-cols-3 justify-between mb-11">
|
||||
<UiTextInput id="address-street" v-model="addressForm.street" label="Rue" wrapper-class="w-[280px]" required />
|
||||
<UiTextInput id="address-street2" v-model="addressForm.street2" label="Complément" wrapper-class="w-[280px]" />
|
||||
<UiTextInput id="address-country" v-model="addressForm.countryCode" label="Pays (code)" wrapper-class="w-[280px]" />
|
||||
</div>
|
||||
<div v-if="!supplierId" class="flex flex-cols-3 justify-between mb-11">
|
||||
<UiTextInput id="address-postalCode" v-model="addressForm.postalCode" label="Code postal" wrapper-class="w-[280px]" required />
|
||||
<UiSelect id="address-city" v-model="addressForm.city" label="Ville"
|
||||
:options="communeOptions" :loading="isLoadingCities"
|
||||
wrapper-class="w-[280px]" required />
|
||||
<div class="w-[280px]" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<UiButton
|
||||
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||
type="submit"
|
||||
:disabled="isLoading || !auth.isAdmin"
|
||||
@click="submitted = true"
|
||||
>
|
||||
<Icon :name="supplierId ? '' : 'mdi:plus'" size="28" />
|
||||
{{ supplierId ? "Valider" : "Ajouter" }}
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<template v-if="supplierId">
|
||||
<div class="flex items-center justify-between mb-7">
|
||||
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du fournisseur</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto mb-11 text-primary-700">
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="text-left border bg-slate-100 border-gray-200">
|
||||
<th class="py-3 px-4 text-sm uppercase">Rue</th>
|
||||
<th class="py-3 px-4 text-sm uppercase">Complément</th>
|
||||
<th class="py-3 px-4 text-sm uppercase">Code postal</th>
|
||||
<th class="py-3 px-4 text-sm uppercase">Ville</th>
|
||||
<th class="py-3 px-4 text-sm uppercase">Pays</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="form.addresses.length === 0">
|
||||
<tr>
|
||||
<td colspan="5" class="py-4 text-slate-400">
|
||||
Aucune adresse.
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr
|
||||
v-for="(address, index) in form.addresses"
|
||||
:key="address.id ?? index"
|
||||
class="border border-gray-100 hover:bg-slate-50"
|
||||
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
@click="goToEditAddress(address.id ?? null)"
|
||||
>
|
||||
<td class="py-3 px-4">{{ address.street || "—" }}</td>
|
||||
<td class="py-3 px-4">{{ address.street2 || "—" }}</td>
|
||||
<td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
|
||||
<td class="py-3 px-4">{{ address.city || "—" }}</td>
|
||||
<td class="py-3 px-4">{{ address.countryCode || "—" }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex justify-center items-center">
|
||||
<UiButton
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center text-xl gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||
:disabled="supplierId === null || !auth.isAdmin"
|
||||
@click="goToAddAddress"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, reactive, ref, watch} from "vue"
|
||||
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
|
||||
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
|
||||
import {createAddress, type AddressPayload} from "~/services/address"
|
||||
import {getCommunesByPostalCode, type CommuneData} from "~/services/geo"
|
||||
import {useAuthStore} from "~/stores/auth"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const resolveId = (param: unknown) => {
|
||||
const idStr = Array.isArray(param) ? param[0] : param
|
||||
if (!idStr) return null
|
||||
const id = Number(idStr)
|
||||
return Number.isFinite(id) ? id : null
|
||||
}
|
||||
const supplierId = computed(() => resolveId(route.params.id))
|
||||
const isLoading = ref(false)
|
||||
const submitted = ref(false)
|
||||
const form = reactive<SupplierFormData>({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
addresses: [],
|
||||
})
|
||||
|
||||
// Address form (creation mode only)
|
||||
const addressForm = reactive<AddressPayload>({
|
||||
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
|
||||
})
|
||||
const communes = ref<CommuneData[]>([])
|
||||
const isLoadingCities = ref(false)
|
||||
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(() => addressForm.postalCode, (cp) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
|
||||
if (cp.length === 5) {
|
||||
debounceTimer = setTimeout(async () => {
|
||||
isLoadingCities.value = true
|
||||
try {
|
||||
communes.value = await getCommunesByPostalCode(cp)
|
||||
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
|
||||
else addressForm.city = ''
|
||||
} finally { isLoadingCities.value = false }
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
|
||||
const goToAddAddress = () => {
|
||||
if (supplierId.value === null || !auth.isAdmin) return
|
||||
router.push({
|
||||
path: "/admin/supplier/address",
|
||||
query: {
|
||||
supplierId: String(supplierId.value),
|
||||
fromSupplier: "1",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const goToEditAddress = (addressId: number | null) => {
|
||||
if (supplierId.value === null || addressId === null || !auth.isAdmin) return
|
||||
router.push({
|
||||
path: "/admin/supplier/address",
|
||||
query: {
|
||||
supplierId: String(supplierId.value),
|
||||
addressId: String(addressId),
|
||||
fromSupplier: "1",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const hydrateFromSupplier = (supplier: SupplierData | null) => {
|
||||
if (!supplier) return
|
||||
form.name = supplier.name ?? ""
|
||||
form.email = supplier.email ?? ""
|
||||
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(
|
||||
() => supplierId.value,
|
||||
async (id) => {
|
||||
if (id === null) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
const supplier = await getSupplier(id)
|
||||
hydrateFromSupplier(supplier)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
|
||||
async function validate() {
|
||||
if (isLoading.value) return
|
||||
if (!auth.isAdmin) return
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const name = form.name.trim()
|
||||
const email = (form.email ?? "").trim() || null
|
||||
const phone = (form.phone ?? "").trim() || null
|
||||
|
||||
const supplierPayload: SupplierPayload = {
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
}
|
||||
let targetId: number | null = null
|
||||
|
||||
if (supplierId.value !== null) {
|
||||
await updateSupplier(supplierId.value, supplierPayload)
|
||||
targetId = supplierId.value
|
||||
} else {
|
||||
const addressData = await createAddress({ ...addressForm })
|
||||
const addressIRI = `/api/addresses/${addressData.id}`
|
||||
const creationPayload = {
|
||||
...supplierPayload,
|
||||
addresses: [addressIRI],
|
||||
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
|
||||
}
|
||||
const created = await createSupplier(creationPayload)
|
||||
targetId = created.id
|
||||
}
|
||||
|
||||
await router.push(`/admin/supplier/${targetId}`)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
42
frontend/pages/admin/supplier/address.vue
Normal file
42
frontend/pages/admin/supplier/address.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<Address type="supplier" :address="address" @validate="validate"/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {AddressData, AddressPayload} from "~/services/address";
|
||||
import {createAddress, getAddress, updateAddress} from "~/services/address";
|
||||
import {getSupplier, updateSupplier} from "~/services/supplier";
|
||||
import type {SupplierData} from "~/services/dto/supplier-data";
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const supplierId = computed(() => { return Number(route.query.supplierId) })
|
||||
const supplier = ref<SupplierData|null>(null);
|
||||
const addressId = computed(() => { return route.query.addressId !== undefined ? Number(route.query.addressId) : null })
|
||||
const address = ref<AddressData|null>(null)
|
||||
|
||||
const validate = async (address: AddressPayload) => {
|
||||
if (addressId.value !== null) {
|
||||
await updateAddress(addressId.value, address)
|
||||
} else {
|
||||
await addAddress(address)
|
||||
await router.push('/admin/supplier/' + supplierId.value)
|
||||
}
|
||||
}
|
||||
|
||||
const addAddress = async (address: AddressPayload) => {
|
||||
const response: AddressData = await createAddress(address)
|
||||
const addressIRI = `/api/addresses/${response.id}`
|
||||
const existingIris = (supplier.value.addresses ?? []).map((item: any) => `/api/addresses/${item.id}`)
|
||||
const next = [...new Set([...existingIris, addressIRI])]
|
||||
|
||||
return await updateSupplier(supplierId.value, { addresses: next })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
supplier.value = await getSupplier(supplierId.value)
|
||||
if (addressId.value !== null) {
|
||||
address.value = await getAddress(addressId.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
87
frontend/pages/admin/supplier/supplier-list.vue
Normal file
87
frontend/pages/admin/supplier/supplier-list.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 mb-11">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToSupplier"
|
||||
>
|
||||
<template #header-name>
|
||||
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
|
||||
</template>
|
||||
<template #header-phone>
|
||||
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
|
||||
</template>
|
||||
<template #header-email>
|
||||
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
|
||||
</template>
|
||||
<template #header-createdBy.username>
|
||||
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/supplier"
|
||||
class="inline-flex items-center mb-16 justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SupplierData } from '~/services/dto/supplier-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<SupplierData>(
|
||||
'suppliers',
|
||||
{
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
'createdBy.username': ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom' },
|
||||
{ key: 'phone', label: 'Téléphone' },
|
||||
{ key: 'email', label: 'Mail' },
|
||||
{ key: 'createdBy.username', label: 'Créé par' }
|
||||
]
|
||||
|
||||
const goToSupplier = (supplier: SupplierData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/supplier/${supplier.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
164
frontend/pages/admin/user/[[id]].vue
Normal file
164
frontend/pages/admin/user/[[id]].vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<form :class="{ submitted }" @submit.prevent="validate">
|
||||
<div class="flex items-center relative">
|
||||
<div class="flex flex-row absolute -left-[60px]">
|
||||
<Icon
|
||||
@click="router.push('/admin/user/list')"
|
||||
name="gg:arrow-left-o"
|
||||
size="40"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
||||
{{ userId ? "Modification de l'utilisateur" : "Ajout d'un utilisateur" }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
|
||||
<UiTextInput
|
||||
id="user-name"
|
||||
v-model="form.username"
|
||||
label="Nom de l'utilisateur"
|
||||
:disabled="!auth.isAdmin"
|
||||
wrapper-class="w-[280px]"
|
||||
required
|
||||
/>
|
||||
|
||||
<UiSelect
|
||||
id="user-role"
|
||||
v-model="form.role"
|
||||
label="Role de l'utilisateur"
|
||||
:options="ROLE"
|
||||
:disabled="!auth.isAdmin"
|
||||
wrapper-class="w-[280px]"
|
||||
required
|
||||
/>
|
||||
|
||||
<UiTextInput
|
||||
id="user-password"
|
||||
v-model="form.password"
|
||||
label="Mot de passe"
|
||||
type="password"
|
||||
:disabled="!auth.isAdmin"
|
||||
wrapper-class="w-[280px]"
|
||||
:required="!userId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-11">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
id="user-locked"
|
||||
v-model="form.isLocked"
|
||||
type="checkbox"
|
||||
:disabled="!auth.isAdmin"
|
||||
class="w-5 h-5 accent-primary-500"
|
||||
/>
|
||||
<span class="text-sm text-primary-700">Verrouiller le compte</span>
|
||||
</label>
|
||||
<p class="ml-4 text-xs text-slate-400">Un compte verrouillé ne peut plus se connecter.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<UiButton
|
||||
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||
type="submit"
|
||||
:disabled="isLoading || isHydrating || !auth.isAdmin"
|
||||
@click="submitted = true"
|
||||
>
|
||||
<Icon :name="userId ? '' : 'mdi:plus'" size="28" />
|
||||
{{ userId ? 'Valider' : 'Ajouter' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ROLE } from '~/utils/constants'
|
||||
import { createUser, updateUser, getUser } from '~/services/auth'
|
||||
import type { UserData, UserFormData, UserPayload } from '~/services/dto/user-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const userId = computed(() => resolveUserId(route.params.id))
|
||||
const isLoading = ref(false)
|
||||
const isHydrating = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
const resolveUserId = (param: unknown) => {
|
||||
const idStr = Array.isArray(param) ? param[0] : param
|
||||
if (!idStr) {
|
||||
return null
|
||||
}
|
||||
const id = Number(idStr)
|
||||
return Number.isFinite(id) ? id : null
|
||||
}
|
||||
|
||||
const form = reactive<UserFormData>({
|
||||
username: '',
|
||||
password: '',
|
||||
role: '',
|
||||
isLocked: false
|
||||
})
|
||||
|
||||
const hydrateFromUser = (user: UserData | null) => {
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
isHydrating.value = true
|
||||
form.username = user.username ?? ''
|
||||
const roles = user.roles ?? []
|
||||
const hasAdmin = roles.includes('ROLE_ADMIN')
|
||||
form.role = hasAdmin ? 'ROLE_ADMIN' : 'ROLE_USER'
|
||||
form.password = ''
|
||||
form.isLocked = user.isLocked ?? false
|
||||
isHydrating.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => userId.value,
|
||||
async (id) => {
|
||||
if (id === null) {
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
try {
|
||||
const user = await getUser(id)
|
||||
hydrateFromUser(user)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function validate() {
|
||||
if (!auth.isAdmin) return
|
||||
|
||||
const normalizedUsername = form.username.trim()
|
||||
const normalizedRole = form.role.trim()
|
||||
const normalizedPassword = form.password.trim()
|
||||
|
||||
const basePayload: UserPayload = {
|
||||
username: normalizedUsername,
|
||||
roles: normalizedRole ? [normalizedRole] : undefined,
|
||||
isLocked: form.isLocked,
|
||||
}
|
||||
if (normalizedPassword) {
|
||||
basePayload.password = normalizedPassword
|
||||
}
|
||||
|
||||
if (userId.value) {
|
||||
await updateUser(userId.value, basePayload)
|
||||
return
|
||||
}
|
||||
|
||||
const created = await createUser(basePayload)
|
||||
if (created) {
|
||||
await router.push('/admin/user/list')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
115
frontend/pages/admin/user/list.vue
Normal file
115
frontend/pages/admin/user/list.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 mb-11">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToUser"
|
||||
>
|
||||
<template #header-username>
|
||||
<UiTextInput
|
||||
v-model="filters.username"
|
||||
placeholder="Utilisateur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-roles>
|
||||
<UiTextInput :model-value="''" placeholder="Role" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-isLocked>
|
||||
<UiSelect
|
||||
v-model="filters.isLocked"
|
||||
placeholder="Statut"
|
||||
:options="statusOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-roles="{ item }">
|
||||
{{ getRoleLabels(item.roles) }}
|
||||
</template>
|
||||
<template #cell-isLocked="{ item }">
|
||||
<span
|
||||
v-if="item.isLocked"
|
||||
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-red-100 text-red-700"
|
||||
>Verrouillé</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-green-100 text-green-700"
|
||||
>Actif</span>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/user"
|
||||
class="inline-flex items-center mb-16 justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { ROLE } from '~/utils/constants'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const roleLabelByValue = new Map(ROLE.map(role => [role.value, role.label]))
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<UserData>(
|
||||
'admin/users',
|
||||
{
|
||||
username: '',
|
||||
isLocked: ''
|
||||
}
|
||||
)
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'false', label: 'Actif' },
|
||||
{ value: 'true', label: 'Verrouillé' }
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ key: 'username', label: 'Utilisateur' },
|
||||
{ key: 'roles', label: 'Role' },
|
||||
{ key: 'isLocked', label: 'Statut', width: '160px' }
|
||||
]
|
||||
|
||||
const getRoleLabels = (roles?: string[]) => {
|
||||
if (!roles || roles.length === 0) return '---'
|
||||
return roles.map(role => roleLabelByValue.get(role) ?? role).join(', ')
|
||||
}
|
||||
|
||||
const goToUser = (user: UserData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/user/${user.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
@@ -1,21 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
<template>
|
||||
<div class="">
|
||||
<h1 class="text-3xl font-bold">Liste des receptions</h1>
|
||||
<ul>
|
||||
<li v-for="reception in receptionList" :key="reception.id">
|
||||
<NuxtLink :to="`/reception/${reception.id}`">Réception numéro {{ reception.id}}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex flex-wrap justify-center pb-16 gap-12">
|
||||
<card-link label="NOUVELLE RÉCEPTION" link="/reception" iconName="mdi:truck-outline" />
|
||||
<card-link label="NOUVELLE EXPÉDITION" link="/shipment" iconName="mdi:truck-fast-outline" />
|
||||
<card-link label="PLAN DE SITE" link="/infrastructure/building" iconName="material-symbols:warehouse-outline-rounded" />
|
||||
<card-link link="/reception/waiting-reception" iconName="mdi:truck-remove-outline">
|
||||
<template #label>
|
||||
Réceptions<br>EN ATTENTE
|
||||
</template>
|
||||
</card-link>
|
||||
<card-link link="/shipment/waiting-shipment" iconName="mdi:truck-cargo-container">
|
||||
<template #label>
|
||||
EXPÉDITIONS<br>EN ATTENTE
|
||||
</template>
|
||||
</card-link>
|
||||
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
|
||||
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
|
||||
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
|
||||
<card-link link="/" iconName="mdi:cow">
|
||||
<template #label>
|
||||
PASSEPORT<br>DU BOVIN
|
||||
</template>
|
||||
</card-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {ReceptionData} from "~/services/dto/reception-data";
|
||||
import {getReceptionList} from "~/services/reception";
|
||||
|
||||
const receptionList = ref<ReceptionData[]>()
|
||||
|
||||
onMounted(async () => {
|
||||
receptionList.value = await getReceptionList()
|
||||
})
|
||||
</script>
|
||||
|
||||
180
frontend/pages/infrastructure/bovine.vue
Normal file
180
frontend/pages/infrastructure/bovine.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<form :class="{ submitted }" @submit.prevent="validate">
|
||||
<div class="flex items-center relative">
|
||||
<div class="flex flex-row absolute -left-[60px]">
|
||||
<Icon
|
||||
@click="goBack"
|
||||
name="gg:arrow-left-o"
|
||||
size="40"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
||||
{{ isEdit ? 'Modification d\'un bovin' : 'Ajout d\'un bovin' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
|
||||
<UiTextInput
|
||||
id="bovine-national-number"
|
||||
v-model="form.nationalNumber"
|
||||
label="Numéro national"
|
||||
:disabled="!auth.isAdmin || isLoading"
|
||||
wrapper-class="w-[280px]"
|
||||
required
|
||||
/>
|
||||
<UiNumberInput
|
||||
id="bovine-received-weight"
|
||||
v-model="form.receivedWeight"
|
||||
label="Poids à l'arrivée (kg)"
|
||||
:min="0"
|
||||
:disabled="!auth.isAdmin || isLoading"
|
||||
wrapper-class="w-[280px] flex-col"
|
||||
label-class="font-bold uppercase"
|
||||
/>
|
||||
<UiDateInput
|
||||
id="bovine-arrival-date"
|
||||
v-model="form.arrivalDate"
|
||||
label="Date d'arrivée"
|
||||
:disabled="!auth.isAdmin || isLoading"
|
||||
wrapper-class="w-[280px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-cols-3 justify-between mb-11">
|
||||
<UiSelect
|
||||
id="bovine-supplier"
|
||||
v-model="form.supplierId"
|
||||
label="Vendeur"
|
||||
:options="supplierOptions"
|
||||
:loading="isLoadingSuppliers"
|
||||
:disabled="!auth.isAdmin || isLoading"
|
||||
wrapper-class="w-[280px]"
|
||||
/>
|
||||
<div class="w-[280px]" />
|
||||
<div class="w-[280px]" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<UiButton
|
||||
type="submit"
|
||||
:disabled="!auth.isAdmin || isLoading"
|
||||
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||
@click="submitted = true"
|
||||
>
|
||||
<Icon :name="isEdit ? '' : 'mdi:plus'" size="28" />
|
||||
{{ isEdit ? 'Valider' : 'Ajouter' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { createBovine, getBovine, updateBovine } from '~/services/bovine'
|
||||
import type { BovinePayload } from '~/services/dto/bovine-data'
|
||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||
import { getSupplierList } from '~/services/supplier'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const caseId = computed(() => {
|
||||
const raw = Number(route.query.caseId)
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : null
|
||||
})
|
||||
|
||||
const bovineId = computed(() => {
|
||||
const raw = Number(route.query.id)
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : null
|
||||
})
|
||||
|
||||
const isEdit = computed(() => bovineId.value !== null)
|
||||
|
||||
const form = reactive<{
|
||||
nationalNumber: string
|
||||
receivedWeight: number | null
|
||||
arrivalDate: string | null
|
||||
supplierId: string
|
||||
}>({
|
||||
nationalNumber: '',
|
||||
receivedWeight: null,
|
||||
arrivalDate: null,
|
||||
supplierId: ''
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const submitted = ref(false)
|
||||
const suppliers = ref<SupplierData[]>([])
|
||||
const isLoadingSuppliers = ref(false)
|
||||
|
||||
const supplierOptions = computed(() =>
|
||||
suppliers.value.map(s => ({ value: String(s.id), label: s.name }))
|
||||
)
|
||||
|
||||
const backRoute = computed(() => ({
|
||||
path: '/infrastructure/case',
|
||||
query: caseId.value ? { id: String(caseId.value) } : {}
|
||||
}))
|
||||
|
||||
const goBack = () => {
|
||||
router.push(backRoute.value)
|
||||
}
|
||||
|
||||
const loadSuppliers = async () => {
|
||||
isLoadingSuppliers.value = true
|
||||
try {
|
||||
suppliers.value = await getSupplierList()
|
||||
} finally {
|
||||
isLoadingSuppliers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hydrate = async () => {
|
||||
if (!isEdit.value || bovineId.value === null) {
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
try {
|
||||
const bovine = await getBovine(bovineId.value)
|
||||
form.nationalNumber = bovine.nationalNumber ?? ''
|
||||
form.receivedWeight = bovine.receivedWeight ?? null
|
||||
form.arrivalDate = bovine.arrivalDate ?? null
|
||||
if (bovine.supplier) {
|
||||
const supplierId = bovine.supplier.replace(/.*\//, '')
|
||||
form.supplierId = supplierId
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const validate = async () => {
|
||||
if (isLoading.value || !auth.isAdmin) return
|
||||
if (!caseId.value) return
|
||||
if (!form.nationalNumber.trim()) return
|
||||
|
||||
const payload: BovinePayload = {
|
||||
nationalNumber: form.nationalNumber.trim(),
|
||||
receivedWeight: form.receivedWeight,
|
||||
arrivalDate: form.arrivalDate,
|
||||
buildingCase: `/api/building_cases/${caseId.value}`,
|
||||
supplier: form.supplierId ? `/api/suppliers/${form.supplierId}` : null
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (isEdit.value && bovineId.value !== null) {
|
||||
await updateBovine(bovineId.value, payload)
|
||||
} else {
|
||||
await createBovine(payload)
|
||||
}
|
||||
router.push(backRoute.value)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSuppliers)
|
||||
watch(bovineId, hydrate, { immediate: true })
|
||||
</script>
|
||||
230
frontend/pages/infrastructure/building.vue
Normal file
230
frontend/pages/infrastructure/building.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<!-- En-tête de page avec retour et titre -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-10">
|
||||
<Icon
|
||||
@click="router.push('/')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px] space-y-6">
|
||||
<!-- Liste des bâtiments + rendu du plan de chaque bâtiment -->
|
||||
<div
|
||||
v-for="entry in buildingLayouts"
|
||||
:key="entry.building.id"
|
||||
>
|
||||
<div class="font-semibold tracking-wide text-primary-500">
|
||||
{{ entry.building.label || `Bâtiment ${entry.building.id}` }}
|
||||
</div>
|
||||
|
||||
<div class="py-4">
|
||||
<!-- Aucun layout disponible pour ce bâtiment -->
|
||||
<div v-if="!entry.layout" class="text-sm text-slate-400">
|
||||
Aucun plan de bâtiment.
|
||||
</div>
|
||||
|
||||
<!-- Grille CSS : les cases sont positionnées via spanStyle -->
|
||||
<div v-else class="overflow-auto">
|
||||
<div class="grid" :style="entry.gridStyle">
|
||||
<NuxtLink
|
||||
v-for="cell in entry.cells"
|
||||
:key="cell.key"
|
||||
class="relative text-white flex h-[50px] items-center justify-center border-y-[3px] border-y-black bg-white hover:opacity-85 focus-visible:outline-none"
|
||||
:class="[cell.sideBorderClass, activeLegendLabel !== null && cell.caseStatusLabel !== activeLegendLabel ? 'opacity-35 hover:opacity-70' : '']"
|
||||
:style="[cell.spanStyle, cell.sideBorderStyle]"
|
||||
:to="cell.caseId ? `/infrastructure/case?id=${cell.caseId}` : '/infrastructure/case'"
|
||||
:title="cell.caseStatusLabel ?? undefined"
|
||||
>
|
||||
<!-- Le blanc latéral est géré sur ce bloc interne (conditionnel par voisinage) -->
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center bg-white"
|
||||
:class="cell.contentInsetClass"
|
||||
:style="cell.caseStyle"
|
||||
>
|
||||
<!-- Numéro de case -->
|
||||
{{ cell.display }}
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Légende : survol d'un statut => atténue les autres cases -->
|
||||
<div class="py-4">
|
||||
<div class="flex gap-6">
|
||||
<div
|
||||
v-for="statut in statutLegend"
|
||||
:key="statut.label"
|
||||
class="flex cursor-pointer items-center gap-2 py-1"
|
||||
@mouseenter="activeLegendLabel = statut.label"
|
||||
@mouseleave="activeLegendLabel = null"
|
||||
>
|
||||
<span
|
||||
class="h-5 w-5 border border-slate-300"
|
||||
:style="statut.couleur ? { backgroundColor: statut.couleur } : {}"
|
||||
></span>
|
||||
<span class="text-sm uppercase text-slate-700">
|
||||
{{ statut.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {BuildingData} from "~/services/dto/building-data"
|
||||
import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
|
||||
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"
|
||||
import {getBuildingList} from "~/services/building"
|
||||
|
||||
definePageMeta({layout: "default"})
|
||||
|
||||
const router = useRouter()
|
||||
// Données brutes chargées depuis l'API
|
||||
const buildingList = ref<BuildingData[]>([])
|
||||
const statutLegend = [
|
||||
{ label: 'Libre', couleur: '#A3B18A' },
|
||||
{ label: 'Occupé', couleur: '#3A506B' },
|
||||
{ label: 'Malade', couleur: '#E07A5F' },
|
||||
]
|
||||
// Statut actuellement survolé dans la légende (pour filtrage visuel)
|
||||
const activeLegendLabel = ref<string | null>(null)
|
||||
// Modèle de vue prêt pour le template (layout + cellules + styles de grille)
|
||||
const buildingLayouts = computed(() =>
|
||||
buildingList.value
|
||||
.filter((building) => building.layouts && building.layouts.length > 0)
|
||||
.map((building) => {
|
||||
const layout = building.layouts![0]
|
||||
const view = buildLayoutView(layout)
|
||||
return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}}
|
||||
})
|
||||
)
|
||||
|
||||
type GridCell = {
|
||||
key: string
|
||||
caseId: number | null
|
||||
display: string
|
||||
caseStatusLabel: string | null
|
||||
// Couleur de fond de la case (dépend du statut)
|
||||
caseStyle?: Record<string, string>
|
||||
// Placement dans la grille CSS (colonne/ligne de départ + span)
|
||||
spanStyle: Record<string, string>
|
||||
// Bordures latérales pointillées si la case touche un gap ou le bord du plan
|
||||
sideBorderClass: string
|
||||
// Couleur des bordures pointillées latérales (reprend la couleur de la cellule)
|
||||
sideBorderStyle?: Record<string, string>
|
||||
// Espace blanc interne uniquement côté(s) adjacent(s) à une autre case
|
||||
contentInsetClass: string
|
||||
}
|
||||
// Type intermédiaire : garde des infos utiles au calcul des bordures, retirées ensuite
|
||||
type GridCellDraft = Omit<GridCell, "sideBorderClass" | "sideBorderStyle" | "contentInsetClass"> & { x: number; columnSpan: number}
|
||||
|
||||
|
||||
// Nettoie la couleur de statut pour éviter les chaînes vides / espaces
|
||||
const normalizeCaseStatusColor = (value: string | null | undefined): string | null => {
|
||||
const color = (value ?? "").trim()
|
||||
return color.length > 0 ? color : null
|
||||
}
|
||||
|
||||
// Styles de base communs à toutes les grilles de bâtiments
|
||||
const BASE_GRID_STYLE = {gridAutoRows: "1fr", rowGap: "18px", columnGap: "0px", width: "100%"} as const
|
||||
|
||||
// Transforme un layout API en structure de rendu (cellules + style de grille)
|
||||
const buildLayoutView = (layout: BuildingLayoutData): {
|
||||
cells: GridCell[];
|
||||
gridStyle: Record<string, string>
|
||||
} | null => {
|
||||
const rows = layout.rows ?? 0, cols = layout.columns ?? 0
|
||||
if (rows <= 0 || cols <= 0) return null
|
||||
|
||||
// Liste des positions de cases (filtre de sécurité sur les valeurs nulles)
|
||||
const positions = (layout.casePositions ?? []).filter(Boolean) as BuildingCasePositionData[]
|
||||
// Colonnes occupées par au moins une case (sert à détecter les gaps)
|
||||
const occupiedColumns = new Set<number>()
|
||||
// Sécurité : si deux positions ont le même x/y, on garde la première
|
||||
const seenCoordinates = new Set<string>()
|
||||
const cellDrafts: GridCellDraft[] = []
|
||||
|
||||
// Tri visuel : de haut en bas, puis de gauche à droite
|
||||
const positionsSorted = [...positions].sort(
|
||||
(leftPosition, rightPosition) =>
|
||||
(leftPosition.y ?? 1) - (rightPosition.y ?? 1) || (leftPosition.x ?? 1) - (rightPosition.x ?? 1)
|
||||
)
|
||||
for (const position of positionsSorted) {
|
||||
const x = position.x ?? 1
|
||||
const y = position.y ?? 1
|
||||
const coordinateKey = `${x}-${y}`
|
||||
if (seenCoordinates.has(coordinateKey)) continue
|
||||
seenCoordinates.add(coordinateKey)
|
||||
|
||||
// w/h = nombre de colonnes / lignes occupées par la case dans la grille
|
||||
const columnSpan = position.w ?? 1
|
||||
const rowSpan = position.h ?? 1
|
||||
|
||||
// Une case peut couvrir plusieurs colonnes : on les marque toutes comme occupées
|
||||
for (let column = x; column < x + columnSpan; column++) {
|
||||
if (column <= cols) occupiedColumns.add(column)
|
||||
}
|
||||
|
||||
// Métadonnées utiles au rendu / navigation / légende
|
||||
const caseId = (position.buildingCase?.id ?? null) as number | null
|
||||
const caseNumber = (position.buildingCase?.caseNumber ?? null) as number | null
|
||||
const caseStatusLabel = position.buildingCase?.statut?.label ?? null
|
||||
const statusColor = normalizeCaseStatusColor(position.buildingCase?.statut?.couleur)
|
||||
|
||||
cellDrafts.push({
|
||||
key: `case-${layout.id}-${position.id}`,
|
||||
x,
|
||||
columnSpan,
|
||||
caseId,
|
||||
display: caseNumber !== null ? String(caseNumber) : "Case",
|
||||
caseStatusLabel,
|
||||
caseStyle: statusColor ? {backgroundColor: statusColor} : undefined,
|
||||
// Exemple : "14 / span 1" => commence en colonne 14 et occupe 1 colonne
|
||||
spanStyle: {gridColumn: `${x} / span ${columnSpan}`, gridRow: `${y} / span ${rowSpan}`}
|
||||
})
|
||||
}
|
||||
|
||||
// Colonnes vides = gaps visuels (plus étroites dans la grille)
|
||||
const gapColumns = Array.from({length: cols}, (_, i) => i + 1).filter((x) => !occupiedColumns.has(x))
|
||||
const gapSet = new Set(gapColumns)
|
||||
|
||||
// Ajoute les bordures latérales pointillées pour les cases au contact d'un gap ou d'un bord
|
||||
const cells: GridCell[] = cellDrafts.map(({x, columnSpan, ...cell}) => {
|
||||
const touchesLeftGapOrEdge = x === 1 || gapSet.has(x - 1)
|
||||
const touchesRightGapOrEdge = x + columnSpan - 1 === cols || gapSet.has(x + columnSpan)
|
||||
const sideBorderClass = [
|
||||
touchesLeftGapOrEdge ? "border-l-[3px] [border-left-style:dashed]" : "",
|
||||
touchesRightGapOrEdge ? "border-r-[3px] [border-right-style:dashed]" : ""
|
||||
].filter(Boolean).join(" ")
|
||||
// Les pointillés latéraux reprennent la couleur de la cellule (si un statut en fournit une)
|
||||
const sideBorderStyle = {
|
||||
...(cell.caseStyle?.backgroundColor && touchesLeftGapOrEdge ? {borderLeftColor: cell.caseStyle.backgroundColor} : {}),
|
||||
...(cell.caseStyle?.backgroundColor && touchesRightGapOrEdge ? {borderRightColor: cell.caseStyle.backgroundColor} : {})
|
||||
}
|
||||
// Le "blanc" n'est ajouté qu'entre deux cellules adjacentes (pas sur bord/gap)
|
||||
const contentInsetClass = [
|
||||
!touchesLeftGapOrEdge ? "ml-[4px]" : "",
|
||||
!touchesRightGapOrEdge ? "mr-[4px]" : ""
|
||||
].filter(Boolean).join(" ")
|
||||
return {...cell, sideBorderClass, sideBorderStyle, contentInsetClass}
|
||||
})
|
||||
|
||||
// Les colonnes de gap sont rendues en 24px, les autres occupent l'espace restant
|
||||
const columnsTemplate = Array.from({length: cols}, (_, i) => (gapSet.has(i + 1) ? "24px" : "minmax(0, 1fr)")).join(" ")
|
||||
return {cells, gridStyle: {gridTemplateColumns: columnsTemplate, ...BASE_GRID_STYLE}}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
buildingList.value = await getBuildingList()
|
||||
})
|
||||
</script>
|
||||
185
frontend/pages/infrastructure/case.vue
Normal file
185
frontend/pages/infrastructure/case.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between relative">
|
||||
<div class="flex flex-row absolute -left-[60px]">
|
||||
<Icon
|
||||
@click="router.push('/infrastructure/building')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="font-bold text-4xl text-primary-500 uppercase">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="hasCaseId"
|
||||
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer"
|
||||
title="Imprimer"
|
||||
@click="printCaseReport"
|
||||
>
|
||||
<Icon name="mdi:printer-outline" size="32" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-if="hasCaseId && auth.isAdmin"
|
||||
:to="addBovineRoute"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
:row-clickable="auth.isAdmin"
|
||||
empty-message="Aucun bovin dans cette case."
|
||||
@row-click="goToBovine"
|
||||
>
|
||||
<template #header-nationalNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.nationalNumber"
|
||||
placeholder="Numéro national"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receivedWeight>
|
||||
<UiTextInput
|
||||
v-model="filters.receivedWeight"
|
||||
placeholder="Poids (kg)"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-arrivalDate>
|
||||
<UiDateInput v-model="arrivalDateFilter" size="compact" />
|
||||
</template>
|
||||
<template #cell-arrivalDate="{ item }">
|
||||
{{ formatDate(item.arrivalDate) }}
|
||||
</template>
|
||||
<template #cell-receivedWeight="{ item }">
|
||||
{{ item.receivedWeight ?? '—' }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BuildingCaseData } from '~/services/dto/building-case-data'
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { printPdf } = usePdfPrinter()
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const caseId = computed(() => Number(route.query.id))
|
||||
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
|
||||
|
||||
const buildingCase = ref<BuildingCaseData | null>(null)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<BovineData>(
|
||||
'bovines',
|
||||
{
|
||||
buildingCase: '',
|
||||
nationalNumber: '',
|
||||
receivedWeight: '',
|
||||
'arrivalDate[after]': '',
|
||||
'arrivalDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const arrivalDateFilter = computed<string>({
|
||||
get: () => (filters.value['arrivalDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['arrivalDate[after]'] = ''
|
||||
filters.value['arrivalDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['arrivalDate[after]'] = value
|
||||
filters.value['arrivalDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'nationalNumber', label: 'Numéro national' },
|
||||
{ key: 'receivedWeight', label: "Poids à l'arrivée (kg)" },
|
||||
{ key: 'arrivalDate', label: "Date d'arrivée" }
|
||||
]
|
||||
|
||||
const title = computed(() => {
|
||||
if (!buildingCase.value) return ''
|
||||
const buildingLabel = buildingCase.value.building?.label ?? ''
|
||||
const caseNumber = buildingCase.value.caseNumber ?? ''
|
||||
return `${buildingLabel} case ${caseNumber}`.trim()
|
||||
})
|
||||
|
||||
const addBovineRoute = computed(() => ({
|
||||
path: '/infrastructure/bovine',
|
||||
query: { caseId: String(caseId.value) }
|
||||
}))
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return date
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const loadCase = async () => {
|
||||
if (!hasCaseId.value) {
|
||||
buildingCase.value = null
|
||||
return
|
||||
}
|
||||
buildingCase.value = await api.get<BuildingCaseData>(`/building_cases/${caseId.value}`)
|
||||
}
|
||||
|
||||
const printCaseReport = async () => {
|
||||
if (!hasCaseId.value) return
|
||||
const filename = `tableau_poids_case_${caseId.value}.pdf`
|
||||
await printPdf(`/building_cases/${caseId.value}/weights-report`, filename)
|
||||
}
|
||||
|
||||
const goToBovine = (bovine: BovineData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push({
|
||||
path: '/infrastructure/bovine',
|
||||
query: { id: String(bovine.id), caseId: String(caseId.value) }
|
||||
})
|
||||
}
|
||||
|
||||
watch(caseId, (id) => {
|
||||
if (!hasCaseId.value) {
|
||||
filters.value.buildingCase = ''
|
||||
buildingCase.value = null
|
||||
return
|
||||
}
|
||||
filters.value.buildingCase = `/api/building_cases/${id}`
|
||||
loadCase()
|
||||
reload()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
@@ -16,6 +16,7 @@
|
||||
<select
|
||||
id="user-select"
|
||||
v-model="selectedUsername"
|
||||
autocomplete="username"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
|
||||
:disabled="isLoadingUsers"
|
||||
>
|
||||
@@ -39,13 +40,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<UiButton
|
||||
type="submit"
|
||||
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Connexion
|
||||
</button>
|
||||
</UiButton>
|
||||
<p class="font-bold">v{{ version }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -57,6 +59,7 @@ import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { version } = useAppVersion()
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth'
|
||||
|
||||
@@ -1,63 +1,102 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between h-[52px] mb-[90px]">
|
||||
<p class="self-center">Indicateur d’étapes</p>
|
||||
<button
|
||||
<div class="flex justify-between h-[52px] mb-[80px]">
|
||||
<div class="flex flex-1 mr-16">
|
||||
<UiStepper
|
||||
:labels="stepLabels"
|
||||
:current-step="storeReception?.currentStep ?? 0"
|
||||
@select="handleStepSelect"
|
||||
/>
|
||||
</div>
|
||||
<UiButton
|
||||
type="button"
|
||||
class="flex flex-col justify-center uppercase text-xl bg-black text-white h-[50px] w-[272px] text-center"
|
||||
@click="saveAndHold"
|
||||
>Mettre en attente</button>
|
||||
</div>
|
||||
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/>
|
||||
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/>
|
||||
<ReceptionUnloading v-if="storeReception?.currentStep === 2"/>
|
||||
<ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/>
|
||||
>Mettre en attente
|
||||
</UiButton>
|
||||
</div>
|
||||
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0" ref="receptionFormRef"/>
|
||||
<WorkflowWeight
|
||||
v-if="storeReception?.currentStep === 1"
|
||||
ref="grossWeightRef"
|
||||
mode="gross"
|
||||
entity-name="reception"
|
||||
api-resource="receptions"
|
||||
:title-label="receptionConfig.weighingLabels.gross"
|
||||
:is-final="false"
|
||||
:entity="storeReception"
|
||||
:get-weight-from-scale="getWeight"
|
||||
:update-entity="receptionStore.updateReception"
|
||||
:load-entity="receptionStore.loadReception"
|
||||
:clear-entity="receptionStore.clearCurrent"
|
||||
:build-receipt-filename="receptionConfig.buildReceiptFilename"
|
||||
/>
|
||||
<ReceptionProductReceived
|
||||
v-if="storeReception?.currentStep === 2 &&
|
||||
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"/>
|
||||
<ReceptionBovineReceived
|
||||
v-if="storeReception?.currentStep === 2 &&
|
||||
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/>
|
||||
<WorkflowWeight
|
||||
v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3"
|
||||
ref="tareWeightRef"
|
||||
mode="tare"
|
||||
entity-name="reception"
|
||||
api-resource="receptions"
|
||||
:title-label="receptionConfig.weighingLabels.tare"
|
||||
:is-final="true"
|
||||
:entity="storeReception"
|
||||
:get-weight-from-scale="getWeight"
|
||||
:update-entity="receptionStore.updateReception"
|
||||
:load-entity="receptionStore.loadReception"
|
||||
:clear-entity="receptionStore.clearCurrent"
|
||||
:build-receipt-filename="receptionConfig.buildReceiptFilename"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useReceptionStore } from '~/stores/reception'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
|
||||
import { receptionConfig } from '~/config/reception.config'
|
||||
import { getWeight } from '~/services/reception'
|
||||
import { RECEPTION_TYPE_CODES } from '~/utils/constants'
|
||||
|
||||
const receptionStore = useReceptionStore()
|
||||
const { current: storeReception } = storeToRefs(receptionStore)
|
||||
const receptionFormRef = ref<{ saveDraft: () => Promise<void>, validateFields: () => boolean } | null>(null)
|
||||
const grossWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
|
||||
const tareWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
|
||||
|
||||
const resolveReceptionId = (param: unknown) => {
|
||||
const idStr = Array.isArray(param) ? param[0] : param
|
||||
if (!idStr) {
|
||||
return null
|
||||
const { stepLabels, handleStepSelect } = useWorkflowSteps(receptionConfig, receptionStore)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saveAndHold = async () => {
|
||||
if (receptionFormRef.value) {
|
||||
if (!receptionFormRef.value.validateFields()) return
|
||||
await receptionFormRef.value.saveDraft()
|
||||
} else {
|
||||
if (grossWeightRef.value) await grossWeightRef.value.saveWeightDraft()
|
||||
if (tareWeightRef.value) await tareWeightRef.value.saveWeightDraft()
|
||||
}
|
||||
const id = Number(idStr)
|
||||
return Number.isFinite(id) ? id : null
|
||||
await router.push('/')
|
||||
}
|
||||
|
||||
// Init route watcher
|
||||
const route = useRoute()
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (param) => {
|
||||
const id = resolveReceptionId(param)
|
||||
if (id === null) {
|
||||
const idStr = Array.isArray(param) ? param[0] : param
|
||||
if (!idStr) {
|
||||
receptionStore.clearCurrent()
|
||||
return
|
||||
}
|
||||
const id = Number(idStr)
|
||||
if (Number.isFinite(id)) {
|
||||
await receptionStore.loadReception(id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
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('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
161
frontend/pages/reception/finish-reception.vue
Normal file
161
frontend/pages/reception/finish-reception.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-start gap-10">
|
||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions finie</h1>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToReception"
|
||||
>
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionDate>
|
||||
<UiDateInput
|
||||
v-model="receptionDateFilter"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-supplier.name>
|
||||
<UiTextInput
|
||||
v-model="filters['supplier.name']"
|
||||
placeholder="Fournisseur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-address.fullAddress>
|
||||
<UiTextInput
|
||||
:model-value="''"
|
||||
placeholder="Adresse"
|
||||
size="compact"
|
||||
disabled
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionType.label>
|
||||
<UiSelect
|
||||
v-model="filters['receptionType.id']"
|
||||
placeholder="Type réception"
|
||||
:options="receptionTypeOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-weighing>
|
||||
<UiTextInput
|
||||
:model-value="''"
|
||||
placeholder="Poids"
|
||||
size="compact"
|
||||
disabled
|
||||
/>
|
||||
</template>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #cell-weighing="{ item }">
|
||||
{{ formatWeighing(item) }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||
import { getReceptionTypeList } from '~/services/reception-type'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const router = useRouter()
|
||||
const receptionTypes = ref<ReceptionTypeData[]>([])
|
||||
|
||||
const receptionTypeOptions = computed(() =>
|
||||
receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label }))
|
||||
)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
isValid: true,
|
||||
'identificationNumber': '',
|
||||
'supplier.name': '',
|
||||
'receptionType.id': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const receptionDateFilter = computed<string>({
|
||||
get: () => (filters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['receptionDate[after]'] = ''
|
||||
filters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['receptionDate[after]'] = value
|
||||
filters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'receptionDate', label: 'Date et heure', width: '120px' },
|
||||
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||
{ key: 'receptionType.label', label: 'Type réception', width: '0.9fr' },
|
||||
{ key: 'weighing', label: 'Poids', width: '82px' }
|
||||
]
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date.replace(' ', 'T'))
|
||||
if (isNaN(d.getTime())) return date
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatWeighing = (reception: ReceptionData) => {
|
||||
const gross = reception.weights?.find((weight) => weight.type === 'gross')?.weight
|
||||
const tare = reception.weights?.find((weight) => weight.type === 'tare')?.weight
|
||||
|
||||
if (gross == null || tare == null) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return `${gross - tare} kg`
|
||||
}
|
||||
|
||||
const goToReception = (reception: ReceptionData) => {
|
||||
router.push(`/reception/update/${reception.id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
receptionTypes.value = await getReceptionTypeList()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
1089
frontend/pages/reception/update/[[id]].vue
Normal file
1089
frontend/pages/reception/update/[[id]].vue
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user