Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59418f2c66 | ||
| e1c9e25187 | |||
| 0b22574932 | |||
|
|
9115699f96 | ||
| 178b4e4eee | |||
|
|
fbc8365405 | ||
| 4561467532 | |||
| c441420522 | |||
| ba9ea2de71 | |||
|
|
961fa63f3d | ||
| bebfabcacc | |||
|
|
e208bcd893 | ||
| 3fe0bbf71e | |||
|
|
d566e5d9f7 | ||
| 5bb0aad620 | |||
|
|
19a29f854e | ||
| c21dcd1869 | |||
|
|
86cb3c276a | ||
| 08a17f91b3 | |||
|
|
7b722bdd17 | ||
| 9038d1726a | |||
| bde59bf9ee | |||
| 097eb39cb0 | |||
| 4cdff1200f | |||
| d5b372e243 | |||
| 2e72f93f29 | |||
|
|
79077c7bbd | ||
| f05fcc5c15 | |||
|
|
023d71381e | ||
| e2695335e7 | |||
|
|
91152c0ed8 | ||
| 1b4764878e | |||
|
|
b94c3a95be | ||
| 394c69e84a | |||
|
|
c2074df562 | ||
| 29bfeeb4ee | |||
|
|
5ac03e359f | ||
|
|
340aa2a3c0 |
@@ -63,6 +63,8 @@ Ajouter dans le fichier .env du frontend
|
||||
* [#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
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ frontend/
|
||||
|
||||
- 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.
|
||||
- Repository custom autorisé dès qu'on a une requête métier non-triviale (agrégations, jointures spécifiques, filtres multiples). Toujours via DQL/QueryBuilder, **jamais de SQL brut** (pas de `Connection::executeQuery`, `fetchAssociative`, etc.). Les CRUD basiques restent sur le repo Doctrine par défaut via `EntityManagerInterface`.
|
||||
- `config/reference.php` est auto-généré — ne pas modifier à la main.
|
||||
- Endpoints toujours au pluriel (convention API Platform).
|
||||
- Ne jamais créer de GET qui crée des ressources : utiliser POST + PATCH.
|
||||
|
||||
152
README.md
152
README.md
@@ -1,68 +1,87 @@
|
||||
# Projet Ferme
|
||||
# Projet Ferme t
|
||||
|
||||
## Installation du projet
|
||||
|
||||
### Windows
|
||||
|
||||
Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm.
|
||||
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows)
|
||||
|
||||
### Linux
|
||||
|
||||
Pour linux, il faut installer docker et nvm.
|
||||
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux)
|
||||
|
||||
### Installation du projet
|
||||
|
||||
Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes
|
||||
|
||||
```bash
|
||||
make start
|
||||
make install
|
||||
```
|
||||
|
||||
Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible.
|
||||
|
||||
### Configuration global
|
||||
|
||||
Pour les variables d'environnement, il faut demander un .env.local pour le backend et un .env pour le frontend à votre collègue.
|
||||
|
||||
Vérifier que dans le .env.local, vous avez :
|
||||
* APP_SECRET (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));" et doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens)
|
||||
* DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
* PONT_BASCULE_BYPASS (doit être à true en dev)
|
||||
* PONT_BASCULE_URL
|
||||
* JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
|
||||
* JWT_PUBLIC_KEY
|
||||
* JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
|
||||
* COOKIE_SECURE=0 (en dev 0 et en prod 1. 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. Si c'est du http, laisser en 0)
|
||||
|
||||
Vérifier que dans le .env du dossier frontend, vous avez :
|
||||
* NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
|
||||
|
||||
- NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
|
||||
|
||||
### Configuration xdebug
|
||||
|
||||
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
|
||||
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
|
||||
* Name : ferme-docker
|
||||
* Host : localhost
|
||||
* Port : 8080
|
||||
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
|
||||
|
||||
- Name : ferme-docker
|
||||
- Host : localhost
|
||||
- Port : 8080
|
||||
- Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
|
||||
|
||||
Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local
|
||||
|
||||
## Utilisation du projet
|
||||
|
||||
### Backend
|
||||
|
||||
L'api est disponible sur http://localhost:8080/api
|
||||
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
|
||||
Vous pouvez modifier le port si nécessaire.
|
||||
|
||||
La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter.
|
||||
C'est un bdd local dans le docker.
|
||||
|
||||
### Frontend
|
||||
|
||||
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
|
||||
|
||||
```bash
|
||||
make dev-nuxt
|
||||
```
|
||||
|
||||
Le front sera accessible sur http://localhost:3000
|
||||
|
||||
### Authentification
|
||||
|
||||
Ce projet utilise l'authentification JWT avec un cookie httpOnly (LexikJWTAuthenticationBundle).
|
||||
Le frontend ne lit jamais directement le token, le navigateur envoie automatiquement le cookie.
|
||||
|
||||
### Login flow
|
||||
|
||||
- Frontend envoie les identifiants à:
|
||||
- `POST /api/login_check`
|
||||
- Backend returns:
|
||||
@@ -72,63 +91,164 @@ Le frontend ne lit jamais directement le token, le navigateur envoie automatique
|
||||
- La déconnexion utilise `POST /api/logout` et redirige vers `/login`.
|
||||
|
||||
### 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
|
||||
|
||||
- tail -f var/log/dev.log
|
||||
- tail -f var/log/prod.log
|
||||
|
||||
## Feed des prix bovins
|
||||
|
||||
Une commande Symfony permet de mettre à jour le **poids à l'arrivée**, le **prix au kilo** et le **fournisseur** des bovins existants à partir d'un fichier XLSX. La commande **ne crée jamais de nouveau bovin** : elle complète seulement ceux déjà présents en BDD.
|
||||
|
||||
### Format du fichier XLSX attendu
|
||||
|
||||
Pas de ligne d'en-tête, 4 colonnes dans cet ordre :
|
||||
|
||||
| Colonne | Champ | Format | Exemple |
|
||||
|---------|-------|--------|---------|
|
||||
| A | Numéro national | Avec ou sans préfixe `FR ` (insensible casse) | `FR 7979580026` ou `7979580026` |
|
||||
| B | Fournisseur | Texte libre, casse ignorée | `TERRENA` |
|
||||
| C | Poids à l'arrivée (kg) | Entier | `368` |
|
||||
| D | Prix au kilo | Décimal | `5.7` |
|
||||
| E | Code bâtiment (optionnel) | `B1`, `B2`, `B3`, `ZT` (casse ignorée) | `B2` |
|
||||
|
||||
### Comportement
|
||||
|
||||
- **Numéro national** : le préfixe `FR` (avec ou sans espace) est retiré s'il est présent. Sinon la valeur est utilisée telle quelle.
|
||||
- **Bovin introuvable** en BDD → ligne ignorée, log warning à la fin avec aperçu.
|
||||
- **Fournisseur introuvable** en BDD → le bovin est mis à jour quand même avec `supplier = null`, log warning.
|
||||
- **Bâtiment** (colonne E) : recherché par `code` (insensible casse). Set uniquement si le bovin n'a pas déjà une `buildingCase` assignée (la case prime sur le bâtiment direct côté affichage). Si code introuvable → log warning, champ non set.
|
||||
- **Cellules `weight` / `price` vides ou non numériques** → champ non modifié.
|
||||
- La commande est **idempotente** : peut être relancée sans effet de bord.
|
||||
|
||||
### Lancement en dev
|
||||
|
||||
Copie le fichier dans la racine du projet (mappée dans le container sous `/var/www/html`), puis :
|
||||
|
||||
```bash
|
||||
# Simulation sans écriture en BDD
|
||||
docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bovin.xlsx --dry-run
|
||||
|
||||
# Persistance effective
|
||||
docker compose exec php bin/console app:feed-bovine-prices /var/www/html/feed_bovin.xlsx
|
||||
```
|
||||
|
||||
### Lancement en prod
|
||||
|
||||
Le user SSH n'a généralement pas les droits d'écriture sur `/var/www/ferme/` ; on passe donc le fichier par `/tmp` et on pointe la commande dessus (le chemin du XLSX est juste un argument).
|
||||
|
||||
```bash
|
||||
# 1. Copier le fichier sur le serveur dans /tmp (accessible en écriture)
|
||||
scp feed_bovin.xlsx <user>@<host>:/tmp/
|
||||
|
||||
# 2. SSH sur le serveur
|
||||
ssh <user>@<host>
|
||||
|
||||
# 3. Se placer dans le dossier de l'app (pour bin/console)
|
||||
cd /var/www/ferme
|
||||
|
||||
# 4. Dry-run pour vérifier sans rien écrire
|
||||
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx --dry-run
|
||||
|
||||
# 5. Persistance effective
|
||||
php bin/console app:feed-bovine-prices /tmp/feed_bovin.xlsx
|
||||
|
||||
# 6. Cleanup
|
||||
rm /tmp/feed_bovin.xlsx
|
||||
```
|
||||
|
||||
> Si à l'étape 4 le user PHP (souvent `www-data`) n'arrive pas à lire le fichier (`Permission denied`), donne-lui les droits de lecture avant : `chmod 644 /tmp/feed_bovin.xlsx`.
|
||||
|
||||
### Sortie attendue
|
||||
|
||||
À la fin, un tableau récapitule :
|
||||
|
||||
- Lignes totales lues
|
||||
- Bovins mis à jour
|
||||
- Bovins introuvables (avec aperçu des 10 premiers numéros)
|
||||
- Lignes invalides (numéro national vide)
|
||||
- Fournisseurs introuvables (avec liste et compte par nom)
|
||||
- Bâtiments introuvables (avec liste des codes inconnus)
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
"doctrine/orm": "^3.6",
|
||||
"dompdf/dompdf": "^3.1",
|
||||
"lexik/jwt-authentication-bundle": "*",
|
||||
"malio/ednotif-bundle": ">=0.0.4",
|
||||
"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.*",
|
||||
|
||||
679
composer.lock
generated
679
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ api_platform:
|
||||
stateless: true
|
||||
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']
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
security:
|
||||
# Hiérarchie des rôles : ADMIN inclut BUREAU qui inclut USER.
|
||||
# Ajouter un nouveau rôle = ajouter une ligne ici (et son équivalent côté
|
||||
# front dans utils/roles.ts).
|
||||
role_hierarchy:
|
||||
ROLE_BUREAU: ROLE_USER
|
||||
ROLE_ADMIN: ROLE_BUREAU
|
||||
|
||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||
password_hashers:
|
||||
App\Entity\User: 'auto'
|
||||
|
||||
@@ -210,18 +210,18 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* initial_marking?: list<scalar|Param|null>,
|
||||
* events_to_dispatch?: list<string|Param>|null,
|
||||
* places?: list<array{ // Default: []
|
||||
* name: scalar|Param|null,
|
||||
* name?: scalar|Param|null,
|
||||
* metadata?: list<mixed>,
|
||||
* }>,
|
||||
* transitions: list<array{ // Default: []
|
||||
* name: string|Param,
|
||||
* transitions?: list<array{ // Default: []
|
||||
* name?: string|Param,
|
||||
* guard?: string|Param, // An expression to block the transition.
|
||||
* from?: list<array{ // Default: []
|
||||
* place: string|Param,
|
||||
* place?: string|Param,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* }>,
|
||||
* to?: list<array{ // Default: []
|
||||
* place: string|Param,
|
||||
* place?: string|Param,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* }>,
|
||||
* weight?: int|Param, // Default: 1
|
||||
@@ -232,7 +232,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* router?: bool|array{ // Router configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* resource: scalar|Param|null,
|
||||
* resource?: scalar|Param|null,
|
||||
* type?: scalar|Param|null,
|
||||
* default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
|
||||
* http_port?: scalar|Param|null, // Default: 80
|
||||
@@ -457,7 +457,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* allow_no_senders?: bool|Param, // Default: true
|
||||
* },
|
||||
* middleware?: list<string|array{ // Default: []
|
||||
* id: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* arguments?: list<mixed>,
|
||||
* }>,
|
||||
* }>,
|
||||
@@ -629,7 +629,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||
* storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
|
||||
* policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
||||
* policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
||||
* limiters?: list<scalar|Param|null>,
|
||||
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
|
||||
* interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||
@@ -674,7 +674,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
|
||||
* routing?: array<string, array{ // Default: []
|
||||
* service: scalar|Param|null,
|
||||
* service?: scalar|Param|null,
|
||||
* secret?: scalar|Param|null, // Default: ""
|
||||
* }>,
|
||||
* },
|
||||
@@ -754,8 +754,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* },
|
||||
* ldap?: array{
|
||||
* service: scalar|Param|null,
|
||||
* base_dn: scalar|Param|null,
|
||||
* service?: scalar|Param|null,
|
||||
* base_dn?: scalar|Param|null,
|
||||
* search_dn?: scalar|Param|null, // Default: null
|
||||
* search_password?: scalar|Param|null, // Default: null
|
||||
* extra_fields?: list<scalar|Param|null>,
|
||||
@@ -766,7 +766,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* password_attribute?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* entity?: array{
|
||||
* class: scalar|Param|null, // The full entity class name of your user class.
|
||||
* class?: scalar|Param|null, // The full entity class name of your user class.
|
||||
* property?: scalar|Param|null, // Default: null
|
||||
* manager_name?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
@@ -774,7 +774,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* class?: scalar|Param|null, // Default: "Lexik\\Bundle\\JWTAuthenticationBundle\\Security\\User\\JWTUser"
|
||||
* },
|
||||
* }>,
|
||||
* firewalls: array<string, array{ // Default: []
|
||||
* firewalls?: array<string, array{ // Default: []
|
||||
* pattern?: scalar|Param|null,
|
||||
* host?: scalar|Param|null,
|
||||
* methods?: list<scalar|Param|null>,
|
||||
@@ -836,9 +836,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* authenticator?: scalar|Param|null, // Default: "lexik_jwt_authentication.security.jwt_authenticator"
|
||||
* },
|
||||
* login_link?: array{
|
||||
* check_route: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
|
||||
* check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
|
||||
* check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
|
||||
* signature_properties: list<scalar|Param|null>,
|
||||
* signature_properties?: list<scalar|Param|null>,
|
||||
* lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600
|
||||
* max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null
|
||||
* used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set.
|
||||
@@ -940,13 +940,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* failure_handler?: scalar|Param|null,
|
||||
* realm?: scalar|Param|null, // Default: null
|
||||
* token_extractors?: list<scalar|Param|null>,
|
||||
* token_handler: string|array{
|
||||
* token_handler?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* oidc_user_info?: string|array{
|
||||
* base_uri: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
|
||||
* base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
|
||||
* discovery?: array{ // Enable the OIDC discovery.
|
||||
* cache?: array{
|
||||
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* },
|
||||
* },
|
||||
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub"
|
||||
@@ -954,25 +954,25 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* oidc?: array{
|
||||
* discovery?: array{ // Enable the OIDC discovery.
|
||||
* base_uri: list<scalar|Param|null>,
|
||||
* base_uri?: list<scalar|Param|null>,
|
||||
* cache?: array{
|
||||
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* },
|
||||
* },
|
||||
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub"
|
||||
* audience: scalar|Param|null, // Audience set in the token, for validation purpose.
|
||||
* issuers: list<scalar|Param|null>,
|
||||
* algorithms: list<scalar|Param|null>,
|
||||
* audience?: scalar|Param|null, // Audience set in the token, for validation purpose.
|
||||
* issuers?: list<scalar|Param|null>,
|
||||
* algorithms?: list<scalar|Param|null>,
|
||||
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).
|
||||
* encryption?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false
|
||||
* algorithms: list<scalar|Param|null>,
|
||||
* keyset: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
|
||||
* algorithms?: list<scalar|Param|null>,
|
||||
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
|
||||
* },
|
||||
* },
|
||||
* cas?: array{
|
||||
* validation_url: scalar|Param|null, // CAS server validation URL
|
||||
* validation_url?: scalar|Param|null, // CAS server validation URL
|
||||
* prefix?: scalar|Param|null, // CAS prefix // Default: "cas"
|
||||
* http_client?: scalar|Param|null, // HTTP Client service // Default: null
|
||||
* },
|
||||
@@ -1036,7 +1036,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* dbal?: array{
|
||||
* default_connection?: scalar|Param|null,
|
||||
* types?: array<string, string|array{ // Default: []
|
||||
* class: scalar|Param|null,
|
||||
* class?: scalar|Param|null,
|
||||
* }>,
|
||||
* driver_schemes?: array<string, scalar|Param|null>,
|
||||
* connections?: array<string, array{ // Default: []
|
||||
@@ -1207,7 +1207,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* datetime_functions?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* filters?: array<string, string|array{ // Default: []
|
||||
* class: scalar|Param|null,
|
||||
* class?: scalar|Param|null,
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* parameters?: array<string, mixed>,
|
||||
* }>,
|
||||
@@ -1320,14 +1320,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* access_token_issuance?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* signature?: array{
|
||||
* algorithm: scalar|Param|null, // The algorithm use to sign the access tokens.
|
||||
* key: scalar|Param|null, // The signature key. It shall be JWK encoded.
|
||||
* algorithm?: scalar|Param|null, // The algorithm use to sign the access tokens.
|
||||
* key?: scalar|Param|null, // The signature key. It shall be JWK encoded.
|
||||
* },
|
||||
* encryption?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* key_encryption_algorithm: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
||||
* content_encryption_algorithm: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
||||
* key: scalar|Param|null, // The encryption key. It shall be JWK encoded.
|
||||
* key_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
||||
* content_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
|
||||
* key?: scalar|Param|null, // The encryption key. It shall be JWK encoded.
|
||||
* },
|
||||
* },
|
||||
* access_token_verification?: bool|array{
|
||||
@@ -1337,7 +1337,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* claim_checkers?: list<scalar|Param|null>,
|
||||
* mandatory_claims?: list<scalar|Param|null>,
|
||||
* allowed_algorithms?: list<scalar|Param|null>,
|
||||
* keyset: scalar|Param|null, // The signature keyset. It shall be JWKSet encoded.
|
||||
* keyset?: scalar|Param|null, // The signature keyset. It shall be JWKSet encoded.
|
||||
* },
|
||||
* encryption?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
@@ -1345,7 +1345,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* header_checkers?: list<scalar|Param|null>,
|
||||
* allowed_key_encryption_algorithms?: list<scalar|Param|null>,
|
||||
* allowed_content_encryption_algorithms?: list<scalar|Param|null>,
|
||||
* keyset: scalar|Param|null, // The encryption keyset. It shall be JWKSet encoded.
|
||||
* keyset?: scalar|Param|null, // The encryption keyset. It shall be JWKSet encoded.
|
||||
* },
|
||||
* },
|
||||
* blocklist_token?: bool|array{
|
||||
@@ -1489,7 +1489,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* termsOfService?: scalar|Param|null, // A URL to the Terms of Service for the API. MUST be in the format of a URL. // Default: null
|
||||
* tags?: list<array{ // Default: []
|
||||
* name: scalar|Param|null,
|
||||
* name?: scalar|Param|null,
|
||||
* description?: scalar|Param|null, // Default: null
|
||||
* }>,
|
||||
* license?: array{
|
||||
@@ -1605,14 +1605,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* name?: mixed,
|
||||
* allow_create?: mixed,
|
||||
* item_uri_template?: mixed,
|
||||
* ...<mixed>
|
||||
* ...<string, mixed>
|
||||
* },
|
||||
* }
|
||||
* @psalm-type MonologConfig = array{
|
||||
* use_microseconds?: scalar|Param|null, // Default: true
|
||||
* channels?: list<scalar|Param|null>,
|
||||
* handlers?: array<string, array{ // Default: []
|
||||
* type: scalar|Param|null,
|
||||
* type?: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* priority?: scalar|Param|null, // Default: 0
|
||||
@@ -1735,7 +1735,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* headers?: list<scalar|Param|null>,
|
||||
* mailer?: scalar|Param|null, // Default: null
|
||||
* email_prototype?: string|array{
|
||||
* id: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* method?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* verbosity_levels?: array{
|
||||
@@ -1752,14 +1752,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* }
|
||||
* @psalm-type EdnotifConfig = array{
|
||||
* guichet_wsdl: scalar|Param|null,
|
||||
* metier_wsdl: scalar|Param|null,
|
||||
* exploitation_code: scalar|Param|null,
|
||||
* guichet_wsdl?: scalar|Param|null, // Default: "/var/www/html/vendor/malio/ednotif-bundle/resources/ednotif-ws/WsGuichet.wsdl"
|
||||
* metier_wsdl?: scalar|Param|null, // Default: "/var/www/html/vendor/malio/ednotif-bundle/resources/ednotif-ws/wsIpBNotif.wsdl"
|
||||
* exploitation_code?: scalar|Param|null,
|
||||
* zone?: scalar|Param|null, // Default: null
|
||||
* application?: scalar|Param|null, // Default: null
|
||||
* login: scalar|Param|null,
|
||||
* password: scalar|Param|null,
|
||||
* exploitation_number: scalar|Param|null,
|
||||
* login?: scalar|Param|null,
|
||||
* password?: scalar|Param|null,
|
||||
* exploitation_number?: scalar|Param|null,
|
||||
* exploitation_country_code?: scalar|Param|null, // Default: "FR"
|
||||
* token_ttl_seconds?: int|Param, // Default: 900
|
||||
* soap_options?: array{
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.0.81'
|
||||
app.version: '0.0.97'
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Export Excel de l'inventaire bovin — Design Spec
|
||||
|
||||
Bouton sur la page `/inventory` qui télécharge un XLSX listant tous les bovins actuellement présents sur l'exploitation.
|
||||
|
||||
## Contexte
|
||||
|
||||
Le métier veut un Excel exportable depuis l'écran inventaire. Ferme n'a aujourd'hui aucun outil d'export Excel (uniquement PDF via dompdf). On choisit `phpoffice/phpspreadsheet` côté serveur, en suivant le même pattern que la génération PDF actuelle (endpoint qui streame le fichier, front qui télécharge via blob).
|
||||
|
||||
Périmètre : tous les bovins actifs (`exitedAt IS NULL`), ordre `birthDate ASC`, ignore les filtres UI. Pas de modale de sélection (à voir si le métier en demande une plus tard).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
**Dépendance** : `composer require phpoffice/phpspreadsheet`
|
||||
|
||||
**Nouveau resource** : `src/ApiResource/BovineInventoryExport.php`
|
||||
- `#[ApiResource]` avec une seule opération `Get` :
|
||||
- `uriTemplate: '/bovines/inventory-export'`
|
||||
- `output: false`
|
||||
- `provider: BovineInventoryExportProvider::class`
|
||||
- `security: "is_granted('ROLE_USER')"` (cohérent avec la page `/inventory`)
|
||||
- OpenApi tag `Bovines`
|
||||
|
||||
**Nouveau provider** : `src/State/Bovin/BovineInventoryExportProvider.php`
|
||||
- Injecte `EntityManagerInterface`
|
||||
- Query Doctrine : `WHERE exitedAt IS NULL ORDER BY birthDate ASC`
|
||||
- Construit le `Spreadsheet` avec PhpSpreadsheet
|
||||
- Retourne une `Symfony\Component\HttpFoundation\Response` avec :
|
||||
- `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
|
||||
- `Content-Disposition: attachment; filename="inventaire_bovins_YYYY-MM-DD.xlsx"`
|
||||
- Body = `IOFactory::createWriter($spreadsheet, 'Xlsx')->save('php://output')` capturé via `ob_*`
|
||||
|
||||
### Frontend
|
||||
|
||||
**Page** : `frontend/pages/inventory.vue`
|
||||
- Nouveau bouton "Exporter Excel" à droite du titre, à côté de "Rafraîchir"
|
||||
- Style : même que "Rafraîchir" (bg-primary-500, h-[50px], icône `mdi:file-excel-outline`)
|
||||
- Visible pour tout user authentifié (pas de gate admin)
|
||||
- Au clic : appelle `useApi().getBlob('bovines/inventory-export')`, crée un blob URL, déclenche un `<a download>` synthétique avec le filename retourné par le backend (lu depuis le header `Content-Disposition`)
|
||||
|
||||
## Génération XLSX — détails
|
||||
|
||||
**Fichier** :
|
||||
- 1 seule feuille `Inventaire`
|
||||
- Filename : `inventaire_bovins_YYYY-MM-DD.xlsx` (date du jour serveur)
|
||||
|
||||
**En-têtes (ligne 1)** :
|
||||
- 9 colonnes dans l'ordre : `N° National`, `N° Travail`, `Sexe`, `Né le`, `Age (mois)`, `Race`, `Bâtiment`, `Case`, `Entrée le`
|
||||
- Style : gras, fond `#f1f5f9` (slate-100), bordure noire fine, alignement centré
|
||||
- Auto-filter activé sur la plage des en-têtes (Excel ajoute les boutons de filtre natifs)
|
||||
- Freeze pane : ligne 2 figée
|
||||
|
||||
**Lignes de données (à partir de la ligne 2)** :
|
||||
- Ordre `birthDate ASC` (plus vieux en haut, NULL à la fin via `NULLS LAST` natif Postgres)
|
||||
- Largeurs de colonnes :
|
||||
- N° National : 18
|
||||
- N° Travail : 12
|
||||
- Sexe : 10
|
||||
- Né le : 12
|
||||
- Age : 12
|
||||
- Race : 12
|
||||
- Bâtiment : 30
|
||||
- Case : 8
|
||||
- Entrée le : 12
|
||||
|
||||
**Mapping des valeurs** :
|
||||
- Sexe : `M` → `Mâle`, `F` → `Femelle`, autre / null → vide
|
||||
- Né le, Entrée le : format `JJ/MM/AAAA`, vide si null
|
||||
- Age : entier (mois), vide si null
|
||||
- Bâtiment, Case : valeurs nestées via `bovine.buildingCase.building.label` et `bovine.buildingCase.caseNumber`, vide si null
|
||||
|
||||
**Couleurs des lignes** (basées sur `ageMonths`, mêmes seuils que l'UI) :
|
||||
| Tranche | Hex | Tailwind |
|
||||
|--------|-----|----------|
|
||||
| 24+ mois | `#ddd6fe` | violet-200 |
|
||||
| 22-24 mois | `#fecaca` | red-200 |
|
||||
| 20-22 mois | `#fed7aa` | orange-200 |
|
||||
| < 20 mois ou NULL | `#ffffff` | blanc |
|
||||
|
||||
Le fond est appliqué sur toute la ligne (9 cellules).
|
||||
|
||||
## Flux d'erreur
|
||||
|
||||
- Exception PhpSpreadsheet (création buffer) → propage en 500 standard API Platform
|
||||
- Pas d'utilisateur (token expiré) → 401 standard via la sécurité
|
||||
|
||||
## Performance
|
||||
|
||||
- 936 lignes × 9 colonnes : génération en mémoire < 1s, fichier < 100 KB
|
||||
- Pas de pagination, pas de streaming row-by-row (overkill pour ce volume)
|
||||
|
||||
## Tests
|
||||
|
||||
Optionnel ce lot : test PHPUnit du provider qui vérifie que :
|
||||
- Status 200
|
||||
- Content-Type XLSX
|
||||
- Header `Content-Disposition: attachment; filename=...xlsx`
|
||||
- Body non vide
|
||||
Mock simple de l'`EntityManagerInterface` pour retourner 2 bovins fictifs.
|
||||
|
||||
À faire en follow-up si on veut couvrir.
|
||||
|
||||
## Verification manuelle
|
||||
|
||||
1. `make composer-install` (après avoir ajouté la dep)
|
||||
2. Recharger `/inventory`
|
||||
3. Clic sur le bouton "Exporter Excel"
|
||||
4. Vérifier le téléchargement : nom de fichier = `inventaire_bovins_2026-04-24.xlsx`
|
||||
5. Ouvrir dans Excel/LibreOffice :
|
||||
- 9 colonnes attendues
|
||||
- En-tête figé en scrollant
|
||||
- Auto-filter natif Excel
|
||||
- Lignes colorées selon âge (violet/rouge/orange)
|
||||
- Tri par date de naissance croissante
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- [ ] L'export contient 100 % des bovins actifs (count = `SELECT COUNT(*) FROM bovine WHERE exited_at IS NULL`)
|
||||
- [ ] Le filename inclut la date du jour
|
||||
- [ ] Les couleurs correspondent aux seuils d'âge
|
||||
- [ ] L'ordre matche l'UI (`birthDate ASC`)
|
||||
- [ ] Pas de régression sur les autres endpoints `/api/bovines`
|
||||
96
frontend/components/inventory/inventory-export-modal.vue
Normal file
96
frontend/components/inventory/inventory-export-modal.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<UiModal v-model="open" title="Exporter l'inventaire bovin" max-width="max-w-2xl">
|
||||
<p class="mb-5 text-sm text-slate-600">
|
||||
Aucun filtre coché : export complet (tous les bovins actifs).
|
||||
</p>
|
||||
|
||||
<div class="mb-5">
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-600">
|
||||
Tranches d'âge
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
v-for="bucket in ageBuckets"
|
||||
:key="bucket.value"
|
||||
class="flex items-center gap-3 cursor-pointer text-primary-700"
|
||||
>
|
||||
<input
|
||||
v-model="filters.ageRanges"
|
||||
type="checkbox"
|
||||
:value="bucket.value"
|
||||
class="h-4 w-4 cursor-pointer accent-primary-500"
|
||||
/>
|
||||
<span :class="['inline-block rounded px-2 py-0.5 text-xs font-semibold text-white', bucket.colorClass]">
|
||||
{{ bucket.badge }}
|
||||
</span>
|
||||
<span>{{ bucket.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="inline-flex h-[50px] items-center justify-center gap-2 rounded bg-primary-500 px-6 text-base text-white uppercase hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@click="onSubmit"
|
||||
>
|
||||
<Icon
|
||||
v-if="loading"
|
||||
name="mdi:loading"
|
||||
size="20"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<Icon v-else name="mdi:file-excel-outline" size="20" />
|
||||
Exporter
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
export interface InventoryExportFilters {
|
||||
ageRanges: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'submit', filters: InventoryExportFilters): void
|
||||
}>()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const ageBuckets = [
|
||||
{ value: 'over24', label: '≥ 24 mois', badge: '24+', colorClass: 'bg-red-500' },
|
||||
{ value: 'between22And24', label: '22 à 24 mois', badge: '22-24', colorClass: 'bg-orange-500' },
|
||||
{ value: 'between20And22', label: '20 à 22 mois', badge: '20-22', colorClass: 'bg-yellow-500' }
|
||||
]
|
||||
|
||||
const filters = reactive<InventoryExportFilters>({
|
||||
ageRanges: []
|
||||
})
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
filters.ageRanges = []
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = () => {
|
||||
emit('submit', { ageRanges: [...filters.ageRanges] })
|
||||
}
|
||||
</script>
|
||||
238
frontend/components/ui/UiDataTable.vue
Normal file
238
frontend/components/ui/UiDataTable.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="relative border border-slate-200">
|
||||
<div
|
||||
class="grid items-center gap-6 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
|
||||
:style="{ gridTemplateColumns: gridCols }"
|
||||
>
|
||||
<div v-for="col in columns" :key="col.key" class="min-w-0">
|
||||
<slot :name="`header-${col.key}`" :column="col">{{ col.label }}</slot>
|
||||
</div>
|
||||
<div v-if="showActions" class="min-w-0">
|
||||
<slot name="header-actions">Actions</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="dimRows ? 'opacity-50 transition-opacity' : ''" :aria-busy="loading || undefined">
|
||||
<template v-if="paginatedItems.length">
|
||||
<div
|
||||
v-for="(item, index) in paginatedItems"
|
||||
:key="item.id ?? index"
|
||||
class="grid gap-6 px-4 py-3 text-sm border-t border-slate-200"
|
||||
:class="[
|
||||
rowClickable ? 'hover:bg-slate-50 cursor-pointer' : '',
|
||||
rowClass ? rowClass(item) : ''
|
||||
]"
|
||||
:style="{ gridTemplateColumns: gridCols }"
|
||||
:role="rowClickable ? 'button' : undefined"
|
||||
:tabindex="rowClickable ? 0 : undefined"
|
||||
@click="onRowClick(item)"
|
||||
@keydown.enter="onRowClick(item)"
|
||||
@keydown.space.prevent="onRowClick(item)"
|
||||
>
|
||||
<div v-for="col in columns" :key="col.key" class="min-w-0 truncate">
|
||||
<slot :name="`cell-${col.key}`" :item="item" :column="col">
|
||||
{{ getNestedValue(item, col.key) }}
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="showActions" @click.stop>
|
||||
<slot name="actions" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="loading"
|
||||
class="flex items-center justify-center border-t border-slate-200 px-4 py-8 text-primary-500"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<UiLoadingDots />
|
||||
<span class="sr-only">Chargement…</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="border-t border-slate-200 px-4 py-8 text-center text-sm text-slate-500"
|
||||
>
|
||||
<slot name="empty">{{ emptyMessage }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="dimRows"
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="rounded bg-white/80 px-4 py-2 text-primary-500 shadow">
|
||||
<UiLoadingDots />
|
||||
<span class="sr-only">Chargement…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > 0" class="flex justify-between pt-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label :for="perPageId" class="whitespace-nowrap text-sm text-slate-700">Lignes :</label>
|
||||
<select
|
||||
:id="perPageId"
|
||||
:value="currentPerPage"
|
||||
class="h-10 rounded border border-slate-300 bg-white px-2 text-sm text-primary-700"
|
||||
@change="onPerPageChange(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="n in perPageOptions" :key="n" :value="n">{{ n }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Pagination" class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
|
||||
:disabled="currentPage <= 1"
|
||||
aria-label="Page précédente"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
|
||||
<template v-for="(entry, i) in visiblePages" :key="`${typeof entry}-${entry}-${i}`">
|
||||
<span
|
||||
v-if="entry === '...'"
|
||||
class="px-1 text-sm text-slate-400"
|
||||
aria-hidden="true"
|
||||
>…</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
|
||||
:class="entry === currentPage
|
||||
? 'bg-primary-500 font-semibold text-white'
|
||||
: 'text-slate-700 hover:bg-slate-100'"
|
||||
:aria-current="entry === currentPage ? 'page' : undefined"
|
||||
@click="goToPage(entry)"
|
||||
>
|
||||
{{ entry }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
|
||||
:disabled="currentPage >= totalPages"
|
||||
aria-label="Page suivante"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import { computed, useId } from 'vue'
|
||||
|
||||
interface Column {
|
||||
key: string
|
||||
label: string
|
||||
width?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
columns: Column[]
|
||||
items: T[]
|
||||
totalItems?: number
|
||||
page?: number
|
||||
perPage?: number
|
||||
perPageOptions?: number[]
|
||||
rowClickable?: boolean
|
||||
showActions?: boolean
|
||||
emptyMessage?: string
|
||||
loading?: boolean
|
||||
rowClass?: (item: T) => string | undefined
|
||||
}>(), {
|
||||
totalItems: undefined,
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
perPageOptions: () => [10, 25, 50],
|
||||
rowClickable: false,
|
||||
showActions: false,
|
||||
emptyMessage: 'Aucune donnée',
|
||||
loading: false,
|
||||
rowClass: undefined
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:page', value: number): void
|
||||
(e: 'update:perPage', value: number): void
|
||||
(e: 'row-click', item: T): void
|
||||
}>()
|
||||
|
||||
const perPageId = useId()
|
||||
|
||||
const currentPage = computed(() => props.page)
|
||||
const currentPerPage = computed(() => props.perPage)
|
||||
|
||||
const isServerSide = computed(() => props.totalItems !== undefined)
|
||||
const total = computed(() => props.totalItems ?? props.items.length)
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(total.value / currentPerPage.value))
|
||||
)
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
if (isServerSide.value) return props.items
|
||||
const start = (currentPage.value - 1) * currentPerPage.value
|
||||
return props.items.slice(start, start + currentPerPage.value)
|
||||
})
|
||||
|
||||
const gridCols = computed(() => {
|
||||
const dataCols = props.columns.map(c => c.width ?? '1fr').join(' ')
|
||||
return props.showActions ? `${dataCols} 60px` : dataCols
|
||||
})
|
||||
|
||||
const dimRows = computed(() => props.loading && paginatedItems.value.length > 0)
|
||||
|
||||
const visiblePages = computed<(number | '...')[]>(() => {
|
||||
const tp = totalPages.value
|
||||
const cp = currentPage.value
|
||||
|
||||
if (tp <= 5) {
|
||||
return Array.from({ length: tp }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
const pages: (number | '...')[] = []
|
||||
pages.push(1)
|
||||
|
||||
if (cp > 3) pages.push('...')
|
||||
|
||||
const start = Math.max(2, cp - 1)
|
||||
const end = Math.min(tp - 1, cp + 1)
|
||||
for (let i = start; i <= end; i++) pages.push(i)
|
||||
|
||||
if (cp < tp - 2) pages.push('...')
|
||||
|
||||
if (tp > 1) pages.push(tp)
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const goToPage = (n: number) => {
|
||||
if (n < 1 || n > totalPages.value || n === currentPage.value) return
|
||||
emit('update:page', n)
|
||||
}
|
||||
|
||||
const onPerPageChange = (value: string) => {
|
||||
emit('update:perPage', Number(value))
|
||||
emit('update:page', 1)
|
||||
}
|
||||
|
||||
const onRowClick = (item: T) => {
|
||||
if (!props.rowClickable) return
|
||||
emit('row-click', item)
|
||||
}
|
||||
|
||||
const getNestedValue = (obj: any, path: string): string => {
|
||||
const value = path.split('.').reduce((acc, key) => acc?.[key], obj)
|
||||
return value ?? '—'
|
||||
}
|
||||
</script>
|
||||
@@ -14,8 +14,9 @@
|
||||
:value="modelValue ?? ''"
|
||||
: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="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
|
||||
@@ -36,12 +37,14 @@ const props = withDefaults(
|
||||
label?: string
|
||||
modelValue: string | null | undefined
|
||||
disabled?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
inputClass?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
inputClass: ''
|
||||
@@ -54,6 +57,11 @@ const emit = defineEmits<{
|
||||
|
||||
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
|
||||
|
||||
108
frontend/components/ui/UiDateMaskedInput.vue
Normal file
108
frontend/components/ui/UiDateMaskedInput.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div :class="['flex flex-col', wrapperClass]">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
class="font-bold uppercase text-xl text-primary-700"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<input
|
||||
:id="id"
|
||||
v-maska="'##/##/####'"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:value="displayValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
v-bind="attrs"
|
||||
class="w-full min-w-0 border-b border-primary-700 bg-transparent"
|
||||
:class="[
|
||||
sizeClass,
|
||||
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-text',
|
||||
inputClass
|
||||
]"
|
||||
@input="onInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { vMaska } from 'maska/vue'
|
||||
import { computed, ref, useAttrs, watch } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
label?: string
|
||||
modelValue: string | null | undefined
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
inputClass?: string
|
||||
}>(),
|
||||
{
|
||||
placeholder: 'JJ/MM/AAAA',
|
||||
disabled: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
inputClass: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const toDisplay = (iso: string | null | undefined): string => {
|
||||
if (!iso) return ''
|
||||
const parts = iso.split('-')
|
||||
if (parts.length !== 3) return ''
|
||||
const [year, month, day] = parts
|
||||
if (year.length !== 4 || month.length !== 2 || day.length !== 2) return ''
|
||||
return `${day}/${month}/${year}`
|
||||
}
|
||||
|
||||
const toIso = (display: string): string | null => {
|
||||
const match = display.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
|
||||
if (!match) return null
|
||||
const [, day, month, year] = match
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const displayValue = ref(toDisplay(props.modelValue))
|
||||
|
||||
watch(() => props.modelValue, (newIso) => {
|
||||
const expected = toDisplay(newIso)
|
||||
if (expected !== displayValue.value) {
|
||||
displayValue.value = expected
|
||||
}
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => !displayValue.value)
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'compact'
|
||||
? 'text-sm h-8 font-normal normal-case tracking-normal'
|
||||
: 'text-xl py-[6px]'
|
||||
)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
displayValue.value = target.value
|
||||
if (target.value === '') {
|
||||
emit('update:modelValue', '')
|
||||
return
|
||||
}
|
||||
const iso = toIso(target.value)
|
||||
emit('update:modelValue', iso ?? '')
|
||||
}
|
||||
</script>
|
||||
96
frontend/components/ui/UiModal.vue
Normal file
96
frontend/components/ui/UiModal.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@mousedown.self="closeOnBackdrop"
|
||||
>
|
||||
<div
|
||||
class="w-full rounded-md bg-white shadow-2xl"
|
||||
:class="maxWidth"
|
||||
@mousedown.stop
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500">{{ title }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-slate-500 hover:text-primary-500 flex items-center"
|
||||
aria-label="Fermer"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="24" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="border-t border-slate-200 px-6 py-4"
|
||||
>
|
||||
<slot name="footer" :close="close" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
closeOnBackdropClick?: boolean
|
||||
maxWidth?: string
|
||||
}>(), {
|
||||
title: '',
|
||||
closeOnBackdropClick: true,
|
||||
maxWidth: 'max-w-lg'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const close = () => emit('update:modelValue', false)
|
||||
|
||||
const closeOnBackdrop = () => {
|
||||
if (props.closeOnBackdropClick) close()
|
||||
}
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && props.modelValue) close()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -13,15 +13,16 @@
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="disabled || loading"
|
||||
v-bind="attrs"
|
||||
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] bg-transparent"
|
||||
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="" disabled class="text-neutral-400">
|
||||
<option value="" class="text-neutral-400">
|
||||
{{ placeholderText }}
|
||||
</option>
|
||||
<option
|
||||
@@ -55,6 +56,7 @@ const props = withDefaults(
|
||||
options: SelectOption[]
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
selectClass?: string
|
||||
@@ -63,6 +65,7 @@ const props = withDefaults(
|
||||
placeholder: 'Sélectionner',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
selectClass: ''
|
||||
@@ -77,6 +80,11 @@ 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
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
:maxlength="maxlength"
|
||||
:disabled="disabled"
|
||||
v-bind="attrs"
|
||||
class="border-b border-black text-xl py-[6px] bg-transparent text-primary-700"
|
||||
class="w-full min-w-0 border-b border-primary-700 bg-transparent"
|
||||
:class="[
|
||||
isEmpty ? 'text-neutral-400' : 'text-black',
|
||||
sizeClass,
|
||||
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-text',
|
||||
inputClass
|
||||
]"
|
||||
@@ -40,6 +41,7 @@ const props = withDefaults(
|
||||
placeholder?: string
|
||||
maxlength?: number | string
|
||||
disabled?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
inputClass?: string
|
||||
@@ -48,6 +50,7 @@ const props = withDefaults(
|
||||
placeholder: '',
|
||||
maxlength: undefined,
|
||||
disabled: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
inputClass: ''
|
||||
@@ -60,6 +63,11 @@ const emit = defineEmits<{
|
||||
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
:maxlength="maxLength"
|
||||
:placeholder="placeholderText"
|
||||
:required="required"
|
||||
class="border-b border-black flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
|
||||
class="border-b border-primary-700 flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<UiCheckbox
|
||||
|
||||
@@ -37,6 +37,7 @@ export const useWeighingStep = (options: UseWeighingStepOptions) => {
|
||||
entityName: options.entityName,
|
||||
apiResource: options.apiResource,
|
||||
titleLabel: options.titleLabel,
|
||||
isFinal: options.isFinal,
|
||||
getWeightFromScale: options.getWeightFromScale,
|
||||
updateEntity: options.updateEntity,
|
||||
loadEntity: options.loadEntity
|
||||
|
||||
88
frontend/composables/useBovineColumns.ts
Normal file
88
frontend/composables/useBovineColumns.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export interface BovineColumn {
|
||||
key: string
|
||||
label: string
|
||||
width?: string
|
||||
}
|
||||
|
||||
export interface UseBovineColumnsOptions {
|
||||
/**
|
||||
* 'inventory' (par défaut) : colonnes complètes incluant Bâtiment + Case.
|
||||
* 'case' : pas de Bâtiment ni Case (déjà dans le titre de la page),
|
||||
* largeurs élargies pour combler l'espace.
|
||||
*/
|
||||
variant?: 'inventory' | 'case'
|
||||
}
|
||||
|
||||
/**
|
||||
* Définition partagée des colonnes des tableaux bovins (inventory + case).
|
||||
* 4 variants : avec/sans colonnes prix × inventory/case.
|
||||
*
|
||||
* Les colonnes Prix/kg et Prix total sont visibles pour les rôles BUREAU
|
||||
* et ADMIN (BUREAU hérite ses droits price-visibility, ADMIN hérite de BUREAU).
|
||||
*/
|
||||
export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
const withPricesInventory: BovineColumn[] = [
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
|
||||
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '72px' },
|
||||
{ key: 'age', label: 'Age', width: '110px' },
|
||||
{ key: 'bovineType.label', label: 'Race', width: '90px' },
|
||||
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
|
||||
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' },
|
||||
{ key: 'pricePerKg', label: 'Prix/kg', width: '65px' },
|
||||
{ key: 'finalPrice', label: 'Prix total', width: '80px' }
|
||||
]
|
||||
|
||||
const withoutPricesInventory: BovineColumn[] = [
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
|
||||
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '72px' },
|
||||
{ key: 'age', label: 'Age', width: '110px' },
|
||||
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '120px' },
|
||||
{ key: 'buildingCase.caseNumber', label: 'Case', width: '42px' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }
|
||||
]
|
||||
|
||||
const withPricesCase: BovineColumn[] = [
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
|
||||
{ key: 'sex', label: 'Sexe', width: '90px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '100px' },
|
||||
{ key: 'age', label: 'Age', width: '90px' },
|
||||
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '110px' },
|
||||
{ key: 'pricePerKg', label: 'Prix/kg', width: '85px' },
|
||||
{ key: 'finalPrice', label: 'Prix total', width: '105px' }
|
||||
]
|
||||
|
||||
const withoutPricesCase: BovineColumn[] = [
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '130px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '100px' },
|
||||
{ key: 'sex', label: 'Sexe', width: '110px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '140px' },
|
||||
{ key: 'age', label: 'Age', width: '130px' },
|
||||
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '170px' }
|
||||
]
|
||||
|
||||
const columns = computed<BovineColumn[]>(() => {
|
||||
const isCase = options.variant === 'case'
|
||||
const seePrice = auth.isBureau
|
||||
|
||||
if (isCase) {
|
||||
return seePrice ? withPricesCase : withoutPricesCase
|
||||
}
|
||||
return seePrice ? withPricesInventory : withoutPricesInventory
|
||||
})
|
||||
|
||||
return { columns }
|
||||
}
|
||||
102
frontend/composables/useDataTableServerState.ts
Normal file
102
frontend/composables/useDataTableServerState.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
type FilterValue = string | number | boolean | null
|
||||
|
||||
export interface UseDataTableServerStateOptions {
|
||||
initialPerPage?: number
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export function useDataTableServerState<T = Record<string, unknown>>(
|
||||
endpoint: string,
|
||||
initialFilters: Record<string, FilterValue> = {},
|
||||
options: UseDataTableServerStateOptions = {}
|
||||
) {
|
||||
const api = useApi()
|
||||
|
||||
const debounceMs = options.debounceMs ?? 300
|
||||
const initialPerPage = options.initialPerPage ?? 10
|
||||
|
||||
const items = ref<T[]>([]) as { value: T[] }
|
||||
const totalItems = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = ref(initialPerPage)
|
||||
const filters = ref<Record<string, FilterValue>>({ ...initialFilters })
|
||||
const loading = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requestToken = 0
|
||||
|
||||
const buildQueryParams = (): Record<string, string | number | boolean> => {
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
page: page.value,
|
||||
itemsPerPage: perPage.value
|
||||
}
|
||||
for (const [key, value] of Object.entries(filters.value)) {
|
||||
if (value === '' || value === null) continue
|
||||
params[key] = value as string | number | boolean
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
const fetchItems = async (): Promise<void> => {
|
||||
const currentToken = ++requestToken
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: T[]; totalItems: number }>(
|
||||
endpoint,
|
||||
buildQueryParams(),
|
||||
{
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' }
|
||||
}
|
||||
)
|
||||
if (currentToken !== requestToken) return
|
||||
items.value = data?.member ?? []
|
||||
totalItems.value = data?.totalItems ?? 0
|
||||
} finally {
|
||||
if (currentToken === requestToken) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reload = (): void => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
void fetchItems()
|
||||
}
|
||||
|
||||
const scheduleReload = (): void => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
void fetchItems()
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
watch([page, perPage], () => {
|
||||
reload()
|
||||
})
|
||||
|
||||
watch(filters, () => {
|
||||
if (page.value !== 1) {
|
||||
page.value = 1
|
||||
return
|
||||
}
|
||||
scheduleReload()
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
items,
|
||||
totalItems,
|
||||
page,
|
||||
perPage,
|
||||
filters,
|
||||
loading,
|
||||
reload
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface UseWeighingOptions {
|
||||
entityName: 'reception' | 'shipment'
|
||||
apiResource: string
|
||||
titleLabel: string
|
||||
isFinal?: boolean
|
||||
getWeightFromScale: () => Promise<WeightData>
|
||||
updateEntity: (id: number, payload: any) => Promise<any>
|
||||
loadEntity?: (id: number) => Promise<any>
|
||||
@@ -23,6 +24,7 @@ export const useWeighing = ({
|
||||
entityName,
|
||||
apiResource,
|
||||
titleLabel,
|
||||
isFinal = false,
|
||||
getWeightFromScale,
|
||||
updateEntity,
|
||||
loadEntity
|
||||
@@ -77,7 +79,7 @@ export const useWeighing = ({
|
||||
})
|
||||
}
|
||||
|
||||
const nextStep = mode === 'tare'
|
||||
const nextStep = isFinal
|
||||
? entity.value.currentStep
|
||||
: entity.value.currentStep + 1
|
||||
await updateEntity(entity.value.id, {
|
||||
@@ -152,7 +154,7 @@ export const useWeighingShipment = ({
|
||||
entity: shipment,
|
||||
entityName: 'shipment',
|
||||
apiResource: 'shipments',
|
||||
titleLabel: modeShipment === 'gross' ? 'Pesée à vide' : 'Pesée à plein',
|
||||
titleLabel: modeShipment === 'gross' ? 'Pesée à plein' : 'Pesée à vide',
|
||||
getWeightFromScale: async () => {
|
||||
const { getWeightShipment } = await import('~/services/shipment')
|
||||
return getWeightShipment()
|
||||
|
||||
@@ -5,13 +5,13 @@ export const shipmentConfig: WorkflowConfig = {
|
||||
apiResource: 'shipments',
|
||||
steps: [
|
||||
{ label: 'Expédition' },
|
||||
{ label: 'Pesée à vide', weighingMode: 'gross' },
|
||||
{ label: 'Pesée à vide', weighingMode: 'tare' },
|
||||
{ label: 'Chargement' },
|
||||
{ label: 'Pesée à plein', weighingMode: 'tare', isFinal: true }
|
||||
{ label: 'Pesée à plein', weighingMode: 'gross', isFinal: true }
|
||||
],
|
||||
weighingLabels: {
|
||||
gross: 'Pesée à vide',
|
||||
tare: 'Pesée à plein'
|
||||
gross: 'Pesée à plein',
|
||||
tare: 'Pesée à vide'
|
||||
},
|
||||
buildReceiptFilename: (entity: WorkflowEntity) => {
|
||||
const ship = entity as any
|
||||
|
||||
27
frontend/middleware/admin-guard.global.ts
Normal file
27
frontend/middleware/admin-guard.global.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
/**
|
||||
* Garde-fou global : empêche les utilisateurs non-admin d'accéder aux pages
|
||||
* sous /admin/*. Renvoie vers la home pour les utilisateurs authentifiés
|
||||
* non-admin, et vers /login pour les non authentifiés.
|
||||
*
|
||||
* L'API back rejette de toute façon les actions admin avec un 403, mais ce
|
||||
* middleware évite l'affichage des pages vides / en erreur quand un user
|
||||
* tape directement l'URL /admin/...
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
if (!to.path.startsWith('/admin')) {
|
||||
return
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
await auth.ensureSession()
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (!auth.isAdmin) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
@@ -32,6 +32,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Type de bovin' })
|
||||
|
||||
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
|
||||
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,67 +1,72 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between ">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des types bovins</h1>
|
||||
</div>
|
||||
<div class="mt-7 border border-slate-200 mb-11 ">
|
||||
<div class="grid grid-cols-2 gap-4 text-primary-700 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
||||
<div>Nom</div>
|
||||
<div>Code</div>
|
||||
<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="px-4 py-6 text-slate-400">
|
||||
|
||||
<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 v-else-if="bovinList.length === 0" class="px-4 py-6 text-slate-400">
|
||||
Aucun type de bovin.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="bovin in bovinList"
|
||||
:key="bovin.id"
|
||||
class="grid grid-cols-2 text-primary-700 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goToBovin(bovin.id)"
|
||||
@keydown.enter="goToBovin(bovin.id)"
|
||||
>
|
||||
<div>{{ bovin.label }}</div>
|
||||
<div>{{ bovin.code }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/bovin"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getBovineTypeList } from "~/services/bovine-type"
|
||||
import type { BovineTypeData } from "~/services/dto/bovine-type-data"
|
||||
import { useAuthStore } from "~/stores/auth"
|
||||
useHead({ title: 'Types de bovins' })
|
||||
|
||||
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const bovinList = ref<BovineTypeData[]>([])
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const goToBovin = (id: number) => {
|
||||
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/${id}`)
|
||||
router.push(`/admin/bovin/${bovin.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isAdmin) return
|
||||
bovinList.value = await getBovineTypeList()
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Transporteur' })
|
||||
|
||||
import {createCarrier, getCarrier, updateCarrier} from "~/services/carrier";
|
||||
import type {CarrierData, CarrierFormData} from "~/services/dto/carrier-data";
|
||||
import {computed} from "vue";
|
||||
|
||||
@@ -1,51 +1,63 @@
|
||||
<template>
|
||||
|
||||
<div class="flex items-center justify-between ">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="mt-7 border border-slate-200 mb-11 ">
|
||||
<div class="grid grid-cols-2 gap-4 text-primary-700 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
||||
<div>Label</div>
|
||||
<div>Code</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="carrier in carrierList"
|
||||
:key="carrier.id"
|
||||
class="grid grid-cols-2 text-primary-700 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goToCarrier(carrier.id)"
|
||||
@keydown.enter="goToCarrier(carrier.id)"
|
||||
<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"
|
||||
>
|
||||
<div>{{ carrier.name}}</div>
|
||||
<div>{{ carrier.code }}</div>
|
||||
</div>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/carrier"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<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 {getCarrierList} from "~/services/carrier";
|
||||
useHead({ title: 'Transporteurs' })
|
||||
|
||||
import type { CarrierData } from '~/services/dto/carrier-data'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const carrierList = ref<CarrierData[]>()
|
||||
const router = useRouter()
|
||||
|
||||
const goToCarrier = (id: number) => {
|
||||
router.push(`/admin/carrier/${id}`)
|
||||
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(async () => {
|
||||
carrierList.value = await getCarrierList(false)
|
||||
})
|
||||
onMounted(reload)
|
||||
</script>
|
||||
|
||||
@@ -96,6 +96,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Client' })
|
||||
|
||||
import {computed, reactive, ref, watch} from "vue"
|
||||
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
|
||||
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Adresse client' })
|
||||
|
||||
import type { AddressData, AddressPayload } from "~/services/address"
|
||||
import { createAddress, getAddress, updateAddress } from "~/services/address"
|
||||
import { getCustomer, updateCustomer } from "~/services/customer"
|
||||
|
||||
@@ -1,73 +1,82 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div
|
||||
class="sticky text-primary-700 top-0 z-10 grid grid-cols-4 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
|
||||
<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"
|
||||
>
|
||||
<div>Nom</div>
|
||||
<div>Téléphone</div>
|
||||
<div>Mail</div>
|
||||
<div>Créé par</div>
|
||||
</div>
|
||||
|
||||
<div v-if="customerList.length === 0" class="px-4 py-6 text-slate-400">
|
||||
Aucun client.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="customer in customerList"
|
||||
:key="customer.id"
|
||||
class="grid grid-cols-4 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
|
||||
@click="goToCustomer(customer.id)"
|
||||
>
|
||||
<div class="truncate">{{ customer.name || "—" }}</div>
|
||||
<div class="truncate">{{ customer.phone || "—" }}</div>
|
||||
<div class="truncate">{{ customer.email || "—" }}</div>
|
||||
<div class="truncate">{{ customer.createdBy?.username || "—" }}</div>
|
||||
</div>
|
||||
<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>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/customer"
|
||||
class="inline-flex items-center mb-16 justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getCustomerList } from "~/services/customer"
|
||||
import type { CustomerData } from "~/services/dto/customer-data"
|
||||
import { useAuthStore } from "~/stores/auth"
|
||||
useHead({ title: 'Clients' })
|
||||
|
||||
import type { CustomerData } from '~/services/dto/customer-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const customerList = ref<CustomerData[]>([])
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const goToCustomer = (id: number) => {
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<CustomerData>(
|
||||
'customers',
|
||||
{
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
'createdBy.username': ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom' },
|
||||
{ key: 'phone', label: 'Téléphone' },
|
||||
{ key: 'email', label: 'Mail' },
|
||||
{ key: 'createdBy.username', label: 'Créé par' }
|
||||
]
|
||||
|
||||
const goToCustomer = (customer: CustomerData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/customer/${id}`)
|
||||
router.push(`/admin/customer/${customer.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isAdmin) return
|
||||
customerList.value = await getCustomerList()
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -97,6 +97,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Fournisseur' })
|
||||
|
||||
import {computed, reactive, ref, watch} from "vue"
|
||||
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
|
||||
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Adresse fournisseur' })
|
||||
|
||||
import type {AddressData, AddressPayload} from "~/services/address";
|
||||
import {createAddress, getAddress, updateAddress} from "~/services/address";
|
||||
import {getSupplier, updateSupplier} from "~/services/supplier";
|
||||
|
||||
@@ -1,73 +1,82 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div
|
||||
class="sticky text-primary-700 top-0 z-10 grid grid-cols-4 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
|
||||
<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"
|
||||
>
|
||||
<div>Nom</div>
|
||||
<div>Téléphone</div>
|
||||
<div>Mail</div>
|
||||
<div>Créé par</div>
|
||||
</div>
|
||||
|
||||
<div v-if="supplierList.length === 0" class="px-4 py-6 text-slate-400">
|
||||
Aucun fournisseur.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="supplier in supplierList"
|
||||
:key="supplier.id"
|
||||
class="grid grid-cols-4 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
|
||||
@click="goToSupplier(supplier.id)"
|
||||
>
|
||||
<div class="truncate">{{ supplier.name || "—" }}</div>
|
||||
<div class="truncate">{{ supplier.phone || "—" }}</div>
|
||||
<div class="truncate">{{ supplier.email || "—" }}</div>
|
||||
<div class="truncate">{{ supplier.createdBy?.username || "—" }}</div>
|
||||
</div>
|
||||
<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>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/supplier"
|
||||
class="inline-flex items-center mb-16 justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getSupplierList } from "~/services/supplier"
|
||||
import type { SupplierData } from "~/services/dto/supplier-data"
|
||||
import { useAuthStore } from "~/stores/auth"
|
||||
useHead({ title: 'Fournisseurs' })
|
||||
|
||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const supplierList = ref<SupplierData[]>([])
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const goToSupplier = (id: number) => {
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<SupplierData>(
|
||||
'suppliers',
|
||||
{
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
'createdBy.username': ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom' },
|
||||
{ key: 'phone', label: 'Téléphone' },
|
||||
{ key: 'email', label: 'Mail' },
|
||||
{ key: 'createdBy.username', label: 'Créé par' }
|
||||
]
|
||||
|
||||
const goToSupplier = (supplier: SupplierData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/supplier/${id}`)
|
||||
router.push(`/admin/supplier/${supplier.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isAdmin) return
|
||||
supplierList.value = await getSupplierList()
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Utilisateur' })
|
||||
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ROLE } from '~/utils/constants'
|
||||
import { createUser, updateUser, getUser } from '~/services/auth'
|
||||
|
||||
@@ -1,92 +1,110 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
|
||||
<div class="grid grid-cols-3 text-primary-700 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
||||
<div>Utilisateur</div>
|
||||
<div>Role</div>
|
||||
<div>Statut</div>
|
||||
</div>
|
||||
<div v-if="userList.length === 0" class="px-4 py-6 text-slate-400">
|
||||
Aucun utilisateur.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="user in userList"
|
||||
:key="user.id"
|
||||
class="grid grid-cols-3 text-primary-700 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200 items-center"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goToUser(user.id)"
|
||||
@keydown.enter="goToUser(user.id)"
|
||||
<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"
|
||||
>
|
||||
<div>{{ user.username }}</div>
|
||||
<div>{{ getRoleLabels(user.roles) }}</div>
|
||||
<div>
|
||||
<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="user.isLocked"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
Acces reserve aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/user"
|
||||
class="inline-flex items-center mb-16 justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</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 { getAdminUsers } from "~/services/auth"
|
||||
import { ROLE } from "~/utils/constants"
|
||||
import { useAuthStore } from "~/stores/auth"
|
||||
useHead({ title: 'Utilisateurs' })
|
||||
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { ROLE } from '~/utils/constants'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const userList = ref<UserData[]>([])
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const roleLabelByValue = new Map(ROLE.map((role) => [role.value, role.label]))
|
||||
const roleLabelByValue = new Map(ROLE.map(role => [role.value, role.label]))
|
||||
|
||||
const goToUser = (id: number) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/user/${id}`)
|
||||
}
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<UserData>(
|
||||
'admin/users',
|
||||
{
|
||||
username: '',
|
||||
isLocked: ''
|
||||
}
|
||||
)
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
if (auth.isAdmin) return
|
||||
event.preventDefault()
|
||||
}
|
||||
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(', ')
|
||||
if (!roles || roles.length === 0) return '---'
|
||||
return roles.map(role => roleLabelByValue.get(role) ?? role).join(', ')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const goToUser = (user: UserData) => {
|
||||
if (!auth.isAdmin) return
|
||||
userList.value = await getAdminUsers()
|
||||
router.push(`/admin/user/${user.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Accueil' })
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-center pb-16 gap-12">
|
||||
@@ -18,9 +19,9 @@
|
||||
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
|
||||
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
|
||||
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
|
||||
<card-link link="/" iconName="mdi:cow">
|
||||
<card-link link="/inventory" iconName="mdi:cow">
|
||||
<template #label>
|
||||
PASSEPORT<br>DU BOVIN
|
||||
INVENTAIRE<br>BOVINS
|
||||
</template>
|
||||
</card-link>
|
||||
</div>
|
||||
|
||||
182
frontend/pages/infrastructure/bovine.vue
Normal file
182
frontend/pages/infrastructure/bovine.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<form :class="{ submitted }" @submit.prevent="validate">
|
||||
<div class="flex items-center relative">
|
||||
<div class="flex flex-row absolute -left-[60px]">
|
||||
<Icon
|
||||
@click="goBack"
|
||||
name="gg:arrow-left-o"
|
||||
size="40"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-3xl text-primary-500 font-bold uppercase">
|
||||
{{ isEdit ? 'Modification d\'un bovin' : 'Ajout d\'un bovin' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
|
||||
<UiTextInput
|
||||
id="bovine-national-number"
|
||||
v-model="form.nationalNumber"
|
||||
label="Numéro national"
|
||||
:disabled="!auth.isAdmin || isLoading"
|
||||
wrapper-class="w-[280px]"
|
||||
required
|
||||
/>
|
||||
<UiNumberInput
|
||||
id="bovine-received-weight"
|
||||
v-model="form.receivedWeight"
|
||||
label="Poids à l'arrivée (kg)"
|
||||
:min="0"
|
||||
:disabled="!auth.isAdmin || isLoading"
|
||||
wrapper-class="w-[280px] flex-col"
|
||||
label-class="font-bold uppercase"
|
||||
/>
|
||||
<UiDateInput
|
||||
id="bovine-arrival-date"
|
||||
v-model="form.arrivalDate"
|
||||
label="Date d'arrivée"
|
||||
:disabled="!auth.isAdmin || isLoading"
|
||||
wrapper-class="w-[280px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-cols-3 justify-between mb-11">
|
||||
<UiSelect
|
||||
id="bovine-supplier"
|
||||
v-model="form.supplierId"
|
||||
label="Vendeur"
|
||||
:options="supplierOptions"
|
||||
:loading="isLoadingSuppliers"
|
||||
:disabled="!auth.isAdmin || isLoading"
|
||||
wrapper-class="w-[280px]"
|
||||
/>
|
||||
<div class="w-[280px]" />
|
||||
<div class="w-[280px]" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<UiButton
|
||||
type="submit"
|
||||
:disabled="!auth.isAdmin || isLoading"
|
||||
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||
@click="submitted = true"
|
||||
>
|
||||
<Icon :name="isEdit ? '' : 'mdi:plus'" size="28" />
|
||||
{{ isEdit ? 'Valider' : 'Ajouter' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Bovins' })
|
||||
|
||||
import { createBovine, getBovine, updateBovine } from '~/services/bovine'
|
||||
import type { BovinePayload } from '~/services/dto/bovine-data'
|
||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||
import { getSupplierList } from '~/services/supplier'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const caseId = computed(() => {
|
||||
const raw = Number(route.query.caseId)
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : null
|
||||
})
|
||||
|
||||
const bovineId = computed(() => {
|
||||
const raw = Number(route.query.id)
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : null
|
||||
})
|
||||
|
||||
const isEdit = computed(() => bovineId.value !== null)
|
||||
|
||||
const form = reactive<{
|
||||
nationalNumber: string
|
||||
receivedWeight: number | null
|
||||
arrivalDate: string | null
|
||||
supplierId: string
|
||||
}>({
|
||||
nationalNumber: '',
|
||||
receivedWeight: null,
|
||||
arrivalDate: null,
|
||||
supplierId: ''
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const submitted = ref(false)
|
||||
const suppliers = ref<SupplierData[]>([])
|
||||
const isLoadingSuppliers = ref(false)
|
||||
|
||||
const supplierOptions = computed(() =>
|
||||
suppliers.value.map(s => ({ value: String(s.id), label: s.name }))
|
||||
)
|
||||
|
||||
const backRoute = computed(() => ({
|
||||
path: '/infrastructure/case',
|
||||
query: caseId.value ? { id: String(caseId.value) } : {}
|
||||
}))
|
||||
|
||||
const goBack = () => {
|
||||
router.push(backRoute.value)
|
||||
}
|
||||
|
||||
const loadSuppliers = async () => {
|
||||
isLoadingSuppliers.value = true
|
||||
try {
|
||||
suppliers.value = await getSupplierList()
|
||||
} finally {
|
||||
isLoadingSuppliers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hydrate = async () => {
|
||||
if (!isEdit.value || bovineId.value === null) {
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
try {
|
||||
const bovine = await getBovine(bovineId.value)
|
||||
form.nationalNumber = bovine.nationalNumber ?? ''
|
||||
form.receivedWeight = bovine.receivedWeight ?? null
|
||||
form.arrivalDate = bovine.arrivalDate ?? null
|
||||
if (bovine.supplier) {
|
||||
const supplierId = bovine.supplier.replace(/.*\//, '')
|
||||
form.supplierId = supplierId
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const validate = async () => {
|
||||
if (isLoading.value || !auth.isAdmin) return
|
||||
if (!caseId.value) return
|
||||
if (!form.nationalNumber.trim()) return
|
||||
|
||||
const payload: BovinePayload = {
|
||||
nationalNumber: form.nationalNumber.trim(),
|
||||
receivedWeight: form.receivedWeight,
|
||||
arrivalDate: form.arrivalDate,
|
||||
buildingCase: `/api/building_cases/${caseId.value}`,
|
||||
supplier: form.supplierId ? `/api/suppliers/${form.supplierId}` : null
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (isEdit.value && bovineId.value !== null) {
|
||||
await updateBovine(bovineId.value, payload)
|
||||
} else {
|
||||
await createBovine(payload)
|
||||
}
|
||||
router.push(backRoute.value)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSuppliers)
|
||||
watch(bovineId, hydrate, { immediate: true })
|
||||
</script>
|
||||
@@ -1,19 +1,18 @@
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<!-- En-tête de page avec retour et titre -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-10">
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between relative">
|
||||
<div class="flex flex-row absolute -left-[60px]">
|
||||
<Icon
|
||||
@click="router.push('/')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">bâtiments</h1>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px] space-y-6">
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- Liste des bâtiments + rendu du plan de chaque bâtiment -->
|
||||
<div
|
||||
v-for="entry in buildingLayouts"
|
||||
@@ -36,7 +35,7 @@
|
||||
v-for="cell in entry.cells"
|
||||
:key="cell.key"
|
||||
class="relative text-white flex h-[50px] items-center justify-center border-y-[3px] border-y-black bg-white hover:opacity-85 focus-visible:outline-none"
|
||||
:class="[cell.sideBorderClass, activeLegendStatutId !== null && cell.caseStatusId !== activeLegendStatutId ? 'opacity-35 hover:opacity-70' : '']"
|
||||
:class="[cell.sideBorderClass, activeLegendLabel !== null && cell.caseStatusLabel !== activeLegendLabel ? 'opacity-35 hover:opacity-70' : '']"
|
||||
:style="[cell.spanStyle, cell.sideBorderStyle]"
|
||||
:to="cell.caseId ? `/infrastructure/case?id=${cell.caseId}` : '/infrastructure/case'"
|
||||
:title="cell.caseStatusLabel ?? undefined"
|
||||
@@ -58,25 +57,19 @@
|
||||
|
||||
<!-- Légende : survol d'un statut => atténue les autres cases -->
|
||||
<div class="py-4">
|
||||
<!-- 3 zones fixes pour forcer gauche / centre / droite sur toute la largeur -->
|
||||
<div class="grid w-full grid-cols-3 gap-3">
|
||||
<div class="flex gap-6">
|
||||
<div
|
||||
v-for="(statut, index) in statutLegend"
|
||||
:key="statut.id"
|
||||
class="flex min-w-0 cursor-pointer items-center gap-2 py-1"
|
||||
:class="[
|
||||
index === 0 ? 'justify-self-start' : '',
|
||||
index === statutLegend.length - 1 ? 'justify-self-end' : '',
|
||||
index > 0 && index < statutLegend.length - 1 ? 'justify-self-center' : ''
|
||||
]"
|
||||
@mouseenter="activeLegendStatutId = statut.id"
|
||||
@mouseleave="activeLegendStatutId = null"
|
||||
v-for="statut in statutLegend"
|
||||
:key="statut.label"
|
||||
class="flex cursor-pointer items-center gap-2 py-1"
|
||||
@mouseenter="activeLegendLabel = statut.label"
|
||||
@mouseleave="activeLegendLabel = null"
|
||||
>
|
||||
<span
|
||||
class="h-5 w-5 border border-slate-300"
|
||||
:style="statut.couleur ? { backgroundColor: statut.couleur } : {}"
|
||||
></span>
|
||||
<span class="truncate text-sm uppercase text-slate-700">
|
||||
<span class="text-sm uppercase text-slate-700">
|
||||
{{ statut.label }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -87,36 +80,40 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Bâtiments' })
|
||||
|
||||
import type {BuildingData} from "~/services/dto/building-data"
|
||||
import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
|
||||
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"
|
||||
import type {BuildingCaseStatusData} from "~/services/dto/building-case-status-data"
|
||||
import {getBuildingList} from "~/services/building"
|
||||
import {getStatutList} from "~/services/statut"
|
||||
|
||||
definePageMeta({layout: "default"})
|
||||
|
||||
const router = useRouter()
|
||||
// Données brutes chargées depuis l'API
|
||||
const buildingList = ref<BuildingData[]>([])
|
||||
const statutLegend = ref<BuildingCaseStatusData[]>([])
|
||||
const statutLegend = [
|
||||
{ label: 'Libre', couleur: '#A3B18A' },
|
||||
{ label: 'Occupé', couleur: '#3A506B' },
|
||||
{ label: 'Malade', couleur: '#E07A5F' },
|
||||
]
|
||||
// Statut actuellement survolé dans la légende (pour filtrage visuel)
|
||||
const activeLegendStatutId = ref<number | null>(null)
|
||||
const activeLegendLabel = ref<string | null>(null)
|
||||
// Modèle de vue prêt pour le template (layout + cellules + styles de grille)
|
||||
const buildingLayouts = computed(() =>
|
||||
buildingList.value.map((building) => {
|
||||
// On affiche uniquement le premier layout du bâtiment
|
||||
const layout = building.layouts?.[0] ?? null
|
||||
const view = layout ? buildLayoutView(layout) : null
|
||||
return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}}
|
||||
})
|
||||
buildingList.value
|
||||
.filter((building) => building.layouts && building.layouts.length > 0)
|
||||
.map((building) => {
|
||||
const layout = building.layouts![0]
|
||||
const view = buildLayoutView(layout)
|
||||
return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}}
|
||||
})
|
||||
)
|
||||
|
||||
type GridCell = {
|
||||
key: string
|
||||
caseId: number | null
|
||||
display: string
|
||||
caseStatusId: number | null
|
||||
caseStatusLabel: string | null
|
||||
// Couleur de fond de la case (dépend du statut)
|
||||
caseStyle?: Record<string, string>
|
||||
@@ -130,7 +127,8 @@ type GridCell = {
|
||||
contentInsetClass: string
|
||||
}
|
||||
// Type intermédiaire : garde des infos utiles au calcul des bordures, retirées ensuite
|
||||
type GridCellDraft = Omit<GridCell, "sideBorderClass" | "sideBorderStyle" | "contentInsetClass"> & { x: number; columnSpan: number }
|
||||
type GridCellDraft = Omit<GridCell, "sideBorderClass" | "sideBorderStyle" | "contentInsetClass"> & { x: number; columnSpan: number}
|
||||
|
||||
|
||||
// Nettoie la couleur de statut pour éviter les chaînes vides / espaces
|
||||
const normalizeCaseStatusColor = (value: string | null | undefined): string | null => {
|
||||
@@ -181,7 +179,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
|
||||
// Métadonnées utiles au rendu / navigation / légende
|
||||
const caseId = (position.buildingCase?.id ?? null) as number | null
|
||||
const caseNumber = (position.buildingCase?.caseNumber ?? null) as number | null
|
||||
const caseStatusId = position.buildingCase?.statut?.id ?? null
|
||||
const caseStatusLabel = position.buildingCase?.statut?.label ?? null
|
||||
const statusColor = normalizeCaseStatusColor(position.buildingCase?.statut?.couleur)
|
||||
|
||||
@@ -191,7 +188,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
|
||||
columnSpan,
|
||||
caseId,
|
||||
display: caseNumber !== null ? String(caseNumber) : "Case",
|
||||
caseStatusId,
|
||||
caseStatusLabel,
|
||||
caseStyle: statusColor ? {backgroundColor: statusColor} : undefined,
|
||||
// Exemple : "14 / span 1" => commence en colonne 14 et occupe 1 colonne
|
||||
@@ -230,13 +226,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Chargement initial des bâtiments et de la légende des statuts
|
||||
const buildings = await getBuildingList()
|
||||
const statuts = await getStatutList()
|
||||
buildingList.value = buildings
|
||||
// Tri alphabétique FR pour une légende stable
|
||||
statutLegend.value = [...statuts].sort((a, b) =>
|
||||
(a.label ?? "").localeCompare(b.label ?? "", "fr", {sensitivity: "base"})
|
||||
)
|
||||
buildingList.value = await getBuildingList()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,27 +1,291 @@
|
||||
<template>
|
||||
<div class="flex justify-center items-center">
|
||||
<UiButton
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
||||
:disabled="!hasCaseId"
|
||||
@click="printCaseReport"
|
||||
>
|
||||
Imprimer
|
||||
</UiButton>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between relative">
|
||||
<div class="flex flex-row absolute -left-[60px]">
|
||||
<Icon
|
||||
@click="router.push('/infrastructure/building')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="font-bold text-4xl text-primary-500 uppercase">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
|
||||
<div
|
||||
v-if="hasCaseId"
|
||||
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer"
|
||||
title="Imprimer"
|
||||
@click="printCaseReport"
|
||||
>
|
||||
<Icon name="mdi:printer-outline" size="32" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-if="hasCaseId && auth.isAdmin"
|
||||
:to="addBovineRoute"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
<div class="flex items-center gap-3 rounded-md bg-red-500 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-white">{{ stats.over24 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-white">≥ 24 mois</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-md bg-orange-500 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-white">{{ stats.between22And24 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-white">22 – 24 mois</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-md bg-yellow-500 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-white">{{ stats.between20And22 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-white">20 – 22 mois</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
:row-clickable="auth.isAdmin"
|
||||
empty-message="Aucun bovin dans cette case."
|
||||
@row-click="goToBovine"
|
||||
>
|
||||
<template #header-nationalNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.nationalNumber"
|
||||
placeholder="N° National"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-workNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.workNumber"
|
||||
placeholder="N° Travail"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-sex>
|
||||
<UiSelect
|
||||
v-model="filters.sex"
|
||||
placeholder="Sexe"
|
||||
:options="sexOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-birthDate>
|
||||
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
|
||||
</template>
|
||||
<template #header-age>
|
||||
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-pricePerKg>
|
||||
<UiTextInput :model-value="''" placeholder="Prix/kg" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-finalPrice>
|
||||
<UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-bovineType.label>
|
||||
<UiTextInput
|
||||
v-model="filters['bovineType.label']"
|
||||
placeholder="Race"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-arrivalDate>
|
||||
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
|
||||
</template>
|
||||
<template #cell-birthDate="{ item }">
|
||||
{{ formatDate(item.birthDate) }}
|
||||
</template>
|
||||
<template #cell-age="{ item }">
|
||||
<span
|
||||
class="inline-block rounded px-2 py-0.5 font-semibold"
|
||||
:class="ageBadgeClass(item.ageMonths)"
|
||||
>
|
||||
{{ formatAgeLabel(item.ageMonths) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-arrivalDate="{ item }">
|
||||
{{ formatDate(item.arrivalDate) }}
|
||||
</template>
|
||||
<template #cell-pricePerKg="{ item }">
|
||||
{{ formatPrice(item.pricePerKg) }}
|
||||
</template>
|
||||
<template #cell-finalPrice="{ item }">
|
||||
{{ formatPrice(item.finalPrice) }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Cases' })
|
||||
|
||||
import type { BuildingCaseData } from '~/services/dto/building-case-data'
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
import { useBovineColumns } from '~/composables/useBovineColumns'
|
||||
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { printPdf } = usePdfPrinter()
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const caseId = computed(() => Number(route.query.id))
|
||||
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
|
||||
|
||||
const printCaseReport = async () => {
|
||||
const buildingCase = ref<BuildingCaseData | null>(null)
|
||||
|
||||
interface InventoryStats {
|
||||
total: number
|
||||
over24: number
|
||||
between22And24: number
|
||||
between20And22: number
|
||||
}
|
||||
|
||||
const stats = ref<InventoryStats>({
|
||||
total: 0,
|
||||
over24: 0,
|
||||
between22And24: 0,
|
||||
between20And22: 0
|
||||
})
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!hasCaseId.value) {
|
||||
stats.value = { total: 0, over24: 0, between22And24: 0, between20And22: 0 }
|
||||
return
|
||||
}
|
||||
try {
|
||||
stats.value = await api.get<InventoryStats>('bovines/inventory-stats', {
|
||||
buildingCaseId: caseId.value
|
||||
}, { toast: false })
|
||||
} catch {
|
||||
// silencieux
|
||||
}
|
||||
}
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<BovineData>(
|
||||
'bovines',
|
||||
{
|
||||
'exists[exitedAt]': 'false',
|
||||
buildingCase: '',
|
||||
nationalNumber: '',
|
||||
workNumber: '',
|
||||
'bovineType.label': '',
|
||||
sex: '',
|
||||
'arrivalDate[after]': '',
|
||||
'arrivalDate[strictly_before]': '',
|
||||
'birthDate[after]': '',
|
||||
'birthDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const sexOptions = [
|
||||
{ value: 'M', label: 'Mâle' },
|
||||
{ value: 'F', label: 'Femelle' }
|
||||
]
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const singleDateFilter = (afterKey: string, beforeKey: string) =>
|
||||
computed<string>({
|
||||
get: () => (filters.value[afterKey] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value[afterKey] = ''
|
||||
filters.value[beforeKey] = ''
|
||||
return
|
||||
}
|
||||
filters.value[afterKey] = value
|
||||
filters.value[beforeKey] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
|
||||
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
|
||||
|
||||
const { columns } = useBovineColumns({ variant: 'case' })
|
||||
|
||||
const title = computed(() => {
|
||||
if (!buildingCase.value) return ''
|
||||
const buildingLabel = buildingCase.value.building?.label ?? ''
|
||||
const caseNumber = buildingCase.value.caseNumber ?? ''
|
||||
return `${buildingLabel} case ${caseNumber}`.trim()
|
||||
})
|
||||
|
||||
const addBovineRoute = computed(() => ({
|
||||
path: '/infrastructure/bovine',
|
||||
query: { caseId: String(caseId.value) }
|
||||
}))
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return date
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const formatPrice = (price: number | null) => {
|
||||
if (price === null || price === undefined) return '—'
|
||||
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||
}
|
||||
|
||||
|
||||
const loadCase = async () => {
|
||||
if (!hasCaseId.value) {
|
||||
buildingCase.value = null
|
||||
return
|
||||
}
|
||||
buildingCase.value = await api.get<BuildingCaseData>(`/building_cases/${caseId.value}`)
|
||||
}
|
||||
|
||||
const printCaseReport = async () => {
|
||||
if (!hasCaseId.value) return
|
||||
const filename = `tableau_poids_case_${caseId.value}.pdf`
|
||||
await printPdf(`/building_cases/${caseId.value}/weights-report`, filename)
|
||||
}
|
||||
|
||||
const goToBovine = (bovine: BovineData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push({
|
||||
path: '/infrastructure/bovine',
|
||||
query: { id: String(bovine.id), caseId: String(caseId.value) }
|
||||
})
|
||||
}
|
||||
|
||||
watch(caseId, (id) => {
|
||||
if (!hasCaseId.value) {
|
||||
filters.value.buildingCase = ''
|
||||
buildingCase.value = null
|
||||
return
|
||||
}
|
||||
filters.value.buildingCase = `/api/building_cases/${id}`
|
||||
loadCase()
|
||||
loadStats()
|
||||
reload()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
314
frontend/pages/inventory.vue
Normal file
314
frontend/pages/inventory.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-between relative">
|
||||
<div class="flex flex-row absolute -left-[60px]">
|
||||
<Icon
|
||||
@click="router.push('/')"
|
||||
name="gg:arrow-left-o"
|
||||
size="44"
|
||||
class="cursor-pointer text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="font-bold text-3xl uppercase text-primary-500">Inventaire bovins</h1>
|
||||
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
|
||||
<div
|
||||
v-if="auth.isBureau"
|
||||
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
|
||||
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
|
||||
title="Exporter en Excel"
|
||||
@click="showExportModal = true"
|
||||
>
|
||||
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="auth.isBureau"
|
||||
type="button"
|
||||
:disabled="syncing"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@click="syncInventory"
|
||||
>
|
||||
<Icon name="mdi:sync" size="28" :class="syncing ? 'animate-spin' : ''" />
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
<div class="flex items-center gap-3 rounded-md bg-red-500 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-white">{{ stats.over24 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-white">≥ 24 mois</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-md bg-orange-500 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-white">{{ stats.between22And24 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-white">22 – 24 mois</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-md bg-yellow-500 px-4 py-2">
|
||||
<span class="text-2xl font-bold text-white">{{ stats.between20And22 }}</span>
|
||||
<span class="text-sm uppercase tracking-wide text-white">20 – 22 mois</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 mb-8">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
>
|
||||
<template #header-nationalNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.nationalNumber"
|
||||
placeholder="N° National"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-workNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.workNumber"
|
||||
placeholder="N° Travail"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-sex>
|
||||
<UiSelect
|
||||
v-model="filters.sex"
|
||||
placeholder="Sexe"
|
||||
:options="sexOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-birthDate>
|
||||
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
|
||||
</template>
|
||||
<template #header-bovineType.label>
|
||||
<UiTextInput
|
||||
v-model="filters['bovineType.label']"
|
||||
placeholder="Race"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-arrivalDate>
|
||||
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
|
||||
</template>
|
||||
<template #header-buildingCase.building.label>
|
||||
<UiTextInput :model-value="''" placeholder="Bâtiment" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-buildingCase.caseNumber>
|
||||
<UiTextInput :model-value="''" placeholder="Case" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-age>
|
||||
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-pricePerKg>
|
||||
<UiTextInput :model-value="''" placeholder="Prix/kg" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-finalPrice>
|
||||
<UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-birthDate="{ item }">
|
||||
{{ formatDate(item.birthDate) }}
|
||||
</template>
|
||||
<template #cell-age="{ item }">
|
||||
<span
|
||||
class="inline-block rounded px-2 py-0.5 font-semibold"
|
||||
:class="ageBadgeClass(item.ageMonths)"
|
||||
>
|
||||
{{ formatAgeLabel(item.ageMonths) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-arrivalDate="{ item }">
|
||||
{{ formatDate(item.arrivalDate) }}
|
||||
</template>
|
||||
<template #cell-buildingCase.building.label="{ item }">
|
||||
{{ item.effectiveBuilding?.label ?? '—' }}
|
||||
</template>
|
||||
<template #cell-buildingCase.caseNumber="{ item }">
|
||||
{{ item.buildingCase?.caseNumber ?? '—' }}
|
||||
</template>
|
||||
<template #cell-pricePerKg="{ item }">
|
||||
{{ formatPrice(item.pricePerKg) }}
|
||||
</template>
|
||||
<template #cell-finalPrice="{ item }">
|
||||
{{ formatPrice(item.finalPrice) }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
|
||||
<InventoryExportModal
|
||||
v-model="showExportModal"
|
||||
:loading="exporting"
|
||||
@submit="exportInventory"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Inventaire' })
|
||||
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import type { InventoryExportFilters } from '~/components/inventory/inventory-export-modal.vue'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
import { useBovineColumns } from '~/composables/useBovineColumns'
|
||||
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const api = useApi()
|
||||
const toast = useToast()
|
||||
|
||||
interface SyncResult {
|
||||
created: number
|
||||
updated: number
|
||||
exited: number
|
||||
total: number
|
||||
}
|
||||
|
||||
interface InventoryStats {
|
||||
total: number
|
||||
over24: number
|
||||
between22And24: number
|
||||
between20And22: number
|
||||
}
|
||||
|
||||
const stats = ref<InventoryStats>({
|
||||
total: 0,
|
||||
over24: 0,
|
||||
between22And24: 0,
|
||||
between20And22: 0
|
||||
})
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
stats.value = await api.get<InventoryStats>('bovines/inventory-stats', {}, { toast: false })
|
||||
} catch {
|
||||
// silencieux : l'écran reste utilisable sans la légende
|
||||
}
|
||||
}
|
||||
|
||||
const syncing = ref(false)
|
||||
const exporting = ref(false)
|
||||
const showExportModal = ref(false)
|
||||
|
||||
const exportInventory = async (filters: InventoryExportFilters) => {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (filters.ageRanges.length > 0) {
|
||||
query.ageRanges = filters.ageRanges.join(',')
|
||||
}
|
||||
const blob = await api.getBlob('bovines/inventory-export', query)
|
||||
const filename = `inventaire_bovins_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
showExportModal.value = false
|
||||
} catch {
|
||||
// toast déjà géré par useApi onResponseError
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const syncInventory = async () => {
|
||||
if (syncing.value) return
|
||||
const confirmed = window.confirm(
|
||||
"Lancer la synchronisation avec EDNOTIF ?\n\nLes bovins absents de la réponse seront marqués comme sortis."
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
syncing.value = true
|
||||
try {
|
||||
const result = await api.post<SyncResult>('bovines/sync-inventory')
|
||||
toast.success({
|
||||
title: 'Inventaire synchronisé',
|
||||
message: `Créés : ${result.created} · Mis à jour : ${result.updated} · Sortis : ${result.exited} · Total EDNOTIF : ${result.total}`
|
||||
})
|
||||
reload()
|
||||
loadStats()
|
||||
} catch {
|
||||
// error toast already handled by useApi onResponseError
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<BovineData>(
|
||||
'bovines',
|
||||
{
|
||||
'exists[exitedAt]': 'false',
|
||||
nationalNumber: '',
|
||||
workNumber: '',
|
||||
'bovineType.label': '',
|
||||
sex: '',
|
||||
'arrivalDate[after]': '',
|
||||
'arrivalDate[strictly_before]': '',
|
||||
'birthDate[after]': '',
|
||||
'birthDate[strictly_before]': ''
|
||||
}
|
||||
)
|
||||
|
||||
const sexOptions = [
|
||||
{ value: 'M', label: 'Mâle' },
|
||||
{ value: 'F', label: 'Femelle' }
|
||||
]
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const singleDateFilter = (afterKey: string, beforeKey: string) =>
|
||||
computed<string>({
|
||||
get: () => (filters.value[afterKey] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value[afterKey] = ''
|
||||
filters.value[beforeKey] = ''
|
||||
return
|
||||
}
|
||||
filters.value[afterKey] = value
|
||||
filters.value[beforeKey] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
|
||||
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
|
||||
|
||||
const { columns } = useBovineColumns()
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return date
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const formatPrice = (price: number | null) => {
|
||||
if (price === null || price === undefined) return '—'
|
||||
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
reload()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
@@ -53,6 +53,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Connexion' })
|
||||
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { getUsers } from '~/services/auth'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Réception' })
|
||||
|
||||
import { useReceptionStore } from '~/stores/reception'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
|
||||
|
||||
@@ -1,45 +1,133 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-start gap-10">
|
||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions finie</h1>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des réceptions finies</h1>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
<div class="mt-6 border border-slate-200 mb-16 ">
|
||||
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
||||
<div>Numéro</div>
|
||||
<div>Date et heure</div>
|
||||
<div>Fournisseur</div>
|
||||
<div>Adresse</div>
|
||||
<div>Type réception</div>
|
||||
<div>Poids</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="reception in receptionList"
|
||||
:key="reception.id"
|
||||
class="grid grid-cols-6 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goToReception(reception.id)"
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToReception"
|
||||
>
|
||||
<div>{{ reception.identificationNumber}}</div>
|
||||
<div>{{ formatDate(reception.receptionDate) }}</div>
|
||||
<div>{{ reception.supplier?.name }}</div>
|
||||
<div>{{ reception.address?.fullAddress }}</div>
|
||||
<div>{{ reception.receptionType?.label }}</div>
|
||||
<div>{{ formatWeighing(reception) }}</div>
|
||||
</div>
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionDate>
|
||||
<UiDateMaskedInput
|
||||
v-model="receptionDateFilter"
|
||||
placeholder="Date"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-supplier.name>
|
||||
<UiTextInput
|
||||
v-model="filters['supplier.name']"
|
||||
placeholder="Fournisseur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-address.fullAddress>
|
||||
<UiTextInput
|
||||
:model-value="''"
|
||||
placeholder="Adresse"
|
||||
size="compact"
|
||||
disabled
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionType.label>
|
||||
<UiSelect
|
||||
v-model="filters['receptionType.id']"
|
||||
placeholder="Type réception"
|
||||
:options="receptionTypeOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-weighing>
|
||||
<UiTextInput
|
||||
:model-value="''"
|
||||
placeholder="Poids"
|
||||
size="compact"
|
||||
disabled
|
||||
/>
|
||||
</template>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #cell-weighing="{ item }">
|
||||
{{ formatWeighing(item) }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {ReceptionData} from "~/services/dto/reception-data";
|
||||
import {getReceptionList} from "~/services/reception";
|
||||
import type {ShipmentData} from "~/services/dto/shipment-data";
|
||||
useHead({ title: 'Validation réception' })
|
||||
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||
import { getReceptionTypeList } from '~/services/reception-type'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const receptionList = ref<ReceptionData[]>()
|
||||
const router = useRouter()
|
||||
const receptionTypes = ref<ReceptionTypeData[]>([])
|
||||
|
||||
const receptionTypeOptions = computed(() =>
|
||||
receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label }))
|
||||
)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
isValid: true,
|
||||
'identificationNumber': '',
|
||||
'supplier.name': '',
|
||||
'receptionType.id': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const receptionDateFilter = computed<string>({
|
||||
get: () => (filters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['receptionDate[after]'] = ''
|
||||
filters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['receptionDate[after]'] = value
|
||||
filters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'receptionDate', label: 'Date', width: '120px' },
|
||||
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||
{ key: 'receptionType.label', label: 'Type réception', width: '0.9fr' },
|
||||
{ key: 'weighing', label: 'Poids', width: '82px' }
|
||||
]
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '—'
|
||||
@@ -65,11 +153,12 @@ const formatWeighing = (reception: ReceptionData) => {
|
||||
return `${gross - tare} kg`
|
||||
}
|
||||
|
||||
const goToReception = (id: number) => {
|
||||
router.push(`/reception/update/${id}`)
|
||||
const goToReception = (reception: ReceptionData) => {
|
||||
router.push(`/reception/update/${reception.id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
receptionList.value = await getReceptionList(true)
|
||||
receptionTypes.value = await getReceptionTypeList()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -226,6 +226,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Modifier réception' })
|
||||
|
||||
import { usePdfPrinter } from '#imports'
|
||||
import { computed } from 'vue'
|
||||
import UpdateBovin from '~/components/reception/update-bovin.vue'
|
||||
|
||||
@@ -1,43 +1,137 @@
|
||||
<template>
|
||||
<WorkflowWaitingList
|
||||
title="listes des réceptions en attente"
|
||||
:columns="columns"
|
||||
:items="receptionList ?? []"
|
||||
route-prefix="/reception"
|
||||
:show-actions="auth.isAdmin"
|
||||
>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<Icon
|
||||
name="mdi:delete-outline"
|
||||
size="24"
|
||||
class="cursor-pointer text-red-500 hover:text-red-700"
|
||||
@click="confirmDelete(item)"
|
||||
/>
|
||||
</template>
|
||||
</WorkflowWaitingList>
|
||||
<div class="flex items-center justify-start gap-10">
|
||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des réceptions en attente</h1>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
:show-actions="auth.isAdmin"
|
||||
row-clickable
|
||||
@row-click="goToReception"
|
||||
>
|
||||
<template #header-receptionDate>
|
||||
<UiDateMaskedInput v-model="receptionDateFilter" placeholder="Date" size="compact" />
|
||||
</template>
|
||||
<template #header-supplier.name>
|
||||
<UiTextInput
|
||||
v-model="filters['supplier.name']"
|
||||
placeholder="Fournisseur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-address.fullAddress>
|
||||
<UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-receptionType.label>
|
||||
<UiSelect
|
||||
v-model="filters['receptionType.id']"
|
||||
placeholder="Type réception"
|
||||
:options="receptionTypeOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-carrier.name>
|
||||
<UiTextInput
|
||||
v-model="filters['carrier.name']"
|
||||
placeholder="Transporteur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-licensePlate>
|
||||
<UiTextInput
|
||||
v-model="filters['licensePlate']"
|
||||
placeholder="Immatriculation"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<UiTextInput :model-value="''" placeholder="Actions" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<Icon
|
||||
name="mdi:delete-outline"
|
||||
size="24"
|
||||
class="cursor-pointer text-red-500 hover:text-red-700"
|
||||
@click="confirmDelete(item)"
|
||||
/>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import { getReceptionList, deleteReception } from '~/services/reception'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
useHead({ title: 'Réceptions en attente' })
|
||||
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||
import { deleteReception } from '~/services/reception'
|
||||
import { getReceptionTypeList } from '~/services/reception-type'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const receptionTypes = ref<ReceptionTypeData[]>([])
|
||||
|
||||
const receptionTypeOptions = computed(() =>
|
||||
receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label }))
|
||||
)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
isValid: false,
|
||||
'supplier.name': '',
|
||||
'carrier.name': '',
|
||||
'licensePlate': '',
|
||||
'receptionType.id': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const receptionDateFilter = computed<string>({
|
||||
get: () => (filters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['receptionDate[after]'] = ''
|
||||
filters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['receptionDate[after]'] = value
|
||||
filters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'receptionDate', label: 'Date et heure' },
|
||||
{ key: 'supplier.name', label: 'Fournisseur' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse' },
|
||||
{ key: 'receptionType.label', label: 'Type réception' },
|
||||
{ key: 'receptionDate', label: 'Date', width: '120px' },
|
||||
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||
{ key: 'receptionType.label', label: 'Type réception', width: '1.1fr' },
|
||||
{ key: 'carrier.name', label: 'Transporteur' },
|
||||
{ key: 'licensePlate', label: 'Immatriculation' }
|
||||
{ key: 'licensePlate', label: 'Immatriculation', width: '110px' }
|
||||
]
|
||||
|
||||
const receptionList = ref<ReceptionData[]>()
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date.replace(' ', 'T'))
|
||||
@@ -51,6 +145,10 @@ const formatDate = (date: string | null) => {
|
||||
})
|
||||
}
|
||||
|
||||
const goToReception = (reception: ReceptionData) => {
|
||||
router.push(`/reception/${reception.id}`)
|
||||
}
|
||||
|
||||
const confirmDelete = async (reception: ReceptionData) => {
|
||||
const confirmed = window.confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer la réception ${reception.identificationNumber ?? `#${reception.id}`} ? Toutes les données liées seront supprimées.`
|
||||
@@ -58,10 +156,11 @@ const confirmDelete = async (reception: ReceptionData) => {
|
||||
if (!confirmed) return
|
||||
|
||||
await deleteReception(reception.id)
|
||||
receptionList.value = receptionList.value?.filter(r => r.id !== reception.id)
|
||||
reload()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
receptionList.value = await getReceptionList(false)
|
||||
receptionTypes.value = await getReceptionTypeList()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Scanner' })
|
||||
|
||||
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||
import { useBarcodeScanner } from '~/composables/useBarcodeScanner'
|
||||
import { createBovine } from '~/services/bovine'
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
|
||||
<WorkflowWeight
|
||||
v-if="storeShipment?.currentStep === 1"
|
||||
ref="grossWeightRef"
|
||||
mode="gross"
|
||||
ref="tareWeightRef"
|
||||
mode="tare"
|
||||
entity-name="shipment"
|
||||
api-resource="shipments"
|
||||
:title-label="shipmentConfig.weighingLabels.gross"
|
||||
:title-label="shipmentConfig.weighingLabels.tare"
|
||||
:is-final="false"
|
||||
:entity="storeShipment"
|
||||
:get-weight-from-scale="getWeightShipment"
|
||||
@@ -34,11 +34,11 @@
|
||||
<ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
|
||||
<WorkflowWeight
|
||||
v-if="storeShipment?.currentStep === 3"
|
||||
ref="tareWeightRef"
|
||||
mode="tare"
|
||||
ref="grossWeightRef"
|
||||
mode="gross"
|
||||
entity-name="shipment"
|
||||
api-resource="shipments"
|
||||
:title-label="shipmentConfig.weighingLabels.tare"
|
||||
:title-label="shipmentConfig.weighingLabels.gross"
|
||||
:is-final="true"
|
||||
:entity="storeShipment"
|
||||
:get-weight-from-scale="getWeightShipment"
|
||||
@@ -51,6 +51,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Expédition' })
|
||||
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useShipmentStore } from '~/stores/shipment'
|
||||
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
|
||||
|
||||
@@ -1,55 +1,154 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-start gap-10">
|
||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions finie</h1>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des expéditions finies</h1>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
<div class="mt-6 border border-slate-200 mb-16 ">
|
||||
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
||||
<div>Numéro</div>
|
||||
<div>Date</div>
|
||||
<div>Client</div>
|
||||
<div>Adresse</div>
|
||||
<div>Type d'expéditon</div>
|
||||
<div>Poids</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="shipment in shipmentList"
|
||||
:key="shipment
|
||||
.id"
|
||||
class="grid grid-cols-6 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goShipment(shipment.id)"
|
||||
<div 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="goToShipment"
|
||||
>
|
||||
<div>{{ shipment.identificationNumber }}</div>
|
||||
<div>{{ shipment.shipmentDate }}</div>
|
||||
<div>{{ shipment.customer?.name }}</div>
|
||||
<div>{{ shipment.address?.fullAddress }}</div>
|
||||
<div>
|
||||
<template v-if="formatShipmentLines(shipment).length">
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-shipmentDate>
|
||||
<UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
|
||||
</template>
|
||||
<template #header-customer.name>
|
||||
<UiTextInput
|
||||
v-model="filters['customer.name']"
|
||||
placeholder="Client"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-address.fullAddress>
|
||||
<UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-shipmentType.label>
|
||||
<UiSelect
|
||||
v-model="filters['shipmentType.id']"
|
||||
placeholder="Type d'expédition"
|
||||
:options="shipmentTypeOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-weighing>
|
||||
<UiTextInput :model-value="''" placeholder="Poids" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-shipmentDate="{ item }">
|
||||
{{ formatDate(item.shipmentDate) }}
|
||||
</template>
|
||||
<template #cell-shipmentType.label="{ item }">
|
||||
<template v-if="formatShipmentLines(item).length">
|
||||
<div
|
||||
v-for="(line, index) in formatShipmentLines(shipment)"
|
||||
v-for="(line, index) in formatShipmentLines(item)"
|
||||
:key="index"
|
||||
class="leading-5"
|
||||
>
|
||||
{{ line }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div>{{ formatWeighing(shipment) }}</div>
|
||||
</div>
|
||||
<template v-else>—</template>
|
||||
</template>
|
||||
<template #cell-weighing="{ item }">
|
||||
{{ formatWeighing(item) }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {ShipmentData} from "~/services/dto/shipment-data";
|
||||
import {getShipmentList} from "~/services/shipment";
|
||||
useHead({ title: 'Validation expédition' })
|
||||
|
||||
import type { ShipmentData } from '~/services/dto/shipment-data'
|
||||
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||
import { getShipmentTypeList } from '~/services/shipment-type'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const shipmentList = ref<ShipmentData[]>()
|
||||
const router = useRouter()
|
||||
const shipmentTypes = ref<ShipmentTypeData[]>([])
|
||||
|
||||
const shipmentTypeOptions = computed(() =>
|
||||
shipmentTypes.value.map(st => ({ value: st.id, label: st.label }))
|
||||
)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<ShipmentData>(
|
||||
'shipments',
|
||||
{
|
||||
isValid: true,
|
||||
'identificationNumber': '',
|
||||
'customer.name': '',
|
||||
'shipmentType.id': '',
|
||||
'shipmentDate[after]': '',
|
||||
'shipmentDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const shipmentDateFilter = computed<string>({
|
||||
get: () => (filters.value['shipmentDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['shipmentDate[after]'] = ''
|
||||
filters.value['shipmentDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['shipmentDate[after]'] = value
|
||||
filters.value['shipmentDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'shipmentDate', label: 'Date', width: '120px' },
|
||||
{ key: 'customer.name', label: 'Client', width: '1.5fr' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||
{ key: 'shipmentType.label', label: "Type d'expédition", width: '1.1fr' },
|
||||
{ key: 'weighing', label: 'Poids', width: '82px' }
|
||||
]
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date.replace(' ', 'T'))
|
||||
if (isNaN(d.getTime())) return date
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatShipmentLines = (shipment: ShipmentData) => {
|
||||
if (!shipment.shipmentType && shipment.nbBovinSend == null) {
|
||||
return []
|
||||
}
|
||||
const label = typeof shipment.shipmentType === 'string'
|
||||
? shipment.shipmentType
|
||||
: shipment.shipmentType?.label
|
||||
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
|
||||
}
|
||||
|
||||
const formatWeighing = (shipment: ShipmentData) => {
|
||||
const gross = shipment.weights?.find((weight) => weight.type === 'gross')?.weight
|
||||
@@ -62,24 +161,12 @@ const formatWeighing = (shipment: ShipmentData) => {
|
||||
return `${gross - tare} kg`
|
||||
}
|
||||
|
||||
|
||||
const formatShipmentLines = (shipment: ShipmentData) => {
|
||||
if (!shipment.shipmentType && shipment.nbBovinSend == null) {
|
||||
return []
|
||||
}
|
||||
|
||||
const label = typeof shipment.shipmentType === 'string'
|
||||
? shipment.shipmentType
|
||||
: shipment.shipmentType?.label
|
||||
|
||||
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
|
||||
}
|
||||
|
||||
const goShipment = (id: number) => {
|
||||
router.push(`/shipment/update/${id}`)
|
||||
const goToShipment = (shipment: ShipmentData) => {
|
||||
router.push(`/shipment/update/${shipment.id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
shipmentList.value = await getShipmentList(true)
|
||||
shipmentTypes.value = await getShipmentTypeList()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -149,16 +149,6 @@
|
||||
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
|
||||
<h1
|
||||
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
|
||||
:class="[
|
||||
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
|
||||
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
|
||||
]"
|
||||
@click="activeTab = 'weights'"
|
||||
>
|
||||
pesée à plein
|
||||
</h1>
|
||||
<h1
|
||||
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
|
||||
:class="[
|
||||
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
|
||||
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
|
||||
@@ -167,6 +157,16 @@
|
||||
>
|
||||
pesée à vide
|
||||
</h1>
|
||||
<h1
|
||||
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
|
||||
:class="[
|
||||
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
|
||||
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
|
||||
]"
|
||||
@click="activeTab = 'weights'"
|
||||
>
|
||||
pesée à plein
|
||||
</h1>
|
||||
</div>
|
||||
<div class="mb-12">
|
||||
<update-weight
|
||||
@@ -197,6 +197,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Modifier expédition' })
|
||||
|
||||
import { usePdfPrinter } from '#imports'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import UpdateWeight from '~/components/commun/update-weight.vue'
|
||||
@@ -248,7 +250,7 @@ const hasTareWeightError = computed(() =>
|
||||
submitted.value && (tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null)
|
||||
)
|
||||
|
||||
const activeTab = ref<'weightsEmpty' | 'weights'>('weights')
|
||||
const activeTab = ref<'weightsEmpty' | 'weights'>('weightsEmpty')
|
||||
const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross'))
|
||||
const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare'))
|
||||
const formIsLoading = ref(false)
|
||||
|
||||
@@ -1,55 +1,149 @@
|
||||
<template>
|
||||
<WorkflowWaitingList
|
||||
title="listes des expéditions en attente"
|
||||
:columns="columns"
|
||||
:items="shipmentList ?? []"
|
||||
route-prefix="/shipment"
|
||||
:show-actions="auth.isAdmin"
|
||||
>
|
||||
<template #cell-shipmentDate="{ item }">
|
||||
{{ formatDate(item.shipmentDate) }}
|
||||
</template>
|
||||
<template #cell-shipmentType="{ item }">
|
||||
<template v-if="formatShipmentLines(item).length">
|
||||
<div
|
||||
v-for="(line, index) in formatShipmentLines(item)"
|
||||
:key="index"
|
||||
class="leading-5"
|
||||
>
|
||||
{{ line }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<Icon
|
||||
name="mdi:delete-outline"
|
||||
size="24"
|
||||
class="cursor-pointer text-red-500 hover:text-red-700"
|
||||
@click="confirmDelete(item)"
|
||||
/>
|
||||
</template>
|
||||
</WorkflowWaitingList>
|
||||
<div class="flex items-center justify-start gap-10">
|
||||
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
|
||||
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des expéditions en attente</h1>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
:show-actions="auth.isAdmin"
|
||||
row-clickable
|
||||
@row-click="goToShipment"
|
||||
>
|
||||
<template #header-shipmentDate>
|
||||
<UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
|
||||
</template>
|
||||
<template #header-customer.name>
|
||||
<UiTextInput
|
||||
v-model="filters['customer.name']"
|
||||
placeholder="Client"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-address.fullAddress>
|
||||
<UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-shipmentType.label>
|
||||
<UiSelect
|
||||
v-model="filters['shipmentType.id']"
|
||||
placeholder="Type d'expé."
|
||||
:options="shipmentTypeOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-carrier.name>
|
||||
<UiTextInput
|
||||
v-model="filters['carrier.name']"
|
||||
placeholder="Transporteur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-licensePlate>
|
||||
<UiTextInput
|
||||
v-model="filters['licensePlate']"
|
||||
placeholder="Immatriculation"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<UiTextInput :model-value="''" placeholder="Actions" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-shipmentDate="{ item }">
|
||||
{{ formatDate(item.shipmentDate) }}
|
||||
</template>
|
||||
<template #cell-shipmentType.label="{ item }">
|
||||
<template v-if="formatShipmentLines(item).length">
|
||||
<div
|
||||
v-for="(line, index) in formatShipmentLines(item)"
|
||||
:key="index"
|
||||
class="leading-5"
|
||||
>
|
||||
{{ line }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<Icon
|
||||
name="mdi:delete-outline"
|
||||
size="24"
|
||||
class="cursor-pointer text-red-500 hover:text-red-700"
|
||||
@click="confirmDelete(item)"
|
||||
/>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ShipmentData } from '~/services/dto/shipment-data'
|
||||
import { getShipmentList, deleteShipment } from '~/services/shipment'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
useHead({ title: 'Expéditions en attente' })
|
||||
|
||||
import type { ShipmentData } from '~/services/dto/shipment-data'
|
||||
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||
import { deleteShipment } from '~/services/shipment'
|
||||
import { getShipmentTypeList } from '~/services/shipment-type'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const shipmentTypes = ref<ShipmentTypeData[]>([])
|
||||
|
||||
const shipmentTypeOptions = computed(() =>
|
||||
shipmentTypes.value.map(st => ({ value: st.id, label: st.label }))
|
||||
)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<ShipmentData>(
|
||||
'shipments',
|
||||
{
|
||||
isValid: false,
|
||||
'customer.name': '',
|
||||
'carrier.name': '',
|
||||
'licensePlate': '',
|
||||
'shipmentType.id': '',
|
||||
'shipmentDate[after]': '',
|
||||
'shipmentDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const shipmentDateFilter = computed<string>({
|
||||
get: () => (filters.value['shipmentDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['shipmentDate[after]'] = ''
|
||||
filters.value['shipmentDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['shipmentDate[after]'] = value
|
||||
filters.value['shipmentDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'shipmentDate', label: 'Date et heure' },
|
||||
{ key: 'customer.name', label: 'Client' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse' },
|
||||
{ key: 'shipmentType', label: "Type d'expé." },
|
||||
{ key: 'shipmentDate', label: 'Date', width: '120px' },
|
||||
{ key: 'customer.name', label: 'Client', width: '1.5fr' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||
{ key: 'shipmentType.label', label: "Type d'expé.", width: '1.1fr' },
|
||||
{ key: 'carrier.name', label: 'Transporteur' },
|
||||
{ key: 'licensePlate', label: 'Immatriculation' }
|
||||
{ key: 'licensePlate', label: 'Immatriculation', width: '110px' }
|
||||
]
|
||||
|
||||
const shipmentList = ref<ShipmentData[]>()
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date.replace(' ', 'T'))
|
||||
@@ -73,6 +167,10 @@ const formatShipmentLines = (shipment: ShipmentData) => {
|
||||
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
|
||||
}
|
||||
|
||||
const goToShipment = (shipment: ShipmentData) => {
|
||||
router.push(`/shipment/${shipment.id}`)
|
||||
}
|
||||
|
||||
const confirmDelete = async (shipment: ShipmentData) => {
|
||||
const confirmed = window.confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer l'expédition ${shipment.identificationNumber ?? `#${shipment.id}`} ? Toutes les données liées seront supprimées.`
|
||||
@@ -80,10 +178,11 @@ const confirmDelete = async (shipment: ShipmentData) => {
|
||||
if (!confirmed) return
|
||||
|
||||
await deleteShipment(shipment.id)
|
||||
shipmentList.value = shipmentList.value?.filter(s => s.id !== shipment.id)
|
||||
reload()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
shipmentList.value = await getShipmentList(false)
|
||||
shipmentTypes.value = await getShipmentTypeList()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -27,3 +27,16 @@ export async function createBovines(nationalNumbers: string[]): Promise<{ create
|
||||
|
||||
return { created, errors }
|
||||
}
|
||||
|
||||
export async function getBovine(id: number) {
|
||||
const api = useApi()
|
||||
return api.get<BovineData>(`bovines/${id}`)
|
||||
}
|
||||
|
||||
export async function updateBovine(id: number, payload: BovinePayload) {
|
||||
const api = useApi()
|
||||
return api.patch<BovineData>(`bovines/${id}`, payload, {
|
||||
toastErrorKey: 'errors.bovine.update',
|
||||
toastSuccessKey: 'success.bovine.update'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
export interface BovineBuildingRef {
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface BovineBuildingCaseRef {
|
||||
caseNumber: number | null
|
||||
building: BovineBuildingRef | null
|
||||
}
|
||||
|
||||
export interface BovineData {
|
||||
id: number
|
||||
nationalNumber: string
|
||||
receivedWeight: number | null
|
||||
pricePerKg: number | null
|
||||
finalPrice: number | null
|
||||
arrivalDate: string | null
|
||||
buildingCase: string | null
|
||||
exitDate: string | null
|
||||
buildingCase: BovineBuildingCaseRef | null
|
||||
building: BovineBuildingRef | null
|
||||
effectiveBuilding: BovineBuildingRef | null
|
||||
supplier: string | null
|
||||
workNumber: string | null
|
||||
birthDate: string | null
|
||||
bovineType: { id: number; label: string; code: string } | null
|
||||
sex: string | null
|
||||
ageMonths: number | null
|
||||
exitedAt: string | null
|
||||
}
|
||||
|
||||
export type BovinePayload = {
|
||||
nationalNumber?: string
|
||||
receivedWeight?: number | null
|
||||
pricePerKg?: number | null
|
||||
arrivalDate?: string | null
|
||||
buildingCase?: string | null
|
||||
supplier?: string | null
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { BuildingCaseStatusData } from '~/services/dto/building-case-status-data'
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
|
||||
export interface BuildingSummary {
|
||||
id: number
|
||||
label: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface BuildingCaseData {
|
||||
id: number
|
||||
caseNumber: number | null
|
||||
code: string | null
|
||||
capacity: number | null
|
||||
statut?: BuildingCaseStatusData | null
|
||||
statut?: { label: string; couleur: string } | null
|
||||
building?: BuildingSummary | null
|
||||
bovines: BovineData[]
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface BuildingCaseStatusData {
|
||||
id: number
|
||||
label: string | null
|
||||
code: string | null
|
||||
couleur: string | null
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import type { BuildingCaseStatusData } from '~/services/dto/building-case-status-data'
|
||||
|
||||
export type StatutListResponse =
|
||||
| BuildingCaseStatusData[]
|
||||
| { 'hydra:member'?: BuildingCaseStatusData[] }
|
||||
|
||||
export async function getStatutList(): Promise<BuildingCaseStatusData[]> {
|
||||
const api = useApi()
|
||||
const response = await api.get<StatutListResponse>('statuts', {}, {
|
||||
toastErrorKey: 'errors.http.get'
|
||||
})
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
return response
|
||||
}
|
||||
|
||||
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
|
||||
return response['hydra:member']
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import {defineStore} from 'pinia'
|
||||
import type {UserData} from '~/services/dto/user-data'
|
||||
import {getCurrentUser, createUser, updateUser, login, logout} from '~/services/auth'
|
||||
import type {UserPayload} from "~/services/dto/user-data";
|
||||
import {ROLE} from '~/utils/constants'
|
||||
import {userHasRole} from '~/utils/roles'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
@@ -12,7 +12,9 @@ export const useAuthStore = defineStore('auth', {
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state) => Boolean(state.user),
|
||||
isAdmin: (state) => Boolean(state.user?.roles?.includes(ROLE[0].value))
|
||||
hasRole: (state) => (role: string): boolean => userHasRole(state.user?.roles, role),
|
||||
isAdmin: (state) => userHasRole(state.user?.roles, 'ROLE_ADMIN'),
|
||||
isBureau: (state) => userHasRole(state.user?.roles, 'ROLE_BUREAU')
|
||||
},
|
||||
actions: {
|
||||
clearSession() {
|
||||
|
||||
18
frontend/utils/bovine-age.ts
Normal file
18
frontend/utils/bovine-age.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const formatAgeLabel = (months: number | null | undefined): string => {
|
||||
if (months === null || months === undefined) return '—'
|
||||
const years = Math.floor(months / 12)
|
||||
const remaining = months % 12
|
||||
let label = ''
|
||||
if (years > 0) label = `${years} an${years > 1 ? 's' : ''}`
|
||||
if (remaining > 0) label += `${label ? ' ' : ''}${remaining} mois`
|
||||
if (!label) label = '< 1 mois'
|
||||
return label
|
||||
}
|
||||
|
||||
export const ageBadgeClass = (months: number | null | undefined): string => {
|
||||
if (months === null || months === undefined) return ''
|
||||
if (months >= 24) return 'bg-red-500 text-white'
|
||||
if (months >= 22) return 'bg-orange-500 text-white'
|
||||
if (months >= 20) return 'bg-yellow-500 text-white'
|
||||
return ''
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const MERCHANDISE_TYPE_CODES = {
|
||||
|
||||
export const ROLE = [
|
||||
{ label: 'Administrateur', value: 'ROLE_ADMIN' },
|
||||
{ label: 'Bureau', value: 'ROLE_BUREAU' },
|
||||
{ label: 'Utilisateur', value: 'ROLE_USER' }
|
||||
]
|
||||
export const SUPPLIER_CODE = {
|
||||
|
||||
38
frontend/utils/roles.ts
Normal file
38
frontend/utils/roles.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Hiérarchie des rôles côté front. Doit rester synchronisée avec
|
||||
* `role_hierarchy` dans config/packages/security.yaml côté back.
|
||||
*
|
||||
* Pour ajouter un nouveau rôle :
|
||||
* 1. Ajouter une entrée ici (son rôle parent dans la chaîne)
|
||||
* 2. Ajouter `ROLE_X: ROLE_Y` dans security.yaml côté back
|
||||
* 3. Ajouter le rôle dans `ROLE` (utils/constants.ts) pour le form admin
|
||||
*/
|
||||
export const ROLE_HIERARCHY: Record<string, string[]> = {
|
||||
ROLE_ADMIN: ['ROLE_BUREAU'],
|
||||
ROLE_BUREAU: ['ROLE_USER'],
|
||||
ROLE_USER: []
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'ensemble des rôles effectifs en expansant la hiérarchie.
|
||||
* Ex : ['ROLE_ADMIN'] → Set { 'ROLE_ADMIN', 'ROLE_BUREAU', 'ROLE_USER' }.
|
||||
*/
|
||||
export const expandRoles = (roles: string[]): Set<string> => {
|
||||
const expanded = new Set<string>(roles)
|
||||
const visit = (role: string): void => {
|
||||
const parents = ROLE_HIERARCHY[role] ?? []
|
||||
for (const parent of parents) {
|
||||
if (!expanded.has(parent)) {
|
||||
expanded.add(parent)
|
||||
visit(parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const r of roles) visit(r)
|
||||
return expanded
|
||||
}
|
||||
|
||||
export const userHasRole = (userRoles: string[] | null | undefined, role: string): boolean => {
|
||||
if (!userRoles || userRoles.length === 0) return false
|
||||
return expandRoles(userRoles).has(role)
|
||||
}
|
||||
35
migrations/Version20260410065020.php
Normal file
35
migrations/Version20260410065020.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260410065020 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine ADD supplier_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F2ADD6D8C FOREIGN KEY (supplier_id) REFERENCES supplier (id)');
|
||||
$this->addSql('CREATE INDEX IDX_2068337F2ADD6D8C ON bovine (supplier_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F2ADD6D8C');
|
||||
$this->addSql('DROP INDEX IDX_2068337F2ADD6D8C');
|
||||
$this->addSql('ALTER TABLE bovine DROP supplier_id');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260410074533.php
Normal file
35
migrations/Version20260410074533.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260410074533 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine ADD work_number VARCHAR(50) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE bovine ADD birth_date DATE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE bovine ADD breed_code VARCHAR(20) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine DROP work_number');
|
||||
$this->addSql('ALTER TABLE bovine DROP birth_date');
|
||||
$this->addSql('ALTER TABLE bovine DROP breed_code');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260410081839.php
Normal file
35
migrations/Version20260410081839.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260410081839 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE building_case DROP CONSTRAINT fk_de2cee50f6203804');
|
||||
$this->addSql('DROP INDEX idx_de2cee50f6203804');
|
||||
$this->addSql('ALTER TABLE building_case DROP statut_id');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE building_case ADD statut_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE building_case ADD CONSTRAINT fk_de2cee50f6203804 FOREIGN KEY (statut_id) REFERENCES statut (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('CREATE INDEX idx_de2cee50f6203804 ON building_case (statut_id)');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260410082723.php
Normal file
31
migrations/Version20260410082723.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260410082723 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP TABLE statut');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE statut (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(255) NOT NULL, code VARCHAR(255) NOT NULL, color VARCHAR(255) NOT NULL, PRIMARY KEY (id))');
|
||||
}
|
||||
}
|
||||
33
migrations/Version20260422155300.php
Normal file
33
migrations/Version20260422155300.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260422155300 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine ADD sex VARCHAR(1) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE bovine ADD exited_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine DROP sex');
|
||||
$this->addSql('ALTER TABLE bovine DROP exited_at');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260423062250.php
Normal file
31
migrations/Version20260423062250.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260423062250 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine ADD exit_date DATE DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine DROP exit_date');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260424074454.php
Normal file
31
migrations/Version20260424074454.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260424074454 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine ADD age_months INT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine DROP age_months');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260424132554.php
Normal file
31
migrations/Version20260424132554.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260424132554 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine ADD price_per_kg DOUBLE PRECISION DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine DROP price_per_kg');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260427154952.php
Normal file
35
migrations/Version20260427154952.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260427154952 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Ajout de la colonne display_order sur building et seed des valeurs par code.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE building ADD display_order INT DEFAULT NULL');
|
||||
|
||||
$this->addSql("UPDATE building SET display_order = 1 WHERE code = 'B3'");
|
||||
$this->addSql("UPDATE building SET display_order = 2 WHERE code = 'B2'");
|
||||
$this->addSql("UPDATE building SET display_order = 3 WHERE code = 'B1'");
|
||||
$this->addSql("UPDATE building SET display_order = 4 WHERE code = 'ZT'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE building DROP display_order');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260428061801.php
Normal file
35
migrations/Version20260428061801.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260428061801 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine ADD building_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F4D2A7E12 FOREIGN KEY (building_id) REFERENCES building (id)');
|
||||
$this->addSql('CREATE INDEX IDX_2068337F4D2A7E12 ON bovine (building_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F4D2A7E12');
|
||||
$this->addSql('DROP INDEX IDX_2068337F4D2A7E12');
|
||||
$this->addSql('ALTER TABLE bovine DROP building_id');
|
||||
}
|
||||
}
|
||||
50
migrations/Version20260428065800.php
Normal file
50
migrations/Version20260428065800.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Bascule de Bovine.breed_code (string) vers une relation Bovine.bovine_type_id (FK).
|
||||
* Ajoute au passage les BovineType manquants (Aubrac=14, Croisé=39, Blonde d'aquitaine=79).
|
||||
*/
|
||||
final class Version20260428065800 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Migration breedCode -> relation BovineType + ajout des races manquantes.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1. Insertion des BovineType manquants (idempotent via NOT EXISTS).
|
||||
$this->addSql("INSERT INTO bovine_type (label, code) SELECT 'Aubrac', '14' WHERE NOT EXISTS (SELECT 1 FROM bovine_type WHERE code = '14')");
|
||||
$this->addSql("INSERT INTO bovine_type (label, code) SELECT 'Croisé', '39' WHERE NOT EXISTS (SELECT 1 FROM bovine_type WHERE code = '39')");
|
||||
$this->addSql("INSERT INTO bovine_type (label, code) SELECT 'Blonde d''aquitaine', '79' WHERE NOT EXISTS (SELECT 1 FROM bovine_type WHERE code = '79')");
|
||||
|
||||
// 2. Ajout de la colonne FK + index.
|
||||
$this->addSql('ALTER TABLE bovine ADD bovine_type_id INT DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX IDX_2068337F7899F32E ON bovine (bovine_type_id)');
|
||||
|
||||
// 3. Backfill : associe chaque bovin à son BovineType via le code.
|
||||
$this->addSql('UPDATE bovine SET bovine_type_id = (SELECT id FROM bovine_type WHERE bovine_type.code = bovine.breed_code) WHERE breed_code IS NOT NULL');
|
||||
|
||||
// 4. Contrainte de clé étrangère (après backfill pour éviter une violation transitoire).
|
||||
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F7899F32E FOREIGN KEY (bovine_type_id) REFERENCES bovine_type (id)');
|
||||
|
||||
// 5. Drop de l'ancienne colonne string.
|
||||
$this->addSql('ALTER TABLE bovine DROP breed_code');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE bovine ADD breed_code VARCHAR(20) DEFAULT NULL');
|
||||
$this->addSql('UPDATE bovine SET breed_code = (SELECT code FROM bovine_type WHERE bovine_type.id = bovine.bovine_type_id) WHERE bovine_type_id IS NOT NULL');
|
||||
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F7899F32E');
|
||||
$this->addSql('DROP INDEX IDX_2068337F7899F32E');
|
||||
$this->addSql('ALTER TABLE bovine DROP bovine_type_id');
|
||||
}
|
||||
}
|
||||
31
src/ApiResource/AnimalSummary.php
Normal file
31
src/ApiResource/AnimalSummary.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
final class AnimalSummary
|
||||
{
|
||||
public ?string $countryCode = null;
|
||||
public ?string $nationalNumber = null;
|
||||
|
||||
public ?string $sex = null;
|
||||
public ?string $breedType = null;
|
||||
public ?string $workNumber = null;
|
||||
|
||||
public ?string $birthDate = null;
|
||||
public ?string $birthDateCompletenessFlag = null;
|
||||
|
||||
public ?bool $isFilie = null;
|
||||
|
||||
public ?string $motherNationalNumber = null;
|
||||
public ?string $motherBreedType = null;
|
||||
|
||||
public ?string $fatherNationalNumber = null;
|
||||
public ?string $fatherBreedType = null;
|
||||
|
||||
public ?string $birthExploitationNumber = null;
|
||||
|
||||
/** @var list<PresencePeriod> */
|
||||
public array $presencePeriods = [];
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\State\AppVersionProvider;
|
||||
use App\State\System\AppVersionProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
|
||||
@@ -7,12 +7,14 @@ namespace App\ApiResource;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\State\BovinIdentificationProvider;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\State\Bovin\BovinIdentificationProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/bovins/{numeroNational}/identification',
|
||||
openapi: new OpenApiOperation(tags: ['Bovins']),
|
||||
provider: BovinIdentificationProvider::class
|
||||
),
|
||||
]
|
||||
|
||||
34
src/ApiResource/BovinInventory.php
Normal file
34
src/ApiResource/BovinInventory.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\State\Bovin\BovinInventoryProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/bovins/inventory/{startDate}',
|
||||
openapi: new OpenApiOperation(tags: ['Bovins']),
|
||||
provider: BovinInventoryProvider::class
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class BovinInventory
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $startDate;
|
||||
|
||||
public ?string $endDate = null;
|
||||
public ?bool $includesEarTagStock = null;
|
||||
public int $nbBovins = 0;
|
||||
public int $earTagSeriesCount = 0;
|
||||
|
||||
/** @var list<AnimalSummary> */
|
||||
public array $animals = [];
|
||||
}
|
||||
12
src/ApiResource/BovinPresumedExit.php
Normal file
12
src/ApiResource/BovinPresumedExit.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
final class BovinPresumedExit
|
||||
{
|
||||
public ?string $countryCode = null;
|
||||
public ?string $nationalNumber = null;
|
||||
public ?string $exitDate = null;
|
||||
}
|
||||
31
src/ApiResource/BovinPresumedExits.php
Normal file
31
src/ApiResource/BovinPresumedExits.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\State\Bovin\BovinPresumedExitsProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/bovins/presumed-exits',
|
||||
openapi: new OpenApiOperation(tags: ['Bovins']),
|
||||
provider: BovinPresumedExitsProvider::class
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class BovinPresumedExits
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $id = 'current';
|
||||
|
||||
public int $nbBovins = 0;
|
||||
|
||||
/** @var list<BovinPresumedExit> */
|
||||
public array $presumedExits = [];
|
||||
}
|
||||
31
src/ApiResource/BovinReturnedDossiers.php
Normal file
31
src/ApiResource/BovinReturnedDossiers.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\State\Bovin\BovinReturnedDossiersProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/bovins/returned-dossiers/{startDate}',
|
||||
openapi: new OpenApiOperation(tags: ['Bovins']),
|
||||
provider: BovinReturnedDossiersProvider::class
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class BovinReturnedDossiers
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $startDate;
|
||||
|
||||
public int $nbBovins = 0;
|
||||
|
||||
/** @var list<AnimalSummary> */
|
||||
public array $animals = [];
|
||||
}
|
||||
32
src/ApiResource/BovineInventoryExport.php
Normal file
32
src/ApiResource/BovineInventoryExport.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\State\Bovin\BovineInventoryExportProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/bovines/inventory-export',
|
||||
openapi: new OpenApiOperation(
|
||||
summary: "Export Excel de l'inventaire bovin actuel.",
|
||||
description: "Retourne un fichier XLSX listant tous les bovins actifs (exitedAt IS NULL) triés par date de naissance croissante, avec colorisation des lignes selon l'âge.",
|
||||
tags: ['Bovines'],
|
||||
),
|
||||
security: "is_granted('ROLE_BUREAU')",
|
||||
output: false,
|
||||
provider: BovineInventoryExportProvider::class,
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class BovineInventoryExport
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $id = 'current';
|
||||
}
|
||||
39
src/ApiResource/BovineInventoryStats.php
Normal file
39
src/ApiResource/BovineInventoryStats.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\State\Bovin\BovineInventoryStatsProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/bovines/inventory-stats',
|
||||
openapi: new OpenApiOperation(
|
||||
summary: "Compteurs de l'inventaire bovin par tranche d'âge.",
|
||||
description: "Renvoie le nombre total de bovins actifs et la répartition par tranche d'âge (>= 24 mois, 22-24, 20-22).",
|
||||
tags: ['Bovines'],
|
||||
),
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: BovineInventoryStatsProvider::class,
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class BovineInventoryStats
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $id = 'current';
|
||||
|
||||
public int $total = 0;
|
||||
|
||||
public int $over24 = 0;
|
||||
|
||||
public int $between22And24 = 0;
|
||||
|
||||
public int $between20And22 = 0;
|
||||
}
|
||||
42
src/ApiResource/BovineSyncInventoryResult.php
Normal file
42
src/ApiResource/BovineSyncInventoryResult.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\State\Bovin\BovineSyncInventoryProcessor;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/bovines/sync-inventory',
|
||||
openapi: new OpenApiOperation(
|
||||
summary: "Synchronise l'inventaire bovin local avec EDNOTIF.",
|
||||
description: 'Upsert des bovins par numéro national ; marque comme sortis ceux absents de la réponse EDNOTIF.',
|
||||
tags: ['Bovines'],
|
||||
),
|
||||
security: "is_granted('ROLE_BUREAU')",
|
||||
input: false,
|
||||
output: self::class,
|
||||
processor: BovineSyncInventoryProcessor::class,
|
||||
read: false,
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class BovineSyncInventoryResult
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $id = 'current';
|
||||
|
||||
public int $created = 0;
|
||||
|
||||
public int $updated = 0;
|
||||
|
||||
public int $exited = 0;
|
||||
|
||||
public int $total = 0;
|
||||
}
|
||||
99
src/Command/EnrichBovinesCommand.php
Normal file
99
src/Command/EnrichBovinesCommand.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Bovine;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
use function count;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:enrich-bovines',
|
||||
description: 'Enrichit les bovins existants avec les données EdNotif (n° travail, date naissance, race).'
|
||||
)]
|
||||
class EnrichBovinesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly BovinApiInterface $bovinApi,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$bovines = $this->entityManager->getRepository(Bovine::class)->findBy(['workNumber' => null]);
|
||||
|
||||
if (0 === count($bovines)) {
|
||||
$io->success('Tous les bovins sont déjà enrichis.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->info(sprintf('%d bovin(s) à enrichir.', count($bovines)));
|
||||
|
||||
$enriched = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($bovines as $bovine) {
|
||||
try {
|
||||
$animalFile = $this->bovinApi->getAnimalFile(
|
||||
nationalNumber: $bovine->getNationalNumber(),
|
||||
countryCode: 'FR',
|
||||
);
|
||||
$identification = $animalFile->identification;
|
||||
|
||||
if (null === $identification) {
|
||||
$io->warning(sprintf(' %s — pas d\'identification retournée.', $bovine->getNationalNumber()));
|
||||
++$failed;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$bovine->setWorkNumber($identification->workNumber);
|
||||
$bovine->setBirthDate($identification->birthDate?->date);
|
||||
$bovine->setBreedCode($this->normalizeBreedCode($identification->breedType));
|
||||
|
||||
++$enriched;
|
||||
$io->text(sprintf(' ✓ %s → n° travail %s', $bovine->getNationalNumber(), $identification->workNumber ?? '—'));
|
||||
} catch (Throwable $e) {
|
||||
++$failed;
|
||||
$io->warning(sprintf(' %s — erreur : %s', $bovine->getNationalNumber(), $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$io->success(sprintf('%d enrichi(s), %d échoué(s).', $enriched, $failed));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalizeBreedCode(mixed $breedType): ?string
|
||||
{
|
||||
if (null === $breedType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_numeric($breedType)) {
|
||||
return (string) $breedType;
|
||||
}
|
||||
|
||||
if (is_string($breedType) && preg_match('/\d+/', $breedType, $matches)) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
215
src/Command/FeedBovinePricesCommand.php
Normal file
215
src/Command/FeedBovinePricesCommand.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Bovine;
|
||||
use App\Entity\Building;
|
||||
use App\Entity\Supplier;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:feed-bovine-prices',
|
||||
description: 'Met à jour le poids, le prix au kilo et le fournisseur des bovins existants depuis un fichier XLSX.'
|
||||
)]
|
||||
final class FeedBovinePricesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('file', InputArgument::REQUIRED, 'Chemin absolu vers le fichier XLSX')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simule sans persister en BDD')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$file = (string) $input->getArgument('file');
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
if (!file_exists($file)) {
|
||||
$io->error(sprintf('Fichier introuvable : %s', $file));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->title('Feed bovins depuis '.basename($file));
|
||||
if ($dryRun) {
|
||||
$io->warning('Dry-run activé : aucune écriture en BDD.');
|
||||
}
|
||||
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($file);
|
||||
} catch (Throwable $e) {
|
||||
$io->error('Impossible de lire le fichier : '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
|
||||
// Pré-chargement des fournisseurs pour des lookups rapides (insensible casse).
|
||||
$supplierByName = [];
|
||||
foreach ($this->em->getRepository(Supplier::class)->findAll() as $supplier) {
|
||||
$supplierByName[mb_strtoupper($supplier->getName())] = $supplier;
|
||||
}
|
||||
|
||||
// Pré-chargement des bâtiments par code (insensible casse).
|
||||
$buildingByCode = [];
|
||||
foreach ($this->em->getRepository(Building::class)->findAll() as $building) {
|
||||
$buildingByCode[mb_strtoupper($building->getCode())] = $building;
|
||||
}
|
||||
|
||||
$bovineRepo = $this->em->getRepository(Bovine::class);
|
||||
|
||||
$stats = [
|
||||
'total' => 0,
|
||||
'updated' => 0,
|
||||
'notFound' => 0,
|
||||
'invalid' => 0,
|
||||
'supplierMissing' => 0,
|
||||
'buildingMissing' => 0,
|
||||
];
|
||||
$missingNationalNumbers = [];
|
||||
$missingSuppliers = [];
|
||||
$missingBuildings = [];
|
||||
|
||||
$io->progressStart($highestRow);
|
||||
for ($row = 1; $row <= $highestRow; ++$row) {
|
||||
++$stats['total'];
|
||||
|
||||
$rawNationalNumber = (string) ($sheet->getCell([1, $row])->getValue() ?? '');
|
||||
$rawSupplier = (string) ($sheet->getCell([2, $row])->getValue() ?? '');
|
||||
$rawWeight = $sheet->getCell([3, $row])->getValue();
|
||||
$rawPrice = $sheet->getCell([4, $row])->getValue();
|
||||
$rawBuilding = (string) ($sheet->getCell([5, $row])->getValue() ?? '');
|
||||
|
||||
$rawNationalNumber = trim($rawNationalNumber);
|
||||
if ('' === $rawNationalNumber) {
|
||||
++$stats['invalid'];
|
||||
$io->progressAdvance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Garde : strip "FR" + espace optionnel uniquement s'il est présent.
|
||||
$nationalNumber = preg_replace('/^FR\s*/i', '', $rawNationalNumber);
|
||||
|
||||
$bovine = $bovineRepo->findOneBy(['nationalNumber' => $nationalNumber]);
|
||||
if (null === $bovine) {
|
||||
++$stats['notFound'];
|
||||
$missingNationalNumbers[] = $nationalNumber;
|
||||
$io->progressAdvance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lookup supplier (peut être null si introuvable ou colonne vide).
|
||||
$supplier = null;
|
||||
$supplierName = mb_strtoupper(trim($rawSupplier));
|
||||
if ('' !== $supplierName) {
|
||||
$supplier = $supplierByName[$supplierName] ?? null;
|
||||
if (null === $supplier) {
|
||||
++$stats['supplierMissing'];
|
||||
$missingSuppliers[$supplierName] = ($missingSuppliers[$supplierName] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$weight = is_numeric($rawWeight) ? (int) $rawWeight : null;
|
||||
$price = is_numeric($rawPrice) ? (float) $rawPrice : null;
|
||||
|
||||
if (null !== $weight) {
|
||||
$bovine->setReceivedWeight($weight);
|
||||
}
|
||||
if (null !== $price) {
|
||||
$bovine->setPricePerKg($price);
|
||||
}
|
||||
$bovine->setSupplier($supplier);
|
||||
|
||||
// Bâtiment direct : on n'écrase pas une affectation à une case existante.
|
||||
$buildingCode = mb_strtoupper(trim($rawBuilding));
|
||||
if ('' !== $buildingCode && null === $bovine->getBuildingCase()) {
|
||||
$building = $buildingByCode[$buildingCode] ?? null;
|
||||
if (null !== $building) {
|
||||
$bovine->setBuilding($building);
|
||||
} else {
|
||||
++$stats['buildingMissing'];
|
||||
$missingBuildings[$buildingCode] = ($missingBuildings[$buildingCode] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
++$stats['updated'];
|
||||
$io->progressAdvance();
|
||||
}
|
||||
$io->progressFinish();
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$io->section('Résultats');
|
||||
$io->table(
|
||||
['Métrique', 'Valeur'],
|
||||
[
|
||||
['Lignes totales', $stats['total']],
|
||||
['Bovins mis à jour', $stats['updated']],
|
||||
['Bovins introuvables', $stats['notFound']],
|
||||
['Lignes invalides', $stats['invalid']],
|
||||
['Fournisseurs introuvables (supplier=null)', $stats['supplierMissing']],
|
||||
['Bâtiments introuvables (building non set)', $stats['buildingMissing']],
|
||||
]
|
||||
);
|
||||
|
||||
if ([] !== $missingNationalNumbers) {
|
||||
$preview = array_slice($missingNationalNumbers, 0, 10);
|
||||
$io->warning(sprintf(
|
||||
'%d bovin(s) introuvable(s). Aperçu : %s%s',
|
||||
count($missingNationalNumbers),
|
||||
implode(', ', $preview),
|
||||
count($missingNationalNumbers) > 10 ? '…' : '',
|
||||
));
|
||||
}
|
||||
|
||||
if ([] !== $missingSuppliers) {
|
||||
$list = [];
|
||||
foreach ($missingSuppliers as $name => $count) {
|
||||
$list[] = sprintf('%s (%d)', $name, $count);
|
||||
}
|
||||
$io->warning('Fournisseurs introuvables (bovins rattachés en null) : '.implode(', ', $list));
|
||||
}
|
||||
|
||||
if ([] !== $missingBuildings) {
|
||||
$list = [];
|
||||
foreach ($missingBuildings as $code => $count) {
|
||||
$list[] = sprintf('%s (%d)', $code, $count);
|
||||
}
|
||||
$io->warning('Bâtiments introuvables (champ non renseigné) : '.implode(', ', $list));
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$io->success('Dry-run terminé. Relance sans --dry-run pour persister.');
|
||||
} else {
|
||||
$io->success('Feed terminé avec succès.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ use App\Entity\MerchandiseType;
|
||||
use App\Entity\PelletType;
|
||||
use App\Entity\ReceptionType;
|
||||
use App\Entity\ShipmentType;
|
||||
use App\Entity\Statut;
|
||||
use App\Entity\Supplier;
|
||||
use App\Entity\Truck;
|
||||
use App\Entity\Vehicle;
|
||||
@@ -230,24 +229,6 @@ class SeedCommand extends Command
|
||||
|
||||
private function seedBuildingInfrastructure(): void
|
||||
{
|
||||
$statusByCode = [];
|
||||
$statusRows = [
|
||||
['label' => 'Libre', 'code' => 'LB', 'color' => '#A3B18A'],
|
||||
['label' => 'Occupé', 'code' => 'OC', 'color' => '#3A506B'],
|
||||
['label' => 'Malade', 'code' => 'ML', 'color' => '#E07A5F'],
|
||||
];
|
||||
foreach ($statusRows as $statusRow) {
|
||||
/** @var Statut $status */
|
||||
$status = $this->upsertByCode(Statut::class, $statusRow['code'], static function (Statut $entity) use ($statusRow) {
|
||||
$entity
|
||||
->setLabel($statusRow['label'])
|
||||
->setCode($statusRow['code'])
|
||||
->setColor($statusRow['color'])
|
||||
;
|
||||
});
|
||||
$statusByCode[$statusRow['code']] = $status;
|
||||
}
|
||||
|
||||
$buildingRepo = $this->entityManager->getRepository(Building::class);
|
||||
$layoutByBuildingCode = [];
|
||||
$layoutRows = [
|
||||
@@ -274,25 +255,15 @@ class SeedCommand extends Command
|
||||
}
|
||||
|
||||
$caseRows = [
|
||||
['buildingCode' => 'B1', 'from' => 1, 'to' => 12, 'status' => 'LB'],
|
||||
['buildingCode' => 'B1', 'from' => 13, 'to' => 24, 'status' => 'OC'],
|
||||
['buildingCode' => 'B1', 'from' => 25, 'to' => 32, 'status' => 'ML'],
|
||||
['buildingCode' => 'B1', 'from' => 33, 'to' => 44, 'status' => 'LB'],
|
||||
['buildingCode' => 'B2', 'from' => 1, 'to' => 10, 'status' => 'OC'],
|
||||
['buildingCode' => 'B2', 'from' => 11, 'to' => 22, 'status' => 'LB'],
|
||||
['buildingCode' => 'B2', 'from' => 23, 'to' => 30, 'status' => 'ML'],
|
||||
['buildingCode' => 'B2', 'from' => 31, 'to' => 44, 'status' => 'OC'],
|
||||
['buildingCode' => 'B3', 'from' => 1, 'to' => 8, 'status' => 'ML'],
|
||||
['buildingCode' => 'B3', 'from' => 9, 'to' => 20, 'status' => 'LB'],
|
||||
['buildingCode' => 'B3', 'from' => 21, 'to' => 34, 'status' => 'OC'],
|
||||
['buildingCode' => 'B3', 'from' => 35, 'to' => 44, 'status' => 'ML'],
|
||||
['buildingCode' => 'B1', 'from' => 1, 'to' => 44],
|
||||
['buildingCode' => 'B2', 'from' => 1, 'to' => 44],
|
||||
['buildingCode' => 'B3', 'from' => 1, 'to' => 44],
|
||||
];
|
||||
|
||||
$caseByCode = [];
|
||||
foreach ($caseRows as $caseRow) {
|
||||
$building = $buildingRepo->findOneBy(['code' => $caseRow['buildingCode']]);
|
||||
$status = $statusByCode[$caseRow['status']] ?? null;
|
||||
if (!$building instanceof Building || !$status instanceof Statut) {
|
||||
if (!$building instanceof Building) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -300,13 +271,12 @@ class SeedCommand extends Command
|
||||
$code = sprintf('%s-C%d', $caseRow['buildingCode'], $caseNumber);
|
||||
|
||||
/** @var BuildingCase $buildingCase */
|
||||
$buildingCase = $this->upsertByCode(BuildingCase::class, $code, static function (BuildingCase $entity) use ($code, $caseNumber, $building, $status) {
|
||||
$buildingCase = $this->upsertByCode(BuildingCase::class, $code, static function (BuildingCase $entity) use ($code, $caseNumber, $building) {
|
||||
$entity
|
||||
->setCode($code)
|
||||
->setCaseNumber($caseNumber)
|
||||
->setCapacity(15)
|
||||
->setIdBuilding($building)
|
||||
->setStatut($status)
|
||||
;
|
||||
});
|
||||
$caseByCode[$code] = $buildingCase;
|
||||
@@ -506,9 +476,12 @@ class SeedCommand extends Command
|
||||
private function seedBovineTypes(): void
|
||||
{
|
||||
$bovineTypes = [
|
||||
['label' => 'Aubrac', 'code' => '14'],
|
||||
['label' => 'Limousine', 'code' => '34'],
|
||||
['label' => 'Charolaise', 'code' => '38'],
|
||||
['label' => 'Croisé', 'code' => '39'],
|
||||
['label' => 'Parthenaise', 'code' => '71'],
|
||||
['label' => "Blonde d'aquitaine", 'code' => '79'],
|
||||
];
|
||||
foreach ($bovineTypes as $type) {
|
||||
$this->upsertByCode(BovineType::class, $type['code'], static function (BovineType $entity) use ($type) {
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Entity\Building;
|
||||
use App\Entity\BuildingCase;
|
||||
use App\Entity\BuildingCasePosition;
|
||||
use App\Entity\BuildingLayout;
|
||||
use App\Entity\Statut;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
@@ -18,10 +17,9 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$statuts = $this->loadStatuts($manager);
|
||||
$buildings = $this->getBuildingsByCode($manager, ['B1', 'B2', 'B3']);
|
||||
$layouts = $this->loadLayouts($manager, $buildings);
|
||||
$cases = $this->loadBuildingCases($manager, $buildings, $statuts);
|
||||
$cases = $this->loadBuildingCases($manager, $buildings);
|
||||
$this->loadCasePositions($manager, $layouts, $cases);
|
||||
|
||||
$manager->flush();
|
||||
@@ -34,38 +32,6 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Statut>
|
||||
*/
|
||||
private function loadStatuts(ObjectManager $manager): array
|
||||
{
|
||||
$repo = $manager->getRepository(Statut::class);
|
||||
|
||||
$data = [
|
||||
['label' => 'Libre', 'code' => 'LB', 'color' => '#A3B18A'],
|
||||
['label' => 'Occupé', 'code' => 'OC', 'color' => '#3A506B'],
|
||||
['label' => 'Malade', 'code' => 'ML', 'color' => '#E07A5F'],
|
||||
];
|
||||
|
||||
$result = [];
|
||||
foreach ($data as $row) {
|
||||
/** @var null|Statut $statut */
|
||||
$statut = $repo->findOneBy(['code' => $row['code']]);
|
||||
if (!$statut instanceof Statut) {
|
||||
$statut = new Statut()
|
||||
->setLabel($row['label'])
|
||||
->setCode($row['code'])
|
||||
->setColor($row['color'])
|
||||
;
|
||||
$manager->persist($statut);
|
||||
}
|
||||
|
||||
$result[$row['code']] = $statut;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $codes
|
||||
*
|
||||
@@ -126,34 +92,21 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
|
||||
|
||||
/**
|
||||
* @param array<string, Building> $buildings
|
||||
* @param array<string, Statut> $statuts
|
||||
*
|
||||
* @return array<string, BuildingCase>
|
||||
*/
|
||||
private function loadBuildingCases(ObjectManager $manager, array $buildings, array $statuts): array
|
||||
private function loadBuildingCases(ObjectManager $manager, array $buildings): array
|
||||
{
|
||||
$repo = $manager->getRepository(BuildingCase::class);
|
||||
|
||||
$statusRanges = [
|
||||
// B1
|
||||
['buildingCode' => 'B1', 'from' => 1, 'to' => 12, 'statut' => 'LB'],
|
||||
['buildingCode' => 'B1', 'from' => 13, 'to' => 24, 'statut' => 'OC'],
|
||||
['buildingCode' => 'B1', 'from' => 25, 'to' => 32, 'statut' => 'ML'],
|
||||
['buildingCode' => 'B1', 'from' => 33, 'to' => 44, 'statut' => 'LB'],
|
||||
// B2
|
||||
['buildingCode' => 'B2', 'from' => 1, 'to' => 10, 'statut' => 'OC'],
|
||||
['buildingCode' => 'B2', 'from' => 11, 'to' => 22, 'statut' => 'LB'],
|
||||
['buildingCode' => 'B2', 'from' => 23, 'to' => 30, 'statut' => 'ML'],
|
||||
['buildingCode' => 'B2', 'from' => 31, 'to' => 44, 'statut' => 'OC'],
|
||||
// B3
|
||||
['buildingCode' => 'B3', 'from' => 1, 'to' => 8, 'statut' => 'ML'],
|
||||
['buildingCode' => 'B3', 'from' => 9, 'to' => 20, 'statut' => 'LB'],
|
||||
['buildingCode' => 'B3', 'from' => 21, 'to' => 34, 'statut' => 'OC'],
|
||||
['buildingCode' => 'B3', 'from' => 35, 'to' => 44, 'statut' => 'ML'],
|
||||
$caseRanges = [
|
||||
['buildingCode' => 'B1', 'from' => 1, 'to' => 44],
|
||||
['buildingCode' => 'B2', 'from' => 1, 'to' => 44],
|
||||
['buildingCode' => 'B3', 'from' => 1, 'to' => 44],
|
||||
];
|
||||
|
||||
$result = [];
|
||||
foreach ($statusRanges as $range) {
|
||||
foreach ($caseRanges as $range) {
|
||||
for ($caseNumber = $range['from']; $caseNumber <= $range['to']; ++$caseNumber) {
|
||||
$code = sprintf('%s-C%d', $range['buildingCode'], $caseNumber);
|
||||
|
||||
@@ -169,7 +122,6 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
|
||||
->setCode($code)
|
||||
->setCapacity(15)
|
||||
->setIdBuilding($buildings[$range['buildingCode']])
|
||||
->setStatut($statuts[$range['statut']])
|
||||
;
|
||||
$manager->persist($buildingCase);
|
||||
}
|
||||
|
||||
@@ -77,9 +77,12 @@ class ReferenceFixtures extends Fixture
|
||||
}
|
||||
|
||||
$bovineTypes = [
|
||||
['label' => 'Aubrac', 'code' => '14'],
|
||||
['label' => 'Limousine', 'code' => '34'],
|
||||
['label' => 'Charolaise', 'code' => '38'],
|
||||
['label' => 'Croisé', 'code' => '39'],
|
||||
['label' => 'Parthenaise', 'code' => '71'],
|
||||
['label' => "Blonde d'aquitaine", 'code' => '79'],
|
||||
];
|
||||
foreach ($bovineTypes as $type) {
|
||||
$bovineType = new BovineType()
|
||||
|
||||
@@ -4,21 +4,41 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Repository\BovineRepository;
|
||||
use App\State\Bovin\BovineProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Context;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Entity(repositoryClass: BovineRepository::class)]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'bovine')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'nationalNumber' => 'ipartial',
|
||||
'workNumber' => 'ipartial',
|
||||
'bovineType.label' => 'ipartial',
|
||||
'bovineType.code' => 'ipartial',
|
||||
'sex' => 'exact',
|
||||
'buildingCase' => 'exact',
|
||||
'receivedWeight' => 'exact',
|
||||
])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
|
||||
#[ApiResource(
|
||||
order: ['birthDate' => 'ASC'],
|
||||
operations: [
|
||||
new Get(
|
||||
requirements: ['id' => '\d+'],
|
||||
@@ -31,12 +51,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
normalizationContext: ['groups' => ['bovine:read']],
|
||||
denormalizationContext: ['groups' => ['bovine:write']],
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: BovineProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
requirements: ['id' => '\d+'],
|
||||
normalizationContext: ['groups' => ['bovine:read']],
|
||||
denormalizationContext: ['groups' => ['bovine:write']],
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: BovineProcessor::class,
|
||||
),
|
||||
],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
@@ -46,26 +68,73 @@ class Bovine
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['bovine:read'])]
|
||||
#[Groups(['bovine:read', 'building_case:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Groups(['bovine:read', 'bovine:write'])]
|
||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||
private string $nationalNumber = '';
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
#[Groups(['bovine:read', 'bovine:write'])]
|
||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||
private ?int $receivedWeight = null;
|
||||
|
||||
#[ORM\Column(type: 'float', nullable: true)]
|
||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
|
||||
private ?float $pricePerKg = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['bovine:read', 'bovine:write'])]
|
||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||
private ?DateTimeImmutable $arrivalDate = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'bovines')]
|
||||
#[Groups(['bovine:read', 'bovine:write'])]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
private ?BuildingCase $buildingCase = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[Groups(['bovine:read'])]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
private ?Building $building = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||
private ?Supplier $supplier = null;
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
#[Groups(['bovine:read', 'building_case:read'])]
|
||||
private ?string $workNumber = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['bovine:read', 'building_case:read'])]
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||
private ?DateTimeImmutable $birthDate = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[Groups(['bovine:read', 'building_case:read'])]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
private ?BovineType $bovineType = null;
|
||||
|
||||
#[ORM\Column(length: 1, nullable: true)]
|
||||
#[Groups(['bovine:read', 'building_case:read'])]
|
||||
private ?string $sex = null;
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
#[Groups(['bovine:read', 'building_case:read'])]
|
||||
private ?int $ageMonths = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['bovine:read', 'building_case:read'])]
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||
private ?DateTimeImmutable $exitDate = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['bovine:read', 'building_case:read'])]
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||
private ?DateTimeImmutable $exitedAt = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -95,6 +164,29 @@ class Bovine
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPricePerKg(): ?float
|
||||
{
|
||||
return $this->pricePerKg;
|
||||
}
|
||||
|
||||
public function setPricePerKg(?float $pricePerKg): static
|
||||
{
|
||||
$this->pricePerKg = $pricePerKg;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['bovine:read', 'building_case:read'])]
|
||||
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
|
||||
public function getFinalPrice(): ?float
|
||||
{
|
||||
if (null === $this->receivedWeight || null === $this->pricePerKg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->receivedWeight * $this->pricePerKg;
|
||||
}
|
||||
|
||||
public function getArrivalDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->arrivalDate;
|
||||
@@ -118,4 +210,136 @@ class Bovine
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBuilding(): ?Building
|
||||
{
|
||||
return $this->building;
|
||||
}
|
||||
|
||||
public function setBuilding(?Building $building): static
|
||||
{
|
||||
$this->building = $building;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bâtiment effectif d'un bovin : la case affectée si elle existe (logique
|
||||
* historique), sinon le bâtiment direct (fed depuis l'XLSX initial).
|
||||
*/
|
||||
#[Groups(['bovine:read', 'building_case:read'])]
|
||||
public function getEffectiveBuilding(): ?Building
|
||||
{
|
||||
return $this->buildingCase?->getIdBuilding() ?? $this->building;
|
||||
}
|
||||
|
||||
public function getSupplier(): ?Supplier
|
||||
{
|
||||
return $this->supplier;
|
||||
}
|
||||
|
||||
public function setSupplier(?Supplier $supplier): static
|
||||
{
|
||||
$this->supplier = $supplier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWorkNumber(): ?string
|
||||
{
|
||||
return $this->workNumber;
|
||||
}
|
||||
|
||||
public function setWorkNumber(?string $workNumber): static
|
||||
{
|
||||
$this->workNumber = $workNumber;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBirthDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->birthDate;
|
||||
}
|
||||
|
||||
public function setBirthDate(?DateTimeImmutable $birthDate): static
|
||||
{
|
||||
$this->birthDate = $birthDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBovineType(): ?BovineType
|
||||
{
|
||||
return $this->bovineType;
|
||||
}
|
||||
|
||||
public function setBovineType(?BovineType $bovineType): static
|
||||
{
|
||||
$this->bovineType = $bovineType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSex(): ?string
|
||||
{
|
||||
return $this->sex;
|
||||
}
|
||||
|
||||
public function setSex(?string $sex): static
|
||||
{
|
||||
$this->sex = $sex;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getExitDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->exitDate;
|
||||
}
|
||||
|
||||
public function setExitDate(?DateTimeImmutable $exitDate): static
|
||||
{
|
||||
$this->exitDate = $exitDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getExitedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->exitedAt;
|
||||
}
|
||||
|
||||
public function setExitedAt(?DateTimeImmutable $exitedAt): static
|
||||
{
|
||||
$this->exitedAt = $exitedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAgeMonths(): ?int
|
||||
{
|
||||
return $this->ageMonths;
|
||||
}
|
||||
|
||||
public function setAgeMonths(?int $ageMonths): static
|
||||
{
|
||||
$this->ageMonths = $ageMonths;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
#[ORM\PreUpdate]
|
||||
public function refreshAgeMonths(): void
|
||||
{
|
||||
if (null === $this->birthDate) {
|
||||
$this->ageMonths = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$diff = $this->birthDate->diff(new DateTimeImmutable());
|
||||
$this->ageMonths = ($diff->y * 12) + $diff->m;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
@@ -13,6 +15,10 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'label' => 'ipartial',
|
||||
'code' => 'ipartial',
|
||||
])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
@@ -45,11 +51,11 @@ class BovineType
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
|
||||
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
|
||||
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])]
|
||||
private ?string $code = null;
|
||||
|
||||
public function getId(): ?int
|
||||
|
||||
@@ -16,6 +16,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'building')]
|
||||
#[ApiResource(
|
||||
order: ['displayOrder' => 'ASC', 'id' => 'ASC'],
|
||||
operations: [
|
||||
new Get(
|
||||
requirements: ['id' => '\d+'],
|
||||
@@ -32,17 +33,21 @@ class Building
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['building:read', 'reception:read'])]
|
||||
#[Groups(['building:read', 'building:summary', 'reception:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['building:read', 'reception:read'])]
|
||||
#[Groups(['building:read', 'building:summary', 'reception:read', 'bovine:read'])]
|
||||
private string $label = '';
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Groups(['building:read', 'reception:read'])]
|
||||
#[Groups(['building:read', 'building:summary', 'reception:read'])]
|
||||
private string $code = '';
|
||||
|
||||
#[ORM\Column(name: 'display_order', type: 'integer', nullable: true)]
|
||||
#[Groups(['building:read', 'building:summary'])]
|
||||
private ?int $displayOrder = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Reception>
|
||||
*/
|
||||
@@ -101,6 +106,18 @@ class Building
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDisplayOrder(): ?int
|
||||
{
|
||||
return $this->displayOrder;
|
||||
}
|
||||
|
||||
public function setDisplayOrder(?int $displayOrder): self
|
||||
{
|
||||
$this->displayOrder = $displayOrder;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Reception>
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace App\Entity;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\State\BuildingCaseWeightsReportProvider;
|
||||
use App\State\Building\BuildingCaseWeightsReportProvider;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -19,7 +19,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
operations: [
|
||||
new Get(
|
||||
requirements: ['id' => '\d+'],
|
||||
normalizationContext: ['groups' => ['building:read']],
|
||||
normalizationContext: ['groups' => ['building_case:read', 'building:summary']],
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/building_cases/{id}/weights-report',
|
||||
@@ -39,20 +39,20 @@ class BuildingCase
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['building:read'])]
|
||||
#[Groups(['building:read', 'building_case:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['building:read'])]
|
||||
#[Groups(['building:read', 'building_case:read', 'bovine:read'])]
|
||||
#[SerializedName('caseNumber')]
|
||||
private ?int $case_number = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['building:read'])]
|
||||
#[Groups(['building:read', 'building_case:read'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['building:read'])]
|
||||
#[Groups(['building:read', 'building_case:read'])]
|
||||
private ?int $capacity = null;
|
||||
|
||||
/**
|
||||
@@ -62,16 +62,15 @@ class BuildingCase
|
||||
private Collection $id_case_position;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'buildingCases')]
|
||||
#[Groups(['building_case:read', 'bovine:read'])]
|
||||
#[SerializedName('building')]
|
||||
private ?Building $id_building = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'id_case')]
|
||||
#[Groups(['building:read'])]
|
||||
private ?Statut $statut = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Bovine>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'buildingCase')]
|
||||
#[Groups(['building_case:read'])]
|
||||
private Collection $bovines;
|
||||
|
||||
public function __construct()
|
||||
@@ -170,16 +169,17 @@ class BuildingCase
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatut(): ?Statut
|
||||
/**
|
||||
* @return array{label: string, couleur: string}
|
||||
*/
|
||||
#[Groups(['building:read', 'building_case:read'])]
|
||||
public function getStatut(): array
|
||||
{
|
||||
return $this->statut;
|
||||
}
|
||||
if ($this->bovines->count() > 0) {
|
||||
return ['label' => 'Occupé', 'couleur' => '#3A506B'];
|
||||
}
|
||||
|
||||
public function setStatut(?Statut $statut): static
|
||||
{
|
||||
$this->statut = $statut;
|
||||
|
||||
return $this;
|
||||
return ['label' => 'Libre', 'couleur' => '#A3B18A'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
@@ -14,6 +16,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier')]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'name' => 'ipartial',
|
||||
'code' => 'ipartial',
|
||||
])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
@@ -17,6 +19,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'customer')]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'name' => 'ipartial',
|
||||
'email' => 'ipartial',
|
||||
'phone' => 'ipartial',
|
||||
'createdBy.username' => 'ipartial',
|
||||
])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
@@ -15,8 +17,8 @@ use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\Dto\PontBasculeReading;
|
||||
use App\State\ReceptionReceiptProvider;
|
||||
use App\State\ReceptionWeighingProvider;
|
||||
use App\State\Reception\ReceptionReceiptProvider;
|
||||
use App\State\Reception\ReceptionWeighingProvider;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -30,6 +32,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'reception')]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'identificationNumber' => 'ipartial',
|
||||
'supplier.name' => 'ipartial',
|
||||
'carrier.name' => 'ipartial',
|
||||
'licensePlate' => 'ipartial',
|
||||
'receptionType.id' => 'exact',
|
||||
])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
|
||||
#[ApiResource(
|
||||
order: ['id' => 'DESC'],
|
||||
operations: [
|
||||
@@ -51,6 +61,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
),
|
||||
new Delete(
|
||||
requirements: ['id' => '\d+'],
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/receptions/weigh',
|
||||
@@ -501,14 +512,10 @@ class Reception
|
||||
$this->identificationNumber = $number;
|
||||
|
||||
$args->getObjectManager()
|
||||
->getConnection()
|
||||
->executeStatement(
|
||||
'UPDATE reception SET identification_number = :number WHERE id = :id',
|
||||
[
|
||||
'number' => $number,
|
||||
'id' => $this->id,
|
||||
]
|
||||
)
|
||||
->createQuery(sprintf('UPDATE %s r SET r.identificationNumber = :number WHERE r.id = :id', self::class))
|
||||
->setParameter('number', $number)
|
||||
->setParameter('id', $this->id)
|
||||
->execute()
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
@@ -15,8 +17,8 @@ use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\Dto\PontBasculeReading;
|
||||
use App\State\ShipmentReceiptProvider;
|
||||
use App\State\ShipmentWeighingProvider;
|
||||
use App\State\Shipment\ShipmentReceiptProvider;
|
||||
use App\State\Shipment\ShipmentWeighingProvider;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -30,6 +32,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'shipment')]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'identificationNumber' => 'ipartial',
|
||||
'customer.name' => 'ipartial',
|
||||
'carrier.name' => 'ipartial',
|
||||
'licensePlate' => 'ipartial',
|
||||
'shipmentType.id' => 'exact',
|
||||
])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['shipmentDate'])]
|
||||
#[ApiResource(
|
||||
order: ['id' => 'DESC'],
|
||||
operations: [
|
||||
@@ -51,6 +61,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
),
|
||||
new Delete(
|
||||
requirements: ['id' => '\d+'],
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/shipments/weigh',
|
||||
@@ -348,14 +359,10 @@ class Shipment
|
||||
$this->identificationNumber = $number;
|
||||
|
||||
$args->getObjectManager()
|
||||
->getConnection()
|
||||
->executeStatement(
|
||||
'UPDATE shipment SET identification_number = :number WHERE id = :id',
|
||||
[
|
||||
'number' => $number,
|
||||
'id' => $this->id,
|
||||
]
|
||||
)
|
||||
->createQuery(sprintf('UPDATE %s s SET s.identificationNumber = :number WHERE s.id = :id', self::class))
|
||||
->setParameter('number', $number)
|
||||
->setParameter('id', $this->id)
|
||||
->execute()
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
requirements: ['id' => '\d+'],
|
||||
normalizationContext: ['groups' => ['building:read']],
|
||||
),
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['building:read']],
|
||||
),
|
||||
],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
)]
|
||||
class Statut
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['building:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['building:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['building:read'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['building:read'])]
|
||||
#[SerializedName('couleur')]
|
||||
private ?string $color = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, BuildingCase>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: BuildingCase::class, mappedBy: 'statut')]
|
||||
private Collection $id_case;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->id_case = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(int $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(string $code): static
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getColor(): ?string
|
||||
{
|
||||
return $this->color;
|
||||
}
|
||||
|
||||
public function setColor(string $color): static
|
||||
{
|
||||
$this->color = $color;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, BuildingCase>
|
||||
*/
|
||||
public function getIdCase(): Collection
|
||||
{
|
||||
return $this->id_case;
|
||||
}
|
||||
|
||||
public function addIdCase(BuildingCase $idCase): static
|
||||
{
|
||||
if (!$this->id_case->contains($idCase)) {
|
||||
$this->id_case->add($idCase);
|
||||
$idCase->setStatut($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeIdCase(BuildingCase $idCase): static
|
||||
{
|
||||
if ($this->id_case->removeElement($idCase)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($idCase->getStatut() === $this) {
|
||||
$idCase->setStatut(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
@@ -17,6 +19,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'supplier')]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'name' => 'ipartial',
|
||||
'email' => 'ipartial',
|
||||
'phone' => 'ipartial',
|
||||
'createdBy.username' => 'ipartial',
|
||||
])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
|
||||
@@ -4,14 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\State\ActiveUsersProvider;
|
||||
use App\State\MeProvider;
|
||||
use App\State\UserPasswordProcessor;
|
||||
use App\State\User\ActiveUsersProvider;
|
||||
use App\State\User\MeProvider;
|
||||
use App\State\User\UserPasswordProcessor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
@@ -20,6 +23,8 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'user', schema: 'public')]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['username' => 'ipartial'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isLocked'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
@@ -53,7 +58,8 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
new GetCollection(
|
||||
uriTemplate: '/admin/users',
|
||||
normalizationContext: ['groups' => ['user:read']],
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
paginationEnabled: true
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['user:read']],
|
||||
|
||||
101
src/Repository/BovineRepository.php
Normal file
101
src/Repository/BovineRepository.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Bovine;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Bovine>
|
||||
*/
|
||||
final class BovineRepository extends ServiceEntityRepository
|
||||
{
|
||||
public const AGE_RANGE_OVER_24 = 'over24';
|
||||
|
||||
public const AGE_RANGE_BETWEEN_22_AND_24 = 'between22And24';
|
||||
|
||||
public const AGE_RANGE_BETWEEN_20_AND_22 = 'between20And22';
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Bovine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des bovins actifs pour l'export inventaire.
|
||||
*
|
||||
* @param null|list<string> $ageRanges Si null/vide → tous. Sinon filtre OR sur les tranches d'âge demandées.
|
||||
*
|
||||
* @return list<Bovine>
|
||||
*/
|
||||
public function findActiveForInventoryExport(?array $ageRanges = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('b')
|
||||
->where('b.exitedAt IS NULL')
|
||||
->orderBy('b.birthDate', 'ASC')
|
||||
;
|
||||
|
||||
if (null !== $ageRanges && [] !== $ageRanges) {
|
||||
$orX = $qb->expr()->orX();
|
||||
foreach ($ageRanges as $idx => $range) {
|
||||
switch ($range) {
|
||||
case self::AGE_RANGE_OVER_24:
|
||||
$orX->add('b.ageMonths >= 24');
|
||||
|
||||
break;
|
||||
|
||||
case self::AGE_RANGE_BETWEEN_22_AND_24:
|
||||
$orX->add($qb->expr()->andX('b.ageMonths >= 22', 'b.ageMonths < 24'));
|
||||
|
||||
break;
|
||||
|
||||
case self::AGE_RANGE_BETWEEN_20_AND_22:
|
||||
$orX->add($qb->expr()->andX('b.ageMonths >= 20', 'b.ageMonths < 22'));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($orX->count() > 0) {
|
||||
$qb->andWhere($orX);
|
||||
}
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compteurs des bovins actifs par tranche d'âge.
|
||||
*
|
||||
* @return array{total: int, over24: int, between22And24: int, between20And22: int}
|
||||
*/
|
||||
public function getInventoryStats(?int $buildingCaseId = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('b')
|
||||
->select(
|
||||
'COUNT(b.id) AS total',
|
||||
'SUM(CASE WHEN b.ageMonths >= 24 THEN 1 ELSE 0 END) AS over24',
|
||||
'SUM(CASE WHEN b.ageMonths >= 22 AND b.ageMonths < 24 THEN 1 ELSE 0 END) AS between22And24',
|
||||
'SUM(CASE WHEN b.ageMonths >= 20 AND b.ageMonths < 22 THEN 1 ELSE 0 END) AS between20And22',
|
||||
)
|
||||
->where('b.exitedAt IS NULL')
|
||||
;
|
||||
|
||||
if (null !== $buildingCaseId) {
|
||||
$qb->andWhere('b.buildingCase = :caseId')
|
||||
->setParameter('caseId', $buildingCaseId)
|
||||
;
|
||||
}
|
||||
|
||||
$row = $qb->getQuery()->getSingleResult();
|
||||
|
||||
return [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'over24' => (int) ($row['over24'] ?? 0),
|
||||
'between22And24' => (int) ($row['between22And24'] ?? 0),
|
||||
'between20And22' => (int) ($row['between20And22'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
53
src/Service/AnimalSummaryMapper.php
Normal file
53
src/Service/AnimalSummaryMapper.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\ApiResource\AnimalSummary;
|
||||
use App\ApiResource\PresencePeriod;
|
||||
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
|
||||
|
||||
final class AnimalSummaryMapper
|
||||
{
|
||||
public function map(AnimalSummaryDto $dto): AnimalSummary
|
||||
{
|
||||
$summary = new AnimalSummary();
|
||||
|
||||
$identification = $dto->identification;
|
||||
|
||||
$summary->countryCode = $identification?->bovin?->countryCode;
|
||||
$summary->nationalNumber = $identification?->bovin?->nationalNumber;
|
||||
|
||||
$summary->sex = $identification?->sex;
|
||||
$summary->breedType = $identification?->breedType;
|
||||
$summary->workNumber = $identification?->workNumber;
|
||||
|
||||
$summary->birthDate = $identification?->birthDate?->date?->format('Y-m-d');
|
||||
$summary->birthDateCompletenessFlag = $identification?->birthDate?->completenessFlag;
|
||||
|
||||
$summary->isFilie = $identification?->isFilie;
|
||||
|
||||
$summary->motherNationalNumber = $identification?->motherCarrier?->bovin?->nationalNumber;
|
||||
$summary->motherBreedType = $identification?->motherCarrier?->breedType;
|
||||
|
||||
$summary->fatherNationalNumber = $identification?->fatherIpg?->bovin?->nationalNumber;
|
||||
$summary->fatherBreedType = $identification?->fatherIpg?->breedType;
|
||||
|
||||
$summary->birthExploitationNumber = $identification?->birthExploitation?->exploitationNumber;
|
||||
|
||||
foreach ($dto->presencePeriods as $presencePeriodDto) {
|
||||
$presencePeriod = new PresencePeriod();
|
||||
|
||||
$presencePeriod->entryDate = $presencePeriodDto->entry?->date?->format('Y-m-d');
|
||||
$presencePeriod->entryCause = $presencePeriodDto->entry?->cause;
|
||||
|
||||
$presencePeriod->exitDate = $presencePeriodDto->exit?->date?->format('Y-m-d');
|
||||
$presencePeriod->exitCause = $presencePeriodDto->exit?->cause;
|
||||
|
||||
$summary->presencePeriods[] = $presencePeriod;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
namespace App\State\Bovin;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user