Compare commits

..

2 Commits

Author SHA1 Message Date
4e1b431e57 feat : update du CHANGELOG.md 2026-01-19 15:22:54 +01:00
ce49785c79 feat : ajout de l'authentification avec lexik 2026-01-19 15:17:43 +01:00
278 changed files with 2292 additions and 24129 deletions

View File

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

50
.env
View File

@@ -1,22 +1,48 @@
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=
COOKIE_SECURE=1
###< lexik/jwt-authentication-bundle ###
# ADAPTER avec la vraie BDD (pas de variables docker)
DATABASE_URL=
PONT_BASCULE_BYPASS=
PONT_BASCULE_URL=

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 }}

1
.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

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>

6
.idea/data_source_mapping.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f407a514-c6b4-4b26-9555-445a85892502/console.sql" value="f407a514-c6b4-4b26-9555-445a85892502" />
</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>

8
.idea/ferme.iml generated
View File

@@ -147,14 +147,6 @@
<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" />

263
.idea/php.xml generated
View File

@@ -4,184 +4,158 @@
<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" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/dompdf/dompdf" />
<path value="$PROJECT_DIR$/vendor/dompdf/php-svg-lib" />
<path value="$PROJECT_DIR$/vendor/sabberworm/php-css-parser" />
<path value="$PROJECT_DIR$/vendor/dompdf/php-font-lib" />
<path value="$PROJECT_DIR$/vendor/lexik/jwt-authentication-bundle" />
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
</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 +164,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>

805
.idea/workspace.xml generated
View File

@@ -4,12 +4,8 @@
<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" />
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : ajout de l'authentification avec lexik">
<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" />
@@ -20,11 +16,6 @@
<pharConfigPath>$PROJECT_DIR$/composer.json</pharConfigPath>
<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" />
@@ -33,7 +24,6 @@
<option name="RECENT_TEMPLATES">
<list>
<option value="TypeScript File" />
<option value="PHP File" />
<option value="Vue Composition API Component" />
</list>
</option>
@@ -41,14 +31,11 @@
<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" />
<entry key="$PROJECT_DIR$" value="develop" />
</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 />
@@ -62,157 +49,149 @@
</server>
</servers>
</component>
<component name="PhpWorkspaceProjectConfiguration" interpreter_name="C:/php-8.4.3/php.exe">
<component name="PhpWorkspaceProjectConfiguration">
<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" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/dompdf/dompdf" />
<path value="$PROJECT_DIR$/vendor/dompdf/php-svg-lib" />
<path value="$PROJECT_DIR$/vendor/sabberworm/php-css-parser" />
<path value="$PROJECT_DIR$/vendor/dompdf/php-font-lib" />
<path value="$PROJECT_DIR$/vendor/lexik/jwt-authentication-bundle" />
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
</include_path>
</component>
<component name="ProjectColorInfo">{
@@ -232,21 +211,16 @@
"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",
"git-widget-placeholder": "feat/202-connexion-utilisateur",
"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",
"settings.editor.selected.configurable": "editor.preferences.tabs",
"vue.rearranger.settings.migration": "true"
},
"keyToStringList": {
"DatabaseDriversLRU": [
"postgresql"
],
"com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File": [
"TEXT"
],
@@ -256,29 +230,21 @@
}
}]]></component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
<recent name="$PROJECT_DIR$/frontend/components/commun" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\pages\shipment" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\composables" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\components\shipment" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
<recent name="C:\Users\m-tristan\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\templates" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches\Ferme_MCD\MCD_DOC" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\frontend\pages\reception" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\frontend\pages" />
</key>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.32098.40" />
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.29346.257" />
</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="" />
@@ -292,435 +258,88 @@
<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)">
<task id="LOCAL-00001" summary="feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)">
<option name="closed" value="true" />
<created>1769529522614</created>
<option name="number" value="00037" />
<option name="presentableId" value="LOCAL-00037" />
<created>1768237763998</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1769529522614</updated>
<updated>1768237763998</updated>
</task>
<task id="LOCAL-00038" summary="feat : ajout du numéro identification des receptions et ajustement du bon de reception">
<task id="LOCAL-00002" summary="feat : Ajout de zod, création d'un composant de chargement loading-dots.vue et finalisation du flow d'une reception">
<option name="closed" value="true" />
<created>1769676223697</created>
<option name="number" value="00038" />
<option name="presentableId" value="LOCAL-00038" />
<created>1768316052474</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1769676223697</updated>
<updated>1768316052474</updated>
</task>
<task id="LOCAL-00039" summary="feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception">
<task id="LOCAL-00003" summary="feat : Ajout d'un composable pour la pesée qui sera réutilisable pour l'expédition, ajout de contrainte sur les entity de reception et weight pour plus de robustesse et correction de la class active des liens dans la nav">
<option name="closed" value="true" />
<created>1769700808988</created>
<option name="number" value="00039" />
<option name="presentableId" value="LOCAL-00039" />
<created>1768316835575</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1769700808988</updated>
<updated>1768316835575</updated>
</task>
<task id="LOCAL-00040" summary="feat : mise en place de composant UI pour les select, checkbox, date, text">
<task id="LOCAL-00004" summary="feat : update du fichier AGENTS.md">
<option name="closed" value="true" />
<created>1769705141157</created>
<option name="number" value="00040" />
<option name="presentableId" value="LOCAL-00040" />
<created>1768316965511</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1769705141157</updated>
<updated>1768316965511</updated>
</task>
<task id="LOCAL-00041" summary="feat : update CHANGELOG.md">
<task id="LOCAL-00005" summary="feat : update du fichier README.md et CHANGELOG.md">
<option name="closed" value="true" />
<created>1769705240487</created>
<option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" />
<created>1768317786187</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1769705240487</updated>
<updated>1768317786187</updated>
</task>
<task id="LOCAL-00042" summary="feat : ajout de commentaire">
<task id="LOCAL-00006" summary="fix : correction du useApi pour qu'il n'y ait plus de retry lors d'une erreur 500 par exemple">
<option name="closed" value="true" />
<created>1769760766200</created>
<option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" />
<created>1768318875533</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1769760766200</updated>
<updated>1768318875533</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">
<task id="LOCAL-00007" summary="test : ajout de TU sur les services et providers">
<option name="closed" value="true" />
<created>1769768517942</created>
<option name="number" value="00043" />
<option name="presentableId" value="LOCAL-00043" />
<created>1768318921478</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1769768517943</updated>
<updated>1768318921478</updated>
</task>
<task id="LOCAL-00044" summary="feat : ajout de colonne pour les Supplier, Address et modification du numéro de réception">
<task id="LOCAL-00008" summary="feat : ajout de la génération du bon de reception, correction de la base du formulaire multi-etape de reception et ajout d'une gestion d'erreur global">
<option name="closed" value="true" />
<created>1769770092190</created>
<option name="number" value="00044" />
<option name="presentableId" value="LOCAL-00044" />
<created>1768498751836</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1769770092190</updated>
<updated>1768498751836</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">
<task id="LOCAL-00009" summary="feat : ajout d'une gestion d'erreur au global côté front avec la lib toaster et I18n pour centraliser les messages d'erreur">
<option name="closed" value="true" />
<created>1769770142624</created>
<option name="number" value="00045" />
<option name="presentableId" value="LOCAL-00045" />
<created>1768555180530</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1769770142624</updated>
<updated>1768555180530</updated>
</task>
<task id="LOCAL-00046" summary="feat : mise à jour du bon de réception">
<task id="LOCAL-00010" summary="feat : ajout de l'authentification avec lexik">
<option name="closed" value="true" />
<created>1769782099473</created>
<option name="number" value="00046" />
<option name="presentableId" value="LOCAL-00046" />
<created>1768832208350</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1769782099473</updated>
<updated>1768832208350</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" />
<option name="localTasksCounter" value="11" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -732,11 +351,6 @@
<entry key="Branch">
<value>
<list>
<RecentGroup>
<option name="FILTER_VALUES">
<option value="HEAD" />
</option>
</RecentGroup>
<RecentGroup>
<option name="FILTER_VALUES">
<option value="develop" />
@@ -757,7 +371,7 @@
<entry key="branch">
<value>
<list>
<option value="HEAD" />
<option value="develop" />
</list>
</value>
</entry>
@@ -770,63 +384,22 @@
</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>
<MESSAGE value="Feat : (WIP) Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions" />
<MESSAGE value="Feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)" />
<MESSAGE value="feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)" />
<MESSAGE value="feat : Ajout de zod, création d'un composant de chargement loading-dots.vue et finalisation du flow d'une reception" />
<MESSAGE value="feat : Ajout d'un composable pour la pesée qui sera réutilisable pour l'expédition, ajout de contrainte sur les entity de reception et weight pour plus de robustesse et correction de la class active des liens dans la nav" />
<MESSAGE value="feat : update du fichier AGENTS.md" />
<MESSAGE value="feat : update du fichier README.md et CHANGELOG.md" />
<MESSAGE value="fix : correction du useApi pour qu'il n'y ait plus de retry lors d'une erreur 500 par exemple" />
<MESSAGE value="test : ajout de TU sur les services et providers" />
<MESSAGE value="feat : ajout de la génération du bon de reception, correction de la base du formulaire multi-etape de reception et ajout d'une gestion d'erreur global" />
<MESSAGE value="feat : ajout d'une gestion d'erreur au global côté front avec la lib toaster et I18n pour centraliser les messages d'erreur" />
<MESSAGE value="feat : ajout de l'authentification avec lexik" />
<option name="LAST_COMMIT_MESSAGE" value="feat : ajout de l'authentification avec lexik" />
</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>

46
AGENTS.md Normal file
View File

@@ -0,0 +1,46 @@
# AGENTS.md
Project overview
- Symfony 8 + API Platform 4 backend, Nuxt 3 frontend in `frontend/`.
- Apache vhost serves API under `/api` and frontend from `frontend/dist`.
- API base URL on frontend uses `NUXT_PUBLIC_API_BASE` (see `frontend/.env`).
Backend conventions
- Use English for code identifiers/messages; keep “pont-bascule” as domain term.
- API Platform operations are defined on Doctrine entities.
- Reception entity is in `src/Entity/Reception.php`, with custom weigh endpoint `/receptions/weigh`.
- Reception fields: `date_reception`, `license_plate`, `current_step` (default 0), `is_valid` (default false).
- `date_reception` is set by the UI, stored as `DateTimeImmutable`, serialized as `Y-m-d`.
- Weight entity (`src/Entity/Weight.php`) is 1N with Reception, each row stores `type` (`gross` or `tare`), `dsd`, `weight`, `weighed_at` (all nullable except `type`).
- Weigh endpoint `/receptions/weigh` returns `PontBasculeReading` with `dsd`, `weight`, `weighedAt` (formatted `Y-m-d`).
- Custom exception: `App\Exception\PontBasculeException` with French messages, mapped to 500 in provider.
- Parsing of pont-bascule payload is in `src/Service/PontBasculePayloadDecoder.php`.
- `config/reference.php` is auto-generated; keep it.
Frontend conventions
- Nuxt SSR disabled; Tailwind used.
- Layout in `frontend/layouts/default.vue`: max width `1050px`, header full width.
- Tailwind custom color palette is `primary` (e.g. `bg-primary-500`).
- API composable in `frontend/composables/useApi.ts` with `get/post/put/patch/delete` and default JSON/PATCH content types.
- API errors/success toasts can be customized via `toastErrorMessage`/`toastSuccessMessage` or i18n keys `toastErrorKey`/`toastSuccessKey`. Global method fallbacks use `errors.http.*` keys.
- `useApi` uses `useNuxtApp().$i18n` (not `useI18n`) to avoid setup-only constraint in service calls.
- Pinia store: `frontend/stores/reception.ts` is the source of truth for the current reception.
- Zod is used for form validation (e.g. `frontend/components/reception/reception-form.vue`); shared helpers live in `frontend/utils/zod-errors.ts`.
- Weighing logic is shared via `frontend/composables/useWeighing.ts`.
- Reception step UI uses store state (`currentStep`) in `frontend/pages/reception/[[id]].vue`.
- Active nav styles in header use `NuxtLink` with `custom` slot.
- Reusable UI components live under `frontend/components/ui/` and are auto-imported with `Ui` prefix (e.g. `UiLoadingDots`).
- Service layer lives in `frontend/services/` with typed DTOs in `frontend/services/dto/`.
- Reception service uses `receptions`, `receptions/{id}`, `receptions/weigh` and supports success/error toast keys.
- Reception receipt endpoint is `receptions/{id}/receipt` (PDF) via `frontend/composables/usePdfPrinter.ts`.
Environment & routing
- Frontend dev server: `npm run dev` in `frontend/`.
- API base for local dev: `http://localhost:8080/api` (set in `frontend/.env` via `NUXT_PUBLIC_API_BASE`).
- CORS handled by Nelmio; `.env` includes `CORS_ALLOW_ORIGIN` regex for localhost.
- Nuxt i18n locales live in `frontend/i18n/locales` (configured via `langDir: 'locales'`).
- Default locale is `fr`; translations in `frontend/i18n/locales/fr.json`.
Notes
- Do not add a GET that creates resources; use POST + PATCH.
- Keep endpoints in plural (API Platform convention).

View File

@@ -13,10 +13,6 @@ Ajouter dans le fichier .env
- JWT_PUBLIC_KEY
- JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
- COOKIE_SECURE=0 (en dev 0 et en prod 1)
- EDNOTIF_EXPLOITATION_CODE
- EDNOTIF_EXPLOITATION_NUMERO
- EDNOTIF_LOGIN
- EDNOTIF_PASSWORD
Ajouter dans le fichier .env du frontend
- NUXT_PUBLIC_API_BASE
@@ -24,49 +20,7 @@ Ajouter dans le fichier .env du frontend
### Added
* [#203] Réceptions — Parcours de pesée multi-étapes (début)
* [#202] Authentification — Connexion utilisateur (JWT)
* Ajout du bundle malio/ednotif-bundle
* Ajout de composant UI
* Finalisation de la partie réception de marchandise
* [#267] Lister les réceptions en attente
* [#268] Lister les réceptions terminées
* [#316] Admin liste des transporteurs
* [#312] Creation administration listing fournisseurs
* [#315] Creation page admin utilisateur
* [#317] Admin modification creation transporteur
* [#318] Affichage modification reception terminée
* [#320] Affichage modification reception terminée suite
* [#271] Créer une nouvelle expédition (étape 1)
* [#272] Créer une nouvelle expédition (étape 2)
* [#273] Créer une nouvelle expédition (étape 3)
* [#256] Créer une nouvelle réception (étape 3 - bovin)
* [#314] Création d'une page d'administration : listing des utilisateurs
* [#313] Admin modification creation fournisseur
* [#275] Lister les expéditions en attente
* [#276] Lister les expéditions terminées
* [#324] Creation page admin listing clients
* [#326] Admin modification creation client
* [#325] Correction diverses
* fix layout admin
* Creation page admin listing bovins
* Creation page admin ajout/modification bovins
* [#331] Mettre à jour l'entité Shipment et bovin_shipment
* [#278] Plan du site
* [#334] Correctifs
* [#332] Refonte écran réception terminée
* [#327] afficher/modifier écran expédition terminée
* [#352] modification front admin fournisseur
* [#355] modification front admin transporteur
* [#356] front page admin bovin
* [#353] modification front admin client
* [#353] modification front admin utilisateur
* [#FER-11] Corriger le problème de bearer token
* [#FER-12] Ajouter un blocage des utilisateurs
* [#FER-13] Faire des recherches sur le scanner des bêtes
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
* [#FER-17] Ecran d'ajout de bovin
* [#FER-18] Mise à jour du tableau d'arrivage
### Changed
### Fixed

169
CLAUDE.md
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.
- Pas de classes Repository custom : utiliser `EntityManagerInterface` avec les repos par défaut.
- `config/reference.php` est auto-généré — ne pas modifier à la main.
- Endpoints toujours au pluriel (convention API Platform).
- Ne jamais créer de GET qui crée des ressources : utiliser POST + PATCH.
- Les noms de `Supplier`, `Customer` et `Carrier` sont automatiquement mis en majuscule via `mb_strtoupper` dans `setName()`.
## Conventions frontend
- SSR désactivé. Tailwind avec palette custom `primary` (ex: `bg-primary-500`).
- `useApi` (`composables/useApi.ts`) : méthodes `get/post/put/patch/delete` avec content-types par défaut.
- Toasts personnalisables via `toastErrorMessage`/`toastSuccessMessage` ou clés i18n `toastErrorKey`/`toastSuccessKey`.
- Utilise `useNuxtApp().$i18n` (pas `useI18n`) pour fonctionner hors setup.
- Validation formulaires avec Zod ; helpers dans `utils/zod-errors.ts`.
- Nav active : `NuxtLink` avec slot `custom`.
- PDFs : `usePdfPrinter` (receipt réception, rapport poids cases).
### Validation required & erreurs visuelles
- Les champs `required` utilisent l'attribut HTML natif forwardé via `v-bind="attrs"` dans les composants UI.
- La bordure rouge n'apparaît qu'après soumission grâce à la classe CSS `submitted` ajoutée sur le `<form>` au clic sur le bouton Valider (`@click="submitted = true"`).
- Règles CSS globales dans `main.css` : `.submitted :invalid` (bordure + texte rouge), `.submitted :has(:invalid) > label` et `.submitted label:has(:invalid)` (labels rouges).
- Pour les validations manuelles (checkboxes, radio groups), les messages d'erreur utilisent `invisible` (pas `hidden`) pour garder l'espace réservé et éviter les décalages de layout.
- Les dates de l'API sont renvoyées au format `Y-m-d H:i` ; les formulaires utilisent `.slice(0, 10)` pour extraire la date seule (compatible `<input type="date">`).
### Workflow réception/expédition (mutualisé)
- Factory service `createWorkflowService` et factory store `useWorkflowStoreLogic` pour éviter la duplication.
- Composables partagés : `useLiotHandling` (logique LIOT), `useFormDataLoading` (users, trucks, carriers), `useAddressSync` (sync adresse fournisseur/client).
- `useWeighing` : une seule fonction paramétrée pour réception et expédition (remplace `useWeighing` + `useWeighingShipment`).
- Configs workflow dans `config/reception.config.ts` et `config/shipment.config.ts` (étapes, labels pesée, filename PDF).
- `WorkflowWeight` composant partagé pour les étapes de pesée (remplace `reception-weight.vue` et `shipment-weight.vue`).
- `WorkflowWaitingList` composant partagé pour les listes en attente, avec support colonnes dynamiques, slot `actions`, et prop `showActions`.
## Domaine métier clé
### Réception (pesée pont-bascule)
- Entité principale `Reception` : `date_reception` (DateTimeImmutable, format lecture `Y-m-d H:i`, écriture `Y-m-d`), `identification_number` (auto `N-BR-####`), `current_step` (défaut 0), `is_valid` (défaut false).
- `Weight` (1-N avec Reception, cascade remove + orphanRemoval) : `type` (`gross`/`tare`), `dsd`, `weight`, `weighed_at`.
- Endpoint pesée : `/receptions/weigh``PontBasculeReading` (dsd, weight, weighedAt).
- Endpoint suppression : `DELETE /receptions/{id}` — supprime en cascade weights, pelletBuildings, bovines.
- Parsing payload pont-bascule : `Service/PontBasculePayloadDecoder.php`.
- Exception : `PontBasculeException` (messages en français, mappée 500).
- Store Pinia `reception.ts` = source de vérité pour la réception en cours.
- UI multi-étapes dans `pages/reception/[[id]].vue` basée sur `currentStep`.
- `PrePersist` : injecte l'heure courante sur `receptionDate` à la création ; `setReceptionDate` préserve l'heure existante au PATCH.
### Expédition
- Entité `Shipment` : même pattern que Reception, `shipment_date` (format lecture `Y-m-d H:i`, écriture `Y-m-d`).
- Endpoint suppression : `DELETE /shipments/{id}`.
- `PrePersist` : injecte l'heure courante sur `shipmentDate` ; `setShipmentDate` préserve l'heure au PATCH.
### LIOT (transport)
- Si carrier code = `LIOT` : afficher sélecteurs driver + vehicle, masquer saisie plaque manuelle.
- Liste véhicules filtrée par type de camion et transporteur.
- Le véhicule sélectionné alimente `license_plate`.
- Logique mutualisée dans `composables/useLiotHandling.ts`.
### Bovins & infrastructure
- `Bovine` : `nationalNumber` (unique), `receivedWeight`, `arrivalDate`, `buildingCase` (ManyToOne).
- `BuildingCase` a `bovines` (OneToMany).
- Rapport PDF cases : `GET /building_cases/{id}/weights-report` → template Twig, projection depuis `arrivalDate`, gain journalier fixe `1.3 kg/jour`.
### Scanner boucles auriculaires
- Page dédiée `/scan` : scan de codes-barres Code 39/128 (boucles auriculaires bovines) depuis un téléphone Android via Chrome.
- Utilise l'API native `BarcodeDetector` (Shape Detection API, Chrome Android 83+) — pas de lib JS, décodage hardware quasi-instantané.
- **Non supporté sur iOS** (tous les navigateurs iOS utilisent WebKit, qui n'implémente pas `BarcodeDetector`).
- Les 4 premiers caractères du code-barres sont retirés avant enregistrement (`rawValue.slice(4)`).
- Composable `useBarcodeScanner` : caméra arrière, anti-doublon 2s, vibration au scan.
- Le bovin est créé via `POST /bovines` avec `Content-Type: application/ld+json` (nécessaire pour la résolution d'IRI de `buildingCase`).
- Sélection bâtiment → case (filtrées dynamiquement) avant de scanner.
### Données de référence
- `ReceptionType`, `MerchandiseType`, `PelletType`, `Building`, `Supplier` (avec `Address` via join table, `createdBy` → User), `Customer` (avec `Address` via join table, `createdBy` → User), `Truck`, `Carrier`, `Driver`, `Vehicle`.
- `Address` : champ `label` nullable (déprécié, retiré du front et du `address:write`), expose `fullAddress` via getter. `countryCode` par défaut `FR` côté front.
### Seed & fixtures
- Commande `app:seed` : seed infrastructure (statut, building_layout, building_case, building_case_position) puis bovins.
- Utilise des flush intermédiaires pour que les queries find fonctionnent sur les records fraîchement créés.
- `upsertAddress` cherche par `street|postalCode` (plus par `label`).
- Fixtures : `BuildingInfrastructureFixtures` + `BovineFixtures` (via dépendances `AppFixtures`).
## Environnement
- API base dev : `http://localhost:8080/api` (via `NUXT_PUBLIC_API_BASE` dans `frontend/.env`)
- CORS : Nelmio, configurable via `CORS_ALLOW_ORIGIN` dans `.env`
- Locale par défaut : `fr` — traductions dans `frontend/i18n/locales/fr.json`
- Docker env : `docker/.env.docker` (défaut) avec override possible via `docker/.env.docker.local`

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`

106
README.md
View File

@@ -1,87 +1,68 @@
# 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)
* 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)
Vérifier que dans le .env du dossier frontend, vous avez :
- NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
* NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
### Configuration xdebug
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
- Name : ferme-docker
- Host : localhost
- Port : 8080
- Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
* Name : ferme-docker
* Host : localhost
* Port : 8080
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local
## Utilisation du projet
### Backend
L'api est disponible sur http://localhost:8080/api
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
Vous pouvez modifier le port si nécessaire.
La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter.
C'est un bdd local dans le docker.
### Frontend
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
```bash
make dev-nuxt
```
Le front sera accessible sur http://localhost:3000
### Authentification
Ce projet utilise l'authentification JWT avec un cookie httpOnly (LexikJWTAuthenticationBundle).
Le frontend ne lit jamais directement le token, le navigateur envoie automatiquement le cookie.
### Login flow
- Frontend envoie les identifiants à:
- `POST /api/login_check`
- Backend returns:
@@ -90,91 +71,34 @@ Le frontend ne lit jamais directement le token, le navigateur envoie automatique
- Le cookie est automatiquement envoyé pour les futures requêtes.
- La déconnexion utilise `POST /api/logout` et redirige vers `/login`.
### Fixtures
Pour lancer les fixtures (Attention sa purge la bdd complètement)
```bash
php bin/console doctrine:fixtures:load
```
Attention cette commande est dangereuse, à utiliser que pour les débuts de la prod ou en recette.
Dans un premier temps pour remplir les listes, vous pouvez lancer la commande symfony
```bash
php bin/console app:seed
```
La commande va faire une update ou une création en fonction des data existante.
## Livraison en recette
### Préparatifs
Avant de déployer, il faut penser à ajouter les variables d'env s'il y a des changements/modifications.
Le .env se trouve /var/www/ferme/.env
Le script de livraison est version dans le repo dans script/deploy-release.sh <br>
Sur la machine, il est disponible dans /usr/local/bin/deploy-ferme <br>
Pour le modifier, il faut copier le contenu du deploy-release.sh dans le deploy-ferme
### Livraison
Sur le serveur de recette, il suffit d'utiliser cette commande pour livrer
```bash
/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

View File

@@ -14,10 +14,8 @@
"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.*",
@@ -26,7 +24,6 @@
"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 +86,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.*"
}
}

1761
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,12 @@ 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],
@@ -25,9 +20,4 @@ return [
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],
];

View File

@@ -5,8 +5,6 @@ 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']

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,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

@@ -20,7 +20,6 @@ security:
pattern: ^/login_check
stateless: true
provider: app_user_provider
user_checker: App\Security\UserChecker
json_login:
check_path: /login_check
username_path: username
@@ -31,10 +30,9 @@ security:
pattern: ^/
stateless: true
provider: app_user_provider
user_checker: App\Security\UserChecker
jwt: ~
logout:
path: /api/logout
path: /logout
target: /login
enable_csrf: false
delete_cookies:
@@ -49,15 +47,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: ^/users, roles: PUBLIC_ACCESS, methods: [GET] }
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
when@test:

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,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:

View File

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

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,32 @@
<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
RewriteEngine On
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /api/index.php [L]
</Directory>
AliasMatch "^/(?!api)(.*)$" "/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

@@ -3,11 +3,3 @@
<NuxtPage />
</NuxtLayout>
</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,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,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,284 +1,67 @@
<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">
<form @submit.prevent="validate">
<div class="grid grid-cols-1 items-start gap-8 mb-16">
<h1 class="font-bold text-5xl uppercase">Réception</h1>
<div>
<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 class="flex flex-col">
<label for="reception-date" class="font-bold uppercase text-xl mb-4">Date de reception</label>
<input
id="reception-date"
v-model="form.receptionDate"
type="date"
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase"
/>
</div>
</div>
<div class="flex justify-center">
<UiButton
<button
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>
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Peser
</button>
</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'
type ReceptionFormData = {
licensePlate: string
receptionDate: string
}
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: ''
receptionDate: new Date().toISOString().slice(0, 10)
})
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
}
}
const allowAnyLicensePlate = ref(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
form.receptionDate = reception?.receptionDate ?? new Date().toISOString().slice(0, 10)
},
{ immediate: true }
)
onMounted(async () => {
receptionTypes.value = await getReceptionTypeList()
await loadSuppliers()
await loadCommonData()
await loadDrivers()
await loadVehicles()
})
const buildPayload = () => {
async function validate() {
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
licensePlate: normalizedLicensePlate,
receptionDate: normalizedReceptionDate
})
if (created) {
await router.push(`/reception/${created.id}`)
@@ -286,20 +69,12 @@ async function validate() {
return
}
const previousTypeCode = receptionStore.current.receptionType?.code ?? null
const nextTypeCode = selectedReceptionType.value?.code ?? null
const receptionIri = `/api/receptions/${receptionStore.current.id}`
if (
previousTypeCode === RECEPTION_TYPE_CODES.BOVINS &&
nextTypeCode === RECEPTION_TYPE_CODES.MERCHANDISES
) {
await clearReceptionBovines(receptionIri)
}
const nextStep = receptionStore.current.currentStep + 1
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: nextStep,
...payload
licensePlate: normalizedLicensePlate,
receptionDate: normalizedReceptionDate
})
}
</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

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col items-center mt-[164px] gap-32">
<div class="flex gap-8 items-center justify-center">
<!--@TODO Prendre en compte que l'on peut aussi décharger de la marchandise-->
<h1 class="text-4xl uppercase font-bold">Décharger les bêtes</h1>
<UiLoadingDots />
</div>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="goNext"
>Peser</button>
</div>
</template>
<script setup lang="ts">
import { useReceptionStore } from '~/stores/reception'
const receptionStore = useReceptionStore()
async function goNext() {
if (!receptionStore.current) {
return
}
const nextStep = receptionStore.current.currentStep + 1
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: nextStep
})
}
</script>

View File

@@ -0,0 +1,104 @@
<template>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase">{{ title }}</h1>
<!--@TODO Voir comment faire pour savoir si le pont-bascule et bien connecté + ajouter un icon comme sur la maquette-->
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
<div
v-if="showLoadingBox"
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
<UiLoadingDots />
</div>
<div v-else-if="displayWeight !== null" class="w-full">
<div
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl">
{{ displayWeight }} kg
</div>
<!-- <div class="grid grid-cols-2 border border-black text-center">-->
<!-- <p class="border-r border-black py-3 text-4xl font-bold">DSD</p>-->
<!-- <p class="py-3 text-4xl">{{ displayDsd }}</p>-->
<!-- </div>-->
</div>
</div>
</div>
<div class="flex justify-center mt-[54px]">
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesee' : 'peser' }}</button>
<button
v-if="displayWeight !== null && !showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="saveWeight"
>Valider la pesée</button>
<button
v-if="showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="printReceipt"
>Générer le bon</button>
</div>
<UiPdfPrinter ref="pdfPrinter" />
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useWeighing } from '~/composables/useWeighing'
import { useReceptionStore } from '~/stores/reception'
const props = defineProps<{
mode: 'gross' | 'tare'
}>()
const router = useRouter()
const receptionStore = useReceptionStore()
const { current: storeReception } = storeToRefs(receptionStore)
type PdfPrinterHandle = {
print: (url: string) => Promise<void>
}
// Ref sur le composant d'impression pour déclencher le print() du PDF.
const pdfPrinter = ref<PdfPrinterHandle | null>(null)
const {
displayWeight,
displayDsd,
title,
showLoadingBox,
fetchWeight,
saveWeight
} = useWeighing({
mode: props.mode,
reception: storeReception,
updateReception: receptionStore.updateReception,
loadReception: receptionStore.loadReception
})
const showGenerateReceipt = computed(
() => props.mode === 'tare' && displayWeight.value !== null
)
const printReceipt = async () => {
if (!import.meta.client || !receptionStore.current || !pdfPrinter.value) {
return
}
await saveWeight()
await pdfPrinter.value.print(`/receptions/${receptionStore.current.id}/receipt`)
// Laisse le temps a la boite de dialogue d'impression de s'ouvrir.
await new Promise((resolve) => setTimeout(resolve, 600))
const result = await receptionStore.updateReception(receptionStore.current.id, {
isValid: true
})
if (!result) {
return
}
receptionStore.clearCurrent()
await router.push('/')
}
onMounted(() => {
fetchWeight()
})
</script>

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,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,28 +1,26 @@
<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>
<label :for="inputId" class="font-bold uppercase text-xl mb-4">{{ label }}</label>
<input
:id="inputId"
:value="modelValue"
v-maska="maskOptions"
type="text"
:maxlength="maxLength"
:placeholder="placeholderText"
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase"
@input="handleInput"
/>
<label :for="checkboxId" class="mt-3 flex items-center gap-3 text-sm">
<input
:id="checkboxId"
:checked="allowAny"
type="checkbox"
class="h-4 w-4 accent-primary-500"
@change="toggleAllowAny"
/>
Autoriser un format libre
</label>
</div>
</template>
@@ -33,14 +31,12 @@ type Props = {
allowAny?: boolean
label?: string
id?: string
required?: boolean
}
const props = withDefaults(defineProps<Props>(), {
allowAny: false,
label: 'Immatriculation',
id: 'license-plate',
required: false
id: 'license-plate'
})
const emit = defineEmits<{
@@ -82,7 +78,13 @@ const handleInput = (event: Event) => {
emit('update:modelValue', target.value)
}
const handleAllowAnyChange = (nextValue: boolean) => {
const toggleAllowAny = (event: Event) => {
const target = event.target as HTMLInputElement | null
if (!target) {
return
}
const nextValue = target.checked
emit('update:allowAny', nextValue)
if (!nextValue) {
emit('update:modelValue', props.modelValue)

View File

@@ -0,0 +1,20 @@
<template>
<iframe ref="printFrame" class="hidden" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
const printFrame = ref<HTMLIFrameElement | null>(null)
const { printPdf } = usePdfPrinter()
// Expose une methode simple pour imprimer un PDF depuis les ecrans.
const print = async (url: string): Promise<void> => {
return printPdf(url, printFrame)
}
defineExpose({
print
})
</script>

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

@@ -29,7 +29,6 @@ export const useApi = (): ApiClient => {
const toast = useToast()
const auth = useAuthStore()
const nuxtApp = useNuxtApp()
let isHandlingUnauthorized = false
const i18n = nuxtApp.$i18n as
| {
t: (key: string) => string
@@ -96,23 +95,7 @@ export const useApi = (): ApiClient => {
})
}
},
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
}
onResponseError({ response, error, options }) {
const apiOptions = options as ApiFetchOptions<'json'>
if (apiOptions?.toast === false) {
return

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,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 +1,40 @@
import type { Ref } from 'vue'
import { useApi } from '~/composables/useApi'
type PrintFrameRef = Ref<HTMLIFrameElement | null>
export const usePdfPrinter = () => {
const api = useApi()
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)
const printPdf = async (url: string, frameRef: PrintFrameRef): Promise<void> => {
if (!import.meta.client) {
return
}
return {
printPdf
const frame = frameRef.value
if (!frame) {
return
}
// On charge le PDF en blob pour rester en same-origin dans l'iframe.
const blob = await api.getBlob(url)
const blobUrl = URL.createObjectURL(blob)
const tryPrint = () => {
frame.contentWindow?.focus()
frame.contentWindow?.print()
}
frame.onload = () => {
tryPrint()
// On libere l'URL blob apres l'impression.
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000)
}
frame.src = blobUrl
setTimeout(tryPrint, 1200)
}
return {
printPdf
}
}

View File

@@ -1,66 +1,60 @@
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'
import type {Ref} from 'vue'
import {computed, ref} from 'vue'
import type {ReceptionData, WeightEntryData} from '~/services/dto/reception-data'
import type {WeightData} from '~/services/dto/weight-data'
import {getWeight} from '~/services/reception'
import {createWeight, updateWeight} from '~/services/weight'
export type WeighingMode = 'gross' | 'tare'
export interface UseWeighingOptions {
type 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>
reception: Ref<ReceptionData | null>
updateReception: (id: number, payload: Partial<ReceptionData>) => Promise<ReceptionData | null>
loadReception?: (id: number) => Promise<ReceptionData | null>
}
export const useWeighing = ({
mode,
entity,
entityName,
apiResource,
titleLabel,
isFinal = false,
getWeightFromScale,
updateEntity,
loadEntity
reception,
updateReception,
loadReception
}: UseWeighingOptions) => {
const weightData = ref<WeightData | null>(null)
const isFetching = ref(false)
const currentWeightEntry = computed<WeightEntryData | null>(() => {
const weights = entity.value?.weights ?? []
const weights = reception.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 title = computed(() => (mode === 'gross' ? 'Pesée à plein' : 'Pesée à vide'))
const showLoadingBox = computed(
() => isFetching.value || (displayWeight.value === null && currentWeightEntry.value === null)
)
const fetchWeight = async () => {
isFetching.value = true
weightData.value = await getWeightFromScale().finally(() => {
weightData.value = await getWeight().finally(() => {
isFetching.value = false
})
}
const saveWeight = async () => {
if (!entity.value) return
if (!reception.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 (baseWeight === null) {
return
}
if (existingEntry?.id) {
await updateWeight(existingEntry.id, {
@@ -71,7 +65,7 @@ export const useWeighing = ({
})
} else {
await createWeight({
...relationPayload,
reception: `/receptions/${reception.value.id}`,
type: mode,
dsd: baseDsd,
weight: baseWeight,
@@ -79,48 +73,16 @@ export const useWeighing = ({
})
}
const nextStep = isFinal
? entity.value.currentStep
: entity.value.currentStep + 1
await updateEntity(entity.value.id, {
const nextStep = mode === 'tare'
? reception.value.currentStep
: reception.value.currentStep + 1
await updateReception(reception.value.id, {
currentStep: nextStep,
isValid: entity.value.isValid
isValid: reception.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
})
if (loadReception) {
await loadReception(reception.value.id)
}
}
@@ -132,34 +94,6 @@ export const useWeighing = ({
title,
showLoadingBox,
fetchWeight,
saveWeight,
saveWeightDraft
saveWeight
}
}
// 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 +1,32 @@
{
"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."
}
"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."
},
"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"
}
"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.",
"weigh": "Impossible de récupérer la pesée."
},
"auth": {
"login": "Identifiants invalides.",
"users": "Impossible de récupérer les utilisateurs.",
"logout": "Impossible de se déconnecter."
}
},
"success": {
"reception": {
"update": "Réception mise à jour avec succès."
},
"auth": {
"login": "Connexion réussie.",
"logout": "Déconnexion réussie."
}
}
}

View File

@@ -1,290 +1,61 @@
<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">
<div class="min-h-screen bg-white text-neutral-900">
<header class="w-full border-b border-neutral-200 bg-primary-500">
<div class="flex w-full items-center px-6 py-4">
<NuxtLink to="/" class="flex items-center gap-3">
<span
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
>
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 }">
<nav class="mx-8 flex flex-1 gap-8 text-2xl font-bold uppercase text-white">
<NuxtLink to="/" custom v-slot="{ href, navigate, isExactActive }">
<a
:href="href"
@click="navigate"
:class="route.path === '/'
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
:class="isExactActive ? 'opacity-100' : 'opacity-50'"
>
Accueil
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/user/list"
custom
v-slot="{ href, navigate }"
>
<NuxtLink to="/reception" custom v-slot="{ href, navigate, isActive }">
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/user')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
:class="isReceptionActive ? 'opacity-100' : 'opacity-50'"
>
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
Reception
</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"
<button
type="button"
class="ml-auto text-xl font-bold uppercase text-white transition hover:opacity-80"
@click="handleLogout"
>
<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>
Déconnexion
</button>
</div>
</header>
<main class="md:mx-auto w-full md:max-w-[1280px] mt-4 md:mt-16">
<main class="mx-auto w-full max-w-[1050px] px-6 pt-[90px] pb-0">
<slot/>
</main>
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">
<p class="font-bold text-white text-right">v{{ version }}</p>
</footer>
</div>
</template>
<script setup lang="ts">
import {useAuthStore} from '~/stores/auth'
import { useAuthStore } from '~/stores/auth'
const route = useRoute()
const auth = useAuthStore()
const {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 isReceptionActive = computed(() => route.path.startsWith('/reception'))
const handleLogout = async () => {
try {
await auth.logout()
} finally {
closeMenu()
await navigateTo('/login')
}
try {
await auth.logout()
} finally {
await navigateTo('/login')
}
}
</script>

View File

@@ -2,26 +2,21 @@ 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'
'@nuxtjs/i18n'
],
css: ['~/assets/css/main.css', '~/assets/css/toast.css'],
css: ['~/assets/css/toast.css'],
runtimeConfig: {
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE,
geoApiBase: ''
apiBase: process.env.NUXT_PUBLIC_API_BASE
}
},
toast: {
settings: {
timeout: 10000,
timeout: 0,
closeOnClick: true,
progressBar: false
}

View File

@@ -7,7 +7,6 @@
"name": "frontend",
"hasInstallScript": true,
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
"izitoast": "^1.4.0",
@@ -36,19 +35,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
"license": "MIT",
"dependencies": {
"package-manager-detector": "^1.3.0",
"tinyexec": "^1.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -77,7 +63,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1078,6 +1063,7 @@
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"peer": true,
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
@@ -1086,6 +1072,7 @@
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"peer": true,
"dependencies": {
"@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
@@ -1099,6 +1086,7 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"peer": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -1108,6 +1096,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"peer": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -1119,6 +1108,7 @@
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"peer": true,
"dependencies": {
"@eslint/core": "^0.17.0"
},
@@ -1130,6 +1120,7 @@
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
},
@@ -1141,6 +1132,7 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
"integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
"peer": true,
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
@@ -1163,6 +1155,7 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"peer": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -1172,6 +1165,7 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"peer": true,
"engines": {
"node": ">= 4"
}
@@ -1180,6 +1174,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"peer": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -1191,6 +1186,7 @@
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -1202,6 +1198,7 @@
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
@@ -1210,6 +1207,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"peer": true,
"dependencies": {
"@eslint/core": "^0.17.0",
"levn": "^0.4.1"
@@ -1222,6 +1220,7 @@
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"peer": true,
"engines": {
"node": ">=18.18.0"
}
@@ -1230,6 +1229,7 @@
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"peer": true,
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0"
@@ -1242,6 +1242,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"peer": true,
"engines": {
"node": ">=12.22"
},
@@ -1254,6 +1255,7 @@
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"peer": true,
"engines": {
"node": ">=18.18"
},
@@ -1262,47 +1264,6 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@iconify/collections": {
"version": "1.0.646",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.646.tgz",
"integrity": "sha512-zA5Gr1MJm1SI0TjOUl7wu4kvBWXQ6Uh8ALEtqQ5ucXyUxP2M8m2bk2hfVtGykSdMlDB+Xs2AHbJ9pQqayz9WGQ==",
"license": "MIT",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@iconify/utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
"integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^1.1.0",
"@iconify/types": "^2.0.0",
"mlly": "^1.8.0"
}
},
"node_modules/@iconify/vue": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz",
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"vue": ">=3"
}
},
"node_modules/@intlify/bundle-utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
@@ -2323,28 +2284,6 @@
"devtools-wizard": "cli.mjs"
}
},
"node_modules/@nuxt/icon": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.1.tgz",
"integrity": "sha512-GI840yYGuvHI0BGDQ63d6rAxGzG96jQcWrnaWIQKlyQo/7sx9PjXkSHckXUXyX1MCr9zY6U25Td6OatfY6Hklw==",
"license": "MIT",
"dependencies": {
"@iconify/collections": "^1.0.641",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^3.1.0",
"@iconify/vue": "^5.0.0",
"@nuxt/devtools-kit": "^3.1.1",
"@nuxt/kit": "^4.2.2",
"consola": "^3.4.2",
"local-pkg": "^1.1.2",
"mlly": "^1.8.0",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinyglobby": "^0.2.15"
}
},
"node_modules/@nuxt/kit": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.2.2.tgz",
@@ -3028,7 +2967,6 @@
"version": "0.95.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz",
"integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.95.0"
},
@@ -4933,7 +4871,8 @@
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"peer": true
},
"node_modules/@types/parse-path": {
"version": "7.0.3",
@@ -5283,7 +5222,6 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.26",
@@ -5481,7 +5419,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5519,6 +5456,7 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5891,7 +5829,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6005,7 +5942,6 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -6082,6 +6018,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -6200,7 +6137,6 @@
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"consola": "^3.2.3"
}
@@ -6811,7 +6747,8 @@
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"peer": true
},
"node_modules/deepmerge": {
"version": "4.3.1",
@@ -7322,6 +7259,7 @@
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
@@ -7348,6 +7286,7 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"peer": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -7357,6 +7296,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -7368,6 +7308,7 @@
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -7379,6 +7320,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"peer": true,
"dependencies": {
"is-glob": "^4.0.3"
},
@@ -7390,6 +7332,7 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"peer": true,
"engines": {
"node": ">= 4"
}
@@ -7398,6 +7341,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"peer": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -7409,6 +7353,7 @@
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"peer": true,
"dependencies": {
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
@@ -7425,6 +7370,7 @@
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -7448,6 +7394,7 @@
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"peer": true,
"dependencies": {
"estraverse": "^5.1.0"
},
@@ -7459,6 +7406,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"peer": true,
"dependencies": {
"estraverse": "^5.2.0"
},
@@ -7556,7 +7504,8 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"peer": true
},
"node_modules/fast-fifo": {
"version": "1.3.2",
@@ -7583,12 +7532,14 @@
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"peer": true
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"peer": true
},
"node_modules/fast-npm-meta": {
"version": "0.4.7",
@@ -7629,6 +7580,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"peer": true,
"dependencies": {
"flat-cache": "^4.0.0"
},
@@ -7658,6 +7610,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"peer": true,
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@@ -7673,6 +7626,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"peer": true,
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
@@ -7684,7 +7638,8 @@
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"peer": true
},
"node_modules/foreground-child": {
"version": "3.3.1",
@@ -7951,6 +7906,7 @@
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -8251,6 +8207,7 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"peer": true,
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
@@ -8266,6 +8223,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"peer": true,
"engines": {
"node": ">=4"
}
@@ -8287,6 +8245,7 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"peer": true,
"engines": {
"node": ">=0.8.19"
}
@@ -8614,8 +8573,7 @@
"node_modules/izitoast": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/izitoast/-/izitoast-1.4.0.tgz",
"integrity": "sha512-Oc1X2wiQtPp39i5VpIjf3GJf5sfCtHKXZ5szx7RareyEeFLUlcEW0FSfBni28+Ul6KNKZRKzhVuWzSP4Xngh0w==",
"peer": true
"integrity": "sha512-Oc1X2wiQtPp39i5VpIjf3GJf5sfCtHKXZ5szx7RareyEeFLUlcEW0FSfBni28+Ul6KNKZRKzhVuWzSP4Xngh0w=="
},
"node_modules/jackspeak": {
"version": "3.4.3",
@@ -8673,17 +8631,20 @@
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"peer": true
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"peer": true
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"peer": true
},
"node_modules/json5": {
"version": "2.2.3",
@@ -8761,6 +8722,7 @@
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"peer": true,
"dependencies": {
"json-buffer": "3.0.1"
}
@@ -9034,6 +8996,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
@@ -9118,6 +9081,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"peer": true,
"dependencies": {
"p-locate": "^5.0.0"
},
@@ -9155,7 +9119,8 @@
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"peer": true
},
"node_modules/lodash.uniq": {
"version": "4.5.0",
@@ -9501,7 +9466,8 @@
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"peer": true
},
"node_modules/negotiator": {
"version": "0.6.3",
@@ -10239,7 +10205,6 @@
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.2.2.tgz",
"integrity": "sha512-n6oYFikgLEb70J4+K19jAzfx4exZcRSRX7yZn09P5qlf2Z59VNOBqNmaZO5ObzvyGUZ308SZfL629/Q2v2FVjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dxup/nuxt": "^0.2.2",
"@nuxt/cli": "^3.31.1",
@@ -10507,6 +10472,7 @@
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@@ -10553,7 +10519,6 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.102.0.tgz",
"integrity": "sha512-xMiyHgr2FZsphQ12ZCsXRvSYzmKXCm1ejmyG4GDZIiKOmhyt5iKtWq0klOfFsEQ6jcgbwrUdwcCVYzr1F+h5og==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.102.0"
},
@@ -10626,6 +10591,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"peer": true,
"dependencies": {
"yocto-queue": "^0.1.0"
},
@@ -10640,6 +10606,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"peer": true,
"dependencies": {
"p-limit": "^3.0.2"
},
@@ -10666,6 +10633,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"peer": true,
"dependencies": {
"callsites": "^3.0.0"
},
@@ -10714,6 +10682,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -10828,7 +10797,6 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
@@ -10934,7 +10902,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11484,7 +11451,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -11552,6 +11518,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"peer": true,
"engines": {
"node": ">= 0.8.0"
}
@@ -11606,6 +11573,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -11957,7 +11925,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -12517,6 +12484,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"peer": true,
"engines": {
"node": ">=8"
},
@@ -12779,7 +12747,6 @@
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -13119,6 +13086,7 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1"
},
@@ -13188,7 +13156,6 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13630,6 +13597,7 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"peer": true,
"dependencies": {
"punycode": "^2.1.0"
}
@@ -13655,7 +13623,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -14012,7 +13979,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26",
@@ -14048,7 +14014,6 @@
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz",
"integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==",
"peer": true,
"dependencies": {
"@intlify/core-base": "11.2.8",
"@intlify/shared": "11.2.8",
@@ -14069,7 +14034,6 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
@@ -14121,6 +14085,7 @@
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14386,6 +14351,7 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"peer": true,
"engines": {
"node": ">=10"
},

View File

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

View File

@@ -1,112 +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">
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,70 +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">
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,117 +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">
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,61 +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">
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>

View File

@@ -1,249 +0,0 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px] ">
<Icon @click="router.push('/admin/customer/customer-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ customerId ? "Modification du client" : "Ajout d'un client" }}
</h1>
</div>
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput id="customer-name" v-model="form.name" label="Nom du client" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="customer-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
</div>
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-street" v-model="addressForm.street" label="Rue" wrapper-class="w-[280px]" required />
<UiTextInput id="address-street2" v-model="addressForm.street2" label="Complément" wrapper-class="w-[280px]" />
<UiTextInput id="address-country" v-model="addressForm.countryCode" label="Pays (code)" wrapper-class="w-[280px]" />
</div>
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-postalCode" v-model="addressForm.postalCode" label="Code postal" wrapper-class="w-[280px]" required />
<UiSelect id="address-city" v-model="addressForm.city" label="Ville"
:options="communeOptions" :loading="isLoadingCities"
wrapper-class="w-[280px]" required />
<div class="w-[280px]" />
</div>
<div class="flex items-center justify-center">
<UiButton
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit"
:disabled="isLoading || !auth.isAdmin"
@click="submitted = true"
>
<Icon :name="customerId ? '' : 'mdi:plus'" size="28" />
{{ customerId ? "Valider" : "Ajouter" }}
</UiButton>
</div>
<template v-if="customerId">
<div class="flex items-center justify-between mb-7">
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du client</h2>
</div>
<div class="overflow-x-auto mb-11 text-primary-700">
<table class="w-full border-collapse text-primary-700">
<thead>
<tr class="text-left border bg-slate-100 border-gray-200">
<th class="py-3 px-4 text-sm uppercase">Rue</th>
<th class="py-3 px-4 text-sm uppercase">Complément</th>
<th class="py-3 px-4 text-sm uppercase">Code postal</th>
<th class="py-3 px-4 text-sm uppercase">Ville</th>
<th class="py-3 px-4 text-sm uppercase">Pays</th>
</tr>
</thead>
<tbody>
<template v-if="form.addresses.length === 0">
<tr>
<td colspan="5" class="py-4 text-slate-400">
Aucune adresse.
</td>
</tr>
</template>
<template v-else>
<tr
v-for="(address, index) in form.addresses"
:key="address.id ?? index"
class="border border-gray-100 hover:bg-slate-50"
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)"
>
<td class="py-3 px-4">{{ address.street || "—" }}</td>
<td class="py-3 px-4">{{ address.street2 || "—" }}</td>
<td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
<td class="py-3 px-4">{{ address.city || "—" }}</td>
<td class="py-3 px-4">{{ address.countryCode || "—" }}</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="flex justify-center items-center">
<UiButton
type="button"
class="inline-flex items-center justify-center text-xl gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
:disabled="customerId === null || !auth.isAdmin"
@click="goToAddAddress"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</UiButton>
</div>
</template>
</form>
</template>
<script setup lang="ts">
import {computed, reactive, ref, watch} from "vue"
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
import {createAddress, type AddressPayload} from "~/services/address"
import {getCommunesByPostalCode, type CommuneData} from "~/services/geo"
import {useAuthStore} from "~/stores/auth"
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const resolveId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) return null
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
const customerId = computed(() => resolveId(route.params.id))
const isLoading = ref(false)
const submitted = ref(false)
const form = reactive<CustomerFormData>({
name: "",
phone: "",
email: "",
addresses: [],
})
// Address form (creation mode only)
const addressForm = reactive<AddressPayload>({
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
})
const communes = ref<CommuneData[]>([])
const isLoadingCities = ref(false)
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => addressForm.postalCode, (cp) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
if (cp.length === 5) {
debounceTimer = setTimeout(async () => {
isLoadingCities.value = true
try {
communes.value = await getCommunesByPostalCode(cp)
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
else addressForm.city = ''
} finally { isLoadingCities.value = false }
}, 300)
}
})
const goToAddAddress = () => {
if (customerId.value === null || !auth.isAdmin) return
router.push({
path: "/admin/customer/address",
query: {
customerId: String(customerId.value),
},
})
}
const goToEditAddress = (addressId: number | null) => {
if (customerId.value === null || addressId === null || !auth.isAdmin) return
router.push({
path: "/admin/customer/address",
query: {
customerId: String(customerId.value),
addressId: String(addressId),
},
})
}
const hydrateFromCustomer = (customer: CustomerData | null) => {
if (!customer) return
form.name = customer.name ?? ""
form.phone = customer.phone ?? ""
form.email = customer.email ?? ""
if (!Array.isArray(customer.addresses) || customer.addresses.length === 0) {
form.addresses = []
return
}
if (typeof customer.addresses[0] === "string") {
form.addresses = []
return
}
form.addresses = customer.addresses.map((address) => ({
id: address.id ?? null,
street: address.street ?? "",
street2: address.street2 ?? null,
postalCode: address.postalCode ?? "",
city: address.city ?? "",
countryCode: address.countryCode ?? "",
}))
}
watch(
() => customerId.value,
async (id) => {
if (id === null) return
isLoading.value = true
try {
const customer = await getCustomer(id)
hydrateFromCustomer(customer)
} finally {
isLoading.value = false
}
},
{immediate: true}
)
async function validate() {
if (isLoading.value) return
if (!auth.isAdmin) return
isLoading.value = true
try {
const name = form.name.trim()
const phone = form.phone?.trim() || null
const email = form.email?.trim() || null
const customerPayload: CustomerPayload = {
name,
phone,
email,
}
let targetId: number | null = null
if (customerId.value !== null) {
await updateCustomer(customerId.value, customerPayload)
targetId = customerId.value
} else {
const addressData = await createAddress({ ...addressForm })
const addressIRI = `/api/addresses/${addressData.id}`
const creationPayload = {
...customerPayload,
addresses: [addressIRI],
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
}
const created = await createCustomer(creationPayload)
targetId = created.id
}
if (targetId !== null) {
await router.push(`/admin/customer/${targetId}`)
}
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -1,44 +0,0 @@
<template>
<Address type="customer" :address="address" @validate="validate"/>
</template>
<script setup lang="ts">
import type { AddressData, AddressPayload } from "~/services/address"
import { createAddress, getAddress, updateAddress } from "~/services/address"
import { getCustomer, updateCustomer } from "~/services/customer"
import type { CustomerData } from "~/services/dto/customer-data"
const route = useRoute()
const router = useRouter()
const customerId = computed(() => Number(route.query.customerId))
const customer = ref<CustomerData | null>(null)
const addressId = computed(() => (route.query.addressId !== undefined ? Number(route.query.addressId) : null))
const address = ref<AddressData | null>(null)
const validate = async (payload: AddressPayload) => {
if (addressId.value !== null) {
await updateAddress(addressId.value, payload)
} else {
await addAddress(payload)
await router.push("/admin/customer/" + customerId.value)
}
}
const addAddress = async (payload: AddressPayload) => {
const response: AddressData = await createAddress(payload)
const addressIRI = `/api/addresses/${response.id}`
const existingIris = (customer.value?.addresses ?? [])
.map((item: any) => (typeof item === "string" ? item : `/api/addresses/${item.id}`))
.filter((iri: string | null) => Boolean(iri)) as string[]
const next = [...new Set([...existingIris, addressIRI])]
return await updateCustomer(customerId.value, { addresses: next })
}
onMounted(async () => {
customer.value = await getCustomer(customerId.value)
if (addressId.value !== null) {
address.value = await getAddress(addressId.value)
}
})
</script>

View File

@@ -1,80 +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 clients</h1>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/customer"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
<div v-if="auth.isAdmin" class="mt-6 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToCustomer"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
</template>
<template #header-phone>
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
</template>
<template #header-email>
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
</template>
<template #header-createdBy.username>
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
</template>
</UiDataTable>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs.
</div>
</div>
</template>
<script setup lang="ts">
import type { CustomerData } from '~/services/dto/customer-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const router = useRouter()
const auth = useAuthStore()
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<CustomerData>(
'customers',
{
name: '',
phone: '',
email: '',
'createdBy.username': ''
}
)
const columns = [
{ key: 'name', label: 'Nom' },
{ key: 'phone', label: 'Téléphone' },
{ key: 'email', label: 'Mail' },
{ key: 'createdBy.username', label: 'Créé par' }
]
const goToCustomer = (customer: CustomerData) => {
if (!auth.isAdmin) return
router.push(`/admin/customer/${customer.id}`)
}
onMounted(() => {
if (auth.isAdmin) reload()
})
</script>

View File

@@ -1,250 +0,0 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px] ">
<Icon @click="router.push('/admin/supplier/supplier-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ supplierId ? "Modification du fournisseur" : "Ajout d'un fournisseur" }}
</h1>
</div>
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput id="supplier-name" v-model="form.name" label="Nom du fournisseur" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="supplier-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="supplier-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
</div>
<div v-if="!supplierId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-street" v-model="addressForm.street" label="Rue" wrapper-class="w-[280px]" required />
<UiTextInput id="address-street2" v-model="addressForm.street2" label="Complément" wrapper-class="w-[280px]" />
<UiTextInput id="address-country" v-model="addressForm.countryCode" label="Pays (code)" wrapper-class="w-[280px]" />
</div>
<div v-if="!supplierId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-postalCode" v-model="addressForm.postalCode" label="Code postal" wrapper-class="w-[280px]" required />
<UiSelect id="address-city" v-model="addressForm.city" label="Ville"
:options="communeOptions" :loading="isLoadingCities"
wrapper-class="w-[280px]" required />
<div class="w-[280px]" />
</div>
<div class="flex items-center justify-center">
<UiButton
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit"
:disabled="isLoading || !auth.isAdmin"
@click="submitted = true"
>
<Icon :name="supplierId ? '' : 'mdi:plus'" size="28" />
{{ supplierId ? "Valider" : "Ajouter" }}
</UiButton>
</div>
<template v-if="supplierId">
<div class="flex items-center justify-between mb-7">
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du fournisseur</h2>
</div>
<div class="overflow-x-auto mb-11 text-primary-700">
<table class="w-full border-collapse">
<thead>
<tr class="text-left border bg-slate-100 border-gray-200">
<th class="py-3 px-4 text-sm uppercase">Rue</th>
<th class="py-3 px-4 text-sm uppercase">Complément</th>
<th class="py-3 px-4 text-sm uppercase">Code postal</th>
<th class="py-3 px-4 text-sm uppercase">Ville</th>
<th class="py-3 px-4 text-sm uppercase">Pays</th>
</tr>
</thead>
<tbody>
<template v-if="form.addresses.length === 0">
<tr>
<td colspan="5" class="py-4 text-slate-400">
Aucune adresse.
</td>
</tr>
</template>
<template v-else>
<tr
v-for="(address, index) in form.addresses"
:key="address.id ?? index"
class="border border-gray-100 hover:bg-slate-50"
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)"
>
<td class="py-3 px-4">{{ address.street || "—" }}</td>
<td class="py-3 px-4">{{ address.street2 || "—" }}</td>
<td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
<td class="py-3 px-4">{{ address.city || "—" }}</td>
<td class="py-3 px-4">{{ address.countryCode || "—" }}</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="flex justify-center items-center">
<UiButton
type="button"
class="inline-flex items-center justify-center text-xl gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
:disabled="supplierId === null || !auth.isAdmin"
@click="goToAddAddress"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</UiButton>
</div>
</template>
</form>
</template>
<script setup lang="ts">
import {computed, reactive, ref, watch} from "vue"
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
import {createAddress, type AddressPayload} from "~/services/address"
import {getCommunesByPostalCode, type CommuneData} from "~/services/geo"
import {useAuthStore} from "~/stores/auth"
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const resolveId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) return null
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
const supplierId = computed(() => resolveId(route.params.id))
const isLoading = ref(false)
const submitted = ref(false)
const form = reactive<SupplierFormData>({
name: "",
email: "",
phone: "",
addresses: [],
})
// Address form (creation mode only)
const addressForm = reactive<AddressPayload>({
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
})
const communes = ref<CommuneData[]>([])
const isLoadingCities = ref(false)
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => addressForm.postalCode, (cp) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
if (cp.length === 5) {
debounceTimer = setTimeout(async () => {
isLoadingCities.value = true
try {
communes.value = await getCommunesByPostalCode(cp)
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
else addressForm.city = ''
} finally { isLoadingCities.value = false }
}, 300)
}
})
const goToAddAddress = () => {
if (supplierId.value === null || !auth.isAdmin) return
router.push({
path: "/admin/supplier/address",
query: {
supplierId: String(supplierId.value),
fromSupplier: "1",
},
})
}
const goToEditAddress = (addressId: number | null) => {
if (supplierId.value === null || addressId === null || !auth.isAdmin) return
router.push({
path: "/admin/supplier/address",
query: {
supplierId: String(supplierId.value),
addressId: String(addressId),
fromSupplier: "1",
},
})
}
const hydrateFromSupplier = (supplier: SupplierData | null) => {
if (!supplier) return
form.name = supplier.name ?? ""
form.email = supplier.email ?? ""
form.phone = supplier.phone ?? ""
if (!Array.isArray(supplier.addresses) || supplier.addresses.length === 0) {
form.addresses = []
return
}
if (typeof supplier.addresses[0] === "string") {
form.addresses = []
return
}
form.addresses = supplier.addresses.map((address) => ({
id: address.id ?? null,
street: address.street ?? "",
street2: address.street2 ?? null,
postalCode: address.postalCode ?? "",
city: address.city ?? "",
countryCode: address.countryCode ?? "",
}))
}
watch(
() => supplierId.value,
async (id) => {
if (id === null) return
isLoading.value = true
try {
const supplier = await getSupplier(id)
hydrateFromSupplier(supplier)
} finally {
isLoading.value = false
}
},
{immediate: true}
)
async function validate() {
if (isLoading.value) return
if (!auth.isAdmin) return
isLoading.value = true
try {
const name = form.name.trim()
const email = (form.email ?? "").trim() || null
const phone = (form.phone ?? "").trim() || null
const supplierPayload: SupplierPayload = {
name,
email,
phone,
}
let targetId: number | null = null
if (supplierId.value !== null) {
await updateSupplier(supplierId.value, supplierPayload)
targetId = supplierId.value
} else {
const addressData = await createAddress({ ...addressForm })
const addressIRI = `/api/addresses/${addressData.id}`
const creationPayload = {
...supplierPayload,
addresses: [addressIRI],
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
}
const created = await createSupplier(creationPayload)
targetId = created.id
}
await router.push(`/admin/supplier/${targetId}`)
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -1,42 +0,0 @@
<template>
<Address type="supplier" :address="address" @validate="validate"/>
</template>
<script setup lang="ts">
import type {AddressData, AddressPayload} from "~/services/address";
import {createAddress, getAddress, updateAddress} from "~/services/address";
import {getSupplier, updateSupplier} from "~/services/supplier";
import type {SupplierData} from "~/services/dto/supplier-data";
const route = useRoute()
const router = useRouter()
const supplierId = computed(() => { return Number(route.query.supplierId) })
const supplier = ref<SupplierData|null>(null);
const addressId = computed(() => { return route.query.addressId !== undefined ? Number(route.query.addressId) : null })
const address = ref<AddressData|null>(null)
const validate = async (address: AddressPayload) => {
if (addressId.value !== null) {
await updateAddress(addressId.value, address)
} else {
await addAddress(address)
await router.push('/admin/supplier/' + supplierId.value)
}
}
const addAddress = async (address: AddressPayload) => {
const response: AddressData = await createAddress(address)
const addressIRI = `/api/addresses/${response.id}`
const existingIris = (supplier.value.addresses ?? []).map((item: any) => `/api/addresses/${item.id}`)
const next = [...new Set([...existingIris, addressIRI])]
return await updateSupplier(supplierId.value, { addresses: next })
}
onMounted(async () => {
supplier.value = await getSupplier(supplierId.value)
if (addressId.value !== null) {
address.value = await getAddress(addressId.value)
}
})
</script>

View File

@@ -1,80 +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 fournisseurs</h1>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/supplier"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
<div v-if="auth.isAdmin" class="mt-6 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToSupplier"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
</template>
<template #header-phone>
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
</template>
<template #header-email>
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
</template>
<template #header-createdBy.username>
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
</template>
</UiDataTable>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs.
</div>
</div>
</template>
<script setup lang="ts">
import type { SupplierData } from '~/services/dto/supplier-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const router = useRouter()
const auth = useAuthStore()
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<SupplierData>(
'suppliers',
{
name: '',
phone: '',
email: '',
'createdBy.username': ''
}
)
const columns = [
{ key: 'name', label: 'Nom' },
{ key: 'phone', label: 'Téléphone' },
{ key: 'email', label: 'Mail' },
{ key: 'createdBy.username', label: 'Créé par' }
]
const goToSupplier = (supplier: SupplierData) => {
if (!auth.isAdmin) return
router.push(`/admin/supplier/${supplier.id}`)
}
onMounted(() => {
if (auth.isAdmin) reload()
})
</script>

View File

@@ -1,164 +0,0 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="router.push('/admin/user/list')"
name="gg:arrow-left-o"
size="40"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ userId ? "Modification de l'utilisateur" : "Ajout d'un utilisateur" }}
</h1>
</div>
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput
id="user-name"
v-model="form.username"
label="Nom de l'utilisateur"
:disabled="!auth.isAdmin"
wrapper-class="w-[280px]"
required
/>
<UiSelect
id="user-role"
v-model="form.role"
label="Role de l'utilisateur"
:options="ROLE"
:disabled="!auth.isAdmin"
wrapper-class="w-[280px]"
required
/>
<UiTextInput
id="user-password"
v-model="form.password"
label="Mot de passe"
type="password"
:disabled="!auth.isAdmin"
wrapper-class="w-[280px]"
:required="!userId"
/>
</div>
<div class="flex items-center mb-11">
<label class="flex items-center gap-2 cursor-pointer">
<input
id="user-locked"
v-model="form.isLocked"
type="checkbox"
:disabled="!auth.isAdmin"
class="w-5 h-5 accent-primary-500"
/>
<span class="text-sm text-primary-700">Verrouiller le compte</span>
</label>
<p class="ml-4 text-xs text-slate-400">Un compte verrouillé ne peut plus se connecter.</p>
</div>
<div class="flex items-center justify-center">
<UiButton
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit"
:disabled="isLoading || isHydrating || !auth.isAdmin"
@click="submitted = true"
>
<Icon :name="userId ? '' : 'mdi:plus'" size="28" />
{{ userId ? 'Valider' : 'Ajouter' }}
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ROLE } from '~/utils/constants'
import { createUser, updateUser, getUser } from '~/services/auth'
import type { UserData, UserFormData, UserPayload } from '~/services/dto/user-data'
import { useAuthStore } from '~/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const userId = computed(() => resolveUserId(route.params.id))
const isLoading = ref(false)
const isHydrating = ref(false)
const submitted = ref(false)
const resolveUserId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) {
return null
}
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
const form = reactive<UserFormData>({
username: '',
password: '',
role: '',
isLocked: false
})
const hydrateFromUser = (user: UserData | null) => {
if (!user) {
return
}
isHydrating.value = true
form.username = user.username ?? ''
const roles = user.roles ?? []
const hasAdmin = roles.includes('ROLE_ADMIN')
form.role = hasAdmin ? 'ROLE_ADMIN' : 'ROLE_USER'
form.password = ''
form.isLocked = user.isLocked ?? false
isHydrating.value = false
}
watch(
() => userId.value,
async (id) => {
if (id === null) {
return
}
isLoading.value = true
try {
const user = await getUser(id)
hydrateFromUser(user)
} finally {
isLoading.value = false
}
},
{ immediate: true }
)
async function validate() {
if (!auth.isAdmin) return
const normalizedUsername = form.username.trim()
const normalizedRole = form.role.trim()
const normalizedPassword = form.password.trim()
const basePayload: UserPayload = {
username: normalizedUsername,
roles: normalizedRole ? [normalizedRole] : undefined,
isLocked: form.isLocked,
}
if (normalizedPassword) {
basePayload.password = normalizedPassword
}
if (userId.value) {
await updateUser(userId.value, basePayload)
return
}
const created = await createUser(basePayload)
if (created) {
await router.push('/admin/user/list')
}
}
</script>

View File

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

View File

@@ -1,27 +1,21 @@
<script setup lang="ts">
</script>
<template>
<div class="flex flex-wrap justify-center pb-16 gap-12">
<card-link label="NOUVELLE RÉCEPTION" link="/reception" iconName="mdi:truck-outline" />
<card-link label="NOUVELLE EXPÉDITION" link="/shipment" iconName="mdi:truck-fast-outline" />
<card-link label="PLAN DE SITE" link="/infrastructure/building" iconName="material-symbols:warehouse-outline-rounded" />
<card-link link="/reception/waiting-reception" iconName="mdi:truck-remove-outline">
<template #label>
Réceptions<br>EN ATTENTE
</template>
</card-link>
<card-link link="/shipment/waiting-shipment" iconName="mdi:truck-cargo-container">
<template #label>
EXPÉDITIONS<br>EN ATTENTE
</template>
</card-link>
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
<card-link link="/inventory" iconName="mdi:cow">
<template #label>
INVENTAIRE<br>BOVINS
</template>
</card-link>
<div class="">
<h1 class="text-3xl font-bold">Liste des receptions</h1>
<ul>
<li v-for="reception in receptionList" :key="reception.id">
<NuxtLink :to="`/reception/${reception.id}`">Réception numéro {{ reception.id}}</NuxtLink>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import type {ReceptionData} from "~/services/dto/reception-data";
import {getReceptionList} from "~/services/reception";
const receptionList = ref<ReceptionData[]>()
onMounted(async () => {
receptionList.value = await getReceptionList()
})
</script>

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