Compare commits

...

79 Commits

Author SHA1 Message Date
gitea-actions
a0557b077b chore: bump version to v0.0.64
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m20s
2026-02-27 10:06:55 +00:00
2d2b38eae4 [#355] modification front de la page admin transporteur (!36)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|       #355           |      modification front de la page admin transporteur           |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #36
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-27 10:06:49 +00:00
gitea-actions
d3581b8ce6 chore: bump version to v0.0.63
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m20s
2026-02-27 09:05:28 +00:00
9e53be8ac3 [#352] modification front admin fournisseur (!35)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|         #352         |       modification front admin fournisseur          |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #35
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-27 09:05:22 +00:00
gitea-actions
2aafa2082a chore: bump version to v0.0.62
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m18s
2026-02-26 14:01:46 +00:00
2b64f024b6 [#327] Afficher modifier une expédition terminée (!34)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|       #327           |       Afficher modifier une expédition terminée     |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #34
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-26 14:01:39 +00:00
gitea-actions
47cac04257 chore: bump version to v0.0.61
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-02-26 09:28:11 +00:00
59d76c5f14 fix : page de modification reception qui crash en prod
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-26 10:28:00 +01:00
gitea-actions
c48cc477da chore: bump version to v0.0.60
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-02-26 08:46:45 +00:00
5967665e9f fix : page de modification reception qui crash en prod
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-02-26 09:46:34 +01:00
gitea-actions
393c420983 chore: bump version to v0.0.59
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-02-26 08:34:02 +00:00
456623b403 fix : CHANGELOG.md
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-26 09:33:52 +01:00
gitea-actions
e2a8e89e55 chore: bump version to v0.0.58
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m14s
2026-02-26 08:25:26 +00:00
92a5c48e5e [#332]Refonte écran réception terminée (!31)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|     #332             |     Refonte écran réception terminée            |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #31
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-26 08:25:20 +00:00
gitea-actions
6766985713 chore: bump version to v0.0.57
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-02-26 08:10:21 +00:00
c0d05264df [#334] Correctifs (!32)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
| 334 |   Correctifs  |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #32
Co-authored-by: Matteo <matteo@yuno.malio.fr>
Co-committed-by: Matteo <matteo@yuno.malio.fr>
2026-02-26 08:10:15 +00:00
gitea-actions
9505201499 chore: bump version to v0.0.56
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m23s
2026-02-26 07:42:10 +00:00
624591c096 feat : finalisation du tableau d'estimation des poids bovin
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
2026-02-26 08:41:56 +01:00
gitea-actions
e31bdce713 chore: bump version to v0.0.55
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m14s
2026-02-25 14:48:38 +00:00
5d72beaf8d Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-25 15:48:19 +01:00
43f34015c6 fix : app seed 2026-02-25 15:39:05 +01:00
gitea-actions
ac5ce07e61 chore: bump version to v0.0.54
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-02-25 14:31:35 +00:00
e9fb36cc24 fix : suppression des repositories qui ne servent à rien
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-25 15:31:26 +01:00
gitea-actions
06a41c5f85 chore: bump version to v0.0.53
All checks were successful
Build Release Artefact / build (push) Successful in 1m19s
Auto Tag Develop / tag (push) Successful in 4s
2026-02-25 14:16:18 +00:00
f263a11fe8 [#278] Plan du site (!33)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #278          |        Plan du site         |

## Description de la PR
[#278] Plan du site

## Modification du .env

## Check list

- [ ] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Co-authored-by: Matteo <matteo@yuno.malio.fr>
Reviewed-on: #33
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-02-25 14:16:11 +00:00
gitea-actions
c52f22472d chore: bump version to v0.0.52
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m19s
2026-02-19 07:47:19 +00:00
e7421e985e [#331] Mettre à jour l'entité Shipment et bovin_shipment (!30)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|      #331           |        Mettre à jour l'entité Shipment et bovin_shipment         |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #30
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-02-19 07:47:13 +00:00
gitea-actions
0d258ae9c6 chore: bump version to v0.0.51
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-02-17 13:22:34 +00:00
7dd615ea34 Bovins Admin (!29)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #29
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: Matteo <matteo@yuno.malio.fr>
Co-committed-by: Matteo <matteo@yuno.malio.fr>
2026-02-17 13:22:29 +00:00
gitea-actions
6eee0745a7 chore: bump version to v0.0.50
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m22s
2026-02-17 07:20:06 +00:00
845f94db8c fix : correction du role pour la récupération de la liste des supplier
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-02-17 08:19:56 +01:00
gitea-actions
86c0e74074 chore: bump version to v0.0.49
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-02-16 15:26:13 +00:00
be29daf4d1 fix : corrections de tous les retours + modification de la seed et fixtures
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-16 16:26:00 +01:00
gitea-actions
08e7c1508c chore: bump version to v0.0.48
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-02-16 14:32:35 +00:00
358da6a8ad Navbar (!28)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
|  | Layout-Admin |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #28
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: Matteo <matteo@yuno.malio.fr>
Co-committed-by: Matteo <matteo@yuno.malio.fr>
2026-02-16 14:32:31 +00:00
gitea-actions
67428186f6 chore: bump version to v0.0.47
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-02-13 16:07:27 +00:00
09d108a1d5 fix : corrections de tous les retours
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
2026-02-13 17:07:15 +01:00
gitea-actions
f58dc36a0d chore: bump version to v0.0.46
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-02-13 13:07:36 +00:00
15c0f414af fix : corrections doublon fixture
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-13 14:07:25 +01:00
gitea-actions
9ed0ba702e chore: bump version to v0.0.45
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-02-13 12:44:39 +00:00
93edd0a563 fix : corrections de l'entity customer.php et de la partie admin front qui lui est lié + update des fixtures/seed
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-13 13:44:21 +01:00
gitea-actions
c361ef9bb9 chore: bump version to v0.0.44
All checks were successful
Auto Tag Develop / tag (push) Successful in 3s
Build Release Artefact / build (push) Successful in 1m11s
2026-02-13 08:10:40 +00:00
7f3d9ef9c6 [#325] Corrections diverses (!26)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|         #325         |       Corrections diverses          |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #26
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-02-13 08:10:33 +00:00
gitea-actions
22b959de85 chore: bump version to v0.0.43
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-02-13 07:37:06 +00:00
d3bc2e11f1 [#326] Admin modification creation client (!25)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
| #326 | Admin modification creation client |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #25
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: Matteo <matteo@yuno.malio.fr>
Co-committed-by: Matteo <matteo@yuno.malio.fr>
2026-02-13 07:36:58 +00:00
gitea-actions
d8b16f5e15 chore: bump version to v0.0.42
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m13s
2026-02-12 09:43:01 +00:00
43213bc6d6 [#324]Création d'une page d'administration : listing des customers (!24)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| 324 | Création d'une page d'administration : listing des customers |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #24
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: Matteo <matteo@yuno.malio.fr>
Co-committed-by: Matteo <matteo@yuno.malio.fr>
2026-02-12 09:42:54 +00:00
gitea-actions
09666d9319 chore: bump version to v0.0.41
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-02-12 08:57:46 +00:00
05ea33735d [#275] Lister les expéditions en attente (!23)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #275          |        Lister les expéditions en attente         |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Co-authored-by: Matteo <matteo@yuno.malio.fr>
Reviewed-on: #23
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-02-12 08:57:40 +00:00
gitea-actions
89c67f7e97 chore: bump version to v0.0.40
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-02-12 08:22:24 +00:00
d527e94bac [#313] Création d'une page d'administration : modification/création d'un fournisseur (!20)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
| 313| Création d'une page d'administration : modification/création d'un fournisseur |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #20
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: Matteo <matteo@yuno.malio.fr>
Co-committed-by: Matteo <matteo@yuno.malio.fr>
2026-02-12 08:22:17 +00:00
gitea-actions
579bdba65b chore: bump version to v0.0.39
All checks were successful
Auto Tag Develop / tag (push) Successful in 3s
Build Release Artefact / build (push) Successful in 1m14s
2026-02-12 07:31:46 +00:00
b1c3952d09 [#271]Créer une nouvelle expédition (étape 1) (!12)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|          #271        |         Créer une nouvelle expédition (étape 1)        |

## Description de la PR

## Modification du .env

## Check list

[x ] Pas de régression
TU/TI/TF rédigée
[x ] TU/TI/TF OK
[x ] CHANGELOG modifié

Co-authored-by: kevin <kevin@yuno.malio.fr>
Reviewed-on: #12
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: Matteo <matteo@yuno.malio.fr>
Co-committed-by: Matteo <matteo@yuno.malio.fr>
2026-02-12 07:31:40 +00:00
gitea-actions
ab6de16319 chore: bump version to v0.0.38
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m18s
2026-02-12 07:06:55 +00:00
800ab1d432 [#320] Modification réception terminé étape 2 (!21)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|            #320      |        Modification réception terminé étape 2         |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #21
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-12 07:06:49 +00:00
gitea-actions
fade51d3ee chore: bump version to v0.0.37
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m13s
2026-02-10 11:05:15 +00:00
9ca0a7511b [#318] Affichage d'une reception terminée (!19)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #318          |          Affichage d'une reception terminée       |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #19
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-10 11:05:07 +00:00
gitea-actions
d3dfde7060 chore: bump version to v0.0.36
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m13s
2026-02-09 15:04:23 +00:00
90c2cfc665 [#317] Création d'une page d'administration : modification/création d'un transporteur (!18)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|           #317       |      Création d'une page d'administration : modification/création d'un transporteur           |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #18
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-09 15:04:16 +00:00
gitea-actions
9fc3e2f9bc chore: bump version to v0.0.35
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m13s
2026-02-09 14:58:25 +00:00
329bb4cee5 [#315] Création d'une page d'administration : modification/création d'un utilisateur (!17)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|         315         |       Création d'une page d'administration : modification/création d'un utilisateur          |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #17
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-02-09 14:58:20 +00:00
gitea-actions
d3af654858 chore: bump version to v0.0.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m13s
2026-02-09 12:32:16 +00:00
168d8c78eb [#312] Création d'une page d'administration listing des fournisseurs (!16)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
| 312 | Création d'une page d'administration listing des fournisseurs|
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #16
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: Matteo <matteo@yuno.malio.fr>
Co-committed-by: Matteo <matteo@yuno.malio.fr>
2026-02-09 12:32:09 +00:00
gitea-actions
338d903cef chore: bump version to v0.0.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-02-09 09:04:26 +00:00
42ce1e2d08 [#316] Admin liste transporteur (!15)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|         #316         |      Admin liste transporteur           |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #15
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-09 09:04:18 +00:00
gitea-actions
0d0aa788db chore: bump version to v0.0.32
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-02-06 10:52:56 +00:00
c010bdc262 feat : ajout du numéro de version de l'application auth/default layout
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-06 11:52:45 +01:00
gitea-actions
0e905bfcbe chore: bump version to v0.0.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-02-06 09:44:54 +00:00
e6bb4ddf6a feat : test auto-tag-develop.yml (auto incrément version)
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
2026-02-06 10:44:43 +01:00
299ea84e87 fix : auto-tag-develop.yml
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m9s
2026-02-06 10:40:57 +01:00
bb0b0092da fix : auto-tag-develop.yml 2026-02-06 10:38:32 +01:00
33d21f6ae6 feat : update numéro de version 2026-02-06 10:30:27 +01:00
98ee62294d feat : ajout d'un numéro de version automatique via la CI 2026-02-06 10:25:54 +01:00
820386b87b Layout admin panel (!13)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m13s
| Numéro du ticket | Layout admin panel |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #13
Co-authored-by: Matteo <matteo@yuno.malio.fr>
Co-committed-by: Matteo <matteo@yuno.malio.fr>
2026-02-06 08:22:08 +00:00
c17f7aa08a fix : logo centré en mod mobile
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m8s
2026-02-05 17:55:05 +01:00
4a0d38d307 feat : ajout du responsive sur la navbar et la page d'accueil
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m10s
2026-02-05 17:28:49 +01:00
e9948d6ac3 [#256] Créer une nouvelle réception (étape 3 - bovin) (!11)
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m9s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|       256           | Créer une nouvelle réception (étape 3 - bovin)                |

## Description de la PR

## Modification du .env

## Check list

- [x ] Pas de régression
- [ ] TU/TI/TF rédigée
- [x ] TU/TI/TF OK
- [x ] CHANGELOG modifié

Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #11
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-02-05 09:29:29 +00:00
80d87b7c9b [#268] Lister les réceptions terminées (!10)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m11s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|       #268           |        Lister les réceptions terminées         |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #10
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-05 07:19:46 +00:00
a69556c554 [#267] Lister les réceptions en attente (!9)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m12s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|          #267        |        Lister les réceptions en attente          |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #9
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-04 14:40:50 +00:00
152 changed files with 11788 additions and 726 deletions

View File

@@ -16,30 +16,50 @@ jobs:
token: ${{ secrets.RELEASE_TOKEN }}
persist-credentials: true
- name: Create next tag v0.0.X
- name: Create next tag from config/version.yaml
shell: bash
run: |
set -euo pipefail
# Skip if current commit already has a v0.0.* tag
if git tag --points-at HEAD | grep -qE '^v0\.0\.'; then
# Skip if current commit already has a vX.Y.Z tag
if git tag --points-at HEAD | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Tag already exists on this commit. Skipping."
exit 0
fi
last_tag="$(git tag -l 'v0.0.*' --sort=-v:refname | head -n1 || true)"
if [ -z "$last_tag" ]; then
next_tag="v0.0.1"
else
patch="${last_tag##v0.0.}"
if ! [[ "$patch" =~ ^[0-9]+$ ]]; then
echo "Unexpected tag format: $last_tag" >&2
exit 1
fi
next_tag="v0.0.$((patch + 1))"
changed_version=false
if git diff --name-only "${{ gitea.event.before }}" "${{ gitea.event.after }}" | grep -q '^config/version\.yaml$'; then
changed_version=true
fi
git config user.name "gitea-actions"
git config user.email "gitea-actions@local"
git tag "$next_tag"
git push origin "$next_tag"
read_version() {
awk -F': *' '/app\.version:/{print $2}' config/version.yaml | tr -d '[:space:]' | tr -d "'\""
}
if $changed_version; then
version="$(read_version)"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid version in version.yaml: $version" >&2
exit 1
fi
else
last_tag="$(git tag -l 'v*' --sort=-v:refname | head -n1 || true)"
if [ -z "$last_tag" ]; then
version="0.1.0"
else
base="${last_tag#v}"
IFS='.' read -r major minor patch <<< "$base"
version="${major}.${minor}.$((patch + 1))"
fi
printf "parameters:\\n app.version: '%s'\\n" "$version" > config/version.yaml
git config user.name "gitea-actions"
git config user.email "gitea-actions@local"
git add config/version.yaml
git commit -m "chore: bump version to v$version" || true
git push origin develop || true
fi
tag="v$version"
git tag "$tag"
git push origin "$tag"

4
.idea/dataSources.xml generated
View File

@@ -5,7 +5,7 @@
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5433/ferme</jdbc-url>
<jdbc-url>jdbc:postgresql://localhost:5432/ferme</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="Ferme recette" uuid="ae622167-c834-4e7b-87a5-c1721036f5dc">
@@ -16,4 +16,4 @@
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
</project>

View File

@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PhpCSFixerValidationInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
</profile>
</component>

1
.idea/php.xml generated
View File

@@ -15,6 +15,7 @@
<component name="PhpCSFixer">
<phpcsfixer_settings>
<PhpCSFixerConfiguration tool_path="$PROJECT_DIR$/vendor/bin/php-cs-fixer" />
<phpcs_fixer_by_interpreter asDefaultInterpreter="true" interpreter_id="990ff521-e6e9-4080-9cc9-228367d597f9" tool_path="\\wsl.localhost\Ubuntu-24.04\home\matte\Ferme\vendor\bin\php-cs-fixer" timeout="30000" />
</phpcsfixer_settings>
</component>
<component name="PhpCodeSniffer">

478
.idea/workspace.xml generated
View File

@@ -4,10 +4,10 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : mise à jour du bon de réception">
<change beforePath="$PROJECT_DIR$/.idea/dataSources.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/dataSources.xml" afterDir="false" />
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : front page admin bovin et changelog">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/admin/supplier/supplier-list.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/admin/supplier/supplier-list.vue" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -18,6 +18,11 @@
<pharConfigPath>$PROJECT_DIR$/composer.json</pharConfigPath>
<execution />
</component>
<component name="CopilotPersistence">
<persistenceIdMap>
<entry key="_//wsl.localhost/Ubuntu-24.04/home/kevin/Stage/Ferme" value="381AhnCm9yPeOiWgMObKHhtgv2C" />
</persistenceIdMap>
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="151" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
@@ -26,6 +31,7 @@
<option name="RECENT_TEMPLATES">
<list>
<option value="TypeScript File" />
<option value="PHP File" />
<option value="Vue Composition API Component" />
</list>
</option>
@@ -33,11 +39,14 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="fix/makefile" />
<entry key="$PROJECT_DIR$" value="feat/356-front-page-admin-bovin" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$PROJECT_DIR$/frontend/pages/admin/supplier/supplier-list.vue" root0="FORCE_HIGHLIGHTING" />
</component>
<component name="McpProjectServerCommands">
<commands />
<urls />
@@ -51,7 +60,7 @@
</server>
</servers>
</component>
<component name="PhpWorkspaceProjectConfiguration" interpreter_name="/bin/php">
<component name="PhpWorkspaceProjectConfiguration" interpreter_name="C:/php-8.4.3/php.exe">
<include_path>
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
@@ -214,37 +223,44 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;develop&quot;,
&quot;last_opened_file_path&quot;: &quot;/home/sroy/Documents/test/Ferme&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;configurable.group.appearance&quot;,
&quot;ts.external.directory.path&quot;: &quot;/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"RunOnceActivity.MCP Project settings loaded": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"git-widget-placeholder": "feat/352-modification-front-admin-fournisseur",
"last_opened_file_path": "/home/sroy/Documents/test/Ferme/frontend/components/commun",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "preferences.pluginManager",
"ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external",
"vue.rearranger.settings.migration": "true"
},
&quot;keyToStringList&quot;: {
&quot;DatabaseDriversLRU&quot;: [
&quot;postgresql&quot;
"keyToStringList": {
"DatabaseDriversLRU": [
"postgresql"
],
&quot;com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File&quot;: [
&quot;TEXT&quot;
"com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File": [
"TEXT"
],
&quot;vue.recent.templates&quot;: [
&quot;Vue Composition API Component&quot;
"vue.recent.templates": [
"Vue Composition API Component"
]
}
}</component>
}]]></component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/frontend/components/commun" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\pages\shipment" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\composables" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\components\shipment" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\templates" />
@@ -256,7 +272,6 @@
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-PS-253.30387.85" />
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.30387.85" />
</set>
</attachedChunks>
@@ -287,158 +302,14 @@
<workItem from="1770055690365" duration="370000" />
<workItem from="1770056515646" duration="21000" />
<workItem from="1770102495553" duration="2280000" />
</task>
<task id="LOCAL-00001" summary="feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)">
<option name="closed" value="true" />
<created>1768237763998</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1768237763998</updated>
</task>
<task id="LOCAL-00002" summary="feat : Ajout de zod, création d'un composant de chargement loading-dots.vue et finalisation du flow d'une reception">
<option name="closed" value="true" />
<created>1768316052474</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1768316052474</updated>
</task>
<task id="LOCAL-00003" summary="feat : Ajout d'un composable pour la pesée qui sera réutilisable pour l'expédition, ajout de contrainte sur les entity de reception et weight pour plus de robustesse et correction de la class active des liens dans la nav">
<option name="closed" value="true" />
<created>1768316835575</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1768316835575</updated>
</task>
<task id="LOCAL-00004" summary="feat : update du fichier AGENTS.md">
<option name="closed" value="true" />
<created>1768316965511</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1768316965511</updated>
</task>
<task id="LOCAL-00005" summary="feat : update du fichier README.md et CHANGELOG.md">
<option name="closed" value="true" />
<created>1768317786187</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1768317786187</updated>
</task>
<task id="LOCAL-00006" summary="fix : correction du useApi pour qu'il n'y ait plus de retry lors d'une erreur 500 par exemple">
<option name="closed" value="true" />
<created>1768318875533</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1768318875533</updated>
</task>
<task id="LOCAL-00007" summary="test : ajout de TU sur les services et providers">
<option name="closed" value="true" />
<created>1768318921478</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1768318921478</updated>
</task>
<task id="LOCAL-00008" summary="feat : ajout de la génération du bon de reception, correction de la base du formulaire multi-etape de reception et ajout d'une gestion d'erreur global">
<option name="closed" value="true" />
<created>1768498751836</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1768498751836</updated>
</task>
<task id="LOCAL-00009" summary="feat : ajout d'une gestion d'erreur au global côté front avec la lib toaster et I18n pour centraliser les messages d'erreur">
<option name="closed" value="true" />
<created>1768555180530</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1768555180530</updated>
</task>
<task id="LOCAL-00010" summary="feat : ajout de l'authentification avec lexik">
<option name="closed" value="true" />
<created>1768832208350</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1768832208350</updated>
</task>
<task id="LOCAL-00011" summary="feat : update du CHANGELOG.md">
<option name="closed" value="true" />
<created>1768832516587</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1768832516587</updated>
</task>
<task id="LOCAL-00012" summary="fix : correction de l'accès au swagger en mode dev qui n'était plus accessible">
<option name="closed" value="true" />
<created>1768940104944</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1768940104944</updated>
</task>
<task id="LOCAL-00013" summary="feat : ajout de la conf pour le déploiement en recette">
<option name="closed" value="true" />
<created>1769005220331</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1769005220331</updated>
</task>
<task id="LOCAL-00014" summary="fix : fix de la conf pour le déploiement en recette">
<option name="closed" value="true" />
<created>1769008700008</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1769008700008</updated>
</task>
<task id="LOCAL-00015" summary="fix : fix de la conf pour le déploiement en recette">
<option name="closed" value="true" />
<created>1769014602062</created>
<option name="number" value="00015" />
<option name="presentableId" value="LOCAL-00015" />
<option name="project" value="LOCAL" />
<updated>1769014602062</updated>
</task>
<task id="LOCAL-00016" summary="fix : migration apache vers nginx pour un déploiement plus simple">
<option name="closed" value="true" />
<created>1769019284586</created>
<option name="number" value="00016" />
<option name="presentableId" value="LOCAL-00016" />
<option name="project" value="LOCAL" />
<updated>1769019284586</updated>
</task>
<task id="LOCAL-00017" summary="fix : dernière modification pour le déploiement en recette et le changement de conf vers nginx">
<option name="closed" value="true" />
<created>1769021756823</created>
<option name="number" value="00017" />
<option name="presentableId" value="LOCAL-00017" />
<option name="project" value="LOCAL" />
<updated>1769021756823</updated>
</task>
<task id="LOCAL-00018" summary="ci : auto tag + release artefact">
<option name="closed" value="true" />
<created>1769021818384</created>
<option name="number" value="00018" />
<option name="presentableId" value="LOCAL-00018" />
<option name="project" value="LOCAL" />
<updated>1769021818384</updated>
</task>
<task id="LOCAL-00019" summary="ci : fix release artefact">
<option name="closed" value="true" />
<created>1769022071620</created>
<option name="number" value="00019" />
<option name="presentableId" value="LOCAL-00019" />
<option name="project" value="LOCAL" />
<updated>1769022071620</updated>
<workItem from="1770195604082" duration="90000" />
<workItem from="1770195718952" duration="215000" />
<workItem from="1770195959162" duration="18915000" />
<workItem from="1770274844804" duration="3940000" />
<workItem from="1770798536017" duration="20774000" />
<workItem from="1770879701502" duration="25805000" />
<workItem from="1770966186589" duration="914000" />
<workItem from="1770967274060" duration="2388000" />
</task>
<task id="LOCAL-00020" summary="ci : fix release artefact">
<option name="closed" value="true" />
@@ -656,7 +527,183 @@
<option name="project" value="LOCAL" />
<updated>1769782099473</updated>
</task>
<option name="localTasksCounter" value="47" />
<task id="LOCAL-00047" summary="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)">
<option name="closed" value="true" />
<created>1770131226364</created>
<option name="number" value="00047" />
<option name="presentableId" value="LOCAL-00047" />
<option name="project" value="LOCAL" />
<updated>1770131226364</updated>
</task>
<task id="LOCAL-00048" summary="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)">
<option name="closed" value="true" />
<created>1770206668867</created>
<option name="number" value="00048" />
<option name="presentableId" value="LOCAL-00048" />
<option name="project" value="LOCAL" />
<updated>1770206668867</updated>
</task>
<task id="LOCAL-00049" summary="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)">
<option name="closed" value="true" />
<created>1770217875423</created>
<option name="number" value="00049" />
<option name="presentableId" value="LOCAL-00049" />
<option name="project" value="LOCAL" />
<updated>1770217875423</updated>
</task>
<task id="LOCAL-00050" summary="feat : creer une nouvelle expedtion (WIP)">
<option name="closed" value="true" />
<created>1770736570645</created>
<option name="number" value="00050" />
<option name="presentableId" value="LOCAL-00050" />
<option name="project" value="LOCAL" />
<updated>1770736570645</updated>
</task>
<task id="LOCAL-00051" summary="feat : ajout d'une page de creation d'une expedition">
<option name="closed" value="true" />
<created>1770880791564</created>
<option name="number" value="00051" />
<option name="presentableId" value="LOCAL-00051" />
<option name="project" value="LOCAL" />
<updated>1770880791565</updated>
</task>
<task id="LOCAL-00052" summary="feat : changelog">
<option name="closed" value="true" />
<created>1770881437439</created>
<option name="number" value="00052" />
<option name="presentableId" value="LOCAL-00052" />
<option name="project" value="LOCAL" />
<updated>1770881437439</updated>
</task>
<task id="LOCAL-00053" summary="feat : lister les expeditions terminees">
<option name="closed" value="true" />
<created>1770883114609</created>
<option name="number" value="00053" />
<option name="presentableId" value="LOCAL-00053" />
<option name="project" value="LOCAL" />
<updated>1770883114609</updated>
</task>
<task id="LOCAL-00054" summary="feat : lister les expeditions terminees">
<option name="closed" value="true" />
<created>1770884154297</created>
<option name="number" value="00054" />
<option name="presentableId" value="LOCAL-00054" />
<option name="project" value="LOCAL" />
<updated>1770884154297</updated>
</task>
<task id="LOCAL-00055" summary="fix : corrections diverses">
<option name="closed" value="true" />
<created>1770969471135</created>
<option name="number" value="00055" />
<option name="presentableId" value="LOCAL-00055" />
<option name="project" value="LOCAL" />
<updated>1770969471135</updated>
</task>
<task id="LOCAL-00056" summary="fix : corrections frontend">
<option name="closed" value="true" />
<created>1772094268366</created>
<option name="number" value="00056" />
<option name="presentableId" value="LOCAL-00056" />
<option name="project" value="LOCAL" />
<updated>1772094268366</updated>
</task>
<task id="LOCAL-00057" summary="feat : affichage et modification expédition et modification bouton valider">
<option name="closed" value="true" />
<created>1772111964268</created>
<option name="number" value="00057" />
<option name="presentableId" value="LOCAL-00057" />
<option name="project" value="LOCAL" />
<updated>1772111964268</updated>
</task>
<task id="LOCAL-00058" summary="fix : erreur customer adress et bouton valider oublie">
<option name="closed" value="true" />
<created>1772112729501</created>
<option name="number" value="00058" />
<option name="presentableId" value="LOCAL-00058" />
<option name="project" value="LOCAL" />
<updated>1772112729502</updated>
</task>
<task id="LOCAL-00059" summary="feat : changelog update">
<option name="closed" value="true" />
<created>1772112812677</created>
<option name="number" value="00059" />
<option name="presentableId" value="LOCAL-00059" />
<option name="project" value="LOCAL" />
<updated>1772112812677</updated>
</task>
<task id="LOCAL-00060" summary="feat : changelog update">
<option name="closed" value="true" />
<created>1772177400063</created>
<option name="number" value="00060" />
<option name="presentableId" value="LOCAL-00060" />
<option name="project" value="LOCAL" />
<updated>1772177400063</updated>
</task>
<task id="LOCAL-00061" summary="feat : changelog update">
<option name="closed" value="true" />
<created>1772177614438</created>
<option name="number" value="00061" />
<option name="presentableId" value="LOCAL-00061" />
<option name="project" value="LOCAL" />
<updated>1772177614438</updated>
</task>
<task id="LOCAL-00062" summary="fix : color tab">
<option name="closed" value="true" />
<created>1772178540489</created>
<option name="number" value="00062" />
<option name="presentableId" value="LOCAL-00062" />
<option name="project" value="LOCAL" />
<updated>1772178540489</updated>
</task>
<task id="LOCAL-00063" summary="feat : modification front de la page admin transporteur">
<option name="closed" value="true" />
<created>1772180053740</created>
<option name="number" value="00063" />
<option name="presentableId" value="LOCAL-00063" />
<option name="project" value="LOCAL" />
<updated>1772180053740</updated>
</task>
<task id="LOCAL-00064" summary="fix : espacement et changelog">
<option name="closed" value="true" />
<created>1772180581178</created>
<option name="number" value="00064" />
<option name="presentableId" value="LOCAL-00064" />
<option name="project" value="LOCAL" />
<updated>1772180581178</updated>
</task>
<task id="LOCAL-00065" summary="fix : espacement">
<option name="closed" value="true" />
<created>1772180684250</created>
<option name="number" value="00065" />
<option name="presentableId" value="LOCAL-00065" />
<option name="project" value="LOCAL" />
<updated>1772180684250</updated>
</task>
<task id="LOCAL-00066" summary="fix : espacement">
<option name="closed" value="true" />
<created>1772180972984</created>
<option name="number" value="00066" />
<option name="presentableId" value="LOCAL-00066" />
<option name="project" value="LOCAL" />
<updated>1772180972984</updated>
</task>
<task id="LOCAL-00067" summary="fix : text">
<option name="closed" value="true" />
<created>1772182545592</created>
<option name="number" value="00067" />
<option name="presentableId" value="LOCAL-00067" />
<option name="project" value="LOCAL" />
<updated>1772182545592</updated>
</task>
<task id="LOCAL-00068" summary="feat : front page admin bovin et changelog">
<option name="closed" value="true" />
<created>1772182707441</created>
<option name="number" value="00068" />
<option name="presentableId" value="LOCAL-00068" />
<option name="project" value="LOCAL" />
<updated>1772182707441</updated>
</task>
<option name="localTasksCounter" value="69" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -706,23 +753,6 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="ci : ajout du script et de la doc déploiement" />
<MESSAGE value="fix : correction du path URI pour la création d'un poids dans une réception" />
<MESSAGE value="feat : Ajout du bundle Monolog pour la gestion des logs" />
<MESSAGE value="fix : affiche plus détail dans les logs en recette/prod" />
<MESSAGE value="fix : modification du script de déploiement pour corriger le problème d'écriture des logs de prod" />
<MESSAGE value="fix : doc de déploiement" />
<MESSAGE value="fix : doc et script de déploiement" />
<MESSAGE value="fix : gitea workflow" />
<MESSAGE value="fix : script de déploiement" />
<MESSAGE value="feat : ajout plus d'information sur la liste des réceptions côté front sur la page d'accueil" />
<MESSAGE value="fix : redirige sur le login sur une 401 et reset du auth state + doc + timeout du toaster" />
<MESSAGE value="feat : ajout de la debug bar en mod dev" />
<MESSAGE value="feat : ajout du bundle Malio ednotif pour l'utilisation des WS" />
<MESSAGE value="fix : modification de la conf du bundle ednotif" />
<MESSAGE value="feat : update du CHANGELOG.md" />
<MESSAGE value="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)" />
<MESSAGE value="feat : ajout du numéro identification des receptions et ajustement du bon de reception" />
<MESSAGE value="feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception" />
<MESSAGE value="feat : mise en place de composant UI pour les select, checkbox, date, text" />
<MESSAGE value="feat : update CHANGELOG.md" />
@@ -731,10 +761,56 @@
<MESSAGE value="feat : ajout de colonne pour les Supplier, Address et modification du numéro de réception" />
<MESSAGE value="feat : ajout de colonne pour les Supplier, Address. Modification du numéro de réception et ajout de fixtures" />
<MESSAGE value="feat : mise à jour du bon de réception" />
<option name="LAST_COMMIT_MESSAGE" value="feat : mise à jour du bon de réception" />
<MESSAGE value="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)" />
<MESSAGE value="feat : creer une nouvelle expedtion (WIP)" />
<MESSAGE value="feat : ajout d'une page de creation d'une expedition" />
<MESSAGE value="feat : changelog" />
<MESSAGE value="feat : lister les expeditions terminees" />
<MESSAGE value="fix: corrections diverses" />
<MESSAGE value="fix : corrections diverses" />
<MESSAGE value="fix : corrections frontend" />
<MESSAGE value="feat : affichage et modification expédition et modification bouton valider" />
<MESSAGE value="fix : erreur customer adress et bouton valider oublie" />
<MESSAGE value="feat : changelog update" />
<MESSAGE value="fix : color tab" />
<MESSAGE value="feat : modification front de la page admin transporteur" />
<MESSAGE value="fix : espacement et changelog" />
<MESSAGE value="fix : espacement" />
<MESSAGE value="fix : text" />
<MESSAGE value="feat : front page admin bovin et changelog" />
<option name="LAST_COMMIT_MESSAGE" value="feat : front page admin bovin et changelog" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<line-breakpoint enabled="true" type="php">
<url>file://$PROJECT_DIR$/src/Entity/ReceptionPelletBuilding.php</url>
<line>6</line>
<option name="timeStamp" value="3" />
</line-breakpoint>
<line-breakpoint enabled="true" type="php">
<url>file://$PROJECT_DIR$/src/Entity/Shipment.php</url>
<line>6</line>
<option name="timeStamp" value="45" />
</line-breakpoint>
<line-breakpoint enabled="true" type="javascript">
<url>file://$PROJECT_DIR$/frontend/layouts/default.vue</url>
<line>72</line>
<option name="timeStamp" value="48" />
</line-breakpoint>
</breakpoints>
</breakpoint-manager>
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />
<select />
</component>
<component name="github-copilot-workspace">
<instructionFileLocations>
<option value=".github/instructions" />
</instructionFileLocations>
<promptFileLocations>
<option value=".github/prompts" />
</promptFileLocations>
</component>
</project>

View File

@@ -8,6 +8,7 @@ Project overview
Backend conventions
- Use English for code identifiers/messages; keep “pont-bascule” as domain term.
- API Platform operations are defined on Doctrine entities.
- No custom repository classes are used (`src/Repository` removed); use default Doctrine repositories via `EntityManagerInterface`.
- Reception entity is in `src/Entity/Reception.php`, with custom weigh endpoint `/receptions/weigh`.
- Reception fields: `date_reception`, `license_plate`, `current_step` (default 0), `is_valid` (default false).
- Reception also has `identification_number` (auto `N-BR-####`), `merchandise_type`, `merchandise_detail`, `buildings` (M2M), and `pellet_buildings` (via `reception_pellet_building`).
@@ -17,6 +18,13 @@ Backend conventions
- Custom exception: `App\Exception\PontBasculeException` with French messages, mapped to 500 in provider.
- Parsing of pont-bascule payload is in `src/Service/PontBasculePayloadDecoder.php`.
- `config/reference.php` is auto-generated; keep it.
- Bovine storage:
- `src/Entity/Bovine.php` with fields `nationalNumber` (unique), `receivedWeight`, `arrivalDate`, and `buildingCase` (ManyToOne).
- `src/Entity/BuildingCase.php` has `bovines` (OneToMany).
- Case PDF report:
- Endpoint: `GET /building_cases/{id}/weights-report` (provider: `App\State\BuildingCaseWeightsReportProvider`).
- Template: `templates/case_weights_report.html.twig`.
- Projection logic is done in backend from `arrivalDate`; daily gain is currently fixed at `1.3 kg/day` for all races.
Frontend conventions
- Nuxt SSR disabled; Tailwind used.
@@ -36,6 +44,7 @@ Frontend conventions
- Service layer lives in `frontend/services/` with typed DTOs in `frontend/services/dto/`.
- Reception service uses `receptions`, `receptions/{id}`, `receptions/weigh` and supports success/error toast keys.
- Reception receipt endpoint is `receptions/{id}/receipt` (PDF) via `frontend/composables/usePdfPrinter.ts`.
- Infrastructure case page prints the case weight report PDF from `frontend/pages/infrastructure/case.vue` using `usePdfPrinter('/building_cases/{id}/weights-report')`.
Environment & routing
- Frontend dev server: `npm run dev` in `frontend/`.
@@ -47,6 +56,11 @@ Environment & routing
Notes
- Do not add a GET that creates resources; use POST + PATCH.
- Keep endpoints in plural (API Platform convention).
- Seed and fixtures conventions:
- `app:seed` now seeds infrastructure (`statut`, `building_layout`, `building_case`, `building_case_position`) and bovines.
- `app:seed` uses intermediate flushes (after buildings and after infrastructure) so find queries can resolve just-created records.
- Bovine seed rows use a legacy case token mapping to building-case code (`B{building}-C{case}`) before fallback to direct id lookup.
- Fixtures include `BuildingInfrastructureFixtures` + `BovineFixtures` (via `AppFixtures` dependencies).
- New reference data added:
- Reception types (`reception_type`, fields: `label`, `code`), selectable on reception form.
- Merchandise types (`merchandise_type`, fields: `label`, `code`) and pellet types (`pellet_type`, fields: `label`, `code`).

View File

@@ -27,7 +27,36 @@ Ajouter dans le fichier .env du frontend
* Ajout du bundle malio/ednotif-bundle
* Ajout de composant UI
* Finalisation de la partie réception de marchandise
* [#267] Lister les réceptions en attente
* [#268] Lister les réceptions terminées
* [#316] Admin liste des transporteurs
* [#312] Creation administration listing fournisseurs
* [#315] Creation page admin utilisateur
* [#317] Admin modification creation transporteur
* [#318] Affichage modification reception terminée
* [#320] Affichage modification reception terminée suite
* [#271] Créer une nouvelle expédition (étape 1)
* [#272] Créer une nouvelle expédition (étape 2)
* [#273] Créer une nouvelle expédition (étape 3)
* [#256] Créer une nouvelle réception (étape 3 - bovin)
* [#314] Création d'une page d'administration : listing des utilisateurs
* [#313] Admin modification creation fournisseur
* [#275] Lister les expéditions en attente
* [#276] Lister les expéditions terminées
* [#324] Creation page admin listing clients
* [#326] Admin modification creation client
* [#325] Correction diverses
* fix layout admin
* Creation page admin listing bovins
* Creation page admin ajout/modification bovins
* [#331] Mettre à jour l'entité Shipment et bovin_shipment
* [#278] Plan du site
* [#334] Correctifs
* [#332] Refonte écran réception terminée
* [#327] afficher/modifier écran expédition terminée
* [#352] modification front admin fournisseur
* [#355] afficher/modifier écran expédition terminée
### Changed
### Fixed

View File

@@ -53,6 +53,8 @@ security:
- { path: ^/api/users, roles: PUBLIC_ACCESS, methods: [GET] }
# Doc API (swagger) en public
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Version de l'application en public
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [GET] }
# Tout le reste nécessite un JWT
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }

View File

@@ -8,6 +8,9 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
imports:
- { resource: version.yaml }
services:
# default configuration for services in *this* file
_defaults:

2
config/version.yaml Normal file
View File

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

View File

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

View File

@@ -0,0 +1,101 @@
<template>
<form @submit.prevent="validateForm">
<div class="flex items-center mb-11 justify-between relative">
<div class="flex flex-row absolute -left-[60px] ">
<Icon @click="goBack" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ props.address ? "Modification d'une adresse" : "Ajout d'une adresse" }}
</h1>
</div>
<div class="grid grid-cols-2 gap-y-16 gap-x-[200px] mb-16">
<UiTextInput id="address-label" v-model="form.label" label="Libellé" />
<UiTextInput id="address-street" v-model="form.street" label="Rue" />
<UiTextInput id="address-street2" v-model="form.street2" label="Complément" />
<UiTextInput id="address-postalCode" v-model="form.postalCode" label="Code postal" />
<UiTextInput id="address-city" v-model="form.city" label="Ville" />
<UiTextInput id="address-country" v-model="form.countryCode" label="Pays (code)" />
</div>
<div class="flex justify-center items-center">
<UiButton
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit"
:disabled="isLoading"
>
Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import type { AddressPayload } from "~/services/address"
const route = useRoute()
const router = useRouter()
const props = defineProps<{
type?: "supplier" | "customer",
address?: AddressPayload | null
}>()
const isLoading = ref(false)
const emptyForm = (): AddressPayload => ({
label: "",
street: "",
street2: null,
postalCode: "",
city: "",
countryCode: "",
})
const form = reactive<AddressPayload>(emptyForm())
const backPath = computed(() => {
if (props.type === "customer") {
const customerId = Number(route.query.customerId)
return Number.isFinite(customerId) && customerId > 0
? `/admin/customer/${customerId}`
: "/admin/customer/customer-list"
}
const supplierId = Number(route.query.supplierId)
return Number.isFinite(supplierId) && supplierId > 0
? `/admin/supplier/${supplierId}`
: "/admin/supplier/supplier-list"
})
const hydrateForm = (address?: AddressPayload | null) => {
const data = address ?? emptyForm()
form.label = data.label ?? ""
form.street = data.street ?? ""
form.street2 = data.street2 ?? null
form.postalCode = data.postalCode ?? ""
form.city = data.city ?? ""
form.countryCode = data.countryCode ?? ""
}
watch(
() => props.address,
(addr) => {
hydrateForm(addr)
},
{ immediate: true }
)
const validateForm = () => {
if (isLoading.value) return
emit("validate", {...form})
}
const goBack = () => {
router.push(backPath.value)
}
const emit = defineEmits<{
(event: 'validate', form: AddressPayload): void
}>()
</script>

View File

@@ -0,0 +1,29 @@
<template>
<NuxtLink :to="link">
<div class="w-[300px] h-[216px] border border-primary-700 rounded-lg p-6 flex flex-col justify-between gap-4">
<div class="flex justify-between">
<div class="rounded-full w-[80px] h-[80px] bg-[#D9D9D9] flex justify-center items-center">
<Icon :name="iconName" class="!text-primary-700" size="44" />
</div>
<div>
<Icon name="mdi:plus" style="color: black" size="44" />
</div>
</div>
<div class="uppercase font-bold">
<p class="text-3xl text-primary-700">
<slot name="label">{{ label }}</slot>
</p>
</div>
</div>
</NuxtLink>
</template>
<script setup lang="ts">
const props = defineProps<{
link: string
iconName: string
label: string
}>()
</script>

View File

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

View File

@@ -0,0 +1,191 @@
<template>
<div
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"
class="flex flex-col gap-16">
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1>
<div
class="flex flex-row gap-8 items-center w-full">
<div
v-for="type in bovineType"
:key="type.id"
class="mt-8 flex flex-row mb-2 w-full">
<UiNumberInput
:id="type.id"
:label="type.label"
:code="type.code"
v-model="bovineQuantities[String(type.id)]"
:placeholder="0"
:min="0"
:max="10"
class="max-w-[150px]"
wrapper-class="gap-3"
/>
</div>
<div
class="mt-8 flex flex-row mb-2 gap-6">
<UiNumberInput
label="Autres"
v-model="otherQuantity"
class="max-w-[80px]"
wrapper-class="gap-3"
/>
</div>
</div>
<div class="flex justify-center">
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="goNext"
>Valider
</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
import {getBovineTypeList} from "~/services/bovine-type";
import {RECEPTION_TYPE_CODES} from "~/utils/constants";
import {useReceptionStore} from '~/stores/reception'
import {
createReceptionBovine,
deleteReceptionBovine,
getReceptionBovineList,
updateReceptionBovine
} from "~/services/reception-bovine";
import {computed, onMounted, reactive, ref, watch} from "vue";
const toast = useToast()
const isLoadingBovineType = ref(false)
const bovineType = ref<BovineTypeData[]>([])
const receptionStore = useReceptionStore()
const bovineQuantities = reactive<Record<string, number | null>>({})
const otherQuantity = ref<number | null>(0)
const receptionId = computed(() => receptionStore.current?.id ?? null)
const receptionIri = computed(() =>
receptionId.value ? `/api/receptions/${receptionId.value}` : null
)
const totalBovines = computed(() => {
const base = Object.values(bovineQuantities).reduce((sum, value) => {
return sum + (value ?? 0)
}, 0)
return base + (otherQuantity.value ?? 0)
})
const loadBovineType = async () => {
isLoadingBovineType.value = true
try {
bovineType.value = await getBovineTypeList()
} finally {
isLoadingBovineType.value = false
}
}
onMounted(async () => {
await loadBovineType()
})
watch(
[() => receptionId.value, () => bovineType.value],
async ([id, types]) => {
if (!id || !receptionIri.value || types.length === 0) {
return
}
const selectionMap: Record<string, number | null> = {}
for (const type of types) {
selectionMap[String(type.id)] = 0
}
const existing = await getReceptionBovineList(receptionIri.value)
for (const selection of existing) {
const bovineTypeId = String(selection.bovineType.id)
selectionMap[bovineTypeId] = selection.quantity ?? 0
}
for (const key of Object.keys(bovineQuantities)) {
delete bovineQuantities[key]
}
Object.assign(bovineQuantities, selectionMap)
const existingOther = receptionStore.current?.bovineDetail
const parsedOther =
typeof existingOther === 'string' && existingOther.trim() !== ''
? Number(existingOther)
: 0
otherQuantity.value = Number.isFinite(parsedOther) ? parsedOther : 0
},
{immediate: true}
)
async function syncBovineSelections(receptionIri: string) {
const existing = await getReceptionBovineList(receptionIri)
const existingMap = new Map<string, { id: number; quantity: number | null }>()
for (const selection of existing) {
const bovineTypeId = String(selection.bovineType.id)
existingMap.set(bovineTypeId, {
id: selection.id,
quantity: selection.quantity ?? 0
})
}
// Supprime les entrées supprimées ou modifiées
for (const [bovineTypeId, entry] of existingMap.entries()) {
const selectedQuantity = bovineQuantities[bovineTypeId] ?? 0
if (!selectedQuantity) {
await deleteReceptionBovine(entry.id)
existingMap.delete(bovineTypeId)
continue
}
if (selectedQuantity !== entry.quantity) {
await updateReceptionBovine(entry.id, {quantity: selectedQuantity})
existingMap.set(bovineTypeId, {
id: entry.id,
quantity: selectedQuantity
})
}
}
// Crée les entrées manquantes
for (const [bovineTypeId, quantity] of Object.entries(bovineQuantities)) {
if (!quantity) {
continue
}
if (existingMap.has(bovineTypeId)) {
// Déjà à jour
continue
}
await createReceptionBovine({
reception: receptionIri,
bovineType: `/api/bovine_types/${bovineTypeId}`,
quantity
})
}
}
async function goNext() {
if (!receptionStore.current || !receptionIri.value) {
return
}
// @TODO Ajouter un composable pour le toaster qui gère les key i18n
if (totalBovines.value > 52) {
toast.error({
title: 'Erreur',
message: ('Le total des bovins ne peut pas dépasser 52.')
})
return
}
const nextStep = receptionStore.current.currentStep + 1
await syncBovineSelections(receptionIri.value)
await receptionStore.updateReception(receptionStore.current.id, {
merchandiseType: null,
merchandiseDetail: null,
bovineDetail: otherQuantity.value ? String(otherQuantity.value) : null,
currentStep: nextStep
})
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<form @submit.prevent="validate">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1">Réception</h1>
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Réception</h1>
<!-- Nom de l'utilisateur -->
<UiSelect
id="reception-user"
@@ -81,20 +81,8 @@
select-class="h-[34px]"
wrapper-class="col-start-2 row-start-3"
/>
<!-- Chauffeur (LIOT) -->
<UiSelect
id="reception-driver"
v-model="form.driverId"
label="Nom du chauffeur si LIOT"
:options="filteredDrivers.map((driver) => ({
value: String(driver.id),
label: driver.name
}))"
:loading="isLoadingDrivers"
wrapper-class="col-start-2 row-start-4"
/>
<!-- Plaque d'immatriculation -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-5">
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput
v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate"
@@ -112,17 +100,31 @@
}))"
:loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4 h-[64px]"
/>
<!-- Chauffeur (LIOT) -->
<UiSelect
id="reception-driver"
v-model="form.driverId"
label="Nom du chauffeur si LIOT"
:options="filteredDrivers.map((driver) => ({
value: String(driver.id),
label: driver.name
}))"
:loading="isLoadingDrivers"
v-if="isLiotCarrier"
wrapper-class="col-start-2 row-start-5"
/>
</div>
<div class="flex justify-center">
<button
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Peser
</button>
>Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
@@ -142,20 +144,9 @@ import type {DriverData} from '~/services/dto/driver-data'
import {getDriverList} from '~/services/driver'
import type {VehicleData} from '~/services/dto/vehicle-data'
import {getVehicleList} from '~/services/vehicle'
import {SUPLLIER_CODE} from "~/utils/constants";
type ReceptionFormData = {
licensePlate: string
receptionDate: string
receptionTypeId: string
userId: string
supplierId: string
addressId: string
truckId: string
carrierId: string
driverId: string
vehicleId: string
}
import {RECEPTION_TYPE_CODES, SUPPLIER_CODE} from "~/utils/constants";
import {deleteReceptionBovine, getReceptionBovineList} from "~/services/reception-bovine";
import type {ReceptionFormData} from "~/services/dto/reception-data";
const router = useRouter()
const receptionStore = useReceptionStore()
@@ -194,7 +185,7 @@ const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
// Indique si le transporteur est LIOT
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPLLIER_CODE.LIOT)
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
// Adresses disponibles pour le fournisseur sélectionné
const supplierAddresses = computed(() => {
const supplierId = Number(form.supplierId)
@@ -222,6 +213,18 @@ const filteredVehicles = computed<VehicleData[]>(() => {
)
})
const selectedReceptionType = computed(() =>
receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null
)
// Supprime les données bovines si on change de type de réception
const clearReceptionBovines = async (receptionIri: string) => {
const existing = await getReceptionBovineList(receptionIri)
for (const selection of existing) {
await deleteReceptionBovine(selection.id)
}
}
// Hydrate le formulaire depuis la réception en cours
watch(
() => receptionStore.current,
@@ -340,7 +343,7 @@ onMounted(async () => {
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
watch(
() => [form.supplierId, suppliers.value],
() => [form.supplierId, form.addressId, suppliers.value],
() => {
if (!form.supplierId) {
form.addressId = ''
@@ -357,7 +360,11 @@ watch(
(address) => String(address.id) === form.addressId
)
if (!matches) {
form.addressId = ''
if (supplierAddresses.value.length === 1) {
form.addressId = String(supplierAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{immediate: true}
@@ -509,6 +516,16 @@ async function validate() {
return
}
const previousTypeCode = receptionStore.current.receptionType?.code ?? null
const nextTypeCode = selectedReceptionType.value?.code ?? null
const receptionIri = `/api/receptions/${receptionStore.current.id}`
if (
previousTypeCode === RECEPTION_TYPE_CODES.BOVINS &&
nextTypeCode === RECEPTION_TYPE_CODES.MERCHANDISES
) {
await clearReceptionBovines(receptionIri)
}
const nextStep = receptionStore.current.currentStep + 1
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: nextStep,

View File

@@ -1,10 +1,9 @@
<template>
<div class="flex flex-col items-center gap-16">
<!-- @TODO voir pour séparer dans un composant au moment de l'implémentation des Bovins -->
<div
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"
class="flex flex-col gap-16 items-center w-full">
<h1 class="text-4xl uppercase font-bold">Sélectionner des marchandises réceptionnnées</h1>
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des marchandises réceptionnnées</h1>
<UiSelect
id="merchandise-type"
v-model="selectedMerchandiseTypeId"
@@ -12,7 +11,6 @@
:options="merchandiseTypes.map((type) => ({ value: String(type.id), label: type.label }))"
wrapper-class="w-[550px]"
/>
<div
v-if="selectedMerchandiseTypeId && isAutres"
class="flex flex-col w-full max-w-[550px]"
@@ -28,7 +26,7 @@
<div
v-if="selectedMerchandiseTypeId && !isGranule"
class="flex gap-4 w-[550px] justify-evenly"
class="flex gap-4 w-[550px] justify-between"
>
<div
v-for="building in buildings"
@@ -49,45 +47,50 @@
>
<div class="grid grid-cols-1 gap-10 md:grid-cols-4">
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4">
<p class="font-bold uppercase">{{ type.label }}</p>
<p class="font-bold uppercase text-primary-500">{{ type.label }}</p>
<div
v-for="building in buildings"
:key="building.id"
class="flex items-center gap-2 text-lg"
class="flex items-center gap-2 text-lg pl-[2px]"
>
<UiCheckbox
v-model="selectedPelletBuildingIds[String(type.id)]"
:value="String(building.id)"
:label="building.label"
label-class="text-lg"
label-class="text-xl"
/>
</div>
</div>
</div>
</div>
</div>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="goNext"
>Peser</button>
<div class="flex justify-center">
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="goNext"
>Valider
</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getBuildingList } from '~/services/building'
import { getMerchandiseTypeList } from '~/services/merchandise-type'
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
import type { BuildingData } from '~/services/dto/building-data'
import type { PelletTypeData } from '~/services/dto/pellet-type-data'
import { getPelletTypeList } from '~/services/pellet-type'
import {computed, onMounted, ref} from 'vue'
import {getBuildingList} from '~/services/building'
import {getMerchandiseTypeList} from '~/services/merchandise-type'
import type {MerchandiseTypeData} from '~/services/dto/merchandise-type-data'
import type {BuildingData} from '~/services/dto/building-data'
import type {PelletTypeData} from '~/services/dto/pellet-type-data'
import {getPelletTypeList} from '~/services/pellet-type'
import {
createReceptionPelletBuilding,
deleteReceptionPelletBuilding,
getReceptionPelletBuildingList
} from '~/services/reception-pellet-building'
import { useReceptionStore } from '~/stores/reception'
import { MERCHANDISE_TYPE_CODES, RECEPTION_TYPE_CODES } from '~/utils/constants'
import {useReceptionStore} from '~/stores/reception'
import {MERCHANDISE_TYPE_CODES, RECEPTION_TYPE_CODES} from '~/utils/constants'
import ReceptionBovineReceived from "~/components/reception/reception-bovine-received.vue";
const receptionStore = useReceptionStore()
const merchandiseTypes = ref<MerchandiseTypeData[]>([])
@@ -173,7 +176,6 @@ onMounted(async () => {
}
selectedPelletBuildingIds.value = selectionMap
})
// Enregistre les sélections et passe à l'étape suivante
async function goNext() {
if (!receptionStore.current) {
@@ -191,6 +193,8 @@ async function goNext() {
buildings: isGranule.value
? []
: selectedBuildingIds.value.map((id) => `/api/buildings/${id}`),
bovineDetail: null,
bovinesTypes: null,
currentStep: nextStep
})
@@ -208,7 +212,6 @@ async function clearPelletSelections(receptionIri: string) {
await deleteReceptionPelletBuilding(selection.id)
}
}
// Synchronise les associations granulés/bâtiments avec l'état du formulaire
async function syncPelletSelections(receptionIri: string) {
const existing = await getReceptionPelletBuildingList(receptionIri)
@@ -227,7 +230,7 @@ async function syncPelletSelections(receptionIri: string) {
const desiredEntries: Array<{ pelletTypeId: string; buildingId: string }> = []
for (const [pelletTypeId, buildingIds] of Object.entries(selectedPelletBuildingIds.value)) {
for (const buildingId of buildingIds) {
desiredEntries.push({ pelletTypeId, buildingId })
desiredEntries.push({pelletTypeId, buildingId})
}
}

View File

@@ -1,9 +1,9 @@
<template>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase">{{ title }}</h1>
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
<!--@TODO Voir comment faire pour savoir si le pont-bascule et bien connecté + ajouter un icon comme sur la maquette-->
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
<p class="text-primary-500 uppercase text-2xl text-primary-500 mt-2">Pont-bascule connecté</p>
<div
v-if="showLoadingBox"
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
@@ -11,32 +11,32 @@
</div>
<div v-else-if="displayWeight !== null" class="w-full">
<div
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl">
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl text-primary-500">
{{ displayWeight }} kg
</div>
</div>
</div>
</div>
<div class="flex justify-center mt-[54px]">
<button
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesee' : 'peser' }}</button>
<button
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
<UiButton
v-if="displayWeight !== null && !showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="saveWeight"
>Valider la pesée</button>
<button
>Valider la pesée</UiButton>
<UiButton
v-if="showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="printReceipt"
>Générer le bon</button>
>Générer le bon</UiButton>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {computed, onMounted} from 'vue'
import { storeToRefs } from 'pinia'
import { useWeighing } from '~/composables/useWeighing'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
@@ -74,7 +74,9 @@ const printReceipt = async () => {
}
await saveWeight()
await printPdf(`/receptions/${receptionStore.current.id}/receipt`)
const reception = receptionStore.current
const filename = `${reception.identificationNumber ?? reception.id}_${reception.supplier?.name ?? 'fournisseur'}_${reception.licensePlate ?? 'immat'}.pdf`
await printPdf(`/receptions/${reception.id}/receipt`, filename)
// Laisse le temps a la boite de dialogue d'impression de s'ouvrir.
await new Promise((resolve) => setTimeout(resolve, 600))
@@ -92,7 +94,7 @@ const printReceipt = async () => {
// Récupère le poids dès l'arrivée sur l'écran
onMounted(() => {
if (false === displayWeight.value) {
if (displayWeight.value === null) {
fetchWeight()
}
})

View File

@@ -0,0 +1,161 @@
<template>
<form>
<div class="flex flex-row justify-between gap-x-12 font-bold uppercase mb-8">
<div
v-for="type in bovineTypes"
:key="type.id"
>
<UiNumberInput
:label="type.label"
:code="type.code"
v-model="localQuantities[String(type.id)]"
:disabled="!isAdmin"
:placeholder="0"
:min="0"
:max="10"
wrapperClass="w-44 flex-col"
inputClass="font-medium"
/>
</div>
<UiNumberInput
label="Autres"
v-model="localOtherQuantity"
:disabled="!isAdmin"
wrapperClass="w-44 flex-col"
inputClass="font-medium"
/>
</div>
</form>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { getBovineTypeList } from '~/services/bovine-type'
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
import type { ReceptionBovineTypeData } from '~/services/dto/reception-bovine-data'
const props = defineProps<{
modelValue: ReceptionBovineTypeData[]
otherQuantity: number | null
isAdmin: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: ReceptionBovineTypeData[]): void
(event: 'update:otherQuantity', value: number | null): void
}>()
const bovineTypes = ref<BovineTypeData[]>([])
const localQuantities = reactive<Record<string, number | null>>({})
const localOtherQuantity = ref<number | null>(props.otherQuantity ?? 0)
// Verrou pour éviter les boucles props -> local -> emit -> props.
const isSyncing = ref(false)
function entriesEqualByTypeAndQuantity(
left: ReceptionBovineTypeData[],
right: ReceptionBovineTypeData[]
): boolean {
const toMap = (entries: ReceptionBovineTypeData[]) => {
const map = new Map<number, number>()
for (const entry of entries) {
const typeId = entry.bovineType?.id ?? 0
map.set(typeId, entry.quantity ?? 0)
}
return map
}
const a = toMap(left)
const b = toMap(right)
if (a.size !== b.size) {
return false
}
for (const [typeId, quantity] of a.entries()) {
if ((b.get(typeId) ?? 0) !== quantity) {
return false
}
}
return true
}
function buildEntriesFromLocal(): ReceptionBovineTypeData[] {
return bovineTypes.value.map((type) => {
const existing = props.modelValue.find((entry) => entry.bovineType.id === type.id)
return {
id: existing?.id ?? 0,
bovineType: type,
quantity: localQuantities[String(type.id)] ?? 0
}
})
}
function syncLocalFromProps() {
isSyncing.value = true
try {
for (const key of Object.keys(localQuantities)) {
delete localQuantities[key]
}
for (const type of bovineTypes.value) {
const existing = props.modelValue.find((entry) => entry.bovineType.id === type.id)
localQuantities[String(type.id)] = existing?.quantity ?? 0
}
} finally {
isSyncing.value = false
}
}
watch(
() => props.otherQuantity,
(value) => {
if (isSyncing.value) {
return
}
const next = value ?? 0
isSyncing.value = true
localOtherQuantity.value = next
isSyncing.value = false
}
)
watch(localOtherQuantity, (value) => {
if (isSyncing.value) {
return
}
const next = value ?? 0
emit('update:otherQuantity', next)
})
watch(
() => props.modelValue,
() => {
// Hydratation locale uniquement quand le parent change.
syncLocalFromProps()
},
{ immediate: true }
)
watch(
localQuantities,
() => {
if (isSyncing.value) {
return
}
// N'émet que si les quantités diffèrent réellement du parent.
const nextEntries = buildEntriesFromLocal()
if (!entriesEqualByTypeAndQuantity(nextEntries, props.modelValue)) {
emit('update:modelValue', nextEntries)
}
},
{ deep: true }
)
onMounted(async () => {
bovineTypes.value = await getBovineTypeList()
syncLocalFromProps()
})
</script>

View File

@@ -0,0 +1,269 @@
<template>
<form>
<div class="flex flex-col">
<div class="w-full col-start-1 row-start-1">
<UiRadioGroup
id="merchandise-type"
v-model="selectedMerchandiseTypeId"
label="Type de marchandises"
:options="merchandiseTypes.map((type) => ({
value: String(type.id),
label: type.label
}))"
input-class="accent-primary-700 focus:ring-primary-700"
option-label-class="uppercase"
wrapper-class="w-full uppercase"
group-class="grid grid-cols-[336px_336px_355px_200px] w-[160px_160px_200px_180px] mt-9 mb-7"
:disabled="!isAdmin"
/>
</div>
<div class="w-full grid grid-cols-[3fr_1fr] gap-12 col-start-2 row-start-1">
<div
v-if="selectedMerchandiseTypeId && !isGranule"
class="flex gap-[218px]"
>
<div
v-for="building in buildings"
:key="building.id"
>
<UiCheckbox
v-model="selectedBuildingIds"
:value="String(building.id)"
:label="building.label"
:disabled="!isAdmin"
input-class="accent-primary-700 focus:ring-primary-700"
label-class="text-xl uppercase"
/>
</div>
</div>
<div
v-if="selectedMerchandiseTypeId && isAutres"
class="flex flex-col justify-self-end max-w-[182px]"
>
<UiTextInput
id="merchandise-detail"
:disabled="!isAdmin"
v-model="merchandiseDetail"
placeholder="Préciser"
:maxlength="255"
class="h-6"
/>
</div>
</div>
<div
v-if="selectedMerchandiseTypeId && isGranule"
class="flex flex-col gap-10 w-full col-start-2 row-start-1"
>
<div class="grid grid-cols-1 md:grid-cols-[max-content_max-content_max-content_max-content] justify-between">
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4">
<p class="mb-1 font-medium uppercase">{{ type.label }}</p>
<div
v-for="building in buildings"
:key="building.id"
class="flex text-lg"
>
<UiCheckbox
v-model="selectedPelletBuildingIds[String(type.id)]"
:value="String(building.id)"
:label="building.label"
:disabled="!isAdmin"
input-class="accent-primary-700 focus:ring-primary-700"
label-class="text-lg"
/>
</div>
</div>
</div>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import type { BuildingData } from '~/services/dto/building-data'
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
import type { PelletTypeData } from '~/services/dto/pellet-type-data'
import type { MerchandiseEntryData } from '~/services/dto/reception-data'
import { getBuildingList } from '~/services/building'
import { getMerchandiseTypeList } from '~/services/merchandise-type'
import { getPelletTypeList } from '~/services/pellet-type'
import { MERCHANDISE_TYPE_CODES } from '~/utils/constants'
const props = defineProps<{
modelValue: MerchandiseEntryData
isAdmin: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: MerchandiseEntryData): void
}>()
const merchandiseTypes = ref<MerchandiseTypeData[]>([])
const buildings = ref<BuildingData[]>([])
const pelletTypes = ref<PelletTypeData[]>([])
const selectedMerchandiseTypeId = ref('')
const selectedBuildingIds = ref<string[]>([])
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
const merchandiseDetail = ref('')
// Verrou de synchro pour empêcher les aller-retours infinis entre parent et composant.
const isSyncing = ref(false)
const isReady = ref(false)
const selectedMerchandiseType = computed(() =>
merchandiseTypes.value.find((type) => String(type.id) === selectedMerchandiseTypeId.value) ?? null
)
const isGranule = computed(
() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.GRANULE
)
const isAutres = computed(
() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES
)
function clonePelletSelections(value: Record<string, string[]>) {
const clone: Record<string, string[]> = {}
for (const [key, buildingIds] of Object.entries(value)) {
clone[key] = [...buildingIds]
}
return clone
}
function sorted(values: string[]): string[] {
return [...values].sort()
}
function normalizeModel(value: MerchandiseEntryData): MerchandiseEntryData {
// Normalisation stable pour comparer deux modèles sans faux positifs (ordre des tableaux).
const pellet: Record<string, string[]> = {}
const pelletKeys = Object.keys(value.selectedPelletBuildingIds ?? {}).sort()
for (const key of pelletKeys) {
pellet[key] = sorted(value.selectedPelletBuildingIds[key] ?? [])
}
return {
merchandiseTypeId: value.merchandiseTypeId ?? '',
merchandiseDetail: value.merchandiseDetail ?? '',
selectedBuildingIds: sorted(value.selectedBuildingIds ?? []),
selectedPelletBuildingIds: pellet
}
}
function buildCurrentModel(): MerchandiseEntryData {
return {
merchandiseTypeId: selectedMerchandiseTypeId.value,
merchandiseDetail: merchandiseDetail.value,
selectedBuildingIds: [...selectedBuildingIds.value],
selectedPelletBuildingIds: clonePelletSelections(selectedPelletBuildingIds.value)
}
}
function isSameModel(left: MerchandiseEntryData, right: MerchandiseEntryData): boolean {
return JSON.stringify(normalizeModel(left)) === JSON.stringify(normalizeModel(right))
}
function ensurePelletKeys() {
for (const pelletType of pelletTypes.value) {
const key = String(pelletType.id)
if (!selectedPelletBuildingIds.value[key]) {
selectedPelletBuildingIds.value[key] = []
}
}
}
function hydrateFromModelValue(value: MerchandiseEntryData) {
isSyncing.value = true
try {
selectedMerchandiseTypeId.value = value.merchandiseTypeId ?? ''
merchandiseDetail.value = value.merchandiseDetail ?? ''
selectedBuildingIds.value = [...(value.selectedBuildingIds ?? [])]
selectedPelletBuildingIds.value = clonePelletSelections(
value.selectedPelletBuildingIds ?? {}
)
ensurePelletKeys()
} finally {
isSyncing.value = false
}
}
function sanitizeLocalState() {
if (isGranule.value) {
if (selectedBuildingIds.value.length > 0) {
selectedBuildingIds.value = []
}
} else {
for (const key of Object.keys(selectedPelletBuildingIds.value)) {
if (selectedPelletBuildingIds.value[key].length > 0) {
selectedPelletBuildingIds.value[key] = []
}
}
}
if (!isAutres.value && merchandiseDetail.value !== '') {
merchandiseDetail.value = ''
}
}
function emitCurrentModel() {
const currentModel = buildCurrentModel()
// Ne pas réémettre si rien n'a changé côté métier.
if (isSameModel(currentModel, props.modelValue)) {
return
}
emit('update:modelValue', currentModel)
}
watch(
() => props.modelValue,
(value) => {
const currentModel = buildCurrentModel()
// Si local == parent, on ignore pour éviter la boucle de réhydratation.
if (isSameModel(currentModel, value)) {
return
}
hydrateFromModelValue(value)
},
{ immediate: true }
)
watch(
[selectedMerchandiseTypeId, selectedBuildingIds, selectedPelletBuildingIds, merchandiseDetail],
() => {
if (isSyncing.value || !isReady.value) {
return
}
const beforeSanitize = buildCurrentModel()
isSyncing.value = true
// Applique les règles métier (granulé / autres) avant émission.
sanitizeLocalState()
isSyncing.value = false
const afterSanitize = buildCurrentModel()
// Si la sanitation a modifié l'état, on laisse le watcher repasser proprement.
if (!isSameModel(beforeSanitize, afterSanitize)) {
return
}
emitCurrentModel()
},
{ deep: true }
)
onMounted(async () => {
const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([
getMerchandiseTypeList(),
getBuildingList(),
getPelletTypeList()
])
merchandiseTypes.value = merchandiseTypeList
buildings.value = buildingList
pelletTypes.value = pelletTypeList
hydrateFromModelValue(props.modelValue)
isReady.value = true
})
</script>

View File

@@ -0,0 +1,554 @@
<template>
<form @submit.prevent="validate">
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Expédition</h1>
<!-- Nom de l'utilisateur -->
<UiSelect
id="shipment-user"
v-model="form.userId"
label="Nom de l'utilisateur"
:options="users.map((user) => ({
value: String(user.id),
label: user.username
}))"
:loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2"
/>
<!-- Date de l'éxpedition -->
<UiDateInput
id="shipment-date"
v-model="form.shipmentDate"
label="Date du jour"
wrapper-class="col-start-1 row-start-3"
/>
<!-- Type d'expédition -->
<div class="col-start-1 row-start-4 h-[64px]">
<div class="flex w-full items-end gap-[104px]">
<UiRadioGroup
id="shipment-type"
name="shipment-type"
label="Type d'expédition bovine"
input-class="accent-primary-700 focus:ring-primary-700"
wrapper-class=""
group-class="flex flex-row gap-[104px] w-[160px_160px] h-[32px]"
v-model="selectedShipmentTypeId"
:options="bovineShipment.map((type) => ({
value: String(type.id),
label: type.label
}))"
/>
<UiNumberInput
id="shipment-type-quantity"
v-model="shipmentQuantity"
:placeholder="0"
:min="0"
:max="1200"
:disabled="!selectedShipmentTypeId"
/>
</div>
</div>
<!-- Client -->
<UiSelect
id="shipment-customer"
v-model="form.customerId"
label="Client"
:options="customers.map((customer) => ({
value: String(customer.id),
label: customer.name || `Client #${customer.id}`
}))"
:loading="isLoadingCustomers"
wrapper-class="col-start-1 row-start-5"
/>
<!-- Adresse du client -->
<UiSelect
id="shipment-address"
v-model="form.addressId"
:options="customerAddressOptions"
:disabled="isLoadingCustomers || customerAddresses.length === 0"
label="Adresse"
wrapper-class="col-start-2 row-start-1"
/>
<!-- Camion -->
<UiSelect
id="shipment-truck"
v-model="form.truckId"
label="Camion"
:options="trucks.map((truck) => ({
value: String(truck.id),
label: truck.name
}))"
:loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2"
/>
<!-- Transporteur -->
<UiSelect
id="shipment-carrier"
v-model="form.carrierId"
label="Transporteur"
:options="carriers.map((carrier) => ({
value: String(carrier.id),
label: carrier.name
}))"
wrapper-class="col-start-2 row-start-3"
/>
<!-- Plaque d'immatriculation (hors LIOT) -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput
v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate"
/>
</div>
<!-- Immatriculation (LIOT) -->
<UiSelect
v-if="isLiotCarrier"
id="shipment-vehicle"
v-model="form.vehicleId"
label="Immatriculation"
:options="filteredVehicles.map((vehicle) => ({
value: String(vehicle.id),
label: vehicle.plate
}))"
:loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4"
/>
<!-- Chauffeur (LIOT) -->
<UiSelect
id="shipment-driver"
v-model="form.driverId"
label="Nom du chauffeur si LIOT"
:options="filteredDrivers.map((driver) => ({
value: String(driver.id),
label: driver.name
}))"
:loading="isLoadingDrivers"
wrapper-class="col-start-2 row-start-5"
v-if="isLiotCarrier"
/>
</div>
<div class="flex justify-center">
<UiButton
type="submit"
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import type {UserData} from '~/services/dto/user-data'
import type {CustomerData} from '~/services/dto/customer-data'
import type {TruckData} from '~/services/dto/truck-data'
import type {CarrierData} from '~/services/dto/carrier-data'
import type {DriverData} from '~/services/dto/driver-data'
import type {VehicleData} from '~/services/dto/vehicle-data'
import type {AddressData} from '~/services/dto/address-data'
import {getUsers} from '~/services/auth'
import {getCustomerList} from '~/services/customer'
import {getTruckList} from '~/services/truck'
import {getCarrierList} from '~/services/carrier'
import {getVehicleList} from '~/services/vehicle'
import {getDriverList} from '~/services/driver'
import type {ShipmentFormData} from '~/services/dto/shipment-data'
import {SUPPLIER_CODE} from "~/utils/constants"
import {useAuthStore} from '~/stores/auth'
import {useShipmentStore} from '~/stores/shipment'
import {computed, reactive, ref, watch, onMounted} from 'vue'
import type {ShipmentTypeData} from "~/services/dto/shipment-type-data";
import {getShipmentTypeList} from "~/services/shipment-type";
const users = ref<UserData[]>([])
const customers = ref<CustomerData[]>([])
const trucks = ref<TruckData[]>([])
const carriers = ref<CarrierData[]>([])
const drivers = ref<DriverData[]>([])
const vehicles = ref<VehicleData[]>([])
const isLoadingUsers = ref(false)
const isLoadingShipmentTypes = ref(false)
const isLoadingCustomers = ref(false)
const isLoadingTrucks = ref(false)
const isLoadingCarriers = ref(false)
const isHydrating = ref(false)
const isLoadingVehicles = ref(false)
const allowAnyLicensePlate = ref(false)
const isLoadingDrivers = ref(false)
const authStore = useAuthStore()
const shipmentStore = useShipmentStore()
const router = useRouter()
const bovineShipment = ref<ShipmentTypeData[]>([])
const selectedShipmentTypeId = ref('')
const shipmentQuantity = ref<number | null>(0)
// Transporteur sélectionné dans le formulaire
const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
const form = reactive<ShipmentFormData>({
userId: '',
shipmentDate: new Date().toISOString().slice(0, 10),
customerId: '',
addressId: '',
truckId: '',
carrierId: '',
driverId: '',
vehicleId: '',
licensePlate: '',
})
// Adresses liées au client sélectionné
const customerAddresses = computed<AddressData[]>(() => {
const customerId = Number(form.customerId)
if (!Number.isFinite(customerId)) {
return []
}
return customers.value.find((customer) => customer.id === customerId)?.addresses ?? []
})
// Options pour le select des adresses du client
const customerAddressOptions = computed(() =>
customerAddresses.value
.map((address) => ({
value: String(address.id),
label: address.fullAddress
}))
)
// Chauffeurs liés au transporteur sélectionné (LIOT)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) {
return []
}
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
// Véhicules liés au transporteur + camion sélectionnés (LIOT)
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) {
return []
}
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
// Chargement des données pour les selects
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
const loadShipmentType = async () => {
isLoadingShipmentTypes.value = true
try {
bovineShipment.value = await getShipmentTypeList()
} finally {
isLoadingShipmentTypes.value = false
}
}
const loadCustomers = async () => {
isLoadingCustomers.value = true
try {
customers.value = await getCustomerList()
} finally {
isLoadingCustomers.value = false
}
}
const loadTrucks = async () => {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
// On met le user connecté par défaut dans le select
const setDefaultUser = () => {
if (form.userId) {
return
}
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
// Chargement initial des données
onMounted(async () => {
await loadShipmentType()
await loadUsers()
await loadCustomers()
await loadTrucks()
await loadCarriers()
await loadVehicles()
await loadDrivers()
await authStore.ensureSession()
setDefaultUser()
})
// Hydrate le formulaire depuis l'expédition en cours
watch(
() => shipmentStore.current,
(shipment) => {
isHydrating.value = true
form.licensePlate = shipment?.licensePlate ?? ''
form.shipmentDate = shipment?.shipmentDate ?? new Date().toISOString().slice(0, 10)
form.userId = shipment?.user?.id ? String(shipment.user.id) :
form.userId
form.customerId = shipment?.customer?.id ?
String(shipment.customer.id) : ''
form.addressId = shipment?.address?.id ? String(shipment.address.id) : ''
form.truckId = shipment?.truck?.id ? String(shipment.truck.id) : ''
form.carrierId = shipment?.carrier?.id ? String(shipment.carrier.id) : ''
form.driverId = shipment?.driver?.id ? String(shipment.driver.id) : ''
form.vehicleId = shipment?.vehicle?.id ? String(shipment.vehicle.id) : ''
selectedShipmentTypeId.value = shipment?.shipmentType?.id
? String(shipment.shipmentType.id)
: ''
shipmentQuantity.value = shipment?.nbBovinSend ?? 0
isHydrating.value = false
},
{immediate: true}
)
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
watch(
() => [form.customerId, form.addressId, customers.value],
() => {
if (!form.customerId) {
form.addressId = ''
return
}
if (!form.addressId && customerAddresses.value.length === 1) {
form.addressId = String(customerAddresses.value[0].id)
return
}
if (!form.addressId) {
return
}
const matches = customerAddresses.value.some(
(address) => String(address.id) === form.addressId
)
if (!matches) {
if (customerAddresses.value.length === 1) {
form.addressId = String(customerAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{immediate: true}
)
// Valide/auto-sélectionne le véhicule selon camion + transporteur (LIOT)
const applyLiotDefaults = () => {
if (isHydrating.value) {
return
}
if (!form.carrierId) {
form.driverId = ''
form.vehicleId = ''
return
}
if (!isLiotCarrier.value) {
form.driverId = ''
form.vehicleId = ''
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
}
watch(
() => form.carrierId,
() => {
applyLiotDefaults()
},
{immediate: true}
)
watch(
() => isHydrating.value,
(value) => {
if (!value) {
applyLiotDefaults()
}
}
)
// Récupère la plaque depuis le véhicule choisi (LIOT)
watch(
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
return
}
if (!form.vehicleId) {
return
}
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
}
},
{immediate: true}
)
// Auto-renseigne le véhicule si la plaque correspond (LIOT)
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (isHydrating.value) {
return
}
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
watch(
() => [form.licensePlate, form.carrierId, form.vehicleId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) {
return
}
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
form.vehicleId = String(match.id)
}
}
)
const buildPayload = () => {
const normalizedLicensePlate = form.licensePlate.trim()
const normalizedShipmentDate = form.shipmentDate.trim()
const normalizedCustomerId = form.customerId.trim()
const normalizedTruckId = form.truckId.trim()
const normalizedCarrierId = form.carrierId.trim()
const normalizedDriverId = form.driverId.trim()
const normalizedUserId = form.userId.trim()
const normalizedAddressId = form.addressId.trim()
const customerIri = normalizedCustomerId
? `/api/customers/${normalizedCustomerId}`
: null
const truckIri = normalizedTruckId
? `/api/trucks/${normalizedTruckId}`
: null
const carrierIri = normalizedCarrierId
? `/api/carriers/${normalizedCarrierId}`
: null
const userIri = normalizedUserId
? `/api/users/${normalizedUserId}`
: null
const driverIri = normalizedDriverId
? `/api/drivers/${normalizedDriverId}`
: null
const addressIri = normalizedAddressId
? `/api/addresses/${normalizedAddressId}`
: null
const normalizedShipmentTypeId = selectedShipmentTypeId.value.trim()
const shipmentTypeIri = normalizedShipmentTypeId
? `/api/shipment_types/${normalizedShipmentTypeId}`
: null
const rawQuantity = Number(shipmentQuantity.value ?? 0)
const normalizedQuantity = Number.isFinite(rawQuantity) ? Math.max(0,
Math.trunc(rawQuantity)) : 0
return {
licensePlate: normalizedLicensePlate,
shipmentDate: normalizedShipmentDate,
customer: customerIri,
truck: truckIri,
carrier: carrierIri,
driver: driverIri,
user: userIri,
address: addressIri,
shipmentType: shipmentTypeIri,
nbBovinSend: normalizedQuantity,
}
}
const saveDraft = async () => {
const payload = buildPayload()
if (!shipmentStore.current) {
await shipmentStore.createShipment({
currentStep: 0,
...payload
})
return
}
await shipmentStore.updateShipment(shipmentStore.current.id, {
currentStep: shipmentStore.current.currentStep,
...payload
})
}
defineExpose({saveDraft})
// Valide le formulaire et crée/met à jour l'expédition
const validate = async () => {
const payload = buildPayload()
if (!shipmentStore.current) {
const created = await shipmentStore.createShipment({
currentStep: 1,
...payload
})
if (created) {
await shipmentStore.loadShipment(created.id)
await router.push(`/shipment/${created.id}`)
}
return
}
const nextStep = shipmentStore.current.currentStep + 1
await shipmentStore.updateShipment(shipmentStore.current.id, {
currentStep: nextStep,
...payload
})
await shipmentStore.loadShipment(shipmentStore.current.id)
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl mb-2"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
@@ -14,9 +14,9 @@
:value="modelValue ?? ''"
:disabled="disabled"
v-bind="attrs"
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase bg-transparent appearance-none h-[34px]"
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
inputClass
]"

View File

@@ -0,0 +1,120 @@
// flex row passer en class wraper class flex col ainsi que le wfull 34
<template>
<div :class="['flex', wrapperClass]">
<label
v-if="label"
:for="id"
class="text-xl flex items-center gap-2 text-primary-700"
:class="labelClass"
>
<span
v-if="label">
{{ label }}
</span>
<span
v-if="code"
class="text-neutral-600">
({{ code }})
</span>
</label>
<input
:id="id"
type="number"
:value="modelValue ?? ''"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
v-bind="attrs"
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
disabled ? 'cursor-not-allowed' : 'cursor-text',
inputClass
]"
@keydown="onKeydown"
@input="onInput"
>
</div>
</template>
<script setup lang="ts">
import {computed, useAttrs} from 'vue'
defineOptions({inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
code?: string
modelValue: number | string | null | undefined
min?: number | string
max?: number | string
step?: number | string
disabled?: boolean
wrapperClass?: string
labelClass?: string
inputClass?: string
}>(),
{
min: undefined,
max: undefined,
step: undefined,
disabled: false,
wrapperClass: '',
labelClass: '',
inputClass: ''
}
)
const emit = defineEmits<{
(event: 'update:modelValue', value: number | null): void
}>()
const attrs = useAttrs()
const isEmpty = computed(() => props.modelValue === null || props.modelValue === undefined || props.modelValue === '')
const toNumberOrNull = (value: number | string | undefined) => {
if (value === undefined || value === '') {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.value === '') {
emit('update:modelValue', null)
return
}
const parsed = Number(target.value)
if (!Number.isFinite(parsed)) {
emit('update:modelValue', null)
return
}
const min = toNumberOrNull(props.min)
const max = toNumberOrNull(props.max)
let numeric = parsed
if (min !== null) {
numeric = Math.max(min, numeric)
} else {
numeric = Math.max(0, numeric)
}
if (max !== null) {
numeric = Math.min(max, numeric)
}
target.value = String(numeric)
emit('update:modelValue', numeric)
}
const onKeydown = (event: KeyboardEvent) => {
if (event.key === '-' || event.key === 'e' || event.key === 'E') {
event.preventDefault()
}
}
</script>

View File

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

View File

@@ -3,7 +3,7 @@
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl mb-2"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
@@ -13,9 +13,9 @@
:value="modelValue ?? ''"
:disabled="disabled || loading"
v-bind="attrs"
class="border-b border-black justify-self-start text-xl pb-[6px] bg-transparent"
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] bg-transparent"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer',
selectClass
]"
@@ -28,7 +28,7 @@
v-for="option in options"
:key="option.value"
:value="option.value"
class="text-black"
class="text-primary-700"
>
{{ option.label }}
</option>

View File

@@ -3,7 +3,7 @@
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl mb-2"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
@@ -16,7 +16,7 @@
:maxlength="maxlength"
:disabled="disabled"
v-bind="attrs"
class="border-b border-black text-xl pb-[6px] bg-transparent"
class="border-b border-black text-xl py-[6px] bg-transparent text-primary-700"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
disabled ? 'cursor-not-allowed' : 'cursor-text',

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col">
<label :for="inputId" class="font-bold uppercase text-xl mb-2">{{ label }}</label>
<label :for="inputId" class="font-bold uppercase text-xl text-primary-500">{{ label }}</label>
<div class="flex items-end gap-8">
<input
:id="inputId"
@@ -9,7 +9,7 @@
type="text"
:maxlength="maxLength"
:placeholder="placeholderText"
class="border-b border-black flex-1 min-w-0 text-xl uppercase h-[30px]"
class="border-b border-black flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
@input="handleInput"
/>
<UiCheckbox

View File

@@ -4,7 +4,7 @@
<div
v-for="(label, index) in labels"
:key="label"
class="absolute top-0 whitespace-nowrap"
class="absolute top-0 whitespace-nowrap text-primary-500"
:class="labelClass(index)"
:style="positionStyle(index)"
>

View File

@@ -0,0 +1,17 @@
export const useAppVersion = () => {
const api = useApi()
const version = useState<string | null>('app-version', () => null)
const load = async () => {
if (version.value) {
return version.value
}
const response = await api.get<{ version: string }>('version', {}, {
toast: false
})
version.value = response.version
return version.value
}
return { version, load }
}

View File

@@ -1,30 +1,26 @@
import {useApi} from '~/composables/useApi'
import { useApi } from '~/composables/useApi'
export const usePdfPrinter = () => {
const api = useApi()
const receptionStore = useReceptionStore()
const currentReception = receptionStore.current
const printPdf = async (url: string): Promise<void> => {
const blob = await api.getBlob(url);
const printPdf = async (url: string, filename = 'document.pdf'): Promise<void> => {
const blob = await api.getBlob(url)
const pdfBlob = blob.type === 'application/pdf'
? blob
: new Blob([blob], { type: 'application/pdf' });
: new Blob([blob], { type: 'application/pdf' })
const blobUrl = URL.createObjectURL(pdfBlob);
const blobUrl = URL.createObjectURL(pdfBlob)
const filename = `${currentReception.identificationNumber}_${currentReception.supplier.name}_${currentReception.licensePlate}.pdf`;
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
a.remove();
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
a.style.display = 'none'
document.body.appendChild(a)
a.click()
a.remove()
// L'ouverture dans un nouvel onglet déclenche un 2e PDF sans le nom personnalisé.
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
}
return {

View File

@@ -3,23 +3,20 @@ import {computed, ref} from 'vue'
import type {ReceptionData, ReceptionPayload, WeightEntryData} from '~/services/dto/reception-data'
import type {WeightData} from '~/services/dto/weight-data'
import {getWeight} from '~/services/reception'
import {getWeightShipment} from '~/services/shipment'
import {createWeight, updateWeight} from '~/services/weight'
import type {UseWeighingShipmentOptions, UseWeighingOptions} from '~/services/weight'
import type {WeightShipmentEntryData} from "~/services/dto/shipment-data";
export type WeighingMode = 'gross' | 'tare'
type UseWeighingOptions = {
mode: WeighingMode
reception: Ref<ReceptionData | null>
updateReception: (id: number, payload: ReceptionPayload) => Promise<ReceptionData | null>
loadReception?: (id: number) => Promise<ReceptionData | null>
}
export const useWeighing = ({
mode,
reception,
updateReception,
loadReception
}: UseWeighingOptions) => {
mode,
reception,
updateReception,
loadReception
}: UseWeighingOptions) => {
const weightData = ref<WeightData | null>(null)
const isFetching = ref(false)
@@ -65,7 +62,7 @@ export const useWeighing = ({
})
} else {
await createWeight({
reception: `api/receptions/${reception.value.id}`,
reception: `/api/receptions/${reception.value.id}`,
type: mode,
dsd: baseDsd,
weight: baseWeight,
@@ -97,3 +94,87 @@ export const useWeighing = ({
saveWeight
}
}
export const useWeighingShipment = ({
modeShipment,
shipment,
updateShipment,
loadShipment
}: UseWeighingShipmentOptions) => {
const weightData = ref<WeightData | null>(null)
const isFetching = ref(false)
const currentWeightEntry = computed<WeightShipmentEntryData | null>(() => {
const weights = shipment.value?.weights ?? []
return weights.find((entry) => entry.type === modeShipment) ?? null
})
const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null)
const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-')
const title = computed(() => (modeShipment === 'gross' ? 'Pesée à vide' : 'Pesée à plein'))
const showLoadingBox = computed(
() => isFetching.value || (displayWeight.value === null && currentWeightEntry.value === null)
)
const fetchWeight = async () => {
isFetching.value = true
weightData.value = await getWeightShipment().finally(() => {
isFetching.value = false
})
}
const saveWeight = async () => {
if (!shipment.value) {
return
}
const existingEntry = currentWeightEntry.value
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
if (baseWeight === null) {
return
}
if (existingEntry?.id) {
await updateWeight(existingEntry.id, {
type: modeShipment,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
} else {
await createWeight({
shipment: `/api/shipments/${shipment.value.id}`,
type: modeShipment,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
}
const nextStep = modeShipment === 'tare'
? shipment.value.currentStep
: shipment.value.currentStep + 1
await updateShipment(shipment.value.id, {
currentStep: nextStep,
isValid: shipment.value.isValid
})
if (loadShipment) {
await loadShipment(shipment.value.id)
}
}
return {
weightData,
currentWeightEntry,
displayWeight,
displayDsd,
title,
showLoadingBox,
fetchWeight,
saveWeight
}
}

View File

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

View File

@@ -1,64 +1,140 @@
{
"errors": {
"http": {
"get": "Impossible de récupérer les données.",
"post": "Impossible de créer la ressource.",
"put": "Impossible de mettre à jour la ressource.",
"patch": "Impossible de mettre à jour la ressource.",
"delete": "Impossible de supprimer la ressource."
"errors": {
"http": {
"get": "Impossible de récupérer les données.",
"post": "Impossible de créer la ressource.",
"put": "Impossible de mettre à jour la ressource.",
"patch": "Impossible de mettre à jour la ressource.",
"delete": "Impossible de supprimer la ressource."
},
"reception": {
"list": "Impossible de récupérer la liste des réceptions.",
"fetch": "Impossible de récupérer la réception.",
"create": "Impossible de créer la réception.",
"update": "Impossible de mettre à jour la réception.",
"weight": "Impossible de récupérer la pesée."
},
"weight": {
"update": "Impossible de mettre à jour la pesée"
},
"shipment": {
"list": "Impossible de récupérer la liste des éxpeditions.",
"fetch": "Impossible de récupérer l'éxpeditions.",
"create": "Impossible de créer l'éxpeditions.",
"update": "Impossible de mettre à jour l'éxpeditions.",
"weigh": "Impossible de récupérer la pesée."
},
"shipmentBovine": {
"list": "Impossible de récupérer la liste des bovins de l'éxpedition.",
"create": "Impossible d'enregistrer le bovin.",
"delete": "Impossible de supprimer le bovin.",
"update": "Impossible de mettre à jour le bovin."
},
"shipmentType": {
"list": "Impossible de récupérer la liste des types d'éxpedition."
},
"receptionType": {
"list": "Impossible de récupérer la liste des types de réception."
},
"merchandiseType": {
"list": "Impossible de récupérer la liste des types de marchandises."
},
"building": {
"list": "Impossible de récupérer la liste des bâtiments."
},
"pelletType": {
"list": "Impossible de récupérer la liste des types de granulés."
},
"receptionPelletBuilding": {
"list": "Impossible de récupérer la liste des dépôts de granulés.",
"create": "Impossible d'enregistrer le dépôt de granulés.",
"delete": "Impossible de supprimer le dépôt de granulés."
},
"receptionBovine": {
"list": "Impossible de récupérer la liste des bovins de la réception.",
"create": "Impossible d'enregistrer le bovin.",
"delete": "Impossible de supprimer le bovin."
},
"supplier": {
"list": "Impossible de récupérer la liste des fournisseurs.",
"fetch": "Impossible de récupérer le fournisseur.",
"create": "Impossible de créer le fournisseur.",
"update": "Impossible de mettre à jour le fournisseur.",
"nameRequired": "Le nom du fournisseur est obligatoire."
},
"address": {
"fetch": "Impossible de récupérer l'adresse.",
"create": "Impossible de créer l'adresse.",
"update": "Impossible de mettre à jour l'adresse.",
"entityNotFound": "Entité introuvable.",
"streetRequired": "La rue est obligatoire.",
"postalCodeRequired": "Le code postal est obligatoire.",
"cityRequired": "La ville est obligatoire.",
"countryCodeInvalid": "Le pays doit être un code ISO2 (2 lettres)."
},
"customer": {
"list": "Impossible de récupérer la liste des clients.",
"fetch": "Impossible de récupérer le client.",
"create": "Impossible de créer le client.",
"update": "Impossible de mettre à jour le client."
},
"truck": {
"list": "Impossible de récupérer la liste des camions."
},
"bovin": {
"list": "Impossible de récupérer la liste des races de bovins."
},
"carrier": {
"list": "Impossible de récupérer la liste des transporteurs.",
"fetch": "Impossible de récupérer les données du transporteur",
"update": "Impossible de mettre à jour le transporteur",
"create": "Impossible de créer le transporteur"
},
"driver": {
"list": "Impossible de récupérer la liste des chauffeurs."
},
"vehicle": {
"list": "Impossible de récupérer la liste des immatriculations."
},
"auth": {
"login": "Identifiants invalides.",
"users": "Impossible de récupérer les utilisateurs.",
"logout": "Impossible de se déconnecter.",
"update": "Impossible de mettre à jour l'utilisateur.",
"create": "Impossible de créer l'utilisateur."
}
},
"reception": {
"list": "Impossible de récupérer la liste des réceptions.",
"fetch": "Impossible de récupérer la réception.",
"create": "Impossible de créer la réception.",
"update": "Impossible de mettre à jour la réception.",
"weigh": "Impossible de récupérer la pesée."
},
"receptionType": {
"list": "Impossible de récupérer la liste des types de réception."
},
"merchandiseType": {
"list": "Impossible de récupérer la liste des types de marchandises."
},
"building": {
"list": "Impossible de récupérer la liste des bâtiments."
},
"pelletType": {
"list": "Impossible de récupérer la liste des types de granulés."
},
"receptionPelletBuilding": {
"list": "Impossible de récupérer la liste des dépôts de granulés.",
"create": "Impossible d'enregistrer le dépôt de granulés.",
"delete": "Impossible de supprimer le dépôt de granulés."
},
"supplier": {
"list": "Impossible de récupérer la liste des fournisseurs."
},
"truck": {
"list": "Impossible de récupérer la liste des camions."
},
"carrier": {
"list": "Impossible de récupérer la liste des transporteurs."
},
"driver": {
"list": "Impossible de récupérer la liste des chauffeurs."
},
"vehicle": {
"list": "Impossible de récupérer la liste des immatriculations."
},
"auth": {
"login": "Identifiants invalides.",
"users": "Impossible de récupérer les utilisateurs.",
"logout": "Impossible de se déconnecter."
"success": {
"reception": {
"update": "Réception mise à jour avec succès."
},
"shipment": {
"update": "Éxpedition mise à jour avec succès."
},
"supplier": {
"create": "Fournisseur créé avec succès.",
"update": "Fournisseur mis à jour avec succès."
},
"customer": {
"create": "Client créé avec succès.",
"update": "Client mis à jour avec succès."
},
"address": {
"create": "Adresse créée avec succès.",
"update": "Adresse mise à jour avec succès."
},
"auth": {
"update": "Utilisateur mis à jour avec succès.",
"create": "Utilisateur créé avec succès.",
"login": "Connexion réussie.",
"logout": "Déconnexion réussie."
},
"carrier": {
"update": "Transporteur mis à jour",
"create": "Transporteur créé"
},
"weight": {
"update": "Pesée mis à jour"
}
}
},
"success": {
"reception": {
"update": "Réception mise à jour avec succès."
},
"auth": {
"login": "Connexion réussie.",
"logout": "Déconnexion réussie."
}
}
}

View File

@@ -1,61 +1,269 @@
<template>
<div class="min-h-screen bg-white text-neutral-900">
<header class="w-full border-b border-neutral-200 bg-primary-500">
<div class="flex w-full items-center px-6 py-4">
<NuxtLink to="/" class="flex items-center gap-3">
<span
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
>
<div class="min-h-screen text-neutral-900 flex flex-col">
<!-- HEADER -->
<header class="w-full bg-primary-500 py-5 px-6">
<div class="flex w-full items-center justify-between">
<!-- Burger (mobile) -->
<button
type="button"
class="inline-flex items-center justify-center text-3xl text-white md:hidden"
aria-label="Ouvrir le menu"
@click="toggleMenu"
>
<span aria-hidden="true" class="flex items-center">
<Icon name="mdi:menu" size="44"/>
</span>
</button>
<!-- Logo -->
<NuxtLink to="/" class="shrink-0">
<span class="flex items-center justify-center bg-white text-xl font-bold uppercase px-6 py-4">
LOGO
</span>
</NuxtLink>
<nav class="mx-8 flex flex-1 gap-8 text-2xl font-bold uppercase text-white">
<NuxtLink to="/" custom v-slot="{ href, navigate, isExactActive }">
<!-- NAV centré (desktop) -->
<nav
class="hidden md:flex flex-1 items-center justify-center gap-8 text-xl font-bold uppercase text-white"
>
<NuxtLink to="/" custom v-slot="{ href, navigate }">
<a
:href="href"
@click="navigate"
:class="isExactActive ? 'opacity-100' : 'opacity-50'"
:class="route.path === '/'
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Accueil
</a>
</NuxtLink>
<NuxtLink to="/reception" custom v-slot="{ href, navigate, isActive }">
<NuxtLink
v-if="auth.isAdmin"
to="/admin/supplier/supplier-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="isReceptionActive ? 'opacity-100' : 'opacity-50'"
:class="route.path.startsWith('/admin/supplier')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Reception
Fournisseurs
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/carrier/carrier-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/carrier')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Transporteurs
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/user/list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/user')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Utilisateurs
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/customer/customer-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/customer')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Clients
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/bovin/list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/bovin')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Bovins
</a>
</NuxtLink>
</nav>
<button
type="button"
class="ml-auto text-xl font-bold uppercase text-white transition hover:opacity-80"
@click="handleLogout"
>
Déconnexion
</button>
<!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
<div class="w-[44px] md:hidden"></div>
<!-- User dropdown à droite (desktop) -->
<div v-if="auth.isAuthenticated" class="ml-auto relative hidden md:flex items-center text-white group">
<button
type="button"
class="inline-flex items-center py-2 -my-2 text-xl leading-none transition hover:opacity-80"
aria-haspopup="true"
>
<span class="capitalize font-bold">{{ userDisplayName }}</span>
<span
class="ml-[6px] inline-flex items-center font-bold transition-transform group-hover:rotate-180 group-focus-within:rotate-180">
<Icon name="mdi:chevron-down" size="20"/>
</span>
</button>
<div
class="absolute right-0 top-full z-10 w-56 rounded-md bg-primary-500 py-2 border-neutral-300 border shadow-lg
opacity-0 invisible pointer-events-none transition
group-hover:opacity-100 group-hover:visible group-hover:pointer-events-auto
group-focus-within:opacity-100 group-focus-within:visible group-focus-within:pointer-events-auto"
role="menu"
>
<button
type="button"
class="w-full px-4 py-2 text-left text-sm font-semibold text-white opacity-85 hover:opacity-100 transition"
@click="handleLogout"
>
Déconnexion
</button>
</div>
</div>
</div>
<!-- Overlay (mobile) -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isMenuOpen"
class="fixed inset-0 z-40 bg-black/40 md:hidden"
@click="closeMenu"
/>
</transition>
<!-- Drawer (mobile) -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full"
>
<aside
v-if="isMenuOpen"
class="fixed left-0 top-0 z-50 h-full w-full bg-primary-500 px-6 pb-8 pt-6 text-white shadow-xl md:hidden"
>
<div class="flex items-center justify-between">
<span class="text-2xl font-bold uppercase">Menu</span>
<button
type="button"
class="text-2xl"
aria-label="Fermer le menu"
@click="closeMenu"
>
<Icon name="mdi:close" size="44"/>
</button>
</div>
<nav class="mt-8 flex flex-col gap-6 text-xl font-bold uppercase">
<NuxtLink to="/admin/dashboard" @click="closeMenu">Accueil</NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/supplier/supplier-list" @click="closeMenu">
Fournisseurs
</NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/carrier/carrier-list" @click="closeMenu">
Transporteurs
</NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/user/list" @click="closeMenu">
Utilisateurs
</NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/customer/customer-list" @click="closeMenu">
Clients
</NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/list" @click="closeMenu">
Bovins
</NuxtLink>
</nav>
<button
v-if="auth.isAuthenticated"
type="button"
class="mt-6 text-xl font-bold uppercase"
@click="handleLogout"
>
Déconnexion
</button>
</aside>
</transition>
</header>
<main class="mx-auto w-full max-w-[1280px] px-6 pt-[85px] pb-0">
<main class="mx-auto w-full max-w-[1280px] mt-16">
<slot/>
</main>
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">
<p class="font-bold text-white text-right">v{{ version }}</p>
</footer>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '~/stores/auth'
import {useAuthStore} from '~/stores/auth'
const route = useRoute()
const auth = useAuthStore()
const isReceptionActive = computed(() => route.path.startsWith('/reception'))
const {version} = useAppVersion()
const isMenuOpen = ref(false)
const userDisplayName = computed(() => auth.user?.username ?? 'Utilisateur')
const closeMenu = () => {
isMenuOpen.value = false
}
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
}
const handleLogout = async () => {
try {
await auth.logout()
} finally {
await navigateTo('/login')
}
try {
await auth.logout()
} finally {
closeMenu()
await navigateTo('/login')
}
}
</script>

View File

@@ -9,7 +9,8 @@ export default defineNuxtConfig({
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'nuxt-toast',
'@nuxtjs/i18n'
'@nuxtjs/i18n',
'@nuxt/icon'
],
css: ['~/assets/css/main.css', '~/assets/css/toast.css'],
runtimeConfig: {

View File

@@ -7,6 +7,7 @@
"name": "frontend",
"hasInstallScript": true,
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
"izitoast": "^1.4.0",
@@ -35,6 +36,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
"license": "MIT",
"dependencies": {
"package-manager-detector": "^1.3.0",
"tinyexec": "^1.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -1248,6 +1262,47 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@iconify/collections": {
"version": "1.0.646",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.646.tgz",
"integrity": "sha512-zA5Gr1MJm1SI0TjOUl7wu4kvBWXQ6Uh8ALEtqQ5ucXyUxP2M8m2bk2hfVtGykSdMlDB+Xs2AHbJ9pQqayz9WGQ==",
"license": "MIT",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@iconify/utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
"integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^1.1.0",
"@iconify/types": "^2.0.0",
"mlly": "^1.8.0"
}
},
"node_modules/@iconify/vue": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz",
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"vue": ">=3"
}
},
"node_modules/@intlify/bundle-utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
@@ -2268,6 +2323,28 @@
"devtools-wizard": "cli.mjs"
}
},
"node_modules/@nuxt/icon": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.1.tgz",
"integrity": "sha512-GI840yYGuvHI0BGDQ63d6rAxGzG96jQcWrnaWIQKlyQo/7sx9PjXkSHckXUXyX1MCr9zY6U25Td6OatfY6Hklw==",
"license": "MIT",
"dependencies": {
"@iconify/collections": "^1.0.641",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^3.1.0",
"@iconify/vue": "^5.0.0",
"@nuxt/devtools-kit": "^3.1.1",
"@nuxt/kit": "^4.2.2",
"consola": "^3.4.2",
"local-pkg": "^1.1.2",
"mlly": "^1.8.0",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinyglobby": "^0.2.15"
}
},
"node_modules/@nuxt/kit": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.2.2.tgz",

View File

@@ -11,6 +11,7 @@
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
},
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
"izitoast": "^1.4.0",

View File

@@ -0,0 +1,104 @@
<template>
<form @submit.prevent="validate">
<div class="text-primary-500 flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase">
{{ route.params.id ? 'Modifier bovin' : 'Ajout bovin' }}
</h1>
<UiButton
type="submit"
:disabled="isLoading || isHydrating"
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="isEdit ? '' : 'mdi:plus'" size="28" />
{{ isEdit ? 'Valider' : 'Ajouter' }}
</UiButton>
</div>
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 py-12">
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" />
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" />
</div>
</form>
</template>
<script setup lang="ts">
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
const router = useRouter()
const route = useRoute()
const isLoading = ref(false)
const isHydrating = ref(false)
function resolveId(param: unknown) {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) return null
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
const idBovin = computed(() => resolveId(route.params.id))
const isEdit = computed(() => idBovin.value !== null)
const form = reactive<BovinFormData>({
label: '',
code: ''
})
const hydrateFromBovin = (bovin: BovineTypeData | null) => {
if (!bovin) {
return
}
isHydrating.value = true
form.label = bovin.label ?? ''
form.code = bovin.code ?? ''
isHydrating.value = false
}
watch(
() => idBovin.value,
async (id) => {
if (id === null) {
return
}
isLoading.value = true
try {
const bovin = await getBovin(id)
hydrateFromBovin(bovin)
} finally {
isLoading.value = false
}
},
{immediate: true}
)
async function validate() {
if (isLoading.value || isHydrating.value) return
const normalizedBovinCode = form.code.trim()
const normalizedBovinLabel = form.label.trim()
const basePayload = {
label: normalizedBovinLabel,
code: normalizedBovinCode
}
isLoading.value = true
try {
if (isEdit.value && idBovin.value !== null) {
await updateBovin(idBovin.value, basePayload)
} else {
await createBovin(basePayload)
}
await navigate()
} finally {
isLoading.value = false
}
}
async function navigate(){
return router.push("/admin/bovin/list")
}
</script>

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
<template>
<div class="flex items-center justify-between ">
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
</div>
<div class="mt-7 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)"
>
<div>{{ carrier.name}}</div>
<div>{{ carrier.code }}</div>
</div>
</div>
<div class="flex justify-center items-center">
<NuxtLink
to="/admin/carrier"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
</template>
<script setup lang="ts">
import type {CarrierData} from "~/services/dto/carrier-data";
import {getCarrierList} from "~/services/carrier";
const carrierList = ref<CarrierData[]>()
const router = useRouter()
const goToCarrier = (id: number) => {
router.push(`/admin/carrier/${id}`)
}
onMounted(async () => {
carrierList.value = await getCarrierList(false)
})
</script>

View File

@@ -0,0 +1,197 @@
<template>
<form @submit.prevent="validate">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase">
{{ customerId ? "Modifications du client" : "Ajout d'un client" }}
</h1>
<UiButton
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"
type="submit"
:disabled="isLoading || !auth.isAdmin"
>
<Icon :name="customerId ? '' : 'mdi:plus'" size="28" />
{{ customerId ? "Valider" : "Ajouter" }}
</UiButton>
</div>
<div class="grid grid-cols-2 gap-y-8 gap-x-80 mb-10 py-12">
<UiTextInput id="customer-name" v-model="form.name" label="Nom du client" :disabled="!auth.isAdmin"/>
<UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin"/>
<UiTextInput id="customer-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin"/>
</div>
<div class="mx-24 mb-4 py-6 border-t border-black"></div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-3xl font-bold uppercase">Adresses client</h2>
<UiButton
type="button"
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"
:disabled="customerId === null || !auth.isAdmin"
@click="goToAddAddress"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</UiButton>
</div>
<div class="overflow-x-auto mb-10">
<table class="w-full border-collapse">
<thead>
<tr class="text-left border-b border-gray-200">
<th class="py-3 pr-4 text-sm uppercase">Libellé</th>
<th class="py-3 pr-4 text-sm uppercase">Rue</th>
<th class="py-3 pr-4 text-sm uppercase">Complément</th>
<th class="py-3 pr-4 text-sm uppercase">Code postal</th>
<th class="py-3 pr-4 text-sm uppercase">Ville</th>
<th class="py-3 pr-4 text-sm uppercase">Pays</th>
</tr>
</thead>
<tbody>
<template v-if="form.addresses.length === 0">
<tr>
<td colspan="6" class="py-4 text-slate-400">
Aucune adresse.
</td>
</tr>
</template>
<template v-else>
<tr
v-for="(address, index) in form.addresses"
:key="address.id ?? index"
class="border-b border-gray-100 hover:bg-slate-50"
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)"
>
<td class="py-3 pr-4">{{ address.label || "—" }}</td>
<td class="py-3 pr-4">{{ address.street || "—" }}</td>
<td class="py-3 pr-4">{{ address.street2 || "—" }}</td>
<td class="py-3 pr-4">{{ address.postalCode || "—" }}</td>
<td class="py-3 pr-4">{{ address.city || "—" }}</td>
<td class="py-3 pr-4">{{ address.countryCode || "—" }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</form>
</template>
<script setup lang="ts">
import {computed, reactive, ref, watch} from "vue"
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
import {useAuthStore} from "~/stores/auth"
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const resolveId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) return null
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
const customerId = computed(() => resolveId(route.params.id))
const isLoading = ref(false)
const form = reactive<CustomerFormData>({
name: "",
phone: "",
email: "",
addresses: [],
})
const goToAddAddress = () => {
if (customerId.value === null || !auth.isAdmin) return
router.push({
path: "/admin/customer/address",
query: {
customerId: String(customerId.value),
},
})
}
const goToEditAddress = (addressId: number | null) => {
if (customerId.value === null || addressId === null || !auth.isAdmin) return
router.push({
path: "/admin/customer/address",
query: {
customerId: String(customerId.value),
addressId: String(addressId),
},
})
}
const hydrateFromCustomer = (customer: CustomerData | null) => {
if (!customer) return
form.name = customer.name ?? ""
form.phone = customer.phone ?? ""
form.email = customer.email ?? ""
if (!Array.isArray(customer.addresses) || customer.addresses.length === 0) {
form.addresses = []
return
}
if (typeof customer.addresses[0] === "string") {
form.addresses = []
return
}
form.addresses = customer.addresses.map((address) => ({
id: address.id ?? null,
label: address.label ?? "",
street: address.street ?? "",
street2: address.street2 ?? null,
postalCode: address.postalCode ?? "",
city: address.city ?? "",
countryCode: address.countryCode ?? "",
}))
}
watch(
() => customerId.value,
async (id) => {
if (id === null) return
isLoading.value = true
try {
const customer = await getCustomer(id)
hydrateFromCustomer(customer)
} finally {
isLoading.value = false
}
},
{immediate: true}
)
async function validate() {
if (isLoading.value) return
if (!auth.isAdmin) return
isLoading.value = true
try {
const name = form.name.trim()
const phone = form.phone?.trim() || null
const email = form.email?.trim() || null
const customerPayload: CustomerPayload = {
name,
phone,
email,
}
let targetId: number | null = null
if (customerId.value !== null) {
await updateCustomer(customerId.value, customerPayload)
targetId = customerId.value
} else {
const created = await createCustomer(customerPayload)
targetId = created.id
}
if (targetId !== null) {
await router.push(`/admin/customer/${targetId}`)
}
} finally {
isLoading.value = false
}
}
</script>

View File

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

View File

@@ -0,0 +1,115 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase text-primary-500">Liste des Clients</h1>
<NuxtLink
to="/admin/customer"
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>
<div v-if="auth.isAdmin" class="mt-6 border border-slate-200 mb-16">
<div class="max-h-96 overflow-y-auto">
<div
class="sticky top-0 z-10 grid grid-cols-8 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
>
<div>Nom</div>
<div>Téléphone</div>
<div>Email</div>
<div>Rue</div>
<div>Complément</div>
<div>Code Postal</div>
<div>Ville</div>
<div>Pays</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">
<div
v-if="!customer.addresses || customer.addresses.length === 0"
class="grid grid-cols-8 border-t gap-4 px-4 py-2 hover:bg-slate-50 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="col-span-1">Pas d'adresse</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
</div>
<template v-else-if="customer.addresses.length > 0">
<div
v-for="(address, idx) in customer.addresses"
:key="address.id ?? `${customer.id}-${idx}-${address.street}-${address.postalCode}`"
class="grid grid-cols-8 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
:class="idx > 0 ? 'pl-4 border-l-4 border-l-slate-200 bg-slate-50' : ''"
@click="goToCustomer(customer.id)"
>
<div class="truncate">
{{ idx === 0 ? (customer.name || "") : "" }}
</div>
<div class="truncate">{{ idx === 0 ? (customer.phone || "") : "" }}</div>
<div class="truncate">{{ idx === 0 ? (customer.email || "") : "" }}</div>
<div class="truncate">{{ address.street || "" }}</div>
<div class="truncate">{{ address.street2 || "" }}</div>
<div>{{ address.postalCode || "" }}</div>
<div class="uppercase truncate">{{ address.city || "" }}</div>
<div class="uppercase truncate">{{ address.countryCode || "" }}</div>
</div>
</template>
<template v-else>
<div
class="grid grid-cols-8 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="col-span-5 text-slate-400">
Adresses non chargées
</div>
</div>
</template>
</div>
</div>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs.
</div>
</template>
<script setup lang="ts">
import { getCustomerList } from "~/services/customer"
import type { CustomerData } from "~/services/dto/customer-data"
import { useAuthStore } from "~/stores/auth"
const customerList = ref<CustomerData[]>([])
const router = useRouter()
const auth = useAuthStore()
const goToCustomer = (id: number) => {
if (!auth.isAdmin) return
router.push(`/admin/customer/${id}`)
}
const handleAddClick = (event: Event) => {
if (auth.isAdmin) return
event.preventDefault()
}
onMounted(async () => {
if (!auth.isAdmin) return
customerList.value = await getCustomerList()
})
</script>

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
<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-7 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
>
<div>Nom</div>
<div>Mail</div>
<div>Rue</div>
<div>Complément</div>
<div>Code Postal</div>
<div>Ville</div>
<div>Pays</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">
<div
v-if="!supplier.addresses || supplier.addresses.length === 0"
class="grid text-primary-700 grid-cols-7 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer"
@click="goToSupplier(supplier.id)"
>
<div class="truncate">{{ supplier.name }}</div>
<div class="truncate">{{ supplier.email }}</div>
<div class="col-span-1">Pas d'adresse</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
</div>
<template v-else-if="supplier.addresses.length > 0">
<div
v-for="(address, idx) in supplier.addresses"
:key="address.id ?? `${supplier.id}-${idx}-${address.street}-${address.postalCode}`"
class="grid grid-cols-7 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
:class="idx > 0 ? 'pl-4 border-l-4 border-l-slate-200 bg-slate-50' : ''"
@click="goToSupplier(supplier.id)"
>
<div class="truncate">
{{ idx === 0 ? supplier.name : "↳" }}
</div>
<div class="truncate">{{ idx === 0 ? supplier.email : "" }}</div>
<div class="truncate">{{ address.street || "—" }}</div>
<div class="truncate">{{ address.street2 || "—" }}</div>
<div>{{ address.postalCode || "—" }}</div>
<div class="uppercase truncate">{{ address.city || "—" }}</div>
<div class="uppercase truncate">{{ address.countryCode || "—" }}</div>
</div>
</template>
<template v-else>
<div
class="grid grid-cols-7 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.email }}</div>
<div class="col-span-5 text-slate-400">
Adresses non chargées
</div>
</div>
</template>
</div>
</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"
const supplierList = ref<SupplierData[]>([])
const router = useRouter()
const auth = useAuthStore()
const goToSupplier = (id: number) => {
if (!auth.isAdmin) return
router.push(`/admin/supplier/${id}`)
}
const handleAddClick = (event: Event) => {
if (auth.isAdmin) return
event.preventDefault()
}
onMounted(async () => {
if (!auth.isAdmin) return
supplierList.value = await getSupplierList()
})
</script>

View File

@@ -0,0 +1,124 @@
<template>
<form @submit.prevent="validate">
<div
class="flex items-center justify-between gap-10">
<h1 class="text-3xl font-bold uppercase">
{{ userId ? "Modifications de l'utilisateur" : "Ajout d'un utilisateur" }}
</h1>
<UiButton
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"
type="submit"
>
<Icon :name="userId ? '' : 'mdi:plus'" size="28" />
{{ userId ? 'Valider' : 'Ajouter' }}
</UiButton>
</div>
<div class="grid gap-y-16 gap-x-40 py-12">
<UiTextInput
id="user-name"
v-model="form.username"
label="Nom de l'utilisateur"
/>
<UiSelect
id="user-role"
v-model="form.role"
label="Rôle de l'utilisateur"
:options="ROLE"
/>
<UiTextInput
id="user-password"
v-model="form.password"
label="Mot de passe"
type="password"
/>
</div>
</form>
</template>
<script setup lang="ts">
import {computed, reactive, ref, watch} from 'vue'
import {ROLE} from '~/utils/constants'
import {createUser, updateUser, getUser} from '~/services/auth'
import type {UserData, UserFormData, UserPayload} from '~/services/dto/user-data'
const route = useRoute()
const router = useRouter()
const userId = computed(() => resolveUserId(route.params.id))
const isLoading = ref(false)
const isHydrating = ref(false)
const resolveUserId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) {
return null
}
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
const form = reactive<UserFormData>({
username: '',
password: '',
role: ''
})
const hydrateFromUser = (user: UserData | null) => {
if (!user) {
return
}
isHydrating.value = true
form.username = user.username ?? ''
const roles = user.roles ?? []
const hasAdmin = roles.includes("ROLE_ADMIN")
form.role = hasAdmin ? "ROLE_ADMIN" : "ROLE_USER"
form.password = ''
isHydrating.value = false
}
watch(
() => userId.value,
async (id) => {
if (id === null) {
return
}
isLoading.value = true
try {
const user = await getUser(id)
hydrateFromUser(user)
} finally {
isLoading.value = false
}
},
{immediate: true}
)
async function validate() {
const normalizedUsername = form.username.trim()
const normalizedRole = form.role.trim()
const normalizedPassword = form.password.trim()
const basePayload: UserPayload = {
username: normalizedUsername,
roles: normalizedRole ? [normalizedRole] : undefined,
}
if (normalizedPassword) {
basePayload.password = normalizedPassword
}
if (userId.value) {
await updateUser(userId.value, basePayload)
await router.push(`/admin/user/list/`)
return
}
const created = await createUser(basePayload)
if (created) {
await router.push(`/admin/user/list/`)
}
}
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
<NuxtLink
to="/admin/user"
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>
<div class="mt-6 border border-slate-200 mb-16 ">
<div class="grid grid-cols-3 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Username</div>
<div>Role</div>
</div>
<div
v-for="user in userList"
:key="user.id"
class="grid grid-cols-3 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t items-center"
role="button"
tabindex="0"
@click="goToUser(user.id)"
>
<div>
{{ user.username }}
</div>
<div>
{{ getRoleLabels(user.roles) }}
</div>
</div>
</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";
const userList = ref<UserData[]>([])
const router = useRouter()
const roleLabelByValue = new Map(ROLE.map((role) => [role.value, role.label]))
const goToUser = (id: number) => {
router.push(`/admin/user/${id}`)
}
const getRoleLabels = (roles?: string[]) => {
if (!roles || roles.length === 0) {
return ' ---'
}
return roles
.map((role) => roleLabelByValue.get(role) ?? role)
.join(', ')
}
onMounted(async () => {
userList.value = await getAdminUsers()
})
</script>

View File

@@ -1,55 +1,27 @@
<script setup lang="ts">
</script>
<template>
<div>
<h1 class="text-3xl font-bold">Liste des receptions</h1>
<div class="mt-6 border border-slate-200">
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>ID</div>
<div>Immatriculation</div>
<div>Pesée plein</div>
<div>Pesée vide</div>
<div>Etape</div>
<div>Date</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)"
@keydown.enter="goToReception(reception.id)"
>
<div>{{ reception.id }}</div>
<div>{{ reception.licensePlate }}</div>
<div>{{ formatWeighing(reception, 'gross') }}</div>
<div>{{ formatWeighing(reception, 'tare') }}</div>
<div>{{ reception.currentStep }}</div>
<div>{{ reception.receptionDate }}</div>
</div>
</div>
<div class="flex flex-wrap justify-center pb-16 gap-12">
<card-link label="NOUVELLE RÉCEPTION" link="/reception" iconName="mdi:truck-outline" />
<card-link label="NOUVELLE EXPÉDITION" link="/shipment" iconName="mdi:truck-fast-outline" />
<card-link label="PLAN DE SITE" link="/infrastructure/building" iconName="material-symbols:warehouse-outline-rounded" />
<card-link link="/reception/waiting-reception" iconName="mdi:truck-remove-outline">
<template #label>
Réceptions<br>EN ATTENTE
</template>
</card-link>
<card-link link="/shipment/waiting-shipment" iconName="mdi:truck-cargo-container">
<template #label>
EXPÉDITIONS<br>EN ATTENTE
</template>
</card-link>
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
<card-link link="/" iconName="mdi:cow">
<template #label>
PASSEPORT<br>DU BOVIN
</template>
</card-link>
</div>
</template>
<script setup lang="ts">
import type {ReceptionData} from "~/services/dto/reception-data";
import {getReceptionList} from "~/services/reception";
const receptionList = ref<ReceptionData[]>()
const router = useRouter()
const goToReception = (id: number) => {
router.push(`/reception/${id}`)
}
const formatWeighing = (reception: ReceptionData, type: 'gross' | 'tare') => {
const entry = reception.weights?.find((weight) => weight.type === type)
if (!entry || entry.weight == null || entry.dsd == null) {
return '—'
}
return `${entry.weight} kg / ${entry.dsd} dsd`
}
onMounted(async () => {
receptionList.value = await getReceptionList()
})
</script>

View File

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

View File

@@ -0,0 +1,27 @@
<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>
</template>
<script setup lang="ts">
const route = useRoute()
const { printPdf } = usePdfPrinter()
const caseId = computed(() => Number(route.query.id))
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
const printCaseReport = async () => {
if (!hasCaseId.value) {
return
}
const filename = `tableau_poids_case_${caseId.value}.pdf`
await printPdf(`/building_cases/${caseId.value}/weights-report`, filename)
}
</script>

View File

@@ -39,14 +39,15 @@
/>
</div>
<button
<UiButton
type="submit"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isSubmitting"
>
Connexion
</button>
</form>
</UiButton>
<p class="font-bold">v{{ version }}</p>
</form>
</div>
</template>
@@ -57,6 +58,7 @@ import { useAuthStore } from '~/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const { version } = useAppVersion()
definePageMeta({
layout: 'auth'

View File

@@ -1,30 +1,35 @@
<template>
<div>
<div class="flex justify-between h-[52px] mb-[80px]">
<div class="flex flex-1 mr-16">
<UiStepper
:labels="RECEPTION_STEP_LABELS"
:current-step="storeReception?.currentStep ?? 0"
@select="handleStepSelect"
/>
</div>
<button
type="button"
class="flex flex-col justify-center uppercase text-xl bg-black text-white h-[50px] w-[272px] text-center"
@click="saveAndHold"
>Mettre en attente</button>
<div class="flex justify-between h-[52px] mb-[80px]">
<div class="flex flex-1 mr-16">
<UiStepper
:labels="RECEPTION_STEP_LABELS"
:current-step="storeReception?.currentStep ?? 0"
@select="handleStepSelect"
/>
</div>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/>
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/>
<ReceptionProductReceived v-if="storeReception?.currentStep === 2"/>
<ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/>
<UiButton
type="button"
class="flex flex-col justify-center uppercase text-xl bg-black text-white h-[50px] w-[272px] text-center"
@click="saveAndHold"
>Mettre en attente
</UiButton>
</div>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/>
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/>
<ReceptionProductReceived
v-if="storeReception?.currentStep === 2 &&
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"/>
<ReceptionBovineReceived
v-if="storeReception?.currentStep === 2 &&
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/>
<ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/>
</template>
<script setup lang="ts">
import {useReceptionStore} from '~/stores/reception'
import {storeToRefs} from 'pinia'
import {RECEPTION_STEP_LABELS} from '~/constants/steps'
import {RECEPTION_TYPE_CODES} from "~/utils/constants";
const route = useRoute()
const router = useRouter()

View File

@@ -0,0 +1,62 @@
<template>
<div class="flex items-center justify-start gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions finie</h1>
</div>
<div class="px-[86px]">
<div class="mt-6 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>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>{{ reception.identificationNumber}}</div>
<div>{{ reception.receptionDate}}</div>
<div>{{ reception.supplier?.name }}</div>
<div>{{ reception.address?.fullAddress }}</div>
<div>{{ reception.receptionType?.label }}</div>
<div>{{ formatWeighing(reception) }}</div>
</div>
</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";
const receptionList = ref<ReceptionData[]>()
const router = useRouter()
const formatWeighing = (reception: ReceptionData) => {
const gross = reception.weights?.find((weight) => weight.type === 'gross')?.weight
const tare = reception.weights?.find((weight) => weight.type === 'tare')?.weight
if (gross == null || tare == null) {
return '—'
}
return `${gross - tare} kg`
}
const goToReception = (id: number) => {
router.push(`/reception/update/${id}`)
}
onMounted(async () => {
receptionList.value = await getReceptionList(true)
})
</script>

View File

@@ -0,0 +1,999 @@
<template>
<form @submit.prevent="validate">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-[60px]">
<div class="flex items-center justify-between gap-10 relative">
<div class="flex flex-row absolute -left-[60px] justify-between">
<Icon @click="router.push('/reception/finish-reception')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
</div>
<h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Réception {{ form.identificationNumber }}</h1>
<Icon @click="printReceipt" name="mdi:printer-outline" size="44" class="cursor-pointer text-primary-500"/>
</div>
<!-- Nom de l'utilisateur -->
<UiSelect
id="reception-user"
:disabled="!auth.isAdmin"
v-model="form.userId"
label="Nom de l'utilisateur"
:options="users.map((user) => ({
value: String(user.id),
label: user.username
}))"
:loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2"
/>
<!-- Date de réception -->
<UiDateInput
id="reception-date"
:disabled="!auth.isAdmin"
v-model="form.receptionDate"
label="Date de réception"
wrapper-class="col-start-1 row-start-3"
/>
<!-- type de reception -->
<UiSelect
id="reception-supplier"
v-model="form.receptionTypeId"
:disabled="!auth.isAdmin"
label="Type de Réception"
:options="receptionTypes.map((receptionType) => ({
value: String(receptionType.id),
label: receptionType.label
}))"
:loading="isLoadingSuppliers"
wrapper-class="col-start-1 row-start-4"
/>
<!-- Fournisseur -->
<UiSelect
id="reception-supplier"
v-model="form.supplierId"
:disabled="!auth.isAdmin"
label="Fournisseur"
:options="suppliers.map((supplier) => ({
value: String(supplier.id),
label: supplier.name
}))"
:loading="isLoadingSuppliers"
wrapper-class="col-start-1 row-start-5"
/>
<!-- Adresse fournisseur -->
<UiSelect
id="reception-address"
v-model="form.addressId"
label="Adresse"
:options="supplierAddresses.map((address) => ({
value: String(address.id),
label: address.fullAddress
}))"
:disabled="(isLoadingSuppliers || supplierAddresses.length === 0) && !auth.isAdmin"
wrapper-class="col-start-2 row-start-1"
/>
<!-- Camion -->
<UiSelect
id="reception-truck"
v-model="form.truckId"
:disabled="!auth.isAdmin"
label="Camion"
:options="trucks.map((truck) => ({
value: String(truck.id),
label: truck.name
}))"
:loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2"
/>
<!-- Transporteur -->
<UiSelect
id="reception-carrier"
v-model="form.carrierId"
label="Transporteur"
:disabled="!auth.isAdmin"
:options="carriers.map((carrier) => ({
value: String(carrier.id),
label: carrier.name
}))"
:loading="isLoadingCarriers"
select-class="h-[34px]"
wrapper-class="col-start-2 row-start-3"
/>
<!-- Chauffeur (LIOT) -->
<UiSelect
id="reception-driver"
v-model="form.driverId"
:disabled="!auth.isAdmin"
label="Nom du chauffeur si LIOT"
:options="filteredDrivers.map((driver) => ({
value: String(driver.id),
label: driver.name
}))"
:loading="isLoadingDrivers"
wrapper-class="col-start-2 row-start-5"
v-if="isLiotCarrier"
/>
<!-- Plaque d'immatriculation -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput
:disabled="!auth.isAdmin"
v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate"
/>
</div>
<!-- Immatriculation (LIOT) -->
<UiSelect
v-if="isLiotCarrier"
id="reception-vehicle"
v-model="form.vehicleId"
label="Immatriculation"
:options="filteredVehicles.map((vehicle) => ({
value: String(vehicle.id),
label: vehicle.plate
}))"
:loading="isLoadingVehicles"
:disabled="(isLoadingVehicles || filteredVehicles.length === 0) && !auth.isAdmin"
wrapper-class="col-start-2 row-start-4"
/>
</div>
<div v-if="formIsLoading">
<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'"
@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'"
@click="activeTab = 'weightsEmpty'"
>
pesée à vide
</h1>
<h1
class="font-bold text-3xl uppercase px-12 col-start-2 row-start-1 cursor-pointer "
:class="activeTab === 'merchandise' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50'"
@click="activeTab = 'merchandise'"
>
{{ isMerchandise ? "Marchandise" : "Bovins" }}
</h1>
</div>
<div class="mb-12 ">
<update-weight
v-show="activeTab === 'weights'"
v-model="grossWeight"
v-if="grossWeight"
:isAdmin="auth.isAdmin"
/>
<update-weight
v-show="activeTab === 'weightsEmpty'"
v-model="tareWeight"
v-if="tareWeight"
:isAdmin="auth.isAdmin"
/>
<update-merchandise
v-if="activeTab === 'merchandise' && isMerchandise"
v-model="merchandiseForm"
:isAdmin="auth.isAdmin"
/>
<update-bovin
v-if="activeTab === 'merchandise' && !isMerchandise"
v-model="bovineEntries"
v-model:otherQuantity="bovineOtherQuantity"
:isAdmin="auth.isAdmin"
/>
</div>
<div class="flex justify-center">
<UiButton
v-if="auth.isAdmin"
type="submit"
class="inline-flex mb-16 items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2 justify-self-end"
>
Valider
</UiButton>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import { usePdfPrinter } from '#imports'
import { computed } from 'vue'
import UpdateBovin from '~/components/reception/update-bovin.vue'
import UpdateMerchandise from '~/components/reception/update-merchandise.vue'
import UpdateWeight from '~/components/commun/update-weight.vue'
import { getUsers } from '~/services/auth'
import { getCarrierList } from '~/services/carrier'
import type { CarrierData } from '~/services/dto/carrier-data'
import type { DriverData } from '~/services/dto/driver-data'
import type { ReceptionBovineTypeData } from '~/services/dto/reception-bovine-data'
import type {
MerchandiseEntryData,
ReceptionData,
ReceptionFormData,
} from '~/services/dto/reception-data'
import type { WeightEntryData } from '~/services/dto/weight-data'
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import type { SupplierData } from '~/services/dto/supplier-data'
import type { TruckData } from '~/services/dto/truck-data'
import type { UserData } from '~/services/dto/user-data'
import type { VehicleData } from '~/services/dto/vehicle-data'
import { getDriverList } from '~/services/driver'
import {
createReceptionBovine,
deleteReceptionBovine,
getReceptionBovineList,
updateReceptionBovine
} from '~/services/reception-bovine'
import {
createReceptionPelletBuilding,
deleteReceptionPelletBuilding,
getReceptionPelletBuildingList
} from '~/services/reception-pellet-building'
import { getReception, updateReception } from '~/services/reception'
import { getReceptionTypeList } from '~/services/reception-type'
import { getSupplierList } from '~/services/supplier'
import { getTruckList } from '~/services/truck'
import { getVehicleList } from '~/services/vehicle'
import { createWeight, updateWeight } from '~/services/weight'
import { useAuthStore } from '~/stores/auth'
import { useReceptionStore } from '~/stores/reception'
import { RECEPTION_TYPE_CODES, SUPPLIER_CODE } from '~/utils/constants'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const authStore = useAuthStore()
const receptionStore = useReceptionStore()
const { printPdf } = usePdfPrinter()
const activeTab = ref<'weightsEmpty' | 'weights' | 'merchandise'>('weights')
const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross'))
const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare'))
const bovineEntries = ref<ReceptionBovineTypeData[]>([])
const bovineOtherQuantity = ref<number | null>(0)
const merchandiseForm = ref<MerchandiseEntryData>({
merchandiseTypeId: '',
merchandiseDetail: '',
selectedBuildingIds: [],
selectedPelletBuildingIds: {}
})
const allowAnyLicensePlate = ref(false)
const isLoading = ref(false)
const users = ref<UserData[]>([])
const isLoadingUsers = ref(false)
const isLoadingTypes = ref(false)
const suppliers = ref<SupplierData[]>([])
const receptionTypes = ref<ReceptionTypeData[]>([])
const isLoadingSuppliers = ref(false)
const trucks = ref<TruckData[]>([])
const isLoadingTrucks = ref(false)
const carriers = ref<CarrierData[]>([])
const isLoadingCarriers = ref(false)
const drivers = ref<DriverData[]>([])
const isLoadingDrivers = ref(false)
const vehicles = ref<VehicleData[]>([])
const isLoadingVehicles = ref(false)
const formIsLoading = ref(false)
const isMerchandise = ref(false)
const isHydrating = ref(false)
const vehicleSyncLock = ref(false)
const idReception = Number(route.params.id)
function runWithVehicleSyncLock(mutator: () => void) {
if (vehicleSyncLock.value) {
return
}
vehicleSyncLock.value = true
try {
mutator()
} finally {
queueMicrotask(() => {
vehicleSyncLock.value = false
})
}
}
const form = reactive<ReceptionFormData>({
identificationNumber: null,
licensePlate: '',
receptionDate: new Date().toISOString().slice(0, 10),
receptionTypeId: '',
userId: '',
supplierId: '',
addressId: '',
truckId: '',
carrierId: '',
driverId: '',
vehicleId: ''
})
const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
const supplierAddresses = computed(() => {
const supplierId = Number(form.supplierId)
if (!Number.isFinite(supplierId)) {
return []
}
return suppliers.value.find((supplier) => supplier.id === supplierId)?.addresses ?? []
})
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) {
return []
}
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) {
return []
}
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
watch(
() => idReception,
async (id) => {
if (!Number.isFinite(id) || id <= 0) {
return
}
isLoading.value = true
try {
const reception = await getReception(id)
hydrateFromReception(reception)
await loadBovineEntries(id)
} finally {
isLoading.value = false
}
},
{immediate: true}
)
watch(
() => form.receptionTypeId,
() => {
const receptionType = receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null
isMerchandise.value = receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES
}
)
function createEmptyWeightEntry(type: 'gross' | 'tare'): WeightEntryData {
return {
type,
dsd: null,
weight: null,
weighedAt: null
}
}
async function clearReceptionBovines(receptionId: number) {
const receptionIri = `/api/receptions/${receptionId}`
const existing = await getReceptionBovineList(receptionIri)
for (const selection of existing) {
await deleteReceptionBovine(selection.id)
}
}
async function loadBovineEntries(receptionId: number) {
const receptionIri = `/api/receptions/${receptionId}`
bovineEntries.value = await getReceptionBovineList(receptionIri)
}
function syncMerchandiseFlag() {
const receptionType =
receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null
isMerchandise.value = receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES
}
function getRelationId(value: unknown): string | null {
if (!value) {
return null
}
if (typeof value === 'string') {
const match = value.match(/\/(\d+)$/)
return match ? match[1] : null
}
if (typeof value === 'object' && 'id' in value) {
const relation = value as { id?: number | string }
if (typeof relation.id === 'number') {
return String(relation.id)
}
if (typeof relation.id === 'string') {
return relation.id
}
}
return null
}
async function clearReceptionMerchandise(receptionId: number) {
const receptionIri = `/api/receptions/${receptionId}`
const existing = await getReceptionPelletBuildingList(receptionIri)
for (const selection of existing) {
await deleteReceptionPelletBuilding(selection.id)
}
await updateReception(receptionId, {
merchandiseType: null,
merchandiseDetail: null,
buildings: []
})
}
function hydrateFromReception(reception: ReceptionData | null) {
if (!reception) {
return
}
isHydrating.value = true
form.identificationNumber = reception?.identificationNumber ?? ''
form.licensePlate = reception?.licensePlate ?? ''
form.receptionDate = reception?.receptionDate ?? new Date().toISOString().slice(0, 10)
form.userId = reception?.user?.id
? String(reception.user.id)
: form.userId
form.supplierId = reception?.supplier?.id
? String(reception.supplier.id)
: ''
form.addressId = reception?.address?.id
? String(reception.address.id)
: ''
form.truckId = reception?.truck?.id
? String(reception.truck.id)
: ''
form.carrierId = reception?.carrier?.id
? String(reception.carrier.id)
: ''
form.driverId = reception?.driver?.id
? String(reception.driver.id)
: ''
form.receptionTypeId = reception?.receptionType?.id
? String(reception.receptionType.id)
: ''
const selectionMap: Record<string, string[]> = {}
for (const selection of reception?.pelletBuildings ?? []) {
const pelletTypeId = getRelationId(selection.pelletType)
const buildingId = getRelationId(selection.building)
if (!pelletTypeId || !buildingId) {
continue
}
if (!selectionMap[pelletTypeId]) {
selectionMap[pelletTypeId] = []
}
selectionMap[pelletTypeId].push(buildingId)
}
merchandiseForm.value = {
merchandiseTypeId: reception?.merchandiseType?.id ? String(reception.merchandiseType.id) : '',
merchandiseDetail: reception?.merchandiseDetail ?? '',
selectedBuildingIds: reception?.buildings?.map((building) => String(building.id)) ?? [],
selectedPelletBuildingIds: selectionMap
}
const parsedOther =
typeof reception?.bovineDetail === 'string' && reception.bovineDetail.trim() !== ''
? Number(reception.bovineDetail)
: 0
bovineOtherQuantity.value = Number.isFinite(parsedOther) ? parsedOther : 0
const gross = reception.weights?.find((weight) => weight.type === 'gross') ?? null
const tare = reception.weights?.find((weight) => weight.type === 'tare') ?? null
grossWeight.value = gross ? { ...gross } : createEmptyWeightEntry('gross')
tareWeight.value = tare ? { ...tare } : createEmptyWeightEntry('tare')
isHydrating.value = false
syncMerchandiseFlag()
}
async function loadUsers() {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
async function loadSuppliers() {
isLoadingSuppliers.value = true
try {
suppliers.value = await getSupplierList()
} finally {
isLoadingSuppliers.value = false
}
}
async function loadTypes() {
isLoadingTypes.value = true
try {
receptionTypes.value = await getReceptionTypeList()
} finally {
isLoadingTypes.value = false
}
}
async function loadTrucks() {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
async function loadCarriers() {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
async function loadDrivers() {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
async function loadVehicles() {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
function setDefaultUser() {
if (form.userId) {
return
}
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
async function printReceipt() {
if (!import.meta.client || !Number.isFinite(idReception) || idReception <= 0) {
return
}
const supplierName =
suppliers.value.find((supplier) => String(supplier.id) === form.supplierId)?.name ??
'fournisseur'
const filename = `${form.identificationNumber || idReception}_${supplierName}_${form.licensePlate || 'immat'}.pdf`
await printPdf(`/receptions/${idReception}/receipt`, filename)
// Laisse le temps a la boite de dialogue d'impression de s'ouvrir.
await new Promise((resolve) => setTimeout(resolve, 600))
}
async function saveWeightEntry(entry: WeightEntryData) {
if (!idReception || entry.weight === null) {
return
}
const payload = {
type: entry.type,
dsd: entry.dsd ?? null,
weight: entry.weight,
weighedAt: entry.weighedAt ?? null
}
if (entry.id) {
await updateWeight(entry.id, payload)
return
}
// Fallback: if id is missing in local state, reuse existing weight by type.
const reception = await getReception(idReception)
const existingEntry = reception?.weights?.find((weight) => weight.type === entry.type) ?? null
if (existingEntry?.id) {
await updateWeight(existingEntry.id, payload)
return
}
await createWeight({
reception: `/api/receptions/${idReception}`,
...payload
})
}
async function saveBovineEntry(entry: ReceptionBovineTypeData) {
if (!idReception || !entry.bovineType || entry.quantity === null || entry.quantity <= 0) {
return
}
const payload = {
quantity: entry.quantity
}
if (entry.id) {
await updateReceptionBovine(entry.id, payload)
return
}
await createReceptionBovine({
reception: `/api/receptions/${idReception}`,
bovineType: `/api/bovine_types/${entry.bovineType.id}`,
...payload
})
}
function getTotalBovines() {
const totalTypes = bovineEntries.value.reduce(
(sum, entry) => sum + (entry.quantity ?? 0),
0
)
return totalTypes +
(bovineOtherQuantity.value ?? 0)
}
async function syncBovineEntries() {
if (!idReception) {
return
}
const receptionIri = `/api/receptions/${idReception}`
const existing = await getReceptionBovineList(receptionIri)
const currentPositive = bovineEntries.value.filter(
(entry) => (entry.quantity ?? 0) > 0
)
for (const existingEntry of existing) {
const stillPresent = currentPositive.some(
(entry) => entry.bovineType.id === existingEntry.bovineType.id
)
if (!stillPresent) {
await deleteReceptionBovine(existingEntry.id)
}
}
for (const entry of currentPositive) {
await saveBovineEntry(entry)
}
}
async function saveMerchandiseEntry(
receptionIri: string,
pelletTypeId: string,
buildingId: string,
existingKeys: Set<string>
) {
const key = `${pelletTypeId}:${buildingId}`
if (existingKeys.has(key)) {
return
}
await createReceptionPelletBuilding({
reception: receptionIri,
pelletType: `/api/pellet_types/${pelletTypeId}`,
building: `/api/buildings/${buildingId}`
})
}
async function syncMerchandiseEntries() {
if (!idReception) {
return
}
const receptionIri = `/api/receptions/${idReception}`
const existing = await getReceptionPelletBuildingList(receptionIri)
const existingMap = new Map<string, number>()
for (const selection of existing) {
const pelletTypeId = getRelationId(selection.pelletType)
const buildingId = getRelationId(selection.building)
if (!pelletTypeId || !buildingId) {
continue
}
existingMap.set(`${pelletTypeId}:${buildingId}`, selection.id)
}
const desiredEntries: Array<{ pelletTypeId: string; buildingId: string }> = []
for (const [pelletTypeId, buildingIds] of Object.entries(
merchandiseForm.value.selectedPelletBuildingIds
)) {
for (const buildingId of buildingIds) {
desiredEntries.push({ pelletTypeId, buildingId })
}
}
const desiredKeys = new Set(
desiredEntries.map((entry) => `${entry.pelletTypeId}:${entry.buildingId}`)
)
for (const [key, selectionId] of existingMap.entries()) {
if (!desiredKeys.has(key)) {
await deleteReceptionPelletBuilding(selectionId)
}
}
const existingKeys = new Set(existingMap.keys())
for (const entry of desiredEntries) {
await saveMerchandiseEntry(
receptionIri,
entry.pelletTypeId,
entry.buildingId,
existingKeys
)
}
}
async function validate() {
const normalizedLicensePlate = form.licensePlate.trim()
const normalizedReceptionDate = form.receptionDate.trim()
const normalizedReceptionTypeId = form.receptionTypeId.trim()
const normalizedUserId = form.userId.trim()
const normalizedSupplierId = form.supplierId.trim()
const normalizedAddressId = form.addressId.trim()
const normalizedTruckId = form.truckId.trim()
const normalizedCarrierId = form.carrierId.trim()
const normalizedDriverId = form.driverId.trim()
const userIri = normalizedUserId
? `/api/users/${normalizedUserId}`
: null
const supplierIri = normalizedSupplierId
? `/api/suppliers/${normalizedSupplierId}`
: null
const addressIri = normalizedAddressId
? `/api/addresses/${normalizedAddressId}`
: null
const truckIri = normalizedTruckId
? `/api/trucks/${normalizedTruckId}`
: null
const carrierIri = normalizedCarrierId
? `/api/carriers/${normalizedCarrierId}`
: null
const driverIri = normalizedDriverId
? `/api/drivers/${normalizedDriverId}`
: null
const typeIri = normalizedReceptionTypeId
? `/api/reception_types/${normalizedReceptionTypeId}`
: null
const basePayload = {
licensePlate: normalizedLicensePlate,
receptionDate: normalizedReceptionDate,
receptionType: typeIri,
user: userIri,
supplier: supplierIri,
address: addressIri,
truck: truckIri,
carrier: carrierIri
}
const payload = {
...basePayload,
...(isLiotCarrier.value && driverIri ? { driver: driverIri } : {}),
}
if (idReception) {
await receptionStore.updateReception(idReception, {
...payload
})
await saveWeightEntry(grossWeight.value)
await saveWeightEntry(tareWeight.value)
if (isMerchandise.value) {
await clearReceptionBovines(idReception)
await updateReception(idReception, {
merchandiseType: merchandiseForm.value.merchandiseTypeId
? `/api/merchandise_types/${merchandiseForm.value.merchandiseTypeId}`
: null,
merchandiseDetail: merchandiseForm.value.merchandiseDetail.trim() || null,
buildings: merchandiseForm.value.selectedBuildingIds.map(
(buildingId) => `/api/buildings/${buildingId}`
),
bovineDetail: null,
bovinesTypes: null
})
await syncMerchandiseEntries()
} else {
if (getTotalBovines() > 52) {
// toast/erreur UI
return
}
await clearReceptionMerchandise(idReception)
await syncBovineEntries()
await updateReception(idReception, {
bovineDetail: bovineOtherQuantity.value ? String(bovineOtherQuantity.value) : null
})
}
// Évite une réhydratation complète après save (source de cascades de watchers).
// On recharge uniquement les bovins quand on est en mode bovins.
if (!isMerchandise.value) {
await loadBovineEntries(idReception)
}
return
}
}
onMounted(async () => {
await loadTypes()
syncMerchandiseFlag()
formIsLoading.value = true
await loadUsers()
await loadSuppliers()
await loadTrucks()
await loadCarriers()
await loadDrivers()
await loadVehicles()
await authStore.ensureSession()
setDefaultUser()
})
watch(
() => [form.supplierId, form.addressId, suppliers.value],
() => {
if (isHydrating.value) {
return
}
if (!form.supplierId) {
if (form.addressId !== '') {
form.addressId = ''
}
return
}
if (!form.addressId && supplierAddresses.value.length === 1) {
const nextAddressId = String(supplierAddresses.value[0].id)
if (form.addressId !== nextAddressId) {
form.addressId = nextAddressId
}
return
}
if (!form.addressId) {
return
}
const matches = supplierAddresses.value.some(
(address) => String(address.id) === form.addressId
)
if (!matches) {
if (supplierAddresses.value.length === 1) {
const nextAddressId = String(supplierAddresses.value[0].id)
if (form.addressId !== nextAddressId) {
form.addressId = nextAddressId
}
} else {
if (form.addressId !== '') {
form.addressId = ''
}
}
}
},
{ immediate: true }
)
watch(
() => form.carrierId,
() => {
if (isHydrating.value || vehicleSyncLock.value) {
return
}
if (!form.carrierId && idReception == null) {
runWithVehicleSyncLock(() => {
form.driverId = ''
form.vehicleId = ''
})
return
}
if (!isLiotCarrier.value && idReception == null) {
runWithVehicleSyncLock(() => {
form.driverId = ''
form.vehicleId = ''
})
return
}
if (filteredDrivers.value.length === 1) {
const nextDriverId = String(filteredDrivers.value[0].id)
if (form.driverId !== nextDriverId) {
form.driverId = nextDriverId
}
}
if (filteredVehicles.value.length === 1) {
const nextVehicleId = String(filteredVehicles.value[0].id)
if (form.vehicleId !== nextVehicleId) {
runWithVehicleSyncLock(() => {
form.vehicleId = nextVehicleId
})
}
}
},
{ immediate: true }
)
watch(
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (isHydrating.value || vehicleSyncLock.value) {
return
}
if (!isLiotCarrier.value) {
return
}
if (filteredVehicles.value.length === 1) {
const nextVehicleId = String(filteredVehicles.value[0].id)
if (form.vehicleId !== nextVehicleId) {
runWithVehicleSyncLock(() => {
form.vehicleId = nextVehicleId
})
}
return
}
if (!form.vehicleId) {
return
}
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
if (form.vehicleId !== '') {
runWithVehicleSyncLock(() => {
form.vehicleId = ''
})
}
}
},
{ immediate: true }
)
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (vehicleSyncLock.value) {
return
}
if (!isLiotCarrier.value) {
return
}
if (isHydrating.value) {
return
}
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected && form.licensePlate !== selected.plate) {
runWithVehicleSyncLock(() => {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
})
}
}
)
watch(
() => [form.licensePlate, form.carrierId, vehicles.value],
() => {
if (vehicleSyncLock.value) {
return
}
if (isHydrating.value) {
return
}
if (!isLiotCarrier.value || form.vehicleId) {
return
}
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
const nextVehicleId = String(match.id)
if (form.vehicleId !== nextVehicleId) {
runWithVehicleSyncLock(() => {
form.vehicleId = nextVehicleId
})
}
}
}
)
</script>

View File

@@ -0,0 +1,51 @@
<template>
<div class="flex items-center justify-between">
<div class="flex items-center gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions en attente</h1>
</div>
</div>
<div class="px-[86px]">
<div class="mt-6 border border-slate-200 mb-16">
<div class="grid grid-cols-5 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Fournisseur</div>
<div>Adresse</div>
<div>Type réception</div>
<div>Transporteur</div>
<div>Immatriculation</div>
</div>
<div
v-for="reception in receptionList"
:key="reception.id"
class="grid grid-cols-5 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)"
@keydown.enter="goToReception(reception.id)"
>
<div>{{ reception.supplier?.name }}</div>
<div>{{ reception.address?.fullAddress }}</div>
<div>{{ reception.receptionType?.label }}</div>
<div>{{ reception.carrier?.name }}</div>
<div>{{ reception.licensePlate }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {ReceptionData} from "~/services/dto/reception-data";
import {getReceptionList} from "~/services/reception";
const receptionList = ref<ReceptionData[]>()
const router = useRouter()
const goToReception = (id: number) => {
router.push(`/reception/${id}`)
}
onMounted(async () => {
receptionList.value = await getReceptionList(false)
})
</script>

View File

@@ -0,0 +1,83 @@
<template>
<div>
<div class="flex justify-between h-[52px] mb-[80px]">
<div class="flex flex-1 mr-16">
<UiStepper
:labels="SHIPMENT_STEP_LABELS"
:current-step="storeShipment?.currentStep ?? 0"
@select="handleStepSelect"
/>
</div>
<UiButton
type="button"
class="flex flex-col justify-center uppercase text-xl bg-black text-white h-[50px] w-[272px] text-center"
@click="saveAndHold"
>Mettre en attente
</UiButton>
</div>
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
<ShipmentWeight v-if="storeShipment?.currentStep === 1" mode="gross"/>
<ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
<ShipmentWeight v-if="storeShipment?.currentStep === 3" mode="tare"/>
</div>
</template>
<script setup lang="ts">
import {SHIPMENT_STEP_LABELS} from "~/constants/steps";
import {storeToRefs} from "pinia";
import {useShipmentStore} from "~/stores/shipment";
import { ref, watch } from 'vue'
const shipmentStore = useShipmentStore()
const {current: storeShipment} = storeToRefs(shipmentStore)
const shipmentFormRef = ref<{ saveDraft: () => Promise<void> } | null>(null)
const route = useRoute()
const router = useRouter()
const resolveShipmentId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) {
return null
}
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
watch (
() => route.params.id,
async (param) => {
const id = resolveShipmentId(param)
if (id === null) {
shipmentStore.clearCurrent()
return
}
await shipmentStore.loadShipment(id)
},
{immediate: true}
)
const saveAndHold = async () => {
if (shipmentFormRef.value) {
await shipmentFormRef.value.saveDraft()
}
await router.push('/')
}
const handleStepSelect = async (step: number) => {
if (!shipmentStore.current) {
return
}
if (step === shipmentStore.current.currentStep) {
return
}
await shipmentStore.updateShipment(shipmentStore.current.id, {
currentStep: step
})
await shipmentStore.loadShipment(shipmentStore.current.id)
}
</script>

View File

@@ -0,0 +1,85 @@
<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>
</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>{{ shipment.identificationNumber }}</div>
<div>{{ shipment.shipmentDate }}</div>
<div>{{ shipment.customer?.name }}</div>
<div>{{ shipment.address?.fullAddress }}</div>
<div>
<template v-if="formatShipmentLines(shipment).length">
<div
v-for="(line, index) in formatShipmentLines(shipment)"
:key="index"
class="leading-5"
>
{{ line }}
</div>
</template>
</div>
<div>{{ formatWeighing(shipment) }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {ShipmentData} from "~/services/dto/shipment-data";
import {getShipmentList} from "~/services/shipment";
const shipmentList = ref<ShipmentData[]>()
const router = useRouter()
const formatWeighing = (shipment: ShipmentData) => {
const gross = shipment.weights?.find((weight) => weight.type === 'gross')?.weight
const tare = shipment.weights?.find((weight) => weight.type === 'tare')?.weight
if (gross == null || tare == null) {
return ''
}
return `${gross - tare} kg`
}
const 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}`)
}
onMounted(async () => {
shipmentList.value = await getShipmentList(true)
})
</script>

View File

@@ -0,0 +1,639 @@
<template>
<form @submit.prevent="validate">
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
<div class="flex items-center justify-between gap-10 relative col-start-1 row-start-1">
<div class="flex flex-row absolute -left-[60px] justify-between">
<Icon @click="router.push('/shipment/finish-shipment')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
</div>
<h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Expédition {{ form.identificationNumber }}</h1>
<Icon @click="printReceipt" name="mdi:printer-outline" size="44" class="cursor-pointer text-primary-500"/>
</div>
<UiSelect
id="shipment-user"
v-model="form.userId"
label="Nom de l'utilisateur"
:options="users.map((user) => ({
value: String(user.id),
label: user.username
}))"
:loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2"
/>
<UiDateInput
id="shipment-date"
v-model="form.shipmentDate"
label="Date d'expédition"
wrapper-class="col-start-1 row-start-3"
/>
<div class="col-start-1 row-start-4 h-[64px]">
<div class="flex w-full items-end gap-[104px]">
<UiRadioGroup
id="shipment-type"
name="shipment-type"
label="Type d'expédition bovine"
input-class="accent-primary-700 focus:ring-primary-700"
group-class="flex flex-row gap-[104px] w-[160px_160px] h-[32px]"
v-model="selectedShipmentTypeId"
:options="bovineShipment.map((type) => ({
value: String(type.id),
label: type.label
}))"
/>
<UiNumberInput
id="shipment-type-quantity"
v-model="shipmentQuantity"
:placeholder="0"
:min="0"
:max="1200"
:disabled="!selectedShipmentTypeId"
/>
</div>
</div>
<UiSelect
id="shipment-customer"
v-model="form.customerId"
label="Client"
:options="customers.map((customer) => ({
value: String(customer.id),
label: customer.name || `Client #${customer.id}`
}))"
:loading="isLoadingCustomers"
wrapper-class="col-start-1 row-start-5"
/>
<UiSelect
id="shipment-address"
v-model="form.addressId"
:options="customerAddressOptions"
:disabled="isLoadingCustomers || customerAddresses.length === 0"
label="Adresse"
wrapper-class="col-start-2 row-start-1"
/>
<UiSelect
id="shipment-truck"
v-model="form.truckId"
label="Camion"
:options="trucks.map((truck) => ({
value: String(truck.id),
label: truck.name
}))"
:loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2"
/>
<UiSelect
id="shipment-carrier"
v-model="form.carrierId"
label="Transporteur"
:options="carriers.map((carrier) => ({
value: String(carrier.id),
label: carrier.name
}))"
wrapper-class="col-start-2 row-start-3"
/>
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput
v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate"
/>
</div>
<UiSelect
v-if="isLiotCarrier"
id="shipment-vehicle"
v-model="form.vehicleId"
label="Immatriculation"
:options="filteredVehicles.map((vehicle) => ({
value: String(vehicle.id),
label: vehicle.plate
}))"
:loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4"
/>
<div class="col-start-2 row-start-5 min-h-[72px]">
<UiSelect
v-if="isLiotCarrier"
id="shipment-driver"
v-model="form.driverId"
label="Nom du chauffeur si LIOT"
:options="filteredDrivers.map((driver) => ({
value: String(driver.id),
label: driver.name
}))"
:loading="isLoadingDrivers"
/>
</div>
</div>
<div v-if="formIsLoading">
<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'"
@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'"
@click="activeTab = 'weightsEmpty'"
>
pesée à vide
</h1>
</div>
<div class="mb-12">
<update-weight
v-show="activeTab === 'weights'"
v-model="grossWeight"
v-if="grossWeight"
:isAdmin="authStore.isAdmin"
/>
<update-weight
v-show="activeTab === 'weightsEmpty'"
v-model="tareWeight"
v-if="tareWeight"
:isAdmin="authStore.isAdmin"
/>
</div>
</div>
<div class="flex justify-center">
<UiButton
type="submit"
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>
Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import { usePdfPrinter } from '#imports'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import UpdateWeight from '~/components/commun/update-weight.vue'
import { getUsers } from '~/services/auth'
import { getCarrierList } from '~/services/carrier'
import { getCustomerList } from '~/services/customer'
import type { AddressData } from '~/services/dto/address-data'
import type { CarrierData } from '~/services/dto/carrier-data'
import type { CustomerData } from '~/services/dto/customer-data'
import type { DriverData } from '~/services/dto/driver-data'
import type { ShipmentData, ShipmentFormData } from '~/services/dto/shipment-data'
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import type { TruckData } from '~/services/dto/truck-data'
import type { UserData } from '~/services/dto/user-data'
import type { VehicleData } from '~/services/dto/vehicle-data'
import type { WeightEntryData } from '~/services/dto/weight-data'
import { getDriverList } from '~/services/driver'
import { getShipment, updateShipment } from '~/services/shipment'
import { getShipmentTypeList } from '~/services/shipment-type'
import { getTruckList } from '~/services/truck'
import { getVehicleList } from '~/services/vehicle'
import { createWeight, updateWeight } from '~/services/weight'
import { useAuthStore } from '~/stores/auth'
import { SUPPLIER_CODE } from '~/utils/constants'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const { printPdf } = usePdfPrinter()
const users = ref<UserData[]>([])
const customers = ref<CustomerData[]>([])
const trucks = ref<TruckData[]>([])
const carriers = ref<CarrierData[]>([])
const drivers = ref<DriverData[]>([])
const vehicles = ref<VehicleData[]>([])
const bovineShipment = ref<ShipmentTypeData[]>([])
const currentShipment = ref<ShipmentData | null>(null)
const selectedShipmentTypeId = ref('')
const shipmentQuantity = ref<number | null>(0)
const allowAnyLicensePlate = ref(false)
const activeTab = ref<'weightsEmpty' | 'weights'>('weights')
const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross'))
const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare'))
const formIsLoading = ref(false)
const isLoadingUsers = ref(false)
const isLoadingShipmentTypes = ref(false)
const isLoadingCustomers = ref(false)
const isLoadingTrucks = ref(false)
const isLoadingCarriers = ref(false)
const isLoadingVehicles = ref(false)
const isLoadingDrivers = ref(false)
const isHydrating = ref(false)
const form = reactive<ShipmentFormData & { identificationNumber: string | null }>({
identificationNumber: null,
userId: '',
shipmentDate: new Date().toISOString().slice(0, 10),
customerId: '',
addressId: '',
truckId: '',
carrierId: '',
driverId: '',
vehicleId: '',
licensePlate: ''
})
const shipmentId = computed(() => {
const id = Number(route.params.id)
return Number.isFinite(id) ? id : null
})
const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
const isAddressData = (value: unknown): value is AddressData =>
typeof value === 'object' &&
value !== null &&
'id' in value &&
'fullAddress' in value
const customerAddresses = computed<AddressData[]>(() => {
if (!form.customerId) return []
const customerId = Number(form.customerId)
if (!Number.isFinite(customerId) || customerId <= 0) return []
const addresses = customers.value.find((c) => c.id === customerId)?.addresses ?? []
return addresses.filter(isAddressData)
})
const customerAddressOptions = computed(() =>
customerAddresses.value.map((address) => ({
value: String(address.id),
label: address.fullAddress
}))
)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) {
return []
}
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) {
return []
}
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
const loadShipmentType = async () => {
isLoadingShipmentTypes.value = true
try {
bovineShipment.value = await getShipmentTypeList()
} finally {
isLoadingShipmentTypes.value = false
}
}
const loadCustomers = async () => {
isLoadingCustomers.value = true
try {
customers.value = await getCustomerList()
} finally {
isLoadingCustomers.value = false
}
}
const loadTrucks = async () => {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
function setDefaultUser() {
if (form.userId) {
return
}
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
function hydrateFromShipment(shipment: ShipmentData | null) {
if (!shipment) {
return
}
isHydrating.value = true
form.identificationNumber = shipment.identificationNumber ?? null
form.licensePlate = shipment.licensePlate ?? ''
form.shipmentDate = shipment.shipmentDate ?? new Date().toISOString().slice(0, 10)
form.userId = shipment.user?.id ? String(shipment.user.id) : form.userId
form.customerId = shipment.customer?.id ? String(shipment.customer.id) : ''
form.addressId = shipment.address?.id ? String(shipment.address.id) : ''
form.truckId = shipment.truck?.id ? String(shipment.truck.id) : ''
form.carrierId = shipment.carrier?.id ? String(shipment.carrier.id) : ''
form.driverId = shipment.driver?.id ? String(shipment.driver.id) : ''
form.vehicleId = shipment.vehicle?.id ? String(shipment.vehicle.id) : ''
selectedShipmentTypeId.value = shipment.shipmentType?.id ? String(shipment.shipmentType.id) : ''
shipmentQuantity.value = shipment.nbBovinSend ?? 0
const gross = shipment.weights?.find((weight) => weight.type === 'gross') ?? null
const tare = shipment.weights?.find((weight) => weight.type === 'tare') ?? null
grossWeight.value = gross ? { ...gross } : createEmptyWeightEntry('gross')
tareWeight.value = tare ? { ...tare } : createEmptyWeightEntry('tare')
isHydrating.value = false
}
async function printReceipt() {
if (!import.meta.client || shipmentId.value === null || shipmentId.value <= 0) {
return
}
const customerName =
customers.value.find((customer) => String(customer.id) === form.customerId)?.name ??
'client'
const filename = `${form.identificationNumber || shipmentId.value}_${customerName}_${form.licensePlate || 'immat'}.pdf`
await printPdf(`/shipments/${shipmentId.value}/receipt`, filename)
await new Promise((resolve) => setTimeout(resolve, 600))
}
async function loadShipmentForUpdate() {
if (shipmentId.value === null) {
return
}
const shipment = await getShipment(shipmentId.value)
currentShipment.value = shipment
hydrateFromShipment(shipment)
}
watch(
() => [form.customerId, form.addressId, customers.value],
() => {
if (!form.customerId) {
form.addressId = ''
return
}
if (!form.addressId && customerAddresses.value.length === 1) {
form.addressId = String(customerAddresses.value[0].id)
return
}
if (!form.addressId) {
return
}
const matches = customerAddresses.value.some(
(address) => String(address.id) === form.addressId
)
if (!matches) {
if (customerAddresses.value.length === 1) {
form.addressId = String(customerAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{ immediate: true }
)
function applyLiotDefaults() {
if (isHydrating.value) {
return
}
if (!form.carrierId) {
form.driverId = ''
form.vehicleId = ''
return
}
if (!isLiotCarrier.value) {
form.driverId = ''
form.vehicleId = ''
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
}
watch(
() => form.carrierId,
() => {
applyLiotDefaults()
},
{ immediate: true }
)
watch(
() => isHydrating.value,
(value) => {
if (!value) {
applyLiotDefaults()
}
}
)
watch(
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
return
}
if (!form.vehicleId) {
return
}
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
}
},
{ immediate: true }
)
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value || isHydrating.value) {
return
}
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
watch(
() => [form.licensePlate, form.carrierId, form.vehicleId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) {
return
}
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
form.vehicleId = String(match.id)
}
}
)
function buildPayload() {
const normalizedLicensePlate = form.licensePlate.trim()
const normalizedShipmentDate = form.shipmentDate.trim()
const normalizedCustomerId = form.customerId.trim()
const normalizedTruckId = form.truckId.trim()
const normalizedCarrierId = form.carrierId.trim()
const normalizedDriverId = form.driverId.trim()
const normalizedUserId = form.userId.trim()
const normalizedAddressId = form.addressId.trim()
const normalizedShipmentTypeId = selectedShipmentTypeId.value.trim()
const customerIri = normalizedCustomerId ? `/api/customers/${normalizedCustomerId}` : null
const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
const shipmentTypeIri = normalizedShipmentTypeId
? `/api/shipment_types/${normalizedShipmentTypeId}`
: null
const rawQuantity = Number(shipmentQuantity.value ?? 0)
const normalizedQuantity = Number.isFinite(rawQuantity)
? Math.max(0, Math.trunc(rawQuantity))
: 0
return {
licensePlate: normalizedLicensePlate,
shipmentDate: normalizedShipmentDate,
customer: customerIri,
truck: truckIri,
carrier: carrierIri,
driver: driverIri,
user: userIri,
address: addressIri,
shipmentType: shipmentTypeIri,
nbBovinSend: normalizedQuantity
}
}
function createEmptyWeightEntry(type: 'gross' | 'tare'): WeightEntryData {
return {
type,
dsd: null,
weight: null,
weighedAt: null
}
}
async function saveWeightEntry(entry: WeightEntryData) {
if (!shipmentId.value || entry.weight === null) {
return
}
const payload = {
type: entry.type,
dsd: entry.dsd ?? null,
weight: entry.weight,
weighedAt: entry.weighedAt ?? null
}
if (entry.id) {
await updateWeight(entry.id, payload)
return
}
await createWeight({
shipment: `api/shipments/${shipmentId.value}`,
...payload
})
}
async function validate() {
if (shipmentId.value === null) {
return
}
await updateShipment(shipmentId.value, {
currentStep: currentShipment.value?.currentStep ?? 0,
...buildPayload()
})
await saveWeightEntry(grossWeight.value)
await saveWeightEntry(tareWeight.value)
await loadShipmentForUpdate()
}
onMounted(async () => {
await loadShipmentType()
await loadUsers()
await loadCustomers()
await loadTrucks()
await loadCarriers()
await loadVehicles()
await loadDrivers()
await authStore.ensureSession()
formIsLoading.value = true
setDefaultUser()
await loadShipmentForUpdate()
})
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div class="flex items-center justify-between">
<div class="flex items-center gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions en attente</h1>
</div>
</div>
<div class="px-[86px]">
<div class="mt-6 border border-slate-200 mb-16 ">
<div class="grid grid-cols-5 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Client</div>
<div>Adresse</div>
<div>Type d'expéditions</div>
<div>Transporteur</div>
<div>Immatriculation</div>
</div>
<div
v-for="shipment in shipmentList"
:key="shipment.id"
class="grid grid-cols-5 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToShipment(shipment.id)"
@keydown.enter="goToShipment(shipment.id)"
>
<div>{{ shipment.customer?.name }}</div>
<div>{{ shipment.address?.fullAddress }}</div>
<div>
<template v-if="formatShipmentLines(shipment).length">
<div
v-for="(line, index) in formatShipmentLines(shipment)"
:key="index"
class="leading-5"
>
{{ line }}
</div>
</template>
</div>
<div>{{ shipment.carrier?.name }}</div>
<div>{{ shipment.licensePlate }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {ShipmentData} from "~/services/dto/shipment-data";
import {getShipmentList} from "~/services/shipment";
const shipmentList = ref<ShipmentData[]>()
const router = useRouter()
const goToShipment = (id: number) => {
router.push(`/shipment/${id}`)
}
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 ?? ''}`]
}
onMounted(async () => {
shipmentList.value = await getShipmentList(false)
})
</script>

View File

@@ -0,0 +1,45 @@
import { useApi } from '~/composables/useApi'
import type { AddressData } from '~/services/dto/address-data'
export interface AddressPayload {
label: string
street: string
street2?: string | null
postalCode: string
city: string
countryCode: string
}
export interface AddressData extends AddressPayload {
id: number
}
export async function createAddress(
payload: AddressPayload
): Promise<AddressData> {
const api = useApi()
return await api.post<AddressData>('addresses', payload, {
toastErrorKey: 'errors.address.create',
toastSuccessKey: 'success.address.create',
})
}
export async function updateAddress(
id: number,
payload: AddressPayload
): Promise<AddressData> {
const api = useApi()
return await api.patch<AddressData>(`addresses/${id}`, payload, {
toastErrorKey: 'errors.address.update',
toastSuccessKey: 'success.address.update',
})
}
export async function getAddress(id: number): Promise<AddressData> {
const api = useApi()
return await api.get<AddressData>(`addresses/${id}`, {}, {
toastErrorKey: 'errors.address.fetch',
})
}

View File

@@ -1,5 +1,6 @@
import { useApi } from '~/composables/useApi'
import type { UserData } from '~/services/dto/user-data'
import type {UserPayload} from "~/services/dto/user-data";
export async function getUsers() {
const api = useApi()
@@ -12,7 +13,40 @@ export async function getUsers() {
return data['hydra:member'] ?? []
}
export async function getAdminUsers() {
const api = useApi()
const data = await api.get<UserData[] | { 'hydra:member': UserData[] }>('admin/users', {}, {
toastErrorKey: 'errors.auth.users'
})
if (Array.isArray(data)) {
return data
}
return data['hydra:member'] ?? []
}
export async function getUser(id: number) {
const api = useApi()
return api.get<UserData>(`users/${id}`, {}, {
toastErrorKey: 'errors.auth.user'
})
}
export async function createUser(payload: UserPayload = {}) {
const api = useApi()
return api.post<UserData>('users', payload, {
toastErrorKey: 'errors.auth.create',
toastSuccessKey : 'success.auth.create'
})
}
export async function updateUser(id : number, playload: UserPayload = {}){
const api = useApi()
return api.patch<UserData>(`users/${id}`, playload, {
toastErrorKey: 'errors.auth.update',
toastSuccessKey: 'success.auth.update'
})
}
export async function getCurrentUser() {
const api = useApi()
return api.get<UserData>('me', {}, {

View File

@@ -0,0 +1,52 @@
import { useApi } from '~/composables/useApi'
import type { BovineTypeData, BovinPayload } from "~/services/dto/bovine-type-data";
export type BovineTypeListResponse =
| BovineTypeData[]
| { 'hydra:member'?: BovineTypeData[] }
export async function getBovineTypeList(): Promise<BovineTypeData[]> {
const api = useApi()
const response = await api.get<BovineTypeListResponse>('bovine_types', {}, {
toastErrorKey: 'errors.bovin.list'
})
if (Array.isArray(response)) {
return response.map(mapToBovineTypeData)
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member'].map(mapToBovineTypeData)
}
return []
}
export async function getBovin(id: number): Promise<BovineTypeData> {
const api = useApi()
const response = await api.get<BovineTypeData>(`bovine_types/${id}`)
return mapToBovineTypeData(response)
}
export async function createBovin(payload: BovinPayload = {}): Promise<BovineTypeData> {
const api = useApi()
const response = await api.post<BovineTypeData>('bovine_types', toBovineTypePayload(payload))
return mapToBovineTypeData(response)
}
export async function updateBovin(id: number, payload: BovinPayload = {}): Promise<BovineTypeData> {
const api = useApi()
const response = await api.patch<BovineTypeData>(`bovine_types/${id}`, toBovineTypePayload(payload))
return mapToBovineTypeData(response)
}
const mapToBovineTypeData = (item: BovineTypeData): BovineTypeData => ({
id: item.id,
label: item.label,
code: item.code
})
const toBovineTypePayload = (payload: BovinPayload): Partial<BovineTypeData> => ({
label: payload.label ?? undefined,
code: payload.code ?? undefined
})

View File

@@ -1,5 +1,5 @@
import { useApi } from '~/composables/useApi'
import type { CarrierData } from '~/services/dto/carrier-data'
import type {CarrierData, CarrierPayload} from "~/services/dto/carrier-data";
export type CarrierListResponse =
| CarrierData[]
@@ -21,3 +21,26 @@ export async function getCarrierList(): Promise<CarrierData[]> {
return []
}
export async function getCarrier(id: number) {
const api = useApi()
return api.get<CarrierData>(`carriers/${id}`, {}, {
toastErrorKey: 'errors.carrier.fetch'
})
}
export async function updateCarrier(id: number, payload: CarrierPayload) {
const api = useApi()
return api.patch<CarrierData>(`carriers/${id}`, payload, {
toastErrorKey: 'errors.carrier.update',
toastSuccessKey: 'success.carrier.update'
})
}
export async function createCarrier(payload: CarrierPayload = {}) {
const api = useApi()
return api.post<CarrierData>('carriers', payload, {
toastErrorKey: 'errors.carrier.create',
toastSuccessKey: 'success.carrier.update'
})
}

View File

@@ -0,0 +1,43 @@
import { useApi } from "~/composables/useApi"
import type { CustomerData, CustomerPayload } from "~/services/dto/customer-data"
export type CustomerListResponse =
| CustomerData[]
| { "hydra:member"?: CustomerData[] }
export async function getCustomerList(): Promise<CustomerData[]> {
const api = useApi()
const response = await api.get<CustomerListResponse>("customers", {}, {
toastErrorKey: "errors.customer.list",
})
if (Array.isArray(response)) return response
if (response && typeof response === "object" && Array.isArray(response["hydra:member"])) {
return response["hydra:member"]
}
return []
}
export async function getCustomer(id: number): Promise<CustomerData> {
const api = useApi()
return api.get<CustomerData>(`customers/${id}`, {}, {
toastErrorKey: "errors.customer.fetch",
})
}
export async function updateCustomer(id: number, payload: Partial<CustomerPayload>): Promise<CustomerData> {
const api = useApi()
return api.patch<CustomerData>(`customers/${id}`, payload, {
toastErrorKey: "errors.customer.update",
toastSuccessKey: "success.customer.update",
})
}
export async function createCustomer(payload: CustomerPayload): Promise<CustomerData> {
const api = useApi()
return api.post<CustomerData>("customers", payload, {
toastErrorKey: "errors.customer.create",
toastSuccessKey: "success.customer.create",
})
}

View File

@@ -6,5 +6,15 @@ export interface AddressData {
postalCode: string
city: string
countryCode: string
fullAddress?: string
fullAddress: string
}
export interface AddressFormData {
id?: number | null
label: string
street: string
street2?: string | null
postalCode: string
city: string
countryCode: string
}

View File

@@ -0,0 +1,15 @@
export interface BovineTypeData{
id: number
label: string
code: string
}
export interface BovinFormData {
label: string
code: string
}
export type BovinPayload = {
label?: string | null
code?: string | null
}

View File

@@ -0,0 +1,9 @@
import type { BuildingCaseStatusData } from '~/services/dto/building-case-status-data'
export interface BuildingCaseData {
id: number
caseNumber: number | null
code: string | null
capacity: number | null
statut?: BuildingCaseStatusData | null
}

View File

@@ -0,0 +1,11 @@
import type { BuildingCaseData } from '~/services/dto/building-case-data'
export interface BuildingCasePositionData {
id: number
x: number | null
y: number | null
w: number | null
h: number | null
renderOrder: string | null
buildingCase: BuildingCaseData | null
}

View File

@@ -0,0 +1,6 @@
export interface BuildingCaseStatusData {
id: number
label: string | null
code: string | null
couleur: string | null
}

View File

@@ -1,5 +1,8 @@
import type { BuildingLayoutData } from '~/services/dto/building-layout-data'
export interface BuildingData {
id: number
label: string
code: string
layouts?: BuildingLayoutData[] | null
}

View File

@@ -0,0 +1,9 @@
import type { BuildingCasePositionData } from '~/services/dto/building-case-position-data'
export interface BuildingLayoutData {
id: number
name: string | null
columns: number | null
rows: number | null
casePositions?: BuildingCasePositionData[] | null
}

View File

@@ -3,3 +3,13 @@ export interface CarrierData {
name: string
code: string
}
export interface CarrierFormData {
name: string
code: string
}
export type CarrierPayload = {
name?: string | null
code?: string
}

View File

@@ -0,0 +1,25 @@
import type { AddressFormData } from "~/services/dto/address-data"
export type CustomerAddresses = AddressFormData[] | string[]
export interface CustomerData {
id: number
name: string
phone?: string | null
email?: string | null
addresses: CustomerAddresses
}
export interface CustomerFormData {
name: string
phone?: string
email?: string
addresses: AddressFormData[]
}
export type CustomerPayload = {
name: string
phone?: string | null
email?: string | null
addresses?: string[]
}

View File

@@ -0,0 +1,8 @@
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
export interface ReceptionBovineTypeData{
id: number
quantity : number
reception?: string
bovineType: BovineTypeData
}

View File

@@ -8,6 +8,7 @@ import type { AddressData } from '~/services/dto/address-data'
import type { TruckData } from '~/services/dto/truck-data'
import type { CarrierData } from '~/services/dto/carrier-data'
import type { DriverData } from '~/services/dto/driver-data'
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
export interface ReceptionData {
id: number
@@ -20,7 +21,9 @@ export interface ReceptionData {
receptionType?: ReceptionTypeData | null
merchandiseType?: MerchandiseTypeData | null
merchandiseDetail?: string | null
bovineDetail?: string | null
buildings?: BuildingData[] | null
bovinesTypes?: BovineTypeData[] | null
pelletBuildings?: ReceptionPelletBuildingData[] | null
user?: UserData | null
supplier?: SupplierData | null
@@ -38,6 +41,23 @@ export interface WeightEntryData {
weighedAt: string | null
}
export interface MerchandiseEntryData {
merchandiseTypeId: string
merchandiseDetail: string
selectedBuildingIds: string[]
selectedPelletBuildingIds: Record<string, string[]>
}
export interface WeightFormData {
id: number
weight: number
weighedAt : string
dsd: number
type: 'gross' | 'tare'
}
export type ReceptionPayload = {
licensePlate?: string | null
receptionDate?: string
@@ -46,7 +66,9 @@ export type ReceptionPayload = {
receptionType?: string | null
merchandiseType?: string | null
merchandiseDetail?: string | null
bovineDetail?: string | null
buildings?: string[] | null
bovinesTypes?: string[] | null
user?: string | null
supplier?: string | null
address?: string | null
@@ -54,3 +76,29 @@ export type ReceptionPayload = {
carrier?: string | null
driver?: string | null
}
export type ReceptionFormData = {
identificationNumber?: null|string,
licensePlate: string
receptionDate: string
receptionTypeId: string
userId: string
supplierId: string
addressId: string
truckId: string
carrierId: string
driverId: string
vehicleId: string
weight?: ReceptionFormWeight | null
}
export type ReceptionFormWeight = {
weights: WeightFormData[]
}
export interface ReceptionUpdatePayload {
weights: {
id: number
weight: number
}[]
}

View File

@@ -0,0 +1,68 @@
import type {CarrierData} from '~/services/dto/carrier-data'
import type {TruckData} from '~/services/dto/truck-data'
import type {CustomerData} from '~/services/dto/customer-data'
import type {AddressData} from "~/services/dto/address-data";
import type {UserData} from '~/services/dto/user-data'
import type {DriverData} from '~/services/dto/driver-data'
import type {VehicleData} from '~/services/dto/vehicle-data'
export interface ShipmentTypeData {
id: number
label: string
code: string
}
export type ShipmentData = {
id: number
identificationNumber?: string | null
licensePlate: string | null
shipmentDate: string
currentStep: number
isValid: boolean
address?: AddressData | null
carrier?: CarrierData | null
truck?: TruckData | null
customer?: CustomerData | null
user?: UserData | null
driver?: DriverData | null
vehicle?: VehicleData | null
shipmentType?: ShipmentTypeData | null
nbBovinSend?: number | null
weights?: WeightShipmentEntryData[] | null
}
export interface WeightShipmentEntryData {
id?: number
type: 'gross' | 'tare'
dsd: number | null
weight: number | null
weighedAt: string | null
}
export type ShipmentFormData = {
userId: string,
shipmentDate: string,
customerId: string,
addressId: string,
truckId: string,
carrierId: string,
driverId: string,
vehicleId: string,
licensePlate: string,
}
export type ShipmentPayload = {
licensePlate?: string | null
shipmentDate?: string
currentStep?: number
isValid?: boolean
carrier?: string | null
truck?: string | null
customer?: string | null
address?: string | null
user?: string | null
driver?: string | null
shipmentType?: string | null
nbBovinSend?: number | null
}

View File

@@ -0,0 +1,5 @@
export interface ShipmentTypeData {
id: number
label: string
code: string
}

View File

@@ -1,9 +1,25 @@
import type { AddressData } from '~/services/dto/address-data'
import type { AddressFormData } from "~/services/dto/address-data"
export type SupplierAddresses = AddressFormData[] | string[]
export interface SupplierData {
id: number
name: string
email?: string | null
phone?: string | null
addresses?: AddressData[] | null
addresses: SupplierAddresses
}
export interface SupplierFormData {
name: string
email?: string
phone?: string
addresses: AddressFormData[]
}
export type SupplierPayload = {
name: string
email?: string | null
phone?: string | null
addresses?: string[]
}

View File

@@ -1,4 +1,17 @@
export interface UserData {
id: number
username: string
roles: string[]
}
export type UserPayload = {
username?: string
password?: string
roles?: string[]
}
export type UserFormData = {
username: string
password: string
role: string
}

View File

@@ -2,4 +2,13 @@ export interface WeightData {
weight: number | null
dsd: number | null
weighedAt: string | null
type : string | null
}
export interface WeightEntryData {
id?: number
type: 'gross' | 'tare'
dsd: number | null
weight: number | null
weighedAt: string | null
}

View File

@@ -0,0 +1,59 @@
import { useApi } from '~/composables/useApi'
import type { ReceptionBovineTypeData } from '~/services/dto/reception-bovine-data'
export type ReceptionBovineListResponse =
| ReceptionBovineTypeData[]
| { 'hydra:member'?: ReceptionBovineTypeData[] }
export type ReceptionBovinePayload = {
quantity: number
reception: string
bovineType: string
}
export async function getReceptionBovineList(
receptionIri: string
): Promise<ReceptionBovineTypeData[]> {
const api = useApi()
const response = await api.get<ReceptionBovineListResponse>(
'reception_bovines',
{ reception: receptionIri },
{
toastErrorKey: 'errors.receptionBovine.list'
}
)
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}
export async function createReceptionBovine(
payload: ReceptionBovinePayload
): Promise<ReceptionBovineTypeData> {
const api = useApi()
return api.post<ReceptionBovineTypeData>('reception_bovines', payload, {
toastErrorKey: 'errors.receptionBovine.create'
})
}
export async function deleteReceptionBovine(id: number): Promise<void> {
const api = useApi()
await api.delete<void>(`reception_bovines/${id}`, {}, {
toastErrorKey: 'errors.receptionBovine.delete'
})
}
export async function updateReceptionBovine(
id: number,
payload: Partial<ReceptionBovinePayload>
): Promise<ReceptionBovineTypeData> {
const api = useApi()
return api.patch<ReceptionBovineTypeData>(`reception_bovines/${id}`, payload, {
toastErrorKey: 'errors.receptionBovine.update'
})
}

View File

@@ -2,13 +2,15 @@ import {useApi} from '~/composables/useApi'
import type {ReceptionData, ReceptionPayload} from '~/services/dto/reception-data'
import type {WeightData} from '~/services/dto/weight-data'
export async function getReceptionList() {
export async function getReceptionList(isValid: boolean|null = null) {
const api = useApi()
return api.get<ReceptionData>(`receptions`, {}, {
const query = isValid !== null ? { isValid: isValid} : {}
return api.get<ReceptionData[]>('receptions', query, {
toastErrorKey: 'errors.reception.list'
})
}
export async function getReception(id: number) {
const api = useApi()
return api.get<ReceptionData>(`receptions/${id}`, {}, {

View File

@@ -0,0 +1,24 @@
import { useApi } from '~/composables/useApi'
import type {ShipmentTypeData} from "~/services/dto/shipment-type-data";
export type ShipmentTypeListResponse =
| ShipmentTypeData[]
| { 'hydra:member'?: ShipmentTypeData[] }
export async function getShipmentTypeList(): Promise<ShipmentTypeData[]> {
const api = useApi()
const response = await api.get<ShipmentTypeListResponse>('shipment_types', {}, {
toastErrorKey: 'errors.shipmentType.list'
})
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}

View File

@@ -0,0 +1,40 @@
import {useApi} from '~/composables/useApi'
import type {ShipmentData, ShipmentPayload} from '~/services/dto/shipment-data'
import type {WeightData} from '~/services/dto/weight-data'
export async function getShipmentList(isValid: boolean|null = null) {
const api = useApi()
const query = isValid !== null ? { isValid: isValid} : {}
return api.get<ShipmentData[]>('shipments', query, {
toastErrorKey: 'errors.shipment.list'
})
}
export async function getShipment(id: number) {
const api = useApi()
return api.get<ShipmentData>(`shipments/${id}`, {}, {
toastErrorKey: 'errors.shipment.fetch'
})
}
export async function createShipment(payload: ShipmentPayload = {}) {
const api = useApi()
return api.post<ShipmentData>('shipments', payload, {
toastErrorKey: 'errors.shipment.create'
})
}
export async function updateShipment(id: number, payload: ShipmentPayload) {
const api = useApi()
return api.patch<ShipmentData>(`shipments/${id}`, payload, {
toastErrorKey: 'errors.shipment.update',
toastSuccessKey: 'success.shipment.update'
})
}
export async function getWeightShipment(): Promise<WeightData> {
const api = useApi()
return api.get<WeightData>('shipments/weigh', {}, {
toastErrorKey: 'errors.shipment.weigh'
})
}

View File

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

View File

@@ -1,23 +1,42 @@
import { useApi } from '~/composables/useApi'
import type { SupplierData } from '~/services/dto/supplier-data'
import { useApi } from "~/composables/useApi"
import type { SupplierData, SupplierPayload } from "~/services/dto/supplier-data"
export type SupplierListResponse =
| SupplierData[]
| { 'hydra:member'?: SupplierData[] }
| { "hydra:member"?: SupplierData[] }
export async function getSupplierList(): Promise<SupplierData[]> {
const api = useApi()
const response = await api.get<SupplierListResponse>('suppliers', {}, {
toastErrorKey: 'errors.supplier.list'
const response = await api.get<SupplierListResponse>("suppliers", {}, {
toastErrorKey: "errors.supplier.list",
})
if (Array.isArray(response)) {
return response
if (Array.isArray(response)) return response
if (response && typeof response === "object" && Array.isArray(response["hydra:member"])) {
return response["hydra:member"]
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}
export async function getSupplier(id: number): Promise<SupplierData> {
const api = useApi()
return api.get<SupplierData>(`suppliers/${id}`, {}, {
toastErrorKey: "errors.supplier.fetch",
})
}
export async function updateSupplier(id: number, payload: Partial<SupplierPayload>): Promise<SupplierData> {
const api = useApi()
return api.patch<SupplierData>(`suppliers/${id}`, payload, {
toastErrorKey: "errors.supplier.update",
toastSuccessKey: "success.supplier.update",
})
}
export async function createSupplier(payload: SupplierPayload): Promise<SupplierData> {
const api = useApi()
return api.post<SupplierData>("suppliers", payload, {
toastErrorKey: "errors.supplier.create",
toastSuccessKey: "success.supplier.create",
})
}

View File

@@ -1,8 +1,12 @@
import { useApi } from '~/composables/useApi'
import type { WeightEntryData } from '~/services/dto/reception-data'
import type {ReceptionData, ReceptionPayload, WeightEntryData} from '~/services/dto/reception-data'
import type {Ref} from "vue";
import type {ShipmentData, ShipmentPayload} from "~/services/dto/shipment-data";
import type {WeighingMode} from "~/composables/useWeighing";
export type WeightPayload = {
reception: string
reception?: string
shipment?: string
type: 'gross' | 'tare'
dsd: number | null
weight: number | null
@@ -16,5 +20,22 @@ export async function createWeight(payload: WeightPayload) {
export async function updateWeight(id: number, payload: Partial<WeightPayload>) {
const api = useApi()
return api.patch<WeightEntryData>(`weights/${id}`, payload)
return api.patch<WeightEntryData>(`weights/${id}`, payload,{
toastErrorKey: 'errors.weight.update',
toastSuccessKey: 'success.weight.update'
})
}
export type UseWeighingShipmentOptions = {
modeShipment: WeighingMode
shipment: Ref<ShipmentData | null>
updateShipment: (id: number, payload: ShipmentPayload) => Promise<ShipmentData | null>
loadShipment?: (id: number) => Promise<ShipmentData | null>
}
export type UseWeighingOptions = {
mode: WeighingMode
reception: Ref<ReceptionData | null>
updateReception: (id: number, payload: ReceptionPayload) => Promise<ReceptionData | null>
loadReception?: (id: number) => Promise<ReceptionData | null>
}

View File

@@ -1,63 +1,80 @@
import { defineStore } from 'pinia'
import type { UserData } from '~/services/dto/user-data'
import { getCurrentUser, login, logout } from '~/services/auth'
import {defineStore} from 'pinia'
import type {UserData} from '~/services/dto/user-data'
import {getCurrentUser, createUser, login, logout} from '~/services/auth'
import type {UserPayload} from "~/services/dto/user-data";
import {ROLE} from '~/utils/constants'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as UserData | null,
isLoading: false,
checked: false
}),
getters: {
isAuthenticated: (state) => Boolean(state.user)
},
actions: {
clearSession() {
this.user = null
this.checked = true
this.isLoading = false
state: () => ({
user: null as UserData | null,
isLoading: false,
checked: false
}),
getters: {
isAuthenticated: (state) => Boolean(state.user),
isAdmin: (state) => Boolean(state.user?.roles?.includes(ROLE[0].value))
},
async ensureSession() {
if (this.checked) {
return this.user
}
actions: {
clearSession() {
this.user = null
this.checked = true
this.isLoading = false
},
async ensureSession() {
if (this.checked) {
return this.user
}
this.checked = true
this.checked = true
try {
const me = await getCurrentUser()
this.user = me
return me
} catch {
this.user = null
return null
}
},
async login(username: string, password: string) {
this.isLoading = true
try {
const me = await getCurrentUser()
this.user = me
return me
} catch {
this.user = null
return null
}
},
async login(username: string, password: string) {
this.isLoading = true
try {
await login(username, password)
const me = await getCurrentUser()
this.user = me
this.checked = true
return me
} finally {
this.isLoading = false
}
},
async logout() {
this.isLoading = true
try {
await login(username, password)
const me = await getCurrentUser()
this.user = me
this.checked = true
return me
} finally {
this.isLoading = false
}
},
async createUser(payload: UserPayload = {}) {
this.isLoading = true
const result = await createUser(payload).finally(() => {
this.isLoading = false
})
return result
},
async updateUser(id: number, payload: UserPayload) {
this.isLoading = true
const result = await createUser(payload).finally(() => {
this.isLoading = false
})
return result
},
async logout() {
this.isLoading = true
try {
await logout()
} catch {
// Ignore logout errors so we can still clear local auth state.
} finally {
this.user = null
this.checked = true
this.isLoading = false
}
try {
await logout()
} catch {
// Ignore logout errors so we can still clear local auth state.
} finally {
this.user = null
this.checked = true
this.isLoading = false
}
},
}
}
})

View File

@@ -0,0 +1,58 @@
import { defineStore } from 'pinia'
import type {ShipmentData, ShipmentPayload} from "~/services/dto/shipment-data";
import {createShipment, getShipment, updateShipment} from "~/services/shipment";
const isShipmentData = (value: unknown): value is ShipmentData => {
return Boolean(value && typeof value === 'object' && 'id' in value)
}
export const useShipmentStore = defineStore('shipment', {
state: () => ({
current: null as ShipmentData | null,
isLoading: false
}),
actions: {
setCurrent(shipment: ShipmentData | null) {
this.current = shipment
},
clearCurrent() {
this.current = null
},
async loadShipment(id: number) {
this.isLoading = true
const result = await getShipment(id).finally(() => {
this.isLoading = false
})
if (!isShipmentData(result)) {
this.current = null
return null
}
this.current = result
return result
},
async createShipment(payload: ShipmentPayload = {}) {
this.isLoading = true
const result = await createShipment(payload).finally(() => {
this.isLoading = false
})
if (!isShipmentData(result)) {
return null
}
this.current = result
return result
},
async updateShipment(id: number, payload: ShipmentPayload) {
this.isLoading = true
const result = await updateShipment(id, payload).finally(() => {
this.isLoading = false
})
if (!isShipmentData(result)) {
return null
}
this.current = result
return result
}
}
})

View File

@@ -8,17 +8,11 @@ export default <Partial<Config>>{
},
colors: {
primary: {
50: '#f6f9ea',
100: '#eaf2cf',
200: '#d6e3a4',
300: '#c1d47a',
400: '#afc85a',
500: '#9ebb43',
600: '#7e9735',
700: '#607228',
800: '#414d1a',
900: '#24290d'
700: '#35453C',
500: '#456452',
}
}
}
}

View File

@@ -1,5 +1,6 @@
export const RECEPTION_TYPE_CODES = {
MERCHANDISES: 'MARCHANDISES'
MERCHANDISES: 'MARCHANDISES',
BOVINS: 'BOVINS'
} as const
export const MERCHANDISE_TYPE_CODES = {
@@ -7,6 +8,10 @@ export const MERCHANDISE_TYPE_CODES = {
AUTRES: 'AUTRES'
} as const
export const SUPLLIER_CODE = {
export const ROLE = [
{ label: 'Administrateur', value: 'ROLE_ADMIN' },
{ label: 'Utilisateur', value: 'ROLE_USER' }
]
export const SUPPLIER_CODE = {
LIOT: 'LIOT'
}

View File

@@ -79,7 +79,7 @@ migration-migrate:
$(SYMFONY_CONSOLE) --no-interaction doctrine:migrations:migrate --allow-no-migration
fixtures:
$(SYMFONY_CONSOLE) doctrine:fixtures:load
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
# Attention, supprime votre bdd local
db-reset:

View File

@@ -0,0 +1,39 @@
<?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 Version20260203123833 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('CREATE TABLE bovine_type (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(120) NOT NULL, code VARCHAR(50) NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE TABLE reception_bovine (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, quantity INT NOT NULL, reception_id INT DEFAULT NULL, bovine_type_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_636B9DB97C14DF52 ON reception_bovine (reception_id)');
$this->addSql('CREATE INDEX IDX_636B9DB97899F32E ON reception_bovine (bovine_type_id)');
$this->addSql('ALTER TABLE reception_bovine ADD CONSTRAINT FK_636B9DB97C14DF52 FOREIGN KEY (reception_id) REFERENCES reception (id)');
$this->addSql('ALTER TABLE reception_bovine ADD CONSTRAINT FK_636B9DB97899F32E FOREIGN KEY (bovine_type_id) REFERENCES bovine_type (id) NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE reception_bovine DROP CONSTRAINT FK_636B9DB97C14DF52');
$this->addSql('ALTER TABLE reception_bovine DROP CONSTRAINT FK_636B9DB97899F32E');
$this->addSql('DROP TABLE bovine_type');
$this->addSql('DROP TABLE reception_bovine');
}
}

View File

@@ -0,0 +1,64 @@
<?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 Version20260204101625 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('CREATE TABLE bovin_shipment (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, nb_bovin_send INT NOT NULL, shipment_id INT DEFAULT NULL, shipment_type_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_7049F4507BE036FC ON bovin_shipment (shipment_id)');
$this->addSql('CREATE INDEX IDX_7049F4502EE48A36 ON bovin_shipment (shipment_type_id)');
$this->addSql('CREATE TABLE customer (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(255) NOT NULL, code VARCHAR(255) NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE TABLE customer_address (customer_id INT NOT NULL, address_id INT NOT NULL, PRIMARY KEY (customer_id, address_id))');
$this->addSql('CREATE INDEX IDX_1193CB3F9395C3F3 ON customer_address (customer_id)');
$this->addSql('CREATE INDEX IDX_1193CB3FF5B7AF75 ON customer_address (address_id)');
$this->addSql('CREATE TABLE shipment (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, licence_plate VARCHAR(255) NOT NULL, identification_number VARCHAR(20) DEFAULT NULL, current_step INT DEFAULT 0 NOT NULL, is_valid BOOLEAN NOT NULL, shipment_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, carrier_id INT DEFAULT NULL, vehicle_id INT DEFAULT NULL, truck_id INT DEFAULT NULL, customer_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_2CB20DC347639A5 ON shipment (identification_number)');
$this->addSql('CREATE INDEX IDX_2CB20DC21DFC797 ON shipment (carrier_id)');
$this->addSql('CREATE INDEX IDX_2CB20DC545317D1 ON shipment (vehicle_id)');
$this->addSql('CREATE INDEX IDX_2CB20DCC6957CCE ON shipment (truck_id)');
$this->addSql('CREATE INDEX IDX_2CB20DC9395C3F3 ON shipment (customer_id)');
$this->addSql('CREATE TABLE shipment_type (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(255) NOT NULL, code VARCHAR(255) NOT NULL, PRIMARY KEY (id))');
$this->addSql('ALTER TABLE bovin_shipment ADD CONSTRAINT FK_7049F4507BE036FC FOREIGN KEY (shipment_id) REFERENCES shipment (id)');
$this->addSql('ALTER TABLE bovin_shipment ADD CONSTRAINT FK_7049F4502EE48A36 FOREIGN KEY (shipment_type_id) REFERENCES shipment_type (id)');
$this->addSql('ALTER TABLE customer_address ADD CONSTRAINT FK_1193CB3F9395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE customer_address ADD CONSTRAINT FK_1193CB3FF5B7AF75 FOREIGN KEY (address_id) REFERENCES address (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE shipment ADD CONSTRAINT FK_2CB20DC21DFC797 FOREIGN KEY (carrier_id) REFERENCES carrier (id) NOT DEFERRABLE');
$this->addSql('ALTER TABLE shipment ADD CONSTRAINT FK_2CB20DC545317D1 FOREIGN KEY (vehicle_id) REFERENCES vehicle (id)');
$this->addSql('ALTER TABLE shipment ADD CONSTRAINT FK_2CB20DCC6957CCE FOREIGN KEY (truck_id) REFERENCES truck (id) NOT DEFERRABLE');
$this->addSql('ALTER TABLE shipment ADD CONSTRAINT FK_2CB20DC9395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovin_shipment DROP CONSTRAINT FK_7049F4507BE036FC');
$this->addSql('ALTER TABLE bovin_shipment DROP CONSTRAINT FK_7049F4502EE48A36');
$this->addSql('ALTER TABLE customer_address DROP CONSTRAINT FK_1193CB3F9395C3F3');
$this->addSql('ALTER TABLE customer_address DROP CONSTRAINT FK_1193CB3FF5B7AF75');
$this->addSql('ALTER TABLE shipment DROP CONSTRAINT FK_2CB20DC21DFC797');
$this->addSql('ALTER TABLE shipment DROP CONSTRAINT FK_2CB20DC545317D1');
$this->addSql('ALTER TABLE shipment DROP CONSTRAINT FK_2CB20DCC6957CCE');
$this->addSql('ALTER TABLE shipment DROP CONSTRAINT FK_2CB20DC9395C3F3');
$this->addSql('DROP TABLE bovin_shipment');
$this->addSql('DROP TABLE customer');
$this->addSql('DROP TABLE customer_address');
$this->addSql('DROP TABLE shipment');
$this->addSql('DROP TABLE shipment_type');
}
}

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