Compare commits

..

1 Commits

Author SHA1 Message Date
972beb00b6 Feat : Ajout de la lib nuxt icon pour l'utilisation des icons 2026-01-07 08:30:24 +01:00
308 changed files with 1220 additions and 31268 deletions

View File

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

55
.env
View File

@@ -1,22 +1,41 @@
APP_ENV=
APP_DEBUG=
APP_SECRET=
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
DEFAULT_URI=
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
#DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN=
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=
JWT_PUBLIC_KEY=
JWT_PASSPHRASE=
COOKIE_SECURE=
###< lexik/jwt-authentication-bundle ###
# ADAPTER avec la vraie BDD (pas de variables docker)
DATABASE_URL=
PONT_BASCULE_BYPASS=
PONT_BASCULE_URL=

View File

@@ -1,23 +0,0 @@
---
name: "Merge Request"
about: "Template de MR"
title: "[#NUMERO_TICKET] TITRE TICKET"
ref: "main"
---
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
| | |
## Description de la PR
## Modification du .env
## Check list
- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

View File

@@ -1,65 +0,0 @@
name: Auto Tag Develop
on:
push:
branches:
- develop
jobs:
tag:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
persist-credentials: true
- name: Create next tag from config/version.yaml
shell: bash
run: |
set -euo pipefail
# 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
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 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"

View File

@@ -1,65 +0,0 @@
name: Build Release Artefact
on:
push:
tags:
- "v0.0.*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install backend deps (prod)
env:
APP_ENV: prod
APP_DEBUG: "0"
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
- name: Build frontend (static)
run: |
cd frontend
npm ci
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ NUXT_PUBLIC_GEO_API_BASE=https://geo.api.gouv.fr npm run generate
test -f .output/public/index.html
- name: Build artefact
shell: bash
run: |
set -euo pipefail
mkdir -p release
tar -czf "release/ferme-${GITHUB_REF_NAME}.tar.gz" \
bin \
config \
migrations \
public \
src \
templates \
vendor \
composer.json \
composer.lock \
symfony.lock \
frontend/.output
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/ferme-${{ github.ref_name }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

6
.gitignore vendored
View File

@@ -1,7 +1,6 @@
###> symfony/framework-bundle ###
/.env.local
/.env.prod
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
@@ -9,7 +8,6 @@
/var/
/vendor/
/LOG/
/config/jwt/*.pem
###< symfony/framework-bundle ###
###> friendsofphp/php-cs-fixer ###
@@ -25,7 +23,3 @@
###> docker ###
docker/.env.docker.local
###< docker ###
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

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

@@ -0,0 +1,12 @@
<?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>
</component>
</project>

View File

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

19
.idea/ferme.iml generated
View File

@@ -137,25 +137,6 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/css-selector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/dompdf" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/php-font-lib" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/php-svg-lib" />
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sabberworm/php-css-parser" />
<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" />
<orderEntry type="sourceFolder" forTests="false" />

253
.idea/php.xml generated
View File

@@ -4,184 +4,148 @@
<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/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/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
<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/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/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/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<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/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<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/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/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/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/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/lcobucci/jwt" />
<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/object-enumerator" />
<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/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/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/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/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/json-schema" />
<path value="$PROJECT_DIR$/vendor/api-platform/openapi" />
<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/phpdocumentor/reflection-common" />
<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/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<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/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" />
<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/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/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
<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/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/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
</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>
@@ -190,11 +154,6 @@
<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>

828
.idea/workspace.xml generated
View File

@@ -1,832 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="ComposerSettings" synchronizationState="SYNCHRONIZE">
<pharConfigPath>$PROJECT_DIR$/composer.json</pharConfigPath>
<component name="ComposerSettings">
<execution />
</component>
<component name="CopilotPersistence">
<persistenceIdMap>
<entry key="_//wsl.localhost/Ubuntu-24.04/home/kevin/Stage/Ferme" value="381AhnCm9yPeOiWgMObKHhtgv2C" />
</persistenceIdMap>
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="151" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="TypeScript File" />
<option value="PHP File" />
<option value="Vue Composition API Component" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$PROJECT_DIR$/frontend/pages/admin/supplier/supplier-list.vue" root0="FORCE_HIGHLIGHTING" />
</component>
<component name="McpProjectServerCommands">
<commands />
<urls />
</component>
<component name="PhpServers">
<servers>
<server host="localhost" id="36c0c232-9151-4654-a36c-e0f5fd99da91" name="ferme-docker" port="8080" use_path_mappings="true">
<path_mappings>
<mapping local-root="$PROJECT_DIR$" remote-root="/var/www/html" />
</path_mappings>
</server>
</servers>
</component>
<component name="PhpWorkspaceProjectConfiguration" interpreter_name="C:/php-8.4.3/php.exe">
<include_path>
<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/polyfill-mbstring" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
<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/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/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<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="ProjectColorInfo">{
&quot;customColor&quot;: &quot;&quot;,
&quot;associatedIndex&quot;: 5
}</component>
<component name="ProjectId" id="381AhnCm9yPeOiWgMObKHhtgv2C" />
<component name="ProjectViewState">
<option name="autoscrollFromSource" value="true" />
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"RunOnceActivity.MCP Project settings loaded": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"git-widget-placeholder": "fix/FER-15-fix-droit-de-suppression-reception-expedition-util",
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "advanced.settings",
"ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external",
"vue.rearranger.settings.migration": "true"
},
"keyToStringList": {
"DatabaseDriversLRU": [
"postgresql"
],
"com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File": [
"TEXT"
],
"vue.recent.templates": [
"Vue Composition API Component"
]
}
}]]></component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
<recent name="$PROJECT_DIR$/frontend/components/commun" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\pages\shipment" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\composables" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\components\shipment" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
<recent name="C:\Users\m-tristan\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\templates" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches\Ferme_MCD\MCD_DOC" />
</key>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.32098.40" />
</set>
</attachedChunks>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="" />
<created>1767956826164</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1767956826164</updated>
<workItem from="1767956827666" duration="7866000" />
<workItem from="1768201706520" duration="13383000" />
<workItem from="1768287908317" duration="28058000" />
<workItem from="1768374298711" duration="12403000" />
<workItem from="1768460547451" duration="26946000" />
<workItem from="1768547023783" duration="11371000" />
<workItem from="1768894030675" duration="83922000" />
<workItem from="1769413136483" duration="58000" />
<workItem from="1769413279223" duration="40490000" />
<workItem from="1769612160652" duration="23952000" />
<workItem from="1769696465294" duration="8573000" />
<workItem from="1769756623432" duration="21592000" />
<workItem from="1770015653091" duration="73000" />
<workItem from="1770040138216" duration="6492000" />
<workItem from="1770050834470" duration="1873000" />
<workItem from="1770054381680" duration="1292000" />
<workItem from="1770055690365" duration="370000" />
<workItem from="1770056515646" duration="21000" />
<workItem from="1770102495553" duration="2280000" />
<workItem from="1770195604082" duration="90000" />
<workItem from="1770195718952" duration="215000" />
<workItem from="1770195959162" duration="18915000" />
<workItem from="1770274844804" duration="3940000" />
<workItem from="1770798536017" duration="20774000" />
<workItem from="1770879701502" duration="25805000" />
<workItem from="1770966186589" duration="914000" />
<workItem from="1770967274060" duration="2388000" />
<workItem from="1772466451823" duration="598000" />
<workItem from="1772626984813" duration="969000" />
<workItem from="1772786360430" duration="21000" />
<workItem from="1772786475316" duration="3016000" />
<workItem from="1773049125640" duration="406000" />
<workItem from="1773049540928" duration="539000" />
<workItem from="1773050154207" duration="1879000" />
<workItem from="1773212999001" duration="652000" />
<workItem from="1773215356754" duration="5754000" />
<workItem from="1773756072697" duration="5450000" />
<workItem from="1773766075191" duration="6202000" />
<workItem from="1773824491213" duration="24805000" />
<workItem from="1774275549972" duration="51000" />
<workItem from="1774276665015" duration="33750000" />
</task>
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)">
<option name="closed" value="true" />
<created>1769529522614</created>
<option name="number" value="00037" />
<option name="presentableId" value="LOCAL-00037" />
<option name="project" value="LOCAL" />
<updated>1769529522614</updated>
</task>
<task id="LOCAL-00038" summary="feat : ajout du numéro identification des receptions et ajustement du bon de reception">
<option name="closed" value="true" />
<created>1769676223697</created>
<option name="number" value="00038" />
<option name="presentableId" value="LOCAL-00038" />
<option name="project" value="LOCAL" />
<updated>1769676223697</updated>
</task>
<task id="LOCAL-00039" summary="feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception">
<option name="closed" value="true" />
<created>1769700808988</created>
<option name="number" value="00039" />
<option name="presentableId" value="LOCAL-00039" />
<option name="project" value="LOCAL" />
<updated>1769700808988</updated>
</task>
<task id="LOCAL-00040" summary="feat : mise en place de composant UI pour les select, checkbox, date, text">
<option name="closed" value="true" />
<created>1769705141157</created>
<option name="number" value="00040" />
<option name="presentableId" value="LOCAL-00040" />
<option name="project" value="LOCAL" />
<updated>1769705141157</updated>
</task>
<task id="LOCAL-00041" summary="feat : update CHANGELOG.md">
<option name="closed" value="true" />
<created>1769705240487</created>
<option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" />
<option name="project" value="LOCAL" />
<updated>1769705240487</updated>
</task>
<task id="LOCAL-00042" summary="feat : ajout de commentaire">
<option name="closed" value="true" />
<created>1769760766200</created>
<option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" />
<option name="project" value="LOCAL" />
<updated>1769760766200</updated>
</task>
<task id="LOCAL-00043" summary="fix : correction de l'affichage de l'immatriculation sur une réception en cours + correction css étape 3 d'une réception">
<option name="closed" value="true" />
<created>1769768517942</created>
<option name="number" value="00043" />
<option name="presentableId" value="LOCAL-00043" />
<option name="project" value="LOCAL" />
<updated>1769768517943</updated>
</task>
<task id="LOCAL-00044" summary="feat : ajout de colonne pour les Supplier, Address et modification du numéro de réception">
<option name="closed" value="true" />
<created>1769770092190</created>
<option name="number" value="00044" />
<option name="presentableId" value="LOCAL-00044" />
<option name="project" value="LOCAL" />
<updated>1769770092190</updated>
</task>
<task id="LOCAL-00045" summary="feat : ajout de colonne pour les Supplier, Address. Modification du numéro de réception et ajout de fixtures">
<option name="closed" value="true" />
<created>1769770142624</created>
<option name="number" value="00045" />
<option name="presentableId" value="LOCAL-00045" />
<option name="project" value="LOCAL" />
<updated>1769770142624</updated>
</task>
<task id="LOCAL-00046" summary="feat : mise à jour du bon de réception">
<option name="closed" value="true" />
<created>1769782099473</created>
<option name="number" value="00046" />
<option name="presentableId" value="LOCAL-00046" />
<option name="project" value="LOCAL" />
<updated>1769782099473</updated>
</task>
<task id="LOCAL-00047" summary="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)">
<option name="closed" value="true" />
<created>1770131226364</created>
<option name="number" value="00047" />
<option name="presentableId" value="LOCAL-00047" />
<option name="project" value="LOCAL" />
<updated>1770131226364</updated>
</task>
<task id="LOCAL-00048" summary="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)">
<option name="closed" value="true" />
<created>1770206668867</created>
<option name="number" value="00048" />
<option name="presentableId" value="LOCAL-00048" />
<option name="project" value="LOCAL" />
<updated>1770206668867</updated>
</task>
<task id="LOCAL-00049" summary="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)">
<option name="closed" value="true" />
<created>1770217875423</created>
<option name="number" value="00049" />
<option name="presentableId" value="LOCAL-00049" />
<option name="project" value="LOCAL" />
<updated>1770217875423</updated>
</task>
<task id="LOCAL-00050" summary="feat : creer une nouvelle expedtion (WIP)">
<option name="closed" value="true" />
<created>1770736570645</created>
<option name="number" value="00050" />
<option name="presentableId" value="LOCAL-00050" />
<option name="project" value="LOCAL" />
<updated>1770736570645</updated>
</task>
<task id="LOCAL-00051" summary="feat : ajout d'une page de creation d'une expedition">
<option name="closed" value="true" />
<created>1770880791564</created>
<option name="number" value="00051" />
<option name="presentableId" value="LOCAL-00051" />
<option name="project" value="LOCAL" />
<updated>1770880791565</updated>
</task>
<task id="LOCAL-00052" summary="feat : changelog">
<option name="closed" value="true" />
<created>1770881437439</created>
<option name="number" value="00052" />
<option name="presentableId" value="LOCAL-00052" />
<option name="project" value="LOCAL" />
<updated>1770881437439</updated>
</task>
<task id="LOCAL-00053" summary="feat : lister les expeditions terminees">
<option name="closed" value="true" />
<created>1770883114609</created>
<option name="number" value="00053" />
<option name="presentableId" value="LOCAL-00053" />
<option name="project" value="LOCAL" />
<updated>1770883114609</updated>
</task>
<task id="LOCAL-00054" summary="feat : lister les expeditions terminees">
<option name="closed" value="true" />
<created>1770884154297</created>
<option name="number" value="00054" />
<option name="presentableId" value="LOCAL-00054" />
<option name="project" value="LOCAL" />
<updated>1770884154297</updated>
</task>
<task id="LOCAL-00055" summary="fix : corrections diverses">
<option name="closed" value="true" />
<created>1770969471135</created>
<option name="number" value="00055" />
<option name="presentableId" value="LOCAL-00055" />
<option name="project" value="LOCAL" />
<updated>1770969471135</updated>
</task>
<task id="LOCAL-00056" summary="fix : corrections frontend">
<option name="closed" value="true" />
<created>1772094268366</created>
<option name="number" value="00056" />
<option name="presentableId" value="LOCAL-00056" />
<option name="project" value="LOCAL" />
<updated>1772094268366</updated>
</task>
<task id="LOCAL-00057" summary="feat : affichage et modification expédition et modification bouton valider">
<option name="closed" value="true" />
<created>1772111964268</created>
<option name="number" value="00057" />
<option name="presentableId" value="LOCAL-00057" />
<option name="project" value="LOCAL" />
<updated>1772111964268</updated>
</task>
<task id="LOCAL-00058" summary="fix : erreur customer adress et bouton valider oublie">
<option name="closed" value="true" />
<created>1772112729501</created>
<option name="number" value="00058" />
<option name="presentableId" value="LOCAL-00058" />
<option name="project" value="LOCAL" />
<updated>1772112729502</updated>
</task>
<task id="LOCAL-00059" summary="feat : changelog update">
<option name="closed" value="true" />
<created>1772112812677</created>
<option name="number" value="00059" />
<option name="presentableId" value="LOCAL-00059" />
<option name="project" value="LOCAL" />
<updated>1772112812677</updated>
</task>
<task id="LOCAL-00060" summary="feat : changelog update">
<option name="closed" value="true" />
<created>1772177400063</created>
<option name="number" value="00060" />
<option name="presentableId" value="LOCAL-00060" />
<option name="project" value="LOCAL" />
<updated>1772177400063</updated>
</task>
<task id="LOCAL-00061" summary="feat : changelog update">
<option name="closed" value="true" />
<created>1772177614438</created>
<option name="number" value="00061" />
<option name="presentableId" value="LOCAL-00061" />
<option name="project" value="LOCAL" />
<updated>1772177614438</updated>
</task>
<task id="LOCAL-00062" summary="fix : color tab">
<option name="closed" value="true" />
<created>1772178540489</created>
<option name="number" value="00062" />
<option name="presentableId" value="LOCAL-00062" />
<option name="project" value="LOCAL" />
<updated>1772178540489</updated>
</task>
<task id="LOCAL-00063" summary="feat : modification front de la page admin transporteur">
<option name="closed" value="true" />
<created>1772180053740</created>
<option name="number" value="00063" />
<option name="presentableId" value="LOCAL-00063" />
<option name="project" value="LOCAL" />
<updated>1772180053740</updated>
</task>
<task id="LOCAL-00064" summary="fix : espacement et changelog">
<option name="closed" value="true" />
<created>1772180581178</created>
<option name="number" value="00064" />
<option name="presentableId" value="LOCAL-00064" />
<option name="project" value="LOCAL" />
<updated>1772180581178</updated>
</task>
<task id="LOCAL-00065" summary="fix : espacement">
<option name="closed" value="true" />
<created>1772180684250</created>
<option name="number" value="00065" />
<option name="presentableId" value="LOCAL-00065" />
<option name="project" value="LOCAL" />
<updated>1772180684250</updated>
</task>
<task id="LOCAL-00066" summary="fix : espacement">
<option name="closed" value="true" />
<created>1772180972984</created>
<option name="number" value="00066" />
<option name="presentableId" value="LOCAL-00066" />
<option name="project" value="LOCAL" />
<updated>1772180972984</updated>
</task>
<task id="LOCAL-00067" summary="fix : text">
<option name="closed" value="true" />
<created>1772182545592</created>
<option name="number" value="00067" />
<option name="presentableId" value="LOCAL-00067" />
<option name="project" value="LOCAL" />
<updated>1772182545592</updated>
</task>
<task id="LOCAL-00068" summary="feat : front page admin bovin et changelog">
<option name="closed" value="true" />
<created>1772182707441</created>
<option name="number" value="00068" />
<option name="presentableId" value="LOCAL-00068" />
<option name="project" value="LOCAL" />
<updated>1772182707441</updated>
</task>
<task id="LOCAL-00069" summary="fix : on ne bloque plus le poids max d'une pesée">
<option name="closed" value="true" />
<created>1772447581744</created>
<option name="number" value="00069" />
<option name="presentableId" value="LOCAL-00069" />
<option name="project" value="LOCAL" />
<updated>1772447581744</updated>
</task>
<task id="LOCAL-00070" summary="feat : ajout de supplier dans la feed et fixtures">
<option name="closed" value="true" />
<created>1773761787472</created>
<option name="number" value="00070" />
<option name="presentableId" value="LOCAL-00070" />
<option name="project" value="LOCAL" />
<updated>1773761787472</updated>
</task>
<task id="LOCAL-00071" summary="feat : ajout de bâtiment dans les fixtures et seed + organisation du menu">
<option name="closed" value="true" />
<created>1773766207721</created>
<option name="number" value="00071" />
<option name="presentableId" value="LOCAL-00071" />
<option name="project" value="LOCAL" />
<updated>1773766207721</updated>
</task>
<task id="LOCAL-00072" summary="fix : on ne pèse plus automatiquement + fix message de création réception">
<option name="closed" value="true" />
<created>1773826699115</created>
<option name="number" value="00072" />
<option name="presentableId" value="LOCAL-00072" />
<option name="project" value="LOCAL" />
<updated>1773826699115</updated>
</task>
<task id="LOCAL-00073" summary="fix : correction des retours de la V0">
<option name="closed" value="true" />
<created>1773841634554</created>
<option name="number" value="00073" />
<option name="presentableId" value="LOCAL-00073" />
<option name="project" value="LOCAL" />
<updated>1773841634554</updated>
</task>
<task id="LOCAL-00074" summary="feat : ajout de l'api de l'état pour chercher les villes via le CP">
<option name="closed" value="true" />
<created>1773842791819</created>
<option name="number" value="00074" />
<option name="presentableId" value="LOCAL-00074" />
<option name="project" value="LOCAL" />
<updated>1773842791819</updated>
</task>
<task id="LOCAL-00075" summary="fix : script de déploiement + CI/CD build de l'app">
<option name="closed" value="true" />
<created>1773843922376</created>
<option name="number" value="00075" />
<option name="presentableId" value="LOCAL-00075" />
<option name="project" value="LOCAL" />
<updated>1773843922377</updated>
</task>
<task id="LOCAL-00076" summary="fix : order navbar + modification création fournisseur et client">
<option name="closed" value="true" />
<created>1773852806120</created>
<option name="number" value="00076" />
<option name="presentableId" value="LOCAL-00076" />
<option name="project" value="LOCAL" />
<updated>1773852806121</updated>
</task>
<task id="LOCAL-00077" summary="fix : order récéption/expédition + correction style bouton récéption">
<option name="closed" value="true" />
<created>1774283204849</created>
<option name="number" value="00077" />
<option name="presentableId" value="LOCAL-00077" />
<option name="project" value="LOCAL" />
<updated>1774283204849</updated>
</task>
<task id="LOCAL-00078" summary="fix : style bon de récéption">
<option name="closed" value="true" />
<created>1774285464091</created>
<option name="number" value="00078" />
<option name="presentableId" value="LOCAL-00078" />
<option name="project" value="LOCAL" />
<updated>1774285464091</updated>
</task>
<task id="LOCAL-00079" summary="fix : bouton de mise en attente">
<option name="closed" value="true" />
<created>1774337609427</created>
<option name="number" value="00079" />
<option name="presentableId" value="LOCAL-00079" />
<option name="project" value="LOCAL" />
<updated>1774337609427</updated>
</task>
<task id="LOCAL-00080" summary="fix : problème de bearer token">
<option name="closed" value="true" />
<created>1774448105945</created>
<option name="number" value="00080" />
<option name="presentableId" value="LOCAL-00080" />
<option name="project" value="LOCAL" />
<updated>1774448105945</updated>
</task>
<task id="LOCAL-00081" summary="feat : système de blocage utilisateur">
<option name="closed" value="true" />
<created>1774450388149</created>
<option name="number" value="00081" />
<option name="presentableId" value="LOCAL-00081" />
<option name="project" value="LOCAL" />
<updated>1774450388149</updated>
</task>
<task id="LOCAL-00082" summary="feat : ajout d'un système de scanner bovin">
<option name="closed" value="true" />
<created>1774543296474</created>
<option name="number" value="00082" />
<option name="presentableId" value="LOCAL-00082" />
<option name="project" value="LOCAL" />
<updated>1774543296474</updated>
</task>
<task id="LOCAL-00083" summary="feat : mise à jour du CLAUDE.md">
<option name="closed" value="true" />
<created>1774543626516</created>
<option name="number" value="00083" />
<option name="presentableId" value="LOCAL-00083" />
<option name="project" value="LOCAL" />
<updated>1774543626516</updated>
</task>
<task id="LOCAL-00084" summary="feat : update CHANGELOG.md">
<option name="closed" value="true" />
<created>1774543766582</created>
<option name="number" value="00084" />
<option name="presentableId" value="LOCAL-00084" />
<option name="project" value="LOCAL" />
<updated>1774543766582</updated>
</task>
<task id="LOCAL-00085" summary="feat : la page de scanner est accessible que pour les admins">
<option name="closed" value="true" />
<created>1774543840891</created>
<option name="number" value="00085" />
<option name="presentableId" value="LOCAL-00085" />
<option name="project" value="LOCAL" />
<updated>1774543840891</updated>
</task>
<option name="localTasksCounter" value="86" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="RECENT_FILTERS">
<map>
<entry key="Branch">
<value>
<list>
<RecentGroup>
<option name="FILTER_VALUES">
<option value="HEAD" />
</option>
</RecentGroup>
<RecentGroup>
<option name="FILTER_VALUES">
<option value="develop" />
</option>
</RecentGroup>
</list>
</value>
</entry>
</map>
</option>
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State>
<option name="FILTERS">
<map>
<entry key="branch">
<value>
<list>
<option value="HEAD" />
</list>
</value>
</entry>
</map>
</option>
</State>
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="feat : changelog update" />
<MESSAGE value="fix : color tab" />
<MESSAGE value="feat : modification front de la page admin transporteur" />
<MESSAGE value="fix : espacement et changelog" />
<MESSAGE value="fix : espacement" />
<MESSAGE value="fix : text" />
<MESSAGE value="feat : front page admin bovin et changelog" />
<MESSAGE value="fix : on ne bloque plus le poids max d'une pesée" />
<MESSAGE value="feat : ajout de supplier dans la feed et fixtures" />
<MESSAGE value="feat : ajout de bâtiment dans les fixtures et seed + organisation du menu" />
<MESSAGE value="fix : on ne pèse plus automatiquement + fix message de création réception" />
<MESSAGE value="fix : correction des retours de la V0" />
<MESSAGE value="feat : ajout de l'api de l'état pour chercher les villes via le CP" />
<MESSAGE value="fix : script de déploiement + CI/CD build de l'app" />
<MESSAGE value="fix : order navbar + modification création fournisseur et client" />
<MESSAGE value="fix : order récéption/expédition + correction style bouton récéption" />
<MESSAGE value="fix : style bon de récéption" />
<MESSAGE value="fix : bouton de mise en attente" />
<MESSAGE value="fix : problème de bearer token" />
<MESSAGE value="feat : système de blocage utilisateur" />
<MESSAGE value="feat : ajout d'un système de scanner bovin" />
<MESSAGE value="feat : mise à jour du CLAUDE.md" />
<MESSAGE value="feat : update CHANGELOG.md" />
<MESSAGE value="feat : la page de scanner est accessible que pour les admins" />
<MESSAGE value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
<option name="LAST_COMMIT_MESSAGE" value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<line-breakpoint enabled="true" type="php">
<url>file://$PROJECT_DIR$/src/Entity/ReceptionPelletBuilding.php</url>
<line>6</line>
<option name="timeStamp" value="3" />
</line-breakpoint>
<line-breakpoint enabled="true" type="php">
<url>file://$PROJECT_DIR$/src/Entity/Shipment.php</url>
<line>6</line>
<option name="timeStamp" value="45" />
</line-breakpoint>
<line-breakpoint enabled="true" type="javascript">
<url>file://$PROJECT_DIR$/frontend/services/dto/shipment-data.ts</url>
<option name="timeStamp" value="43" />
</line-breakpoint>
</breakpoints>
</breakpoint-manager>
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />
<select />
</component>
<component name="github-copilot-workspace">
<instructionFileLocations>
<option value=".github/instructions" />
</instructionFileLocations>
<promptFileLocations>
<option value=".github/prompts" />
</promptFileLocations>
</component>
</project>

View File

@@ -1,72 +0,0 @@
# Changelog
Liste des évolutions du projet Ferme
## [0.0.0]
### Parameters
Ajouter dans le fichier .env
- DEFAULT_URI
- DATABASE_URL
- 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)
- EDNOTIF_EXPLOITATION_CODE
- EDNOTIF_EXPLOITATION_NUMERO
- EDNOTIF_LOGIN
- EDNOTIF_PASSWORD
Ajouter dans le fichier .env du frontend
- NUXT_PUBLIC_API_BASE
### 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
View File

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

View File

@@ -1,90 +0,0 @@
# Déploiement Ferme (release Gitea)
## 1) Premier déploiement
### Pré-requis système (Ubuntu)
1. Mettre à jour la machine
```bash
sudo apt update
sudo apt install -y software-properties-common ca-certificates curl gnupg unzip git nginx
```
2. Installer PHP 8.4 + FPM + extensions
```bash
sudo add-apt-repository -y ppa:ondrej/php
sudo apt update
sudo apt install -y \
php8.4 php8.4-fpm php8.4-cli php8.4-common \
php8.4-mbstring php8.4-xml php8.4-curl php8.4-intl \
php8.4-zip php8.4-gd php8.4-pgsql php8.4-opcache
```
3. Installer PostgreSQL (si la DB est locale)
```bash
sudo apt install -y postgresql postgresql-contrib
sudo -u postgres psql
```
Dans psql :
```sql
CREATE USER ferme_user WITH PASSWORD 'motdepassefort';
CREATE DATABASE ferme OWNER ferme_user;
\q
```
### Dossier de déploiement
1. Créer le dossier de déploiement
```bash
sudo mkdir -p /var/www/ferme
sudo chown -R malio:malio /var/www/ferme
```
2. Créer le fichier denvironnement
- Backend : `/var/www/ferme/.env`
- `APP_ENV=prod`
- `APP_DEBUG=0`
- `APP_SECRET=...`
- `DATABASE_URL=postgresql://ferme_user:motdepassefort@127.0.0.1:5432/ferme?serverVersion=16&charset=utf8`
- `JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem`
- `JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem`
- `JWT_PASSPHRASE=...`
- `COOKIE_SECURE=1`
- `PONT_BASCULE_BYPASS=false`
3. Générer les clés JWT
```bash
cd /var/www/ferme
mkdir -p config/jwt
php bin/console lexik:jwt:generate-keypair
```
4. Config Nginx (sous-domaine)<br>
Copier le fichier de conf /deploy/nginx/ferme.conf dans /etc/nginx/sites-available/ferme.conf
```bash
sudo ln -s /etc/nginx/sites-available/ferme.conf /etc/nginx/sites-enabled/ferme.conf
sudo nginx -t && sudo systemctl reload nginx
```
5. Installer le script de déploiement (disponible /scripts/deploy-release.sh)
```bash
sudo nano /usr/local/bin/deploy-ferme
sudo chmod +x /usr/local/bin/deploy-ferme
```
## 2) Déployer une release
1. Créer un tag sur `develop` (auto-tag `v0.0.X`)
2. Attendre que la release Gitea soit publiée
3. (Une seule fois) Donner les droits d'écriture à PHP sur `var/` via ACL
```bash
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/`
- API : `http://ferme.malio-dev.fr/api/users`
- Login : `POST http://ferme.malio-dev.fr/api/login_check`

225
README.md
View File

@@ -1,254 +1,33 @@
# Projet Ferme t
# Projet Ferme
## Installation du projet
### Windows
Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm.
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows)
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows)
### Linux
Pour linux, il faut installer docker et nvm.
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux)
### Installation du projet
Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes
```bash
make start
make install
```
Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible.
### Configuration global
Pour les variables d'environnement, il faut demander un .env.local pour le backend et un .env pour le frontend à votre collègue.
Vérifier que dans le .env.local, vous avez :
- APP_SECRET (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));" et doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens)
- DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
- PONT_BASCULE_BYPASS (doit être à true en dev)
- PONT_BASCULE_URL
- JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
- JWT_PUBLIC_KEY
- JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
- COOKIE_SECURE=0 (en dev 0 et en prod 1. Si c'est du http, laisser en 0)
Vérifier que dans le .env du dossier frontend, vous avez :
- 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
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:
- `204 No Content` (normal)
- `Set-Cookie: BEARER=...; HttpOnly`
- 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
/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
## Feed des prix bovins
Une commande Symfony permet de mettre à jour le **poids à l'arrivée**, le **prix au kilo** et le **fournisseur** des bovins existants à partir d'un fichier XLSX. La commande **ne crée jamais de nouveau bovin** : elle complète seulement ceux déjà présents en BDD.
### Format du fichier XLSX attendu
Pas de ligne d'en-tête, 4 colonnes dans cet ordre :
| Colonne | Champ | Format | Exemple |
|---------|-------|--------|---------|
| A | Numéro national | Avec ou sans préfixe `FR ` (insensible casse) | `FR 7979580026` ou `7979580026` |
| B | Fournisseur | Texte libre, casse ignorée | `TERRENA` |
| C | Poids à l'arrivée (kg) | Entier | `368` |
| D | Prix au kilo | Décimal | `5.7` |
| E | Code bâtiment (optionnel) | `B1`, `B2`, `B3`, `ZT` (casse ignorée) | `B2` |
### Comportement
- **Numéro national** : le préfixe `FR` (avec ou sans espace) est retiré s'il est présent. Sinon la valeur est utilisée telle quelle.
- **Bovin introuvable** en BDD → ligne ignorée, log warning à la fin avec aperçu.
- **Fournisseur introuvable** en BDD → le bovin est mis à jour quand même avec `supplier = null`, log warning.
- **Bâtiment** (colonne E) : recherché par `code` (insensible casse). Set uniquement si le bovin n'a pas déjà une `buildingCase` assignée (la case prime sur le bâtiment direct côté affichage). Si code introuvable → log warning, champ non set.
- **Cellules `weight` / `price` vides ou non numériques** → champ non modifié.
- La commande est **idempotente** : peut être relancée sans effet de bord.
### Lancement en dev
Copie le fichier dans la racine du projet (mappée dans le container sous `/var/www/html`), puis :
```bash
# Simulation sans écriture en BDD
docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bovin.xlsx --dry-run
# Persistance effective
docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bovin.xlsx
```
### Lancement en prod
Le user SSH n'a généralement pas les droits d'écriture sur `/var/www/ferme/` ; on passe donc le fichier par `/tmp` et on pointe la commande dessus (le chemin du XLSX est juste un argument).
```bash
# 1. Copier le fichier sur le serveur dans /tmp (accessible en écriture)
scp feed_bovin.xlsx <user>@<host>:/tmp/
# 2. SSH sur le serveur
ssh <user>@<host>
# 3. Se placer dans le dossier de l'app (pour bin/console)
cd /var/www/ferme
# 4. Dry-run pour vérifier sans rien écrire
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx --dry-run
# 5. Persistance effective
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx
# 6. Cleanup
rm /tmp/feed_bovin.xlsx
```
> Si à l'étape 4 le user PHP (souvent `www-data`) n'arrive pas à lire le fichier (`Permission denied`), donne-lui les droits de lecture avant : `chmod 644 /tmp/feed_bovin.xlsx`.
### Sortie attendue
À la fin, un tableau récapitule :
- Lignes totales lues
- Bovins mis à jour
- Bovins introuvables (avec aperçu des 10 premiers numéros)
- Lignes invalides (numéro national vide)
- Fournisseurs introuvables (avec liste et compte par nom)
- Bâtiments introuvables (avec liste des codes inconnus)

View File

@@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
MSG_FILE="${1}"
FIRST_LINE="$(head -n 1 "$MSG_FILE" | tr -d '\r')"
# Autoriser commits auto-générés par git
if [[ "$FIRST_LINE" =~ ^Merge\ ]]; then
exit 0
fi
# Types autorisés (MINUSCULES uniquement)
# Optionnel: scope => feat(auth) : ...
REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9._-]+\))?\ :\ .+'
if [[ ! "$FIRST_LINE" =~ $REGEX ]]; then
echo "❌ Message de commit invalide."
echo ""
echo "➡️ Format attendu : <type>(<scope optionnel>) : <message>"
echo "➡️ Types autorisés (minuscules uniquement) :"
echo " build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test"
echo ""
echo "✅ Exemples :"
echo " feat : add login page"
echo " fix(auth) : prevent null token crash"
echo " docs : update README"
echo ""
echo "❌ Exemple refusé :"
echo " Feat : add login page"
exit 1
fi

View File

@@ -12,12 +12,8 @@
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"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",
"phpoffice/phpspreadsheet": "^5.7",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "8.0.*",
"symfony/console": "8.0.*",
@@ -25,8 +21,6 @@
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/runtime": "8.0.*",
@@ -89,19 +83,9 @@
}
},
"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/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"
}
]
"symfony/css-selector": "8.0.*"
}
}

2547
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,11 @@
<?php
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],
TwigBundle::class => ['all' => true],
SecurityBundle::class => ['all' => true],
DoctrineBundle::class => ['all' => true],
DoctrineMigrationsBundle::class => ['all' => true],
NelmioCorsBundle::class => ['all' => true],
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],
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
];

View File

@@ -5,10 +5,3 @@ 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']
patch_formats:
json: ['application/merge-patch+json']

View File

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

View File

@@ -1,20 +0,0 @@
lexik_jwt_authentication:
secret_key: '%kernel.project_dir%/config/jwt/private.pem'
public_key: '%kernel.project_dir%/config/jwt/public.pem'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 86400
token_extractors:
authorization_header:
enabled: true
prefix: Bearer
name: Authorization
cookie:
enabled: true
name: BEARER
set_cookies:
BEARER:
lifetime: 86400
path: /
samesite: lax
secure: '%env(bool:COOKIE_SECURE)%'
httpOnly: true

View File

@@ -1,28 +0,0 @@
monolog:
channels: [deprecation]
when@dev:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@prod:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!deprecation"]
deprecation:
type: stream
channels: [deprecation]
path: "%kernel.logs_dir%/deprecations.log"

View File

@@ -4,7 +4,6 @@ nelmio_cors:
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
allow_credentials: true
expose_headers: ['Link']
max_age: 3600
paths:

View File

@@ -1,4 +0,0 @@
api_platform:
enable_docs: false
enable_swagger: false
enable_swagger_ui: false

View File

@@ -1,52 +1,20 @@
security:
# Hiérarchie des rôles : ADMIN inclut BUREAU qui inclut USER.
# Ajouter un nouveau rôle = ajouter une ligne ici (et son équivalent côté
# front dans utils/roles.ts).
role_hierarchy:
ROLE_BUREAU: ROLE_USER
ROLE_ADMIN: ROLE_BUREAU
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
App\Entity\User: 'auto'
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
app_user_provider:
entity:
class: App\Entity\User
property: username
users_in_memory: { memory: null }
firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
login:
pattern: ^/login_check
stateless: true
provider: app_user_provider
user_checker: App\Security\UserChecker
json_login:
check_path: /login_check
username_path: username
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
api:
pattern: ^/
stateless: true
provider: app_user_provider
user_checker: App\Security\UserChecker
jwt: ~
logout:
path: /api/logout
target: /login
enable_csrf: false
delete_cookies:
BEARER:
path: /
main:
lazy: true
provider: users_in_memory
# Activate different ways to authenticate:
# https://symfony.com/doc/current/security.html#the-firewall
@@ -56,16 +24,8 @@ security:
# Note: Only the *first* matching rule is applied
access_control:
# Login JWT
- { path: ^/login_check, roles: PUBLIC_ACCESS }
# Liste des users en lecture publique
- { 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 }
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
when@test:
security:

View File

@@ -1,7 +0,0 @@
when@dev:
web_profiler:
toolbar: true
framework:
profiler:
collect_serializer_data: true

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
api_platform:
resource: .
type: api_platform
prefix: /api
prefix: /

View File

@@ -1,7 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service
api_login:
path: /login_check
methods: [POST]

View File

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

View File

@@ -8,9 +8,6 @@
# 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:
@@ -22,10 +19,5 @@ services:
App\:
resource: '../src/'
App\Service\PontBasculeService:
arguments:
$endpoint: '%env(PONT_BASCULE_URL)%'
$bypass: '%env(bool:PONT_BASCULE_BYPASS)%'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

View File

@@ -1,2 +0,0 @@
parameters:
app.version: '0.0.97'

View File

@@ -1,43 +0,0 @@
server {
listen 80;
server_name ferme.malio-dev.fr;
root /var/www/ferme/frontend/.output/public;
index index.html;
location ^~ /api/ {
root /var/www/ferme/public;
try_files $uri /index.php?$query_string;
}
location ^~ /bundles/ {
root /var/www/ferme/public;
try_files $uri =404;
}
location = /api/login_check {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/ferme/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/ferme/public;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param PATH_INFO /login_check;
fastcgi_param REQUEST_URI /login_check;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/ferme/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/ferme/public;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
internal;
}
location ~ \.php$ {
return 404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,6 +1,6 @@
services:
php:
container_name: php-${DOCKER_APP_NAME}-fpm
web:
container_name: php-${DOCKER_APP_NAME}-apache
build:
context: ./docker/php
dockerfile: Dockerfile
@@ -14,34 +14,24 @@ 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
- ~/.config:/var/www/.config # Pour la config de yarn
- ~/.composer:/var/www/.composer # Pour la config de composer
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
- ./docker/php/config/vhost.conf:/etc/apache2/sites-available/000-default.conf
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./LOG:/var/www/html/LOG
- ./LOG/logs_apache:/var/log/apache2/
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- db
ports:
- "8080:80"
- "3000:3000"
restart: unless-stopped
nginx:
image: nginx:1.27-alpine
container_name: nginx-${DOCKER_APP_NAME}
depends_on:
- php
ports:
- "8080:80"
volumes:
- ./:/var/www/html:ro
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
restart: unless-stopped
db:
image: postgres:16-alpine
environment:

View File

@@ -7,5 +7,3 @@ POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_PORT=5432
XDEBUG_CLIENT_HOST=host.docker.internal
CURRENT_UID=1004
CURRENT_GID=1004

View File

@@ -1,52 +0,0 @@
server {
listen 80;
server_name localhost;
root /var/www/html/frontend/dist;
index index.html;
location ^~ /api/ {
root /var/www/html/public;
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;
}
location = /api/login_check {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param PATH_INFO /login_check;
fastcgi_param REQUEST_URI /login_check;
fastcgi_pass php:9000;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_pass php:9000;
}
location ~ \.php$ {
return 404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,6 +1,6 @@
ARG DOCKER_PHP_VERSION
FROM php:${DOCKER_PHP_VERSION}-fpm-bullseye
FROM php:${DOCKER_PHP_VERSION}-apache-bullseye
ARG DOCKER_NODE_VERSION
ENV DOCKER_NODE_VERSION="${DOCKER_NODE_VERSION}"
@@ -102,14 +102,13 @@ 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
# Activation du module pour Apache2 proxy_http et rewrite
RUN a2enmod proxy_http && \
a2enmod rewrite
###> User ###
ARG CURRENT_UID
ARG CURRENT_GID

View File

@@ -0,0 +1,26 @@
<VirtualHost *:80>
DocumentRoot /var/www/html
AliasMatch "^/api(/.*)?" "/var/www/html/public$1"
<Directory /var/www/html/public>
Options FollowSymLinks
AllowOverride All
Require all granted
</Directory>
AliasMatch "^(/.*)?" "/var/www/html/frontend/dist$1"
<Directory /var/www/html/frontend/dist>
AllowOverride All
Order allow,deny
Allow from All
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^ index.html [L]
</Directory>
ErrorLog "${APACHE_LOG_DIR}/error.log"
CustomLog "${APACHE_LOG_DIR}/access.log" combined
</VirtualHost>

View File

@@ -1,123 +0,0 @@
# Export Excel de l'inventaire bovin — Design Spec
Bouton sur la page `/inventory` qui télécharge un XLSX listant tous les bovins actuellement présents sur l'exploitation.
## Contexte
Le métier veut un Excel exportable depuis l'écran inventaire. Ferme n'a aujourd'hui aucun outil d'export Excel (uniquement PDF via dompdf). On choisit `phpoffice/phpspreadsheet` côté serveur, en suivant le même pattern que la génération PDF actuelle (endpoint qui streame le fichier, front qui télécharge via blob).
Périmètre : tous les bovins actifs (`exitedAt IS NULL`), ordre `birthDate ASC`, ignore les filtres UI. Pas de modale de sélection (à voir si le métier en demande une plus tard).
## Architecture
### Backend
**Dépendance** : `composer require phpoffice/phpspreadsheet`
**Nouveau resource** : `src/ApiResource/BovineInventoryExport.php`
- `#[ApiResource]` avec une seule opération `Get` :
- `uriTemplate: '/bovines/inventory-export'`
- `output: false`
- `provider: BovineInventoryExportProvider::class`
- `security: "is_granted('ROLE_USER')"` (cohérent avec la page `/inventory`)
- OpenApi tag `Bovines`
**Nouveau provider** : `src/State/Bovin/BovineInventoryExportProvider.php`
- Injecte `EntityManagerInterface`
- Query Doctrine : `WHERE exitedAt IS NULL ORDER BY birthDate ASC`
- Construit le `Spreadsheet` avec PhpSpreadsheet
- Retourne une `Symfony\Component\HttpFoundation\Response` avec :
- `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
- `Content-Disposition: attachment; filename="inventaire_bovins_YYYY-MM-DD.xlsx"`
- Body = `IOFactory::createWriter($spreadsheet, 'Xlsx')->save('php://output')` capturé via `ob_*`
### Frontend
**Page** : `frontend/pages/inventory.vue`
- Nouveau bouton "Exporter Excel" à droite du titre, à côté de "Rafraîchir"
- Style : même que "Rafraîchir" (bg-primary-500, h-[50px], icône `mdi:file-excel-outline`)
- Visible pour tout user authentifié (pas de gate admin)
- Au clic : appelle `useApi().getBlob('bovines/inventory-export')`, crée un blob URL, déclenche un `<a download>` synthétique avec le filename retourné par le backend (lu depuis le header `Content-Disposition`)
## Génération XLSX — détails
**Fichier** :
- 1 seule feuille `Inventaire`
- Filename : `inventaire_bovins_YYYY-MM-DD.xlsx` (date du jour serveur)
**En-têtes (ligne 1)** :
- 9 colonnes dans l'ordre : `N° National`, `N° Travail`, `Sexe`, `Né le`, `Age (mois)`, `Race`, `Bâtiment`, `Case`, `Entrée le`
- Style : gras, fond `#f1f5f9` (slate-100), bordure noire fine, alignement centré
- Auto-filter activé sur la plage des en-têtes (Excel ajoute les boutons de filtre natifs)
- Freeze pane : ligne 2 figée
**Lignes de données (à partir de la ligne 2)** :
- Ordre `birthDate ASC` (plus vieux en haut, NULL à la fin via `NULLS LAST` natif Postgres)
- Largeurs de colonnes :
- N° National : 18
- N° Travail : 12
- Sexe : 10
- Né le : 12
- Age : 12
- Race : 12
- Bâtiment : 30
- Case : 8
- Entrée le : 12
**Mapping des valeurs** :
- Sexe : `M``Mâle`, `F``Femelle`, autre / null → vide
- Né le, Entrée le : format `JJ/MM/AAAA`, vide si null
- Age : entier (mois), vide si null
- Bâtiment, Case : valeurs nestées via `bovine.buildingCase.building.label` et `bovine.buildingCase.caseNumber`, vide si null
**Couleurs des lignes** (basées sur `ageMonths`, mêmes seuils que l'UI) :
| Tranche | Hex | Tailwind |
|--------|-----|----------|
| 24+ mois | `#ddd6fe` | violet-200 |
| 22-24 mois | `#fecaca` | red-200 |
| 20-22 mois | `#fed7aa` | orange-200 |
| < 20 mois ou NULL | `#ffffff` | blanc |
Le fond est appliqué sur toute la ligne (9 cellules).
## Flux d'erreur
- Exception PhpSpreadsheet (création buffer) → propage en 500 standard API Platform
- Pas d'utilisateur (token expiré) → 401 standard via la sécurité
## Performance
- 936 lignes × 9 colonnes : génération en mémoire < 1s, fichier < 100 KB
- Pas de pagination, pas de streaming row-by-row (overkill pour ce volume)
## Tests
Optionnel ce lot : test PHPUnit du provider qui vérifie que :
- Status 200
- Content-Type XLSX
- Header `Content-Disposition: attachment; filename=...xlsx`
- Body non vide
Mock simple de l'`EntityManagerInterface` pour retourner 2 bovins fictifs.
À faire en follow-up si on veut couvrir.
## Verification manuelle
1. `make composer-install` (après avoir ajouté la dep)
2. Recharger `/inventory`
3. Clic sur le bouton "Exporter Excel"
4. Vérifier le téléchargement : nom de fichier = `inventaire_bovins_2026-04-24.xlsx`
5. Ouvrir dans Excel/LibreOffice :
- 9 colonnes attendues
- En-tête figé en scrollant
- Auto-filter natif Excel
- Lignes colorées selon âge (violet/rouge/orange)
- Tri par date de naissance croissante
## Critères d'acceptation
- [ ] L'export contient 100 % des bovins actifs (count = `SELECT COUNT(*) FROM bovine WHERE exited_at IS NULL`)
- [ ] Le filename inclut la date du jour
- [ ] Les couleurs correspondent aux seuils d'âge
- [ ] L'ordre matche l'UI (`birthDate ASC`)
- [ ] Pas de régression sur les autres endpoints `/api/bovines`

View File

@@ -1,13 +1,3 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<NuxtPage/>
</template>
<script setup lang="ts">
const { load } = useAppVersion()
onMounted(() => {
load()
})
</script>

View File

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

View File

@@ -1,18 +0,0 @@
.iziToast {
font-size: 16px;
min-height: 72px;
}
.iziToast > .iziToast-body {
padding: 18px 24px;
}
.iziToast > .iziToast-body .iziToast-title {
font-size: 18px;
line-height: 1.3;
}
.iziToast > .iziToast-body .iziToast-message {
font-size: 16px;
line-height: 1.5;
}

View File

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

View File

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

View File

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

View File

@@ -1,96 +0,0 @@
<template>
<UiModal v-model="open" title="Exporter l'inventaire bovin" max-width="max-w-2xl">
<p class="mb-5 text-sm text-slate-600">
Aucun filtre coché&nbsp;: export complet (tous les bovins actifs).
</p>
<div class="mb-5">
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-600">
Tranches d'âge
</h3>
<div class="flex flex-col gap-2">
<label
v-for="bucket in ageBuckets"
:key="bucket.value"
class="flex items-center gap-3 cursor-pointer text-primary-700"
>
<input
v-model="filters.ageRanges"
type="checkbox"
:value="bucket.value"
class="h-4 w-4 cursor-pointer accent-primary-500"
/>
<span :class="['inline-block rounded px-2 py-0.5 text-xs font-semibold text-white', bucket.colorClass]">
{{ bucket.badge }}
</span>
<span>{{ bucket.label }}</span>
</label>
</div>
</div>
<template #footer>
<div class="flex justify-center">
<button
type="button"
:disabled="loading"
class="inline-flex h-[50px] items-center justify-center gap-2 rounded bg-primary-500 px-6 text-base text-white uppercase hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-60"
@click="onSubmit"
>
<Icon
v-if="loading"
name="mdi:loading"
size="20"
class="animate-spin"
/>
<Icon v-else name="mdi:file-excel-outline" size="20" />
Exporter
</button>
</div>
</template>
</UiModal>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
export interface InventoryExportFilters {
ageRanges: string[]
}
const props = withDefaults(defineProps<{
modelValue: boolean
loading?: boolean
}>(), {
loading: false
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'submit', filters: InventoryExportFilters): void
}>()
const open = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const ageBuckets = [
{ value: 'over24', label: ' 24 mois', badge: '24+', colorClass: 'bg-red-500' },
{ value: 'between22And24', label: '22 à 24 mois', badge: '22-24', colorClass: 'bg-orange-500' },
{ value: 'between20And22', label: '20 à 22 mois', badge: '20-22', colorClass: 'bg-yellow-500' }
]
const filters = reactive<InventoryExportFilters>({
ageRanges: []
})
watch(open, (isOpen) => {
if (isOpen) {
filters.ageRanges = []
}
})
const onSubmit = () => {
emit('submit', { ageRanges: [...filters.ageRanges] })
}
</script>

View File

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

View File

@@ -1,305 +0,0 @@
<template>
<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>
<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 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 { useReceptionStore } from '~/stores/reception'
import { useFormDataLoading } from '~/composables/useFormDataLoading'
import { useLiotHandling } from '~/composables/useLiotHandling'
import { useAddressSync } from '~/composables/useAddressSync'
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import { getReceptionTypeList } from '~/services/reception-type'
import type { SupplierData } from '~/services/dto/supplier-data'
import { getSupplierList } from '~/services/supplier'
import { RECEPTION_TYPE_CODES } from '~/utils/constants'
import { deleteReceptionBovine, getReceptionBovineList } from '~/services/reception-bovine'
import type { ReceptionFormData } from '~/services/dto/reception-data'
const router = useRouter()
const receptionStore = useReceptionStore()
const isHydrating = ref(false)
const submitted = ref(false)
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<ReceptionFormData>({
licensePlate: '',
receptionDate: new Date().toISOString().slice(0, 10),
receptionTypeId: '',
userId: '',
supplierId: '',
addressId: '',
truckId: '',
carrierId: '',
driverId: '',
vehicleId: ''
})
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?.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 }
)
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,
...payload
})
if (created) {
await router.push(`/reception/${created.id}`)
}
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,
...payload
})
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,238 +0,0 @@
<template>
<div class="w-full">
<div class="relative border border-slate-200">
<div
class="grid items-center gap-6 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
:style="{ gridTemplateColumns: gridCols }"
>
<div v-for="col in columns" :key="col.key" class="min-w-0">
<slot :name="`header-${col.key}`" :column="col">{{ col.label }}</slot>
</div>
<div v-if="showActions" class="min-w-0">
<slot name="header-actions">Actions</slot>
</div>
</div>
<div :class="dimRows ? 'opacity-50 transition-opacity' : ''" :aria-busy="loading || undefined">
<template v-if="paginatedItems.length">
<div
v-for="(item, index) in paginatedItems"
:key="item.id ?? index"
class="grid gap-6 px-4 py-3 text-sm border-t border-slate-200"
:class="[
rowClickable ? 'hover:bg-slate-50 cursor-pointer' : '',
rowClass ? rowClass(item) : ''
]"
:style="{ gridTemplateColumns: gridCols }"
:role="rowClickable ? 'button' : undefined"
:tabindex="rowClickable ? 0 : undefined"
@click="onRowClick(item)"
@keydown.enter="onRowClick(item)"
@keydown.space.prevent="onRowClick(item)"
>
<div v-for="col in columns" :key="col.key" class="min-w-0 truncate">
<slot :name="`cell-${col.key}`" :item="item" :column="col">
{{ getNestedValue(item, col.key) }}
</slot>
</div>
<div v-if="showActions" @click.stop>
<slot name="actions" :item="item" />
</div>
</div>
</template>
<div
v-else-if="loading"
class="flex items-center justify-center border-t border-slate-200 px-4 py-8 text-primary-500"
role="status"
aria-live="polite"
>
<UiLoadingDots />
<span class="sr-only">Chargement</span>
</div>
<div
v-else
class="border-t border-slate-200 px-4 py-8 text-center text-sm text-slate-500"
>
<slot name="empty">{{ emptyMessage }}</slot>
</div>
</div>
<div
v-if="dimRows"
class="pointer-events-none absolute inset-0 flex items-center justify-center"
role="status"
aria-live="polite"
>
<div class="rounded bg-white/80 px-4 py-2 text-primary-500 shadow">
<UiLoadingDots />
<span class="sr-only">Chargement</span>
</div>
</div>
</div>
<div v-if="total > 0" class="flex justify-between pt-2">
<div class="flex items-center gap-3">
<label :for="perPageId" class="whitespace-nowrap text-sm text-slate-700">Lignes&nbsp;:</label>
<select
:id="perPageId"
:value="currentPerPage"
class="h-10 rounded border border-slate-300 bg-white px-2 text-sm text-primary-700"
@change="onPerPageChange(($event.target as HTMLSelectElement).value)"
>
<option v-for="n in perPageOptions" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<nav aria-label="Pagination" class="flex items-center gap-1">
<button
type="button"
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
:disabled="currentPage <= 1"
aria-label="Page précédente"
@click="goToPage(currentPage - 1)"
>
Précédent
</button>
<template v-for="(entry, i) in visiblePages" :key="`${typeof entry}-${entry}-${i}`">
<span
v-if="entry === '...'"
class="px-1 text-sm text-slate-400"
aria-hidden="true"
></span>
<button
v-else
type="button"
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
:class="entry === currentPage
? 'bg-primary-500 font-semibold text-white'
: 'text-slate-700 hover:bg-slate-100'"
:aria-current="entry === currentPage ? 'page' : undefined"
@click="goToPage(entry)"
>
{{ entry }}
</button>
</template>
<button
type="button"
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
:disabled="currentPage >= totalPages"
aria-label="Page suivante"
@click="goToPage(currentPage + 1)"
>
Suivant
</button>
</nav>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends Record<string, any>">
import { computed, useId } from 'vue'
interface Column {
key: string
label: string
width?: string
}
const props = withDefaults(defineProps<{
columns: Column[]
items: T[]
totalItems?: number
page?: number
perPage?: number
perPageOptions?: number[]
rowClickable?: boolean
showActions?: boolean
emptyMessage?: string
loading?: boolean
rowClass?: (item: T) => string | undefined
}>(), {
totalItems: undefined,
page: 1,
perPage: 10,
perPageOptions: () => [10, 25, 50],
rowClickable: false,
showActions: false,
emptyMessage: 'Aucune donnée',
loading: false,
rowClass: undefined
})
const emit = defineEmits<{
(e: 'update:page', value: number): void
(e: 'update:perPage', value: number): void
(e: 'row-click', item: T): void
}>()
const perPageId = useId()
const currentPage = computed(() => props.page)
const currentPerPage = computed(() => props.perPage)
const isServerSide = computed(() => props.totalItems !== undefined)
const total = computed(() => props.totalItems ?? props.items.length)
const totalPages = computed(() =>
Math.max(1, Math.ceil(total.value / currentPerPage.value))
)
const paginatedItems = computed(() => {
if (isServerSide.value) return props.items
const start = (currentPage.value - 1) * currentPerPage.value
return props.items.slice(start, start + currentPerPage.value)
})
const gridCols = computed(() => {
const dataCols = props.columns.map(c => c.width ?? '1fr').join(' ')
return props.showActions ? `${dataCols} 60px` : dataCols
})
const dimRows = computed(() => props.loading && paginatedItems.value.length > 0)
const visiblePages = computed<(number | '...')[]>(() => {
const tp = totalPages.value
const cp = currentPage.value
if (tp <= 5) {
return Array.from({ length: tp }, (_, i) => i + 1)
}
const pages: (number | '...')[] = []
pages.push(1)
if (cp > 3) pages.push('...')
const start = Math.max(2, cp - 1)
const end = Math.min(tp - 1, cp + 1)
for (let i = start; i <= end; i++) pages.push(i)
if (cp < tp - 2) pages.push('...')
if (tp > 1) pages.push(tp)
return pages
})
const goToPage = (n: number) => {
if (n < 1 || n > totalPages.value || n === currentPage.value) return
emit('update:page', n)
}
const onPerPageChange = (value: string) => {
emit('update:perPage', Number(value))
emit('update:page', 1)
}
const onRowClick = (item: T) => {
if (!props.rowClickable) return
emit('row-click', item)
}
const getNestedValue = (obj: any, path: string): string => {
const value = path.split('.').reduce((acc, key) => acc?.[key], obj)
return value ?? '—'
}
</script>

View File

@@ -1,70 +0,0 @@
<template>
<div :class="['flex flex-col', wrapperClass]">
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
</label>
<input
:id="id"
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>

View File

@@ -1,108 +0,0 @@
<template>
<div :class="['flex flex-col', wrapperClass]">
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
</label>
<input
:id="id"
v-maska="'##/##/####'"
type="text"
inputmode="numeric"
:value="displayValue"
:placeholder="placeholder"
:disabled="disabled"
v-bind="attrs"
class="w-full min-w-0 border-b border-primary-700 bg-transparent"
:class="[
sizeClass,
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-text',
inputClass
]"
@input="onInput"
/>
</div>
</template>
<script setup lang="ts">
import { vMaska } from 'maska/vue'
import { computed, ref, useAttrs, watch } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
label?: string
modelValue: string | null | undefined
placeholder?: string
disabled?: boolean
size?: 'default' | 'compact'
wrapperClass?: string
labelClass?: string
inputClass?: string
}>(),
{
placeholder: 'JJ/MM/AAAA',
disabled: false,
size: 'default',
wrapperClass: '',
labelClass: '',
inputClass: ''
}
)
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const attrs = useAttrs()
const toDisplay = (iso: string | null | undefined): string => {
if (!iso) return ''
const parts = iso.split('-')
if (parts.length !== 3) return ''
const [year, month, day] = parts
if (year.length !== 4 || month.length !== 2 || day.length !== 2) return ''
return `${day}/${month}/${year}`
}
const toIso = (display: string): string | null => {
const match = display.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
if (!match) return null
const [, day, month, year] = match
return `${year}-${month}-${day}`
}
const displayValue = ref(toDisplay(props.modelValue))
watch(() => props.modelValue, (newIso) => {
const expected = toDisplay(newIso)
if (expected !== displayValue.value) {
displayValue.value = expected
}
})
const isEmpty = computed(() => !displayValue.value)
const sizeClass = computed(() =>
props.size === 'compact'
? 'text-sm h-8 font-normal normal-case tracking-normal'
: 'text-xl py-[6px]'
)
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
displayValue.value = target.value
if (target.value === '') {
emit('update:modelValue', '')
return
}
const iso = toIso(target.value)
emit('update:modelValue', iso ?? '')
}
</script>

View File

@@ -1,96 +0,0 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="modelValue"
class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 px-4"
role="dialog"
aria-modal="true"
@mousedown.self="closeOnBackdrop"
>
<div
class="w-full rounded-md bg-white shadow-2xl"
:class="maxWidth"
@mousedown.stop
>
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<h2 class="text-xl font-bold uppercase text-primary-500">{{ title }}</h2>
<button
type="button"
class="text-slate-500 hover:text-primary-500 flex items-center"
aria-label="Fermer"
@click="close"
>
<Icon name="mdi:close" size="24" />
</button>
</div>
<div class="px-6 py-5">
<slot />
</div>
<div
v-if="$slots.footer"
class="border-t border-slate-200 px-6 py-4"
>
<slot name="footer" :close="close" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, watch } from 'vue'
const props = withDefaults(defineProps<{
modelValue: boolean
title?: string
closeOnBackdropClick?: boolean
maxWidth?: string
}>(), {
title: '',
closeOnBackdropClick: true,
maxWidth: 'max-w-lg'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const close = () => emit('update:modelValue', false)
const closeOnBackdrop = () => {
if (props.closeOnBackdropClick) close()
}
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.modelValue) close()
}
watch(() => props.modelValue, (open) => {
if (typeof document === 'undefined') return
document.body.style.overflow = open ? 'hidden' : ''
})
onMounted(() => {
if (typeof document !== 'undefined') {
document.addEventListener('keydown', onKeydown)
}
})
onBeforeUnmount(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('keydown', onKeydown)
document.body.style.overflow = ''
}
})
</script>

View File

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

View File

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

View File

@@ -1,93 +0,0 @@
<template>
<div :class="['flex flex-col', wrapperClass]">
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
</label>
<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>

View File

@@ -1,76 +0,0 @@
<template>
<div :class="['flex flex-col', wrapperClass]">
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
</label>
<input
:id="id"
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>

View File

@@ -1,91 +0,0 @@
<template>
<div class="flex flex-col">
<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"
v-maska="maskOptions"
type="text"
:maxlength="maxLength"
:placeholder="placeholderText"
:required="required"
class="border-b border-primary-700 flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
@input="handleInput"
/>
<UiCheckbox
:id="checkboxId"
: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"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { vMaska } from 'maska/vue'
type Props = {
modelValue: string
allowAny?: boolean
label?: string
id?: string
required?: boolean
}
const props = withDefaults(defineProps<Props>(), {
allowAny: false,
label: 'Immatriculation',
id: 'license-plate',
required: false
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'update:allowAny', value: boolean): void
}>()
const inputId = computed(() => props.id)
const checkboxId = computed(() => `${props.id}-format`)
const maskOptions = computed(() =>
props.allowAny
? undefined
: {
mask: '@@-###-@@',
eager: true,
tokens: {
'@': {
pattern: /[A-Za-z]/,
transform: (char: string) => char.toUpperCase()
}
}
}
)
const placeholderText = computed(() => (props.allowAny ? '' : 'AA-123-AA'))
const maxLength = computed(() => (props.allowAny ? 20 : 9))
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement | null
if (!target) {
return
}
if (props.allowAny) {
emit('update:modelValue', target.value)
return
}
emit('update:modelValue', target.value)
}
const handleAllowAnyChange = (nextValue: boolean) => {
emit('update:allowAny', nextValue)
if (!nextValue) {
emit('update:modelValue', props.modelValue)
}
}
</script>

View File

@@ -1,50 +0,0 @@
<template>
<div class="flex items-center gap-2 text-sm uppercase">
<span class="loader-dots">
<span class="loader-dot"></span>
<span class="loader-dot"></span>
<span class="loader-dot"></span>
</span>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.loader-dots {
display: inline-flex;
gap: 4px;
align-items: center;
}
.loader-dot {
width: 20px;
height: 20px;
border-radius: 9999px;
background: currentColor;
animation: loader-bounce 1s infinite ease-in-out;
}
.loader-dot:nth-child(2) {
animation-delay: 0.15s;
}
.loader-dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes loader-bounce {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,183 +0,0 @@
import type { FetchOptions } from 'ofetch'
import { $fetch, FetchError } from 'ofetch'
import { useAuthStore } from '~/stores/auth'
export type AnyObject = Record<string, unknown>
export type ApiClient = {
get<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
getBlob(url: string, query?: AnyObject, options?: ApiFetchOptions<'blob'>): Promise<Blob>
post<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
put<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
patch<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
delete<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
}
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
FetchOptions<ResponseType> & {
toast?: boolean
toastTitle?: string
toastErrorMessage?: string
toastSuccessMessage?: string
toastErrorKey?: string
toastSuccessKey?: string
}
export const useApi = (): ApiClient => {
const config = useRuntimeConfig()
const baseURL = config.public.apiBase ?? '/api'
const toast = useToast()
const auth = useAuthStore()
const nuxtApp = useNuxtApp()
let isHandlingUnauthorized = false
const i18n = nuxtApp.$i18n as
| {
t: (key: string) => string
te?: (key: string) => boolean
}
| undefined
const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key)
const te = (key: string) => (i18n?.te ? i18n.te(key) : false)
const extractErrorMessage = (error: unknown, responseData?: unknown): string => {
const data = responseData ?? (error as FetchError)?.data
if (typeof data === 'string') {
return data
}
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>
return (
(record['hydra:description'] as string) ||
(record.detail as string) ||
(record.message as string) ||
(record.error as string) ||
(record.title as string) ||
(record['hydra:title'] as string) ||
''
)
}
return (error as FetchError)?.message ?? 'Erreur inconnue.'
}
const methodErrorKeys: Record<string, string> = {
GET: 'errors.http.get',
POST: 'errors.http.post',
PUT: 'errors.http.put',
PATCH: 'errors.http.patch',
DELETE: 'errors.http.delete'
}
const client = $fetch.create({
baseURL,
retry: 0,
credentials: 'include',
onResponse({ options, response }) {
const apiOptions = options as ApiFetchOptions<'json'>
if (apiOptions?.toast === false) {
return
}
if (response?.status && response.status >= 400) {
return
}
const successKey = apiOptions?.toastSuccessKey
const successMessage =
apiOptions?.toastSuccessMessage ||
(successKey ? (te(successKey) ? t(successKey) : successKey) : '')
if (successMessage) {
toast.success({
title: 'Succès',
message: successMessage
})
}
},
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
}
const method =
typeof options?.method === 'string' ? options.method.toUpperCase() : 'GET'
const defaultKey = methodErrorKeys[method]
const defaultMessage =
defaultKey && te(defaultKey) ? t(defaultKey) : ''
const errorKey = apiOptions?.toastErrorKey
const errorMessage =
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
const extractedMessage = extractErrorMessage(error, response?._data)
const message =
apiOptions?.toastErrorMessage ||
errorMessage ||
defaultMessage ||
extractedMessage ||
'Une erreur est survenue.'
toast.error({
title: apiOptions?.toastTitle ?? 'Erreur',
message
})
}
})
const request = <T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
url: string,
options: ApiFetchOptions<'json'> = {}
) => {
const needsJsonBody = method === 'POST' || method === 'PUT'
const needsMergePatch = method === 'PATCH'
const headers = new Headers(options.headers as HeadersInit | undefined)
if (needsMergePatch && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/merge-patch+json')
} else if (needsJsonBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
return client<T>(url, { ...options, method, headers })
}
return {
get<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('GET', url, { ...options, query })
},
getBlob(url: string, query: AnyObject = {}, options: ApiFetchOptions<'blob'> = {}) {
return client<Blob>(url, { ...options, method: 'GET', query, responseType: 'blob' })
},
post<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('POST', url, { ...options, body })
},
put<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('PUT', url, { ...options, body })
},
patch<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('PATCH', url, { ...options, body })
},
delete<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('DELETE', url, { ...options, query })
}
}
}

View File

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

View File

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

View File

@@ -1,88 +0,0 @@
import { computed } from 'vue'
import { useAuthStore } from '~/stores/auth'
export interface BovineColumn {
key: string
label: string
width?: string
}
export interface UseBovineColumnsOptions {
/**
* 'inventory' (par défaut) : colonnes complètes incluant Bâtiment + Case.
* 'case' : pas de Bâtiment ni Case (déjà dans le titre de la page),
* largeurs élargies pour combler l'espace.
*/
variant?: 'inventory' | 'case'
}
/**
* Définition partagée des colonnes des tableaux bovins (inventory + case).
* 4 variants : avec/sans colonnes prix × inventory/case.
*
* Les colonnes Prix/kg et Prix total sont visibles pour les rôles BUREAU
* et ADMIN (BUREAU hérite ses droits price-visibility, ADMIN hérite de BUREAU).
*/
export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
const auth = useAuthStore()
const withPricesInventory: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '72px' },
{ key: 'age', label: 'Age', width: '110px' },
{ key: 'bovineType.label', label: 'Race', width: '90px' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' },
{ key: 'pricePerKg', label: 'Prix/kg', width: '65px' },
{ key: 'finalPrice', label: 'Prix total', width: '80px' }
]
const withoutPricesInventory: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '72px' },
{ key: 'age', label: 'Age', width: '110px' },
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '120px' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }
]
const withPricesCase: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
{ key: 'sex', label: 'Sexe', width: '90px' },
{ key: 'birthDate', label: 'Né le', width: '100px' },
{ key: 'age', label: 'Age', width: '90px' },
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
{ key: 'arrivalDate', label: 'Entrée le', width: '110px' },
{ key: 'pricePerKg', label: 'Prix/kg', width: '85px' },
{ key: 'finalPrice', label: 'Prix total', width: '105px' }
]
const withoutPricesCase: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '130px' },
{ key: 'workNumber', label: 'N° Travail', width: '100px' },
{ key: 'sex', label: 'Sexe', width: '110px' },
{ key: 'birthDate', label: 'Né le', width: '140px' },
{ key: 'age', label: 'Age', width: '130px' },
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
{ key: 'arrivalDate', label: 'Entrée le', width: '170px' }
]
const columns = computed<BovineColumn[]>(() => {
const isCase = options.variant === 'case'
const seePrice = auth.isBureau
if (isCase) {
return seePrice ? withPricesCase : withoutPricesCase
}
return seePrice ? withPricesInventory : withoutPricesInventory
})
return { columns }
}

View File

@@ -1,102 +0,0 @@
import { ref, watch } from 'vue'
import { useApi } from '~/composables/useApi'
type FilterValue = string | number | boolean | null
export interface UseDataTableServerStateOptions {
initialPerPage?: number
debounceMs?: number
}
export function useDataTableServerState<T = Record<string, unknown>>(
endpoint: string,
initialFilters: Record<string, FilterValue> = {},
options: UseDataTableServerStateOptions = {}
) {
const api = useApi()
const debounceMs = options.debounceMs ?? 300
const initialPerPage = options.initialPerPage ?? 10
const items = ref<T[]>([]) as { value: T[] }
const totalItems = ref(0)
const page = ref(1)
const perPage = ref(initialPerPage)
const filters = ref<Record<string, FilterValue>>({ ...initialFilters })
const loading = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let requestToken = 0
const buildQueryParams = (): Record<string, string | number | boolean> => {
const params: Record<string, string | number | boolean> = {
page: page.value,
itemsPerPage: perPage.value
}
for (const [key, value] of Object.entries(filters.value)) {
if (value === '' || value === null) continue
params[key] = value as string | number | boolean
}
return params
}
const fetchItems = async (): Promise<void> => {
const currentToken = ++requestToken
loading.value = true
try {
const data = await api.get<{ member: T[]; totalItems: number }>(
endpoint,
buildQueryParams(),
{
toast: false,
headers: { Accept: 'application/ld+json' }
}
)
if (currentToken !== requestToken) return
items.value = data?.member ?? []
totalItems.value = data?.totalItems ?? 0
} finally {
if (currentToken === requestToken) {
loading.value = false
}
}
}
const reload = (): void => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
void fetchItems()
}
const scheduleReload = (): void => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = null
void fetchItems()
}, debounceMs)
}
watch([page, perPage], () => {
reload()
})
watch(filters, () => {
if (page.value !== 1) {
page.value = 1
return
}
scheduleReload()
}, { deep: true })
return {
items,
totalItems,
page,
perPage,
filters,
loading,
reload
}
}

View File

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

View File

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

View File

@@ -1,29 +0,0 @@
import { useApi } from '~/composables/useApi'
export const usePdfPrinter = () => {
const api = useApi()
const printPdf = async (url: string, filename = 'document.pdf'): Promise<void> => {
const blob = await api.getBlob(url)
const pdfBlob = blob.type === 'application/pdf'
? blob
: new Blob([blob], { type: 'application/pdf' })
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 {
printPdf
}
}

View File

@@ -1,165 +0,0 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import type { WeightEntryData } from '~/services/dto/reception-data'
import type { WeightData } from '~/services/dto/weight-data'
import { createWeight, updateWeight } from '~/services/weight'
export type WeighingMode = 'gross' | 'tare'
export interface UseWeighingOptions {
mode: WeighingMode
entity: Ref<{ id: number; currentStep: number; isValid: boolean; weights?: WeightEntryData[] | null } | null>
entityName: 'reception' | 'shipment'
apiResource: string
titleLabel: string
isFinal?: boolean
getWeightFromScale: () => Promise<WeightData>
updateEntity: (id: number, payload: any) => Promise<any>
loadEntity?: (id: number) => Promise<any>
}
export const useWeighing = ({
mode,
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 = 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(() => titleLabel)
const showLoadingBox = computed(() => isFetching.value)
const fetchWeight = async () => {
isFetching.value = true
weightData.value = await getWeightFromScale().finally(() => {
isFetching.value = false
})
}
const saveWeight = async () => {
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
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
})
}
const nextStep = isFinal
? entity.value.currentStep
: entity.value.currentStep + 1
await updateEntity(entity.value.id, {
currentStep: nextStep,
isValid: entity.value.isValid
})
if (loadEntity) {
await loadEntity(entity.value.id)
}
}
const saveWeightDraft = async () => {
if (!entity.value) return
if (!weightData.value && !currentWeightEntry.value) return
const existingEntry = currentWeightEntry.value
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
if (baseWeight === null) return
const relationPayload: Record<string, string> = {}
relationPayload[entityName] = `/api/${apiResource}/${entity.value.id}`
if (existingEntry?.id) {
await updateWeight(existingEntry.id, {
type: mode,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
} else {
await createWeight({
...relationPayload,
type: mode,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
}
}
return {
weightData,
currentWeightEntry,
displayWeight,
displayDsd,
title,
showLoadingBox,
fetchWeight,
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
})
}

View File

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

View File

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

View File

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

View File

@@ -1,159 +0,0 @@
{
"errors": {
"http": {
"get": "Impossible de récupérer les données.",
"post": "Impossible de créer la ressource.",
"put": "Impossible de mettre à jour la ressource.",
"patch": "Impossible de mettre à jour la ressource.",
"delete": "Impossible de supprimer la ressource."
},
"reception": {
"list": "Impossible de récupérer la liste des réceptions.",
"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.",
"update": "Impossible de mettre à jour l'utilisateur.",
"create": "Impossible de créer l'utilisateur."
}
},
"success": {
"reception": {
"create": "Réception créée avec succès",
"update": "Réception mise à jour avec succès.",
"delete": "Réception supprimée avec succès."
},
"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"
}
}
}

View File

@@ -1,7 +0,0 @@
<template>
<div class="min-h-screen bg-primary-500 from-primary-50 via-white to-neutral-100 text-neutral-900">
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
<slot />
</main>
</div>
</template>

View File

@@ -1,290 +0,0 @@
<template>
<div class="min-h-screen text-neutral-900 flex flex-col">
<!-- HEADER -->
<header class="w-full bg-primary-500 py-5 px-6">
<div class="flex w-full items-center justify-between">
<!-- Burger (mobile) -->
<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 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="route.path === '/'
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Accueil
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/user/list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/user')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Utilisateurs
</a>
</NuxtLink>
<NuxtLink
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="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="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'
const route = useRoute()
const auth = useAuthStore()
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')
}
}
</script>

View File

@@ -1,27 +0,0 @@
import { useAuthStore } from '~/stores/auth'
/**
* Garde-fou global : empêche les utilisateurs non-admin d'accéder aux pages
* sous /admin/*. Renvoie vers la home pour les utilisateurs authentifiés
* non-admin, et vers /login pour les non authentifiés.
*
* L'API back rejette de toute façon les actions admin avec un 403, mais ce
* middleware évite l'affichage des pages vides / en erreur quand un user
* tape directement l'URL /admin/...
*/
export default defineNuxtRouteMiddleware(async (to) => {
if (!to.path.startsWith('/admin')) {
return
}
const auth = useAuthStore()
await auth.ensureSession()
if (!auth.isAuthenticated) {
return navigateTo('/login')
}
if (!auth.isAdmin) {
return navigateTo('/')
}
})

View File

@@ -1,17 +0,0 @@
import { useAuthStore } from '~/stores/auth'
export default defineNuxtRouteMiddleware(async (to) => {
const auth = useAuthStore()
if (to.path === '/login') {
return
}
if (!auth.isAuthenticated) {
await auth.ensureSession()
}
if (!auth.isAuthenticated) {
return navigateTo('/login')
}
})

View File

@@ -2,39 +2,8 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
ssr: false,
app: {
baseURL: process.env.NUXT_PUBLIC_APP_BASE || '/'
},
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'nuxt-toast',
'@nuxtjs/i18n',
'@nuxt/icon'
],
css: ['~/assets/css/main.css', '~/assets/css/toast.css'],
runtimeConfig: {
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE,
geoApiBase: ''
}
},
toast: {
settings: {
timeout: 10000,
closeOnClick: true,
progressBar: false
}
},
i18n: {
strategy: 'no_prefix',
defaultLocale: 'fr',
langDir: 'locales',
locales: [
{ code: 'fr', file: 'fr.json', name: 'Français' }
]
},
modules: ['@nuxtjs/tailwindcss', '@nuxt/icon'],
typescript: {
strict: true
}
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -11,17 +11,10 @@
"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",
"maska": "^3.2.0",
"@nuxt/icon": "^2.2.0",
"nuxt": "^4.2.2",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-router": "^4.6.4",
"zod": "^4.3.5"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0"

View File

@@ -1,114 +0,0 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="router.push('/admin/bovin/bovin-list')"
name="gg:arrow-left-o"
size="40"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ route.params.id ? 'Modifications du type bovin' : 'Ajout d\'un type bovin' }}
</h1>
</div>
<div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" required />
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" required />
</div>
<div class="flex justify-center items-center">
<UiButton
type="submit"
:disabled="isLoading || isHydrating"
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
@click="submitted = true"
>
Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
useHead({ title: 'Type de bovin' })
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
const router = useRouter()
const route = useRoute()
const isLoading = ref(false)
const isHydrating = ref(false)
const submitted = ref(false)
const idBovin = computed(() => resolveId(route.params.id))
const isEdit = computed(() => idBovin.value !== null)
function resolveId(param: unknown) {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) return null
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
const form = reactive<BovinFormData>({
label: '',
code: ''
})
const hydrateFromBovin = (bovin: BovineTypeData | null) => {
if (!bovin) {
return
}
isHydrating.value = true
form.label = bovin.label ?? ''
form.code = bovin.code ?? ''
isHydrating.value = false
}
watch(
() => idBovin.value,
async (id) => {
if (id === null) {
return
}
isLoading.value = true
try {
const bovin = await getBovin(id)
hydrateFromBovin(bovin)
} finally {
isLoading.value = false
}
},
{immediate: true}
)
async function validate() {
if (isLoading.value || isHydrating.value) return
const normalizedBovinCode = form.code.trim()
const normalizedBovinLabel = form.label.trim()
const basePayload = {
label: normalizedBovinLabel,
code: normalizedBovinCode
}
isLoading.value = true
try {
if (isEdit.value && idBovin.value !== null) {
await updateBovin(idBovin.value, basePayload)
} else {
await createBovin(basePayload)
}
} finally {
isLoading.value = false
}
}
async function navigate(){
return router.push("/admin/bovin/list")
}
</script>

View File

@@ -1,72 +0,0 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des types bovins</h1>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/bovin"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
<div v-if="auth.isAdmin" class="mt-6 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToBovin"
>
<template #header-label>
<UiTextInput v-model="filters.label" placeholder="Nom" size="compact" />
</template>
<template #header-code>
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
</template>
</UiDataTable>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs.
</div>
</div>
</template>
<script setup lang="ts">
useHead({ title: 'Types de bovins' })
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const router = useRouter()
const auth = useAuthStore()
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<BovineTypeData>(
'bovine_types',
{
label: '',
code: ''
}
)
const columns = [
{ key: 'label', label: 'Nom' },
{ key: 'code', label: 'Code' }
]
const goToBovin = (bovin: BovineTypeData) => {
if (!auth.isAdmin) return
router.push(`/admin/bovin/${bovin.id}`)
}
onMounted(() => {
if (auth.isAdmin) reload()
})
</script>

View File

@@ -1,119 +0,0 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="router.push('/admin/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">
useHead({ title: 'Transporteur' })
import {createCarrier, getCarrier, updateCarrier} from "~/services/carrier";
import type {CarrierData, CarrierFormData} from "~/services/dto/carrier-data";
import {computed} from "vue";
const router = useRouter()
const route = useRoute()
const idCarrier = 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>

View File

@@ -1,63 +0,0 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
<NuxtLink
to="/admin/carrier"
class="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-6 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToCarrier"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Label" size="compact" />
</template>
<template #header-code>
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
</template>
</UiDataTable>
</div>
</div>
</template>
<script setup lang="ts">
useHead({ title: 'Transporteurs' })
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>

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