Compare commits

...

34 Commits

Author SHA1 Message Date
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
110 changed files with 6866 additions and 622 deletions

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

201
.idea/workspace.xml generated
View File

@@ -4,11 +4,10 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : panel scrollable plus interface revue">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : corrections diverses">
<change beforePath="$PROJECT_DIR$/.idea/data_source_mapping.xml" beforeDir="false" />
<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/carrier/carrier-list.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/admin/carrier/carrier-list.vue" 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" />
<change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -33,17 +32,21 @@
<list>
<option value="Vue Composition API Component" />
<option value="TypeScript File" />
<option value="PHP File" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="feat/256-reception-etape-3-bovin" />
<entry key="$PROJECT_DIR$" value="feat/276-lister-expeditions-terminees" />
</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 />
@@ -220,43 +223,48 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"RunOnceActivity.MCP Project settings loaded": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"git-widget-placeholder": "feat/312-creation-d-une-page-d-administration-listing-des-fournisseurs",
"last_opened_file_path": "/home/sroy/Documents/test/Ferme",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "configurable.tailwindcss",
"ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external",
"vue.rearranger.settings.migration": "true"
<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;fix/325-corrections-diverses&quot;,
&quot;last_opened_file_path&quot;: &quot;//wsl.localhost/Ubuntu-24.04/home/kevin/Stage/Ferme/frontend/pages/shipment&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;preferences.pluginManager&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;
},
"keyToStringList": {
"DatabaseDriversLRU": [
"postgresql"
&quot;keyToStringList&quot;: {
&quot;DatabaseDriversLRU&quot;: [
&quot;postgresql&quot;
],
"com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File": [
"TEXT"
&quot;com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File&quot;: [
&quot;TEXT&quot;
],
"vue.recent.templates": [
"Vue Composition API Component"
&quot;vue.recent.templates&quot;: [
&quot;Vue Composition API Component&quot;
]
}
}]]></component>
}</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<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\matte\Ferme\frontend\pages\admin\supplier" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\templates" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches\Ferme_MCD\MCD_DOC" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\frontend\pages\reception" />
</key>
</component>
<component name="SharedIndexes">
@@ -296,6 +304,34 @@
<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-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" />
@@ -617,79 +653,55 @@
<option name="project" value="LOCAL" />
<updated>1770217875423</updated>
</task>
<task id="LOCAL-00050" summary="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)">
<task id="LOCAL-00050" summary="feat : creer une nouvelle expedtion (WIP)">
<option name="closed" value="true" />
<created>1770283622425</created>
<created>1770736570645</created>
<option name="number" value="00050" />
<option name="presentableId" value="LOCAL-00050" />
<option name="project" value="LOCAL" />
<updated>1770283622425</updated>
<updated>1770736570645</updated>
</task>
<task id="LOCAL-00051" summary="feat : ajout du responsive sur la navbar et la page d'accueil">
<task id="LOCAL-00051" summary="feat : ajout d'une page de creation d'une expedition">
<option name="closed" value="true" />
<created>1770308927948</created>
<created>1770880791564</created>
<option name="number" value="00051" />
<option name="presentableId" value="LOCAL-00051" />
<option name="project" value="LOCAL" />
<updated>1770308927948</updated>
<updated>1770880791565</updated>
</task>
<task id="LOCAL-00052" summary="fix : logo centré en mod mobile">
<task id="LOCAL-00052" summary="feat : changelog">
<option name="closed" value="true" />
<created>1770310504254</created>
<created>1770881437439</created>
<option name="number" value="00052" />
<option name="presentableId" value="LOCAL-00052" />
<option name="project" value="LOCAL" />
<updated>1770310504254</updated>
<updated>1770881437439</updated>
</task>
<task id="LOCAL-00053" summary="feat : ajout d'un numéro de version automatique via la CI">
<task id="LOCAL-00053" summary="feat : lister les expeditions terminees">
<option name="closed" value="true" />
<created>1770369945257</created>
<created>1770883114609</created>
<option name="number" value="00053" />
<option name="presentableId" value="LOCAL-00053" />
<option name="project" value="LOCAL" />
<updated>1770369945257</updated>
<updated>1770883114609</updated>
</task>
<task id="LOCAL-00054" summary="feat : update numéro de version">
<task id="LOCAL-00054" summary="feat : lister les expeditions terminees">
<option name="closed" value="true" />
<created>1770370216428</created>
<created>1770884154297</created>
<option name="number" value="00054" />
<option name="presentableId" value="LOCAL-00054" />
<option name="project" value="LOCAL" />
<updated>1770370216428</updated>
<updated>1770884154297</updated>
</task>
<task id="LOCAL-00055" summary="fix : auto-tag-develop.yml">
<task id="LOCAL-00055" summary="fix : corrections diverses">
<option name="closed" value="true" />
<created>1770370700697</created>
<created>1770969471135</created>
<option name="number" value="00055" />
<option name="presentableId" value="LOCAL-00055" />
<option name="project" value="LOCAL" />
<updated>1770370700698</updated>
<updated>1770969471135</updated>
</task>
<task id="LOCAL-00056" summary="fix : auto-tag-develop.yml">
<option name="closed" value="true" />
<created>1770370919043</created>
<option name="number" value="00056" />
<option name="presentableId" value="LOCAL-00056" />
<option name="project" value="LOCAL" />
<updated>1770370919043</updated>
</task>
<task id="LOCAL-00057" summary="feat : test auto-tag-develop.yml (auto incrément version)">
<option name="closed" value="true" />
<created>1770371073055</created>
<option name="number" value="00057" />
<option name="presentableId" value="LOCAL-00057" />
<option name="project" value="LOCAL" />
<updated>1770371073055</updated>
</task>
<task id="LOCAL-00058" summary="fix : nom page fournisseur">
<option name="closed" value="true" />
<created>1770632525875</created>
<option name="number" value="00058" />
<option name="presentableId" value="LOCAL-00058" />
<option name="project" value="LOCAL" />
<updated>1770632525875</updated>
</task>
<option name="localTasksCounter" value="59" />
<option name="localTasksCounter" value="56" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -739,6 +751,7 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<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" />
@@ -757,14 +770,38 @@
<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" />
<MESSAGE value="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)" />
<MESSAGE value="feat : ajout du responsive sur la navbar et la page d'accueil" />
<MESSAGE value="fix : logo centré en mod mobile" />
<MESSAGE value="feat : ajout d'un numéro de version automatique via la CI" />
<MESSAGE value="feat : update numéro de version" />
<MESSAGE value="fix : auto-tag-develop.yml" />
<MESSAGE value="feat : test auto-tag-develop.yml (auto incrément version)" />
<MESSAGE value="fix : nom page fournisseur" />
<option name="LAST_COMMIT_MESSAGE" value="fix : nom page fournisseur" />
<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" />
<option name="LAST_COMMIT_MESSAGE" value="fix : corrections diverses" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<line-breakpoint enabled="true" type="php">
<url>file://$PROJECT_DIR$/src/Entity/ReceptionPelletBuilding.php</url>
<line>6</line>
<option name="timeStamp" value="3" />
</line-breakpoint>
<line-breakpoint enabled="true" type="php">
<url>file://$PROJECT_DIR$/src/Entity/Shipment.php</url>
<line>6</line>
<option name="timeStamp" value="45" />
</line-breakpoint>
<line-breakpoint enabled="true" type="javascript">
<url>file://$PROJECT_DIR$/frontend/services/dto/shipment-data.ts</url>
<option name="timeStamp" value="43" />
</line-breakpoint>
<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 />

View File

@@ -31,7 +31,24 @@ Ajouter dans le fichier .env du frontend
* [#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
### Changed
### Fixed

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.34'
app.version: '0.0.51'

View File

@@ -0,0 +1,81 @@
<template>
<form @submit.prevent="validateForm">
<div class="flex items-center justify-between gap-10">
<div>
<h1 class="text-3xl font-bold uppercase">
{{ props.address ? "Modification d'une adresse" : "Ajout d'une adresse" }}
</h1>
</div>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
type="submit"
:disabled="isLoading"
>
{{ props.address? "Sauvegarder" : "Ajouter" }}
</button>
</div>
<div class="grid grid-cols-2 gap-y-16 gap-x-12 mb-16 mt-10">
<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" />
</div>
</form>
</template>
<script setup lang="ts">
import { 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 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 emit = defineEmits<{
(event: 'validate', form: AddressPayload): void
}>()
</script>

View File

@@ -2,9 +2,9 @@
<template>
<NuxtLink :to="link">
<div class="w-[324px] h-[228px] border border-black rounded-md p-6 flex flex-col justify-between">
<div class="w-[300px] h-[216px] border border-black 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-neutral-400 flex justify-center items-center">
<div class="rounded-full w-[80px] h-[80px] bg-[#D9D9D9] flex justify-center items-center">
<Icon :name="iconName" style="color: black" size="44" />
</div>
<div>
@@ -12,12 +12,12 @@
</div>
</div>
<div class="uppercase font-bold">
<p class="text-3xl"> {{ label }} </p>
<p class="text-3xl text-primary-500">
<slot name="label">{{ label }}</slot>
</p>
</div>
</div>
</NuxtLink>
</template>
<script setup lang="ts">
@@ -27,4 +27,3 @@ const props = defineProps<{
label: string
}>()
</script>

View File

@@ -2,7 +2,7 @@
<div
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"
class="flex flex-col items-center gap-16">
<h1 class="text-4xl uppercase font-bold">Sélection des marchandises réceptionnnées</h1>
<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">
<div
@@ -10,6 +10,7 @@
:key="type.id"
class="mt-8 flex flex-row mb-2 gap-6">
<UiNumberInput
:id="type.id"
:label="type.label"
:code="type.code"
v-model="bovineQuantities[String(type.id)]"
@@ -29,7 +30,7 @@
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="goNext"
>Peser
>Valider
</button>
</div>
</template>
@@ -77,14 +78,14 @@ onMounted(async () => {
})
watch(
() => receptionId.value,
async (id) => {
if (!id || !receptionIri.value) {
[() => 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 bovineType.value) {
for (const type of types) {
selectionMap[String(type.id)] = 0
}

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,21 +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 {RECEPTION_TYPE_CODES, SUPLLIER_CODE} from "~/utils/constants";
import {RECEPTION_TYPE_CODES, SUPPLIER_CODE} from "~/utils/constants";
import {deleteReceptionBovine, getReceptionBovineList} from "~/services/reception-bovine";
type ReceptionFormData = {
licensePlate: string
receptionDate: string
receptionTypeId: string
userId: string
supplierId: string
addressId: string
truckId: string
carrierId: string
driverId: string
vehicleId: string
}
import type {ReceptionFormData} from "~/services/dto/reception-data";
const router = useRouter()
const receptionStore = useReceptionStore()
@@ -195,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)
@@ -353,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 = ''
@@ -370,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}

View File

@@ -3,7 +3,7 @@
<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élection 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"
@@ -26,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"
@@ -47,17 +47,17 @@
>
<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>
@@ -67,7 +67,7 @@
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="goNext"
>Peser
>Valider
</button>
</div>
</template>

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,183 @@
<template>
<form @submit.prevent="validate">
<div
class="flex flex-col items-center gap-16">
<div
class="flex flex-row gap-6 items-center">
<div
v-for="type in bovineType"
:key="type.id"
class="flex flex-row mb-2 gap-6 ">
<UiNumberInput
:label="type.label"
:code="type.code"
v-model="bovineQuantities[String(type.id)]"
:disabled="!auth.isAdmin"
:placeholder="0"
:min="0"
:max="10"
/>
</div>
<div
class=" flex flex-row mb-2 gap-6">
<UiNumberInput
label="Autres"
v-model="otherQuantity"
:disabled="!auth.isAdmin"
/>
</div>
</div>
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!auth.isAdmin"
>Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
import {getBovineTypeList} from "~/services/bovine-type";
import {
createReceptionBovine,
deleteReceptionBovine,
getReceptionBovineList,
updateReceptionBovine
} from "~/services/reception-bovine";
import {computed, onMounted, reactive, ref, watch} from "vue";
import {getReception, updateReception} from "~/services/reception";
const toast = useToast()
const isLoadingBovineType = ref(false)
const bovineType = ref<BovineTypeData[]>([])
const bovineQuantities = reactive<Record<string, number | null>>({})
const otherQuantity = ref<number | null>(0)
const auth = useAuthStore()
const props = defineProps<{
idReception: number
}>()
const receptionId = props.idReception
const reception = await getReception(receptionId)
const receptionIri = computed(() =>
receptionId ? `/api/receptions/${receptionId}` : 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, () => 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 = reception.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 validate() {
// @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
}
await syncBovineSelections(receptionIri.value)
await updateReception(receptionId, {
merchandiseType: null,
merchandiseDetail: null,
bovineDetail: otherQuantity.value ? String(otherQuantity.value) : null,
})
}
</script>

View File

@@ -0,0 +1,258 @@
<template>
<form @submit.prevent="validate">
<div class="flex flex-col items-center gap-16">
<div
class="flex flex-col gap-16 items-center w-full">
<UiTextInput
id="merchandise-type"
v-model="selectedMerchandiseTypeId"
label="Type de marchandises"
:value="reception.merchandiseType?.label"
wrapper-class="w-[550px]"
:disabled="true"
/>
<div
v-if="merchandiseTypeId && isAutres"
class="flex flex-col w-full max-w-[550px]"
>
<UiTextInput
id="merchandise-detail"
:disabled="!auth.isAdmin"
v-model="merchandiseDetail"
label="Préciser"
placeholder="Précisions complémentaires"
:maxlength="255"
/>
</div>
<div
v-if="merchandiseTypeId && !isGranule"
class="flex gap-4 w-[550px] justify-evenly"
>
<div
v-for="building in buildings"
:key="building.id"
>
<UiCheckbox
v-model="selectedBuildingIds"
:value="String(building.id)"
:label="building.label"
:disabled="!auth.isAdmin"
label-class="text-xl"
/>
</div>
</div>
<div
v-if="merchandiseTypeId && isGranule"
class="flex flex-col gap-10 w-full max-w-[1100px]"
>
<div class="grid grid-cols-1 gap-10 md:grid-cols-4">
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4">
<p class="font-bold uppercase">{{ type.label }}</p>
<div
v-for="building in buildings"
:key="building.id"
class="flex items-center gap-2 text-lg"
>
<UiCheckbox
v-model="selectedPelletBuildingIds[String(type.id)]"
:value="String(building.id)"
:label="building.label"
:disabled="!auth.isAdmin"
label-class="text-lg"
/>
</div>
</div>
</div>
</div>
</div>
<UiButton
v-if="auth.isAdmin"
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!auth.isAdmin"
>Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import {computed, onMounted, ref} from 'vue'
import {getBuildingList} from '~/services/building'
import {getMerchandiseTypeList} from '~/services/merchandise-type'
import type {MerchandiseTypeData} from '~/services/dto/merchandise-type-data'
import type {BuildingData} from '~/services/dto/building-data'
import type {PelletTypeData} from '~/services/dto/pellet-type-data'
import {getPelletTypeList} from '~/services/pellet-type'
import {
createReceptionPelletBuilding,
deleteReceptionPelletBuilding,
getReceptionPelletBuildingList
} from '~/services/reception-pellet-building'
import {MERCHANDISE_TYPE_CODES} from '~/utils/constants'
import {getReception, updateReception} from "~/services/reception";
const merchandiseTypes = ref<MerchandiseTypeData[]>([])
const buildings = ref<BuildingData[]>([])
const pelletTypes = ref<PelletTypeData[]>([])
const selectedMerchandiseTypeId = ref('')
const selectedBuildingIds = ref<string[]>([])
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
const merchandiseDetail = ref('')
const auth = useAuthStore()
const props = defineProps<{
idReception: number
}>()
const receptionId = props.idReception
const reception = await getReception(receptionId)
const merchandiseTypeId = await reception.receptionType?.id
// Extrait l'ID d'une relation depuis un IRI ou un objet complet.
const getRelationId = (value: unknown): string | null => {
if (!value) {
return null
}
if (typeof value === 'string') {
const match = value.match(/\/(\d+)$/)
return match ? match[1] : null
}
if (typeof value === 'object' && 'id' in value) {
const record = value as { id?: number | string }
if (typeof record.id === 'number') {
return String(record.id)
}
if (typeof record.id === 'string') {
return record.id
}
}
return null
}
// Type de marchandise sélectionné dans le select
const selectedMerchandiseType = computed(() =>
merchandiseTypes.value.find((type) => String(type.id) === selectedMerchandiseTypeId.value)
)
// Indique si le type est "Granulé"
const isGranule = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.GRANULE)
// Indique si le type est "Autres"
const isAutres = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES)
// Charge les référentiels et hydrate le formulaire depuis la réception
onMounted(async () => {
const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([
getMerchandiseTypeList(),
getBuildingList(),
getPelletTypeList()
])
merchandiseTypes.value = merchandiseTypeList
buildings.value = buildingList
pelletTypes.value = pelletTypeList
const currentId = reception.merchandiseType?.id
if (currentId) {
selectedMerchandiseTypeId.value = String(currentId)
}
merchandiseDetail.value = reception.merchandiseDetail ?? ''
selectedBuildingIds.value =
reception.buildings?.map((building) => String(building.id)) ?? []
const existingPelletSelections = reception.pelletBuildings ?? []
const selectionMap: Record<string, string[]> = {}
for (const selection of existingPelletSelections) {
// L'API peut renvoyer les relations comme IRI ou comme objets selon le contexte.
const pelletTypeId = getRelationId(selection.pelletType)
const buildingId = getRelationId(selection.building)
if (!pelletTypeId || !buildingId) {
continue
}
if (!selectionMap[pelletTypeId]) {
selectionMap[pelletTypeId] = []
}
selectionMap[pelletTypeId].push(buildingId)
}
for (const pelletType of pelletTypes.value) {
const key = String(pelletType.id)
if (!selectionMap[key]) {
selectionMap[key] = []
}
}
selectedPelletBuildingIds.value = selectionMap
})
// Enregistre les sélections et passe à l'étape suivante
async function validate() {
const receptionIri = `/api/receptions/${reception.id}`
await updateReception(reception.id, {
merchandiseDetail: isAutres.value ? merchandiseDetail.value.trim() : null,
buildings: isGranule.value
? []
: selectedBuildingIds.value.map((id) => `/api/buildings/${id}`),
bovineDetail: null,
bovinesTypes: null,
})
if (isGranule.value) {
await syncPelletSelections(receptionIri)
} else {
await clearPelletSelections(receptionIri)
}
}
// Supprime toutes les associations granulés/bâtiments existantes
async function clearPelletSelections(receptionIri: string) {
const existing = await getReceptionPelletBuildingList(receptionIri)
for (const selection of existing) {
await deleteReceptionPelletBuilding(selection.id)
}
}
// Synchronise les associations granulés/bâtiments avec l'état du formulaire
async function syncPelletSelections(receptionIri: string) {
const existing = await getReceptionPelletBuildingList(receptionIri)
const existingMap = new Map<string, number>()
for (const selection of existing) {
// Construit la table de correspondance avec des IDs normalisés pour éviter les doublons.
const pelletTypeId = getRelationId(selection.pelletType)
const buildingId = getRelationId(selection.building)
if (!pelletTypeId || !buildingId) {
continue
}
const key = `${pelletTypeId}:${buildingId}`
existingMap.set(key, selection.id)
}
const desiredEntries: Array<{ pelletTypeId: string; buildingId: string }> = []
for (const [pelletTypeId, buildingIds] of Object.entries(selectedPelletBuildingIds.value)) {
for (const buildingId of buildingIds) {
desiredEntries.push({pelletTypeId, buildingId})
}
}
const desiredKeys = new Set(desiredEntries.map(
(entry) => `${entry.pelletTypeId}:${entry.buildingId}`
))
for (const [key, id] of existingMap.entries()) {
if (!desiredKeys.has(key)) {
await deleteReceptionPelletBuilding(id)
}
}
for (const entry of desiredEntries) {
const key = `${entry.pelletTypeId}:${entry.buildingId}`
if (!existingMap.has(key)) {
await createReceptionPelletBuilding({
reception: receptionIri,
pelletType: `/api/pellet_types/${entry.pelletTypeId}`,
building: `/api/buildings/${entry.buildingId}`
})
}
}
}
</script>

View File

@@ -0,0 +1,124 @@
<template>
<form @submit.prevent="validate">
<div class="grid grid-cols-2 gap-x-40 gap-y-8 mb-8">
<UiNumberInput
label="Dsd"
class="col-start-2"
labelClass="font-bold uppercase"
v-model="sharedWeightMeta.dsd"
:disabled="!auth.isAdmin"
/>
<UiDateInput
label="Date pesée"
v-model="sharedWeightMeta.weighedAt"
:disabled="!auth.isAdmin"
/>
</div>
<div class="grid grid-cols-2 gap-x-40 mb-16">
<UiNumberInput
v-for="weight in form.weights"
:key="weight.type"
:label="getWeightLabel(weight.type)"
labelClass="font-bold uppercase text-xl"
inputClass="w-24"
v-model="weight.weight"
:wrapper-class="weight.type === 'tare' ? 'col-start-1 row-start-1' : 'col-start-2 row-start-1'"
:disabled="!auth.isAdmin"
:min="0"
:max="48000"
/>
</div>
<div class="flex justify-center">
<UiButton
v-if="auth.isAdmin"
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
>
Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import type {ReceptionFormWeight} from '~/services/dto/reception-data'
import {getReception} from '~/services/reception'
import {updateWeight} from "~/services/weight";
import {useAuthStore} from "~/stores/auth";
const props = defineProps<{
idReception: number
}>()
const idReception = props.idReception
const auth = useAuthStore()
const form = reactive({
weights: [
{id: 0, type: 'tare' as const, weight: 0, dsd: null, weighedAt: null},
{id: 0, type: 'gross' as const, weight: 0, dsd: null, weighedAt: null}
]
})
// DSD et date de pesée sont partagés entre tare et gross dans l'UI.
const sharedWeightMeta = reactive<{
dsd: number | string | null
weighedAt: string | null
}>({
dsd: null,
weighedAt: null
})
const getWeightLabel = (type: 'tare' | 'gross'): string => {
return type === 'tare' ? 'Pesée à vide' : 'Pesée à plein'
}
const hydrateFromReception = (reception: ReceptionFormWeight) => {
// On hydrate chaque ligne par son type (tare/gross), sans dépendre d'un index.
for (const receptionWeight of reception.weights) {
const formWeight = form.weights.find(weight => weight.type === receptionWeight.type)
if (formWeight) {
Object.assign(formWeight, receptionWeight)
}
}
// On récupère une valeur existante pour préremplir les champs partagés.
const weightWithMeta = reception.weights.find(weight =>
(weight.dsd !== null && weight.dsd !== undefined)
|| (weight.weighedAt !== null && weight.weighedAt !== undefined && weight.weighedAt !== '')
)
if (weightWithMeta) {
sharedWeightMeta.dsd = weightWithMeta.dsd ?? null
sharedWeightMeta.weighedAt = weightWithMeta.weighedAt ?? null
}
}
onMounted(async () => {
const reception = await getReception(idReception)
hydrateFromReception(reception)
})
async function validate() {
const sharedDsd =
sharedWeightMeta.dsd === null || sharedWeightMeta.dsd === undefined || sharedWeightMeta.dsd === ''
? null
: Number(sharedWeightMeta.dsd)
const sharedWeighedAt =
sharedWeightMeta.weighedAt === null || sharedWeightMeta.weighedAt === undefined || sharedWeightMeta.weighedAt === ''
? null
: sharedWeightMeta.weighedAt
for (const weight of form.weights) {
if (weight.id) {
await updateWeight(weight.id, {
weight: weight.weight,
dsd: Number.isFinite(sharedDsd) ? sharedDsd : null,
weighedAt: sharedWeighedAt
})
}
}
}
</script>

View File

@@ -0,0 +1,626 @@
<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 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 items-end gap-8 justify-between">
<UiRadioGroup
id="shipment-type"
name="shipment-type"
label="Type d'expédition bovine"
v-model="selectedShipmentTypeId"
:options="bovineShipment.map((type) => ({
value: String(type.id),
label: type.label
}))"
/>
<UiNumberInput
id="shipment-type-quantity"
label="Quantité"
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.licencePlate"
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 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";
import {
createShipmentBovine,
deleteShipmentBovine,
getBovinShipmentList,
updateShipmentBovine
} from "~/services/bovin-shipment";
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: '',
licencePlate: '',
})
// 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.licencePlate = shipment?.licencePlate ?? ''
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) : ''
if (!shipment || !shipment.bovinShipments) {
selectedShipmentTypeId.value = ''
shipmentQuantity.value = 0
} else {
const selectedEntry = shipment.bovinShipments.find((entry) => {
const typeId = entry.shipmentType?.id
return Boolean(typeId) && Number(entry.nbBovinSend ?? 0) > 0
}) ?? shipment.bovinShipments.find((entry) => Boolean(entry.shipmentType?.id))
if (!selectedEntry?.shipmentType?.id) {
selectedShipmentTypeId.value = ''
shipmentQuantity.value = 0
} else {
selectedShipmentTypeId.value = String(selectedEntry.shipmentType.id)
shipmentQuantity.value = selectedEntry.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.licencePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
watch(
() => [form.licencePlate, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) {
return
}
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licencePlate
)
if (match) {
form.vehicleId = String(match.id)
}
}
)
const buildDesiredBovinShipments = () => {
const typeId = Number(selectedShipmentTypeId.value)
if (!Number.isFinite(typeId)) {
return []
}
const type = bovineShipment.value.find((entry) => entry.id === typeId)
if (!type) {
return []
}
const raw = shipmentQuantity.value
const quantity = raw === null || raw === undefined ? 0 : Number(raw)
const normalizedQuantity = Number.isFinite(quantity) ? Math.max(0, Math.trunc(quantity)) : 0
if (normalizedQuantity <= 0) {
return []
}
return [{type, quantity: normalizedQuantity}]
}
const syncBovinShipments = async (
shipmentId: number,
existing: Array<{ id?: number; nbBovinSend: number | null; shipmentType?: unknown }> = []
) => {
const shipmentIri = `/api/shipments/${shipmentId}`
const desired = buildDesiredBovinShipments()
const desiredByTypeId = new Map<number, number>()
for (const entry of desired) {
desiredByTypeId.set(entry.type.id, entry.quantity)
}
for (const entry of existing) {
if (!entry.id) {
continue
}
const rawType = entry.shipmentType
let typeId: number | null = null
if (rawType && typeof rawType === 'object' && 'id' in rawType) {
typeId = Number((rawType as { id: number }).id)
} else if (typeof rawType === 'string') {
const match = rawType.match(/\/shipment_types\/(\\d+)$/)
typeId = match ? Number(match[1]) : null
}
if (!typeId) {
continue
}
const desiredQuantity = desiredByTypeId.get(typeId)
if (!desiredQuantity) {
await deleteShipmentBovine(entry.id)
continue
}
if (entry.nbBovinSend !== desiredQuantity) {
await updateShipmentBovine(entry.id, {nbBovinSend: desiredQuantity})
}
desiredByTypeId.delete(typeId)
}
for (const [typeId, quantity] of desiredByTypeId.entries()) {
await createShipmentBovine({
shipment: shipmentIri,
shipmentType: `/api/shipment_types/${typeId}`,
nbBovinSend: quantity
})
}
}
const buildPayload = () => {
const normalizedLicensePlate = form.licencePlate.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
return {
licencePlate: normalizedLicensePlate,
shipmentDate: normalizedShipmentDate,
customer: customerIri,
truck: truckIri,
carrier: carrierIri,
driver: driverIri,
user: userIri,
address: addressIri
}
}
const saveDraft = async () => {
const payload = buildPayload()
if (!shipmentStore.current) {
const created = await shipmentStore.createShipment({
currentStep: 0,
...payload
})
if (created) {
await syncBovinShipments(created.id, [])
}
return
}
await shipmentStore.updateShipment(shipmentStore.current.id, {
currentStep: shipmentStore.current.currentStep,
...payload
})
await syncBovinShipments(
shipmentStore.current.id,
shipmentStore.current?.bovinShipments ?? []
)
}
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 syncBovinShipments(created.id, shipmentStore.current?.bovinShipments ?? [])
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)
await syncBovinShipments(shipmentStore.current.id, shipmentStore.current?.bovinShipments ?? [])
}
</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">Charment 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.licencePlate ?? '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 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-500"
:class="labelClass"
>
<input
type="checkbox"
:checked="checked"
:disabled="disabled"
:class="inputClass"
:class="['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-500"
:class="labelClass"
>
{{ label }}
@@ -14,7 +14,7 @@
: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-black justify-self-start text-xl text-primary-500 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',

View File

@@ -3,7 +3,7 @@
<label
v-if="label"
:for="id"
class="text-xl text-bold flex items-center gap-2"
class="text-xl flex items-center gap-2 text-primary-500"
:class="labelClass"
>
<span
@@ -11,7 +11,8 @@
{{ label }}
</span>
<span
v-if="code" class="text-neutral-600">
v-if="code"
class="text-neutral-600">
({{ code }})
</span>
</label>
@@ -24,7 +25,7 @@
:step="step"
:disabled="disabled"
v-bind="attrs"
class="border-b border-black text-xl bg-transparent w-48"
class="border-b border-black text-xl bg-transparent w-16 text-primary-500"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
disabled ? 'cursor-not-allowed' : 'cursor-text',
@@ -73,14 +74,41 @@ const emit = defineEmits<{
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 numeric = Math.max(0, Number(target.value))
emit('update:modelValue', Number.isNaN(numeric) ? null : numeric)
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) => {

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-500"
: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-500"
: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-slate-300 text-primary-500 focus:ring-primary-500"
: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-500"
:class="labelClass"
>
{{ label }}
@@ -13,7 +13,7 @@
: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-black justify-self-start text-xl text-primary-500 py-[6px] bg-transparent"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer',

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-500"
: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-500"
: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

@@ -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)
@@ -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 à plein' : 'Pesée à vide'))
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

@@ -12,8 +12,27 @@
"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."
},
@@ -37,7 +56,27 @@
"delete": "Impossible de supprimer le bovin."
},
"supplier": {
"list": "Impossible de récupérer la liste des fournisseurs."
"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."
@@ -46,7 +85,10 @@
"list": "Impossible de récupérer la liste des races de bovins."
},
"carrier": {
"list": "Impossible de récupérer la liste des transporteurs."
"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."
@@ -57,16 +99,42 @@
"auth": {
"login": "Identifiants invalides.",
"users": "Impossible de récupérer les utilisateurs.",
"logout": "Impossible de se déconnecter."
"logout": "Impossible de se déconnecter.",
"update": "Impossible de mettre à jour l'utilisateur.",
"create": "Impossible de créer l'utilisateur."
}
},
"success": {
"reception": {
"update": "Réception mise à jour avec succès."
},
"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"
}
}
}

View File

@@ -1,67 +0,0 @@
<template>
<div class="min-h-screen text-neutral-900 grid grid-rows-[85px,1fr]">
<!-- HEADER -->
<header class="bg-primary-500 z-50 h-[85px]">
<div class="h-full w-full px-6 grid grid-cols-[auto,1fr,auto] items-center gap-8">
<NuxtLink to="/" class="grid place-items-center">
<span class="grid place-items-center bg-white text-xl font-bold uppercase text-primary-500 p-4">
LOGO
</span>
</NuxtLink>
<nav class="text-2xl font-bold uppercase text-white"></nav>
<NuxtLink
to="/"
class="text-xl font-bold uppercase text-white transition hover:opacity-80 justify-self-end"
>
Quitter le panel admin
</NuxtLink>
</div>
</header>
<div class="grid grid-cols-[16rem,1fr] h-[calc(100vh-85px)] min-h-0">
<aside class="bg-primary-500 text-white min-h-0 flex flex-col justify-between">
<div class="flex flex-col gap-4 p-4 font-bold text-xl">
<!-- Liste des liens à ajouter ci-dessous -->
<NuxtLink to="/admin/dashboard">
Tableau de bord
</NuxtLink>
<NuxtLink to="/admin/supplier/supplier-list">
Fournisseur
</NuxtLink>
<NuxtLink to="/admin/carrier/carrier-list">
Transporteur
</NuxtLink>
</div>
<div class="p-4">
<button
@click="handleLogout"
class="w-full bg-red-600 hover:bg-red-700 py-2 rounded font-bold"
>
Déconnexion
</button>
</div>
</aside>
<main class="min-h-0 overflow-auto px-12 py-12 ">
<div class="w-full ">
<slot />
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
const handleLogout = async () => {
try {
await auth.logout()
} finally {
await navigateTo('/login')
}
}
</script>

View File

@@ -1,50 +1,165 @@
<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 justify-center px-6 py-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>
<span aria-hidden="true" class="flex items-center">
<Icon name="mdi:menu" size="44"/>
</span>
</button>
<nav class="ml-4 hidden items-center gap-8 text-2xl font-bold uppercase text-white md:flex">
<NuxtLink to="/" custom v-slot="{ href, navigate, isExactActive }">
<!-- Logo -->
<NuxtLink to="/" class="shrink-0">
<span class="flex items-center justify-center bg-white text-xl font-bold uppercase px-6 py-4">
LOGO
</span>
</NuxtLink>
<!-- NAV centré (desktop) -->
<nav
class="hidden md:flex flex-1 items-center justify-center gap-8 text-xl font-bold uppercase text-white"
>
<NuxtLink to="/" custom v-slot="{ href, navigate }">
<a
:href="href"
@click="navigate"
:class="isExactActive ? 'opacity-100' : 'opacity-50'"
:class="route.path === '/'
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Accueil
</a>
</NuxtLink>
<NuxtLink to="/admin/dashboard" 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="route.path.startsWith('/admin/supplier')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Admin
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>
<NuxtLink to="/" class="flex flex-1 items-center justify-center gap-3">
<span
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
>
LOGO
</span>
</NuxtLink>
<!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
<div class="w-[44px] md:hidden"></div>
<button
type="button"
class="ml-auto hidden text-xl font-bold uppercase text-white transition hover:opacity-80 md:inline-flex"
@click="handleLogout"
>
Déconnexion
</button>
<!-- 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"
@@ -59,6 +174,8 @@
@click="closeMenu"
/>
</transition>
<!-- Drawer (mobile) -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="-translate-x-full"
@@ -69,9 +186,7 @@
>
<aside
v-if="isMenuOpen"
class="fixed left-0 top-0 z-50 h-full w-full bg-primary-600 px-6 pb-8 pt-6 text-white shadow-xl md:hidden"
role="dialog"
aria-modal="true"
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>
@@ -84,12 +199,30 @@
<Icon name="mdi:close" size="44"/>
</button>
</div>
<nav class="mt-8 flex flex-col gap-6 text-xl font-bold uppercase">
<NuxtLink to="/" class="opacity-100" @click="closeMenu">Accueil</NuxtLink>
<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-5 text-xl font-bold uppercase"
class="mt-6 text-xl font-bold uppercase"
@click="handleLogout"
>
Déconnexion
@@ -97,37 +230,40 @@
</aside>
</transition>
</header>
<main class="mx-auto w-full max-w-[1280px] pb-0">
<main class="mx-auto w-full max-w-[1280px] mt-16">
<slot/>
</main>
<footer class="w-full mt-8 bg-primary-500 p-6">
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">
<p class="font-bold text-white text-right">v{{ version }}</p>
</footer>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '~/stores/auth'
import {useAuthStore} from '~/stores/auth'
const route = useRoute()
const auth = useAuthStore()
const {version} = useAppVersion()
const isMenuOpen = ref(false)
const { version } = useAppVersion()
const userDisplayName = computed(() => auth.user?.username ?? 'Utilisateur')
const closeMenu = () => {
isMenuOpen.value = false
isMenuOpen.value = false
}
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
isMenuOpen.value = !isMenuOpen.value
}
const handleLogout = async () => {
try {
await auth.logout()
} finally {
closeMenu()
await navigateTo('/login')
}
try {
await auth.logout()
} finally {
closeMenu()
await navigateTo('/login')
}
}
</script>

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="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] hover:opacity-80"
>
<Icon name="mdi:check" size="28" />
{{ isEdit ? 'Modifier' : '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,108 @@
<template>
<form @submit.prevent="validate">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase">
{{ route.params.id ? 'Modifier transporteur' : 'Ajout transporteur' }}
</h1>
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Enregistrer
</UiButton>
</div>
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 py-12">
<UiTextInput
label = "nom du fournisseur"
id="carrier-name"
v-model="form.name"
/>
<UiTextInput
label = "code fournisseur"
id="code-id"
v-model="form.code"
/>
</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:''
})
definePageMeta({
layout: 'default'
})
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

@@ -1,12 +1,14 @@
<template>
<div class="flex items-center justify-between ">
<h1 class="text-3xl font-bold uppercase">listes des transporteurs</h1>
<button
@Click="goToCarrier()"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] "
>Ajouter
</button>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
<NuxtLink
to="/admin/carrier"
class="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
<div class="mt-6 border border-slate-200 mb-16 ">
@@ -37,12 +39,12 @@ import {getCarrierList} from "~/services/carrier";
const carrierList = ref<CarrierData[]>()
const router = useRouter()
const goToCarrier = (id: number|null = null) => {
id !== null ? router.push(`/admin/carrier/${id}`) : router.push(`/admin/carrier`)
const goToCarrier = (id: number) => {
router.push(`/admin/carrier/${id}`)
}
definePageMeta({
layout: 'admin'
layout: 'default'
})
onMounted(async () => {

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="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
type="submit"
:disabled="isLoading || !auth.isAdmin"
>
{{ customerId ? "Sauvegarder" : "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="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="customerId === null || !auth.isAdmin"
@click="goToAddAddress"
>
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"
definePageMeta({layout: "default"})
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,49 @@
<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"
definePageMeta({ layout: "default" })
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,117 @@
<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 gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded-md"
: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"
definePageMeta({ layout: "default" })
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

@@ -1,13 +1,7 @@
<template>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin'
layout: 'default'
})
</script>
<template>
<h1>test</h1>
</template>
<style scoped>
</style>

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">
{{ supplierId ? "Modifications du fournisseur" : "Ajout d'un fournisseur" }}
</h1>
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
type="submit"
:disabled="isLoading || !auth.isAdmin"
>
{{ supplierId ? "Sauvegarder" : "Ajouter" }}
</UiButton>
</div>
<div class="grid grid-cols-2 gap-y-8 gap-x-80 mb-10 py-12">
<UiTextInput id="supplier-name" v-model="form.name" label="Nom du fournisseur" :disabled="!auth.isAdmin"/>
<UiTextInput id="supplier-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin"/>
<UiTextInput id="supplier-phone" v-model="form.phone" label="Téléphone" :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 fournisseur</h2>
<UiButton
type="button"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="supplierId === null || !auth.isAdmin"
@click="goToAddAddress"
>
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 {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
import {useAuthStore} from "~/stores/auth"
definePageMeta({layout: "default"})
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,47 @@
<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";
definePageMeta({ layout: "default" })
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

@@ -1,16 +1,21 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase"> Fournisseurs </h1>
<NuxtLink to="/admin/supplier"
class="flex items-center justify-center text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
<h1 class="text-3xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1>
<NuxtLink
to="/admin/supplier"
class="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded"
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
@click="handleAddClick"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
<div class="mt-6 border border-slate-200 mb-16">
<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-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
class="sticky 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>
@@ -18,57 +23,91 @@
<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">
<template v-if="supplier.addresses?.length">
<div
v-if="!supplier.addresses || supplier.addresses.length === 0"
class="grid 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="addr in supplier.addresses"
:key="addr.id"
class="grid grid-cols-6 hover:bg-slate-50 border-t gap-4 px-4 py-2"
v-for="(address, idx) in supplier.addresses"
:key="address.id ?? `${supplier.id}-${idx}-${address.street}-${address.postalCode}`"
class="grid grid-cols-7 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">
{{ supplier.name }}
{{ idx === 0 ? supplier.name : "" }}
</div>
<div class="truncate">
{{ supplier.email }}
</div>
<div class="truncate">
{{ addr.street }}
</div>
<div class="truncate">
{{ addr.street2 }}
</div>
<div>{{ addr.postalCode }}</div>
<div class="uppercase truncate">
{{ addr.city }}
<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 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-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 type {SupplierData} from "~/services/dto/supplier-data"
import {getSupplierList} from "~/services/supplier"
import { getSupplierList } from "~/services/supplier"
import type { SupplierData } from "~/services/dto/supplier-data"
import { useAuthStore } from "~/stores/auth"
definePageMeta({layout: "admin"})
definePageMeta({ layout: "default" })
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 () => {
supplierList.value = (await getSupplierList(false)) ?? []
if (!auth.isAdmin) return
supplierList.value = await getSupplierList()
})
</script>

View File

@@ -0,0 +1,127 @@
<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="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
type="submit"
>
{{ userId ? 'Sauvegarder' : '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">
definePageMeta({
layout: 'default'
})
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,70 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
<NuxtLink
class="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded-md"
@click="router.push('/admin/user/')"
>
<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">
definePageMeta({
layout: 'default'
})
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,15 +1,27 @@
<script setup lang="ts">
</script>
<template>
<div class="flex flex-wrap justify-center mt-8 gap-8 mb-8 md:mb-0">
<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="/" iconName="mdi:truck-fast-outline" />
<card-link label="PLAN DE SITE" link="/" iconName="mdi:warehouse" />
<card-link label="RÉCEPTIONS EN ATTENTE" link="/reception/waiting-reception" iconName="mdi:truck-remove-outline" />
<card-link label="EXPÉDITIONS EN ATTENTE" link="/" iconName="mdi:truck-cargo-container" />
<card-link label="CASES" link="/" iconName="mdi:cube-outline" />
<card-link label="NOUVELLE EXPÉDITION" link="/shipment" iconName="mdi:truck-fast-outline" />
<card-link label="PLAN DE SITE" link="/" 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="/" 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="/" iconName="mdi:truck-delivery-outline" />
<card-link label="PASSEPORT DU BOVIN" link="/" iconName="mdi:cow" />
<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>

View File

@@ -39,13 +39,13 @@
/>
</div>
<button
<UiButton
type="submit"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isSubmitting"
>
Connexion
</button>
</UiButton>
<p class="font-bold">v{{ version }}</p>
</form>
</div>

View File

@@ -1,29 +1,28 @@
<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 &&
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"/>
<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">

View File

@@ -1,10 +1,10 @@
<template>
<div class="flex items-center justify-start gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" style="color: black" size="44" />
<h1 class="text-3xl font-bold uppercase">listes des réceptions finie</h1>
<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="ps-20 " >
<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>
@@ -20,13 +20,14 @@
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, 'gross') }} | {{ formatWeighing(reception, 'tare') }}</div>
<div>{{ formatWeighing(reception) }}</div>
</div>
</div>
</div>
@@ -35,16 +36,24 @@
<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, type: 'gross' | 'tare') => {
const entry = reception.weights?.find((weight) => weight.type === type)
if (!entry || entry.weight == null || entry.dsd == null) {
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 `${entry.weight} kg`
return `${gross - tare} kg`
}
const goToReception = (id: number) => {
router.push(`/reception/update/${id}`)
}
onMounted(async () => {

View File

@@ -0,0 +1,567 @@
<template>
<form @submit.prevent="validate">
<div class="flex items-center justify-between mt-12 mb-8 ">
<h1 class="font-bold text-5xl uppercase">Réception {{ receptionLoad?.identificationNumber }}</h1>
</div>
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-12">
<!-- 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-1"
/>
<!-- 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-2"
/>
<!-- 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-3"
/>
<!-- 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-1 row-start-4"
/>
<!-- 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-1"
/>
<!-- 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-2"
/>
<!-- 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-3"
/>
<!-- 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 class="flex justify-center mb-2">
<UiButton
v-if="auth.isAdmin"
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] mb-16"
>
Enregistrer
</UiButton>
</div>
<div class="flex justify-evenly gap-y-8 gap-x-40 mb-8 border-b border-slate-400">
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 cursor-pointer"
:class="activeTab === 'weights' ? 'underline' : ''"
@click="activeTab = 'weights'"
>
pesées
</h1>
<h1
class="font-bold text-3xl uppercase col-start-2 row-start-1 cursor-pointer"
:class="activeTab === 'merchandise' ? 'underline' : ''"
@click="activeTab = 'merchandise'"
>
{{ isMerchandise ? "Marchandise" : "Bovins" }}
</h1>
</div>
<update-weight
v-if="activeTab === 'weights'"
:idReception="idReception"
:disabled="!auth.isAdmin"
/>
<update-merchandise
v-else-if="activeTab === 'merchandise' && isMerchandise"
:idReception="idReception"
:disabled="!auth.isAdmin"
/>
<update-bovin
v-else
:idReception="idReception"
:disabled="!auth.isAdmin"
/>
</form>
</template>
<script setup lang="ts">
import {useReceptionStore} from '~/stores/reception'
import type {UserData} from '~/services/dto/user-data'
import {getUsers} from '~/services/auth'
import {useAuthStore} from '~/stores/auth'
import type {SupplierData} from '~/services/dto/supplier-data'
import {getSupplierList} from '~/services/supplier'
import type {TruckData} from '~/services/dto/truck-data'
import {getTruckList} from '~/services/truck'
import type {CarrierData} from '~/services/dto/carrier-data'
import {getCarrierList} from '~/services/carrier'
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 {SUPPLIER_CODE} from "~/utils/constants";
import {deleteReceptionBovine, getReceptionBovineList} from "~/services/reception-bovine";
import type {ReceptionData, ReceptionFormData} from "~/services/dto/reception-data";
import {getReception} from "~/services/reception";
import UpdateWeight from "~/components/reception/update-weight.vue";
import UpdateMerchandise from "~/components/reception/update-merchandise.vue";
import UpdateBovin from "~/components/reception/update-bovin.vue";
const activeTab = ref<'weights' | 'merchandise'>('weights')
const router = useRouter()
const receptionStore = useReceptionStore()
const form = reactive<ReceptionFormData>({
licensePlate: '',
receptionDate: new Date().toISOString().slice(0, 10),
receptionTypeId: '',
userId: '',
supplierId: '',
addressId: '',
truckId: '',
carrierId: '',
driverId: '',
vehicleId: ''
})
const allowAnyLicensePlate = ref(false)
const isLoading = ref(false)
const users = ref<UserData[]>([])
const isLoadingUsers = ref(false)
const suppliers = ref<SupplierData[]>([])
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 authStore = useAuthStore()
// Empêche les watchers de reset des champs pendant le remplissage initial
const isHydrating = ref(false)
const route = useRoute()
const idReception = Number(route.params.id)
const receptionLoad = await getReception(idReception)
const receptionType = receptionLoad.receptionType
const auth = useAuthStore()
const isBtWeight = ref(true)
const isMerchandise = ref(receptionType.code === 'MARCHANDISES')
// Transporteur sélectionné dans le formulaire
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 === SUPPLIER_CODE.LIOT)
// Adresses disponibles pour le fournisseur sélectionné
const supplierAddresses = computed(() => {
const supplierId = Number(form.supplierId)
if (!Number.isFinite(supplierId)) {
return []
}
return suppliers.value.find((supplier) => supplier.id === supplierId)?.addresses ?? []
})
// Chauffeurs filtrés par transporteur (LIOT)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) {
return []
}
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
// Véhicules filtrés par transporteur + type de camion
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)
)
})
// 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)
}
}
const hydrateFromUser = (reception: ReceptionData | null) => {
if (!reception) {
return
}
isHydrating.value = true
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)
: ''
isHydrating.value = false
}
watch(
() => idReception,
async (id) => {
if (id === null) {
return
}
isLoading.value = true
try {
const user = await getReception(id)
hydrateFromUser(user)
} finally {
isLoading.value = false
}
},
{immediate: true}
)
// Charge la liste des users pour le select
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
// Charge la liste des fournisseurs pour le select
const loadSuppliers = async () => {
isLoadingSuppliers.value = true
try {
suppliers.value = await getSupplierList()
} finally {
isLoadingSuppliers.value = false
}
}
// Charge la liste des camions pour le select
const loadTrucks = async () => {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
// Charge la liste des transporteurs pour le select
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
// Charge la liste des chauffeurs pour le select
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
// Charge la liste des véhicules pour le select
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.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)
}
}
// On récupère toutes les données des selects au chargement du composant
onMounted(async () => {
await loadUsers()
await loadSuppliers()
await loadTrucks()
await loadCarriers()
await loadDrivers()
await loadVehicles()
await authStore.ensureSession()
setDefaultUser()
})
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
watch(
() => [form.supplierId, form.addressId, suppliers.value],
() => {
if (!form.supplierId) {
form.addressId = ''
return
}
if (!form.addressId && supplierAddresses.value.length === 1) {
form.addressId = String(supplierAddresses.value[0].id)
return
}
if (!form.addressId) {
return
}
const matches = supplierAddresses.value.some(
(address) => String(address.id) === form.addressId
)
if (!matches) {
if (supplierAddresses.value.length === 1) {
form.addressId = String(supplierAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{immediate: true}
)
// Valide/auto-sélectionne le véhicule selon camion + transporteur (LIOT)
watch(
() => form.carrierId,
() => {
if (isHydrating.value) {
return
}
if (!form.carrierId && idReception == null) {
form.driverId = ''
form.vehicleId = ''
return
}
if (!isLiotCarrier.value && idReception == null) {
form.driverId = ''
form.vehicleId = ''
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
},
{immediate: true}
)
// 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, 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)
}
}
)
// Valide le formulaire et crée/met à jour la réception
async function validate() {
const normalizedLicensePlate = form.licensePlate.trim()
const normalizedReceptionDate = form.receptionDate.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 basePayload = {
licensePlate: normalizedLicensePlate,
receptionDate: normalizedReceptionDate,
user: userIri,
supplier: supplierIri,
address: addressIri,
truck: truckIri,
carrier: carrierIri
}
const payload = {
...basePayload,
...(isLiotCarrier.value && driverIri ? {driver: driverIri} : {})
}
if (idReception) {
const updated = await receptionStore.updateReception(idReception, {
...payload
})
if (updated) {
await router.push(`/reception/update/${updated.id}`)
}
router.push("/reception/finish-reception")
return
}
}
</script>

View File

@@ -1,13 +1,13 @@
<template>
<div class="flex items-center justify-between ">
<div class="flex items-center justify-between">
<div class="flex items-center gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" style="color: black" size="44" />
<h1 class="text-3xl font-bold uppercase">listes des réceptions en attente</h1>
<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="ps-20 " >
<div class="mt-6 border border-slate-200 mb-16 ">
<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>

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,84 @@
<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="formatBovinShipmentLines(shipment).length">
<div
v-for="(line, index) in formatBovinShipmentLines(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 formatBovinShipmentLines = (shipment: ShipmentData) => {
if (!shipment.bovinShipments?.length) {
return []
}
return shipment.bovinShipments.map((entry) => {
const label = typeof entry.shipmentType === 'string'
? entry.shipmentType
: entry.shipmentType?.label
return `${label ?? ''} : ${entry.nbBovinSend ?? ''}`
})
}
const goShipment = (id: number) => {
router.push(`/shipment/update/${id}`)
}
onMounted(async () => {
shipmentList.value = await getShipmentList(true)
})
</script>

View File

@@ -0,0 +1,73 @@
<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?.label }}</div>
<div>{{ shipment.address?.fullAddress }}</div>
<div>
<template v-if="formatBovinShipmentLines(shipment).length">
<div
v-for="(line, index) in formatBovinShipmentLines(shipment)"
:key="index"
class="leading-5"
>
{{ line }}
</div>
</template>
</div>
<div>{{ shipment.carrier?.name }}</div>
<div>{{ shipment.licencePlate }}</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 formatBovinShipmentLines = (shipment: ShipmentData) => {
if (!shipment.bovinShipments?.length) {
return []
}
return shipment.bovinShipments.map((entry) => {
const label = typeof entry.shipmentType === 'string'
? entry.shipmentType
: entry.shipmentType?.label
return `${label ?? ''} : ${entry.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,50 @@
import { useApi } from '~/composables/useApi'
import type { BovinShipmentData } from '~/services/dto/bovin-shipment-data'
import type { ShipmentBovinePayload, BovinShipmentListResponse } from '~/services/dto/bovin-shipment-data'
export async function getBovinShipmentList(
shipmentIri: string
): Promise<BovinShipmentData[]> {
const api = useApi()
const response = await api.get<BovinShipmentListResponse>(
'bovin_shipments',
{ shipment: shipmentIri },
{
toastErrorKey: 'errors.shipmentBovine.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 createShipmentBovine(
payload: ShipmentBovinePayload
): Promise<BovinShipmentData> {
const api = useApi()
return api.post<BovinShipmentData>('bovin_shipments', payload, {
toastErrorKey: 'errors.shipmentBovine.create'
})
}
export async function deleteShipmentBovine(id: number): Promise<void> {
const api = useApi()
await api.delete<void>(`bovin_shipments/${id}`, {}, {
toastErrorKey: 'errors.shipmentBovine.delete'
})
}
export async function updateShipmentBovine(
id: number,
payload: Partial<ShipmentBovinePayload>
): Promise<BovinShipmentData> {
const api = useApi()
return api.patch<BovinShipmentData>(`bovin_shipments/${id}`, payload, {
toastErrorKey: 'errors.shipmentBovine.update'
})
}

View File

@@ -1,5 +1,5 @@
import { useApi } from '~/composables/useApi'
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
import type { BovineTypeData, BovinPayload } from "~/services/dto/bovine-type-data";
export type BovineTypeListResponse =
| BovineTypeData[]
@@ -12,12 +12,41 @@ export async function getBovineTypeList(): Promise<BovineTypeData[]> {
})
if (Array.isArray(response)) {
return response
return response.map(mapToBovineTypeData)
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return 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,18 @@
import type {ShipmentTypeData} from "~/services/dto/shipment-type-data";
export interface BovinShipmentData {
id: number
nbBovinSend: number | null
shipment?: string | null
shipmentType?: ShipmentTypeData | null
}
export type ShipmentBovinePayload = {
nbBovinSend: number
shipment: string
shipmentType: string
}
export type BovinShipmentListResponse =
| BovinShipmentData[]
| { 'hydra:member'?: BovinShipmentData[] }

View File

@@ -3,3 +3,13 @@ export interface BovineTypeData{
label: string
code: string
}
export interface BovinFormData {
label: string
code: string
}
export type BovinPayload = {
label?: string | null
code?: string | 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

@@ -41,6 +41,14 @@ export interface WeightEntryData {
weighedAt: string | null
}
export interface WeightFormData {
id: number
weight: number
type: 'gross' | 'tare'
}
export type ReceptionPayload = {
licensePlate?: string | null
receptionDate?: string
@@ -59,3 +67,27 @@ export type ReceptionPayload = {
carrier?: string | null
driver?: string | null
}
export type ReceptionFormData = {
licensePlate: string
receptionDate: string
receptionTypeId: string
userId: string
supplierId: string
addressId: string
truckId: string
carrierId: string
driverId: string
vehicleId: string
}
export type ReceptionFormWeight = {
weights: WeightFormData[]
}
export interface ReceptionUpdatePayload {
weights: {
id: number
weight: number
}[]
}

View File

@@ -0,0 +1,67 @@
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";
export interface ShipmentTypeData {
id: number
label: string
code: string
}
export interface BovinShipmentData {
id?: number
shipmentType?: ShipmentTypeData | string | null
nbBovinSend: number | null
}
export type ShipmentData = {
id: number
identificationNumber?: string | null
licencePlate: string | null
shipmentDate: string
currentStep: number
isValid: boolean
address?: AddressData | null
carrier?: CarrierData | null
truck?: TruckData | null
customer?: CustomerData | null
bovinShipments?: BovinShipmentData[] | 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,
licencePlate: string,
}
export type ShipmentPayload = {
licencePlate?: string | null
shipmentDate?: string
currentStep?: number
isValid?: boolean
carrier?: string | null
truck?: string | null
customer?: string | null
bovinShipments?: string[] | null
address?: string | null
user?: string | null
driver?: string | 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,5 @@ export interface WeightData {
weight: number | null
dsd: number | null
weighedAt: string | null
type : string | null
}

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

@@ -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,16 +8,7 @@ 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'
500: '#456452',
}
}
}

View File

@@ -8,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,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');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260204102423 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE shipment DROP CONSTRAINT fk_2cb20dc545317d1');
$this->addSql('DROP INDEX idx_2cb20dc545317d1');
$this->addSql('ALTER TABLE shipment DROP vehicle_id');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE shipment ADD vehicle_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE shipment ADD CONSTRAINT fk_2cb20dc545317d1 FOREIGN KEY (vehicle_id) REFERENCES vehicle (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_2cb20dc545317d1 ON shipment (vehicle_id)');
}
}

View File

@@ -0,0 +1,49 @@
<?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 Version20260211075656 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 UNIQUE INDEX uniq_bovin_shipment ON bovin_shipment (shipment_id, shipment_type_id)');
$this->addSql('ALTER TABLE shipment ADD user_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE shipment ADD driver_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE shipment ADD address_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE shipment ADD CONSTRAINT FK_2CB20DCA76ED395 FOREIGN KEY (user_id) REFERENCES public."user" (id) NOT DEFERRABLE');
$this->addSql('ALTER TABLE shipment ADD CONSTRAINT FK_2CB20DCC3423909 FOREIGN KEY (driver_id) REFERENCES driver (id) NOT DEFERRABLE');
$this->addSql('ALTER TABLE shipment ADD CONSTRAINT FK_2CB20DCF5B7AF75 FOREIGN KEY (address_id) REFERENCES address (id) NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_2CB20DCA76ED395 ON shipment (user_id)');
$this->addSql('CREATE INDEX IDX_2CB20DCC3423909 ON shipment (driver_id)');
$this->addSql('CREATE INDEX IDX_2CB20DCF5B7AF75 ON shipment (address_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP INDEX uniq_bovin_shipment');
$this->addSql('ALTER TABLE shipment DROP CONSTRAINT FK_2CB20DCA76ED395');
$this->addSql('ALTER TABLE shipment DROP CONSTRAINT FK_2CB20DCC3423909');
$this->addSql('ALTER TABLE shipment DROP CONSTRAINT FK_2CB20DCF5B7AF75');
$this->addSql('DROP INDEX IDX_2CB20DCA76ED395');
$this->addSql('DROP INDEX IDX_2CB20DCC3423909');
$this->addSql('DROP INDEX IDX_2CB20DCF5B7AF75');
$this->addSql('ALTER TABLE shipment DROP user_id');
$this->addSql('ALTER TABLE shipment DROP driver_id');
$this->addSql('ALTER TABLE shipment DROP address_id');
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260211123000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Allow weight to belong to reception or shipment.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE weight ALTER COLUMN reception_id DROP NOT NULL');
$this->addSql('ALTER TABLE weight ADD shipment_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE weight ADD CONSTRAINT FK_WEIGHT_SHIPMENT FOREIGN KEY (shipment_id) REFERENCES shipment (id) NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_WEIGHT_SHIPMENT ON weight (shipment_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_weight_reception_type ON weight (reception_id, type)');
$this->addSql('CREATE UNIQUE INDEX uniq_weight_shipment_type ON weight (shipment_id, type)');
$this->addSql('ALTER TABLE weight ADD CONSTRAINT chk_weight_reception_or_shipment CHECK ((reception_id IS NOT NULL AND shipment_id IS NULL) OR (reception_id IS NULL AND shipment_id IS NOT NULL))');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE weight DROP CONSTRAINT chk_weight_reception_or_shipment');
$this->addSql('DROP INDEX uniq_weight_shipment_type');
$this->addSql('DROP INDEX uniq_weight_reception_type');
$this->addSql('DROP INDEX IDX_WEIGHT_SHIPMENT');
$this->addSql('ALTER TABLE weight DROP CONSTRAINT FK_WEIGHT_SHIPMENT');
$this->addSql('ALTER TABLE weight DROP shipment_id');
$this->addSql('ALTER TABLE weight ALTER COLUMN reception_id SET NOT NULL');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260213093000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add name, phone and email fields to customer.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE customer ADD name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE customer ADD phone VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE customer ADD email VARCHAR(255) DEFAULT NULL');
$this->addSql('UPDATE customer SET name = label WHERE name IS NULL');
$this->addSql('ALTER TABLE customer ALTER COLUMN name SET NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE customer DROP name');
$this->addSql('ALTER TABLE customer DROP phone');
$this->addSql('ALTER TABLE customer DROP email');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260213101500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Align customer with supplier: keep name/email/phone and drop label/code.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE customer ALTER COLUMN name TYPE VARCHAR(180)');
$this->addSql('ALTER TABLE customer ALTER COLUMN email TYPE VARCHAR(180)');
$this->addSql('ALTER TABLE customer ALTER COLUMN phone TYPE VARCHAR(40)');
$this->addSql('ALTER TABLE customer DROP COLUMN label');
$this->addSql('ALTER TABLE customer DROP COLUMN code');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE customer ADD label VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE customer ADD code VARCHAR(255) DEFAULT NULL');
$this->addSql('UPDATE customer SET label = name WHERE label IS NULL');
$this->addSql("UPDATE customer SET code = regexp_replace(upper(name), '[^A-Z0-9]+', '_', 'g') WHERE code IS NULL");
$this->addSql('ALTER TABLE customer ALTER COLUMN label SET NOT NULL');
$this->addSql('ALTER TABLE customer ALTER COLUMN code SET NOT NULL');
$this->addSql('ALTER TABLE customer ALTER COLUMN email TYPE VARCHAR(255)');
$this->addSql('ALTER TABLE customer ALTER COLUMN phone TYPE VARCHAR(255)');
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260213114000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Allow only one bovin_shipment row per shipment.';
}
public function up(Schema $schema): void
{
// Keep one row per shipment (latest id), required before adding unique index.
$this->addSql(<<<'SQL'
DELETE FROM bovin_shipment bs
USING (
SELECT id, ROW_NUMBER() OVER (PARTITION BY shipment_id ORDER BY id DESC) AS rn
FROM bovin_shipment
WHERE shipment_id IS NOT NULL
) d
WHERE bs.id = d.id
AND d.rn > 1
SQL);
$this->addSql('DROP INDEX IF EXISTS uniq_bovin_shipment');
$this->addSql('CREATE UNIQUE INDEX uniq_bovin_shipment_one_type ON bovin_shipment (shipment_id)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS uniq_bovin_shipment_one_type');
$this->addSql('CREATE UNIQUE INDEX uniq_bovin_shipment ON bovin_shipment (shipment_id, shipment_type_id)');
}
}

View File

@@ -5,12 +5,15 @@ declare(strict_types=1);
namespace App\Command;
use App\Entity\Address;
use App\Entity\BovineType;
use App\Entity\Building;
use App\Entity\Carrier;
use App\Entity\Customer;
use App\Entity\Driver;
use App\Entity\MerchandiseType;
use App\Entity\PelletType;
use App\Entity\ReceptionType;
use App\Entity\ShipmentType;
use App\Entity\Supplier;
use App\Entity\Truck;
use App\Entity\Vehicle;
@@ -50,7 +53,11 @@ class SeedCommand extends Command
$this->seedPelletTypes();
$this->seedBuildings();
$this->seedReceptionTypes();
$this->seedBovineTypes();
$this->seedShipmentTypes();
$this->seedSuppliers();
$this->entityManager->flush();
$this->seedCustomers($io);
$this->entityManager->flush();
@@ -61,7 +68,7 @@ class SeedCommand extends Command
private function seedTrucks(): array
{
$trucks = ['Citerne', 'Porteur'];
$trucks = ['Citerne', 'Porteur', 'Plateau', 'Remorque', 'Benne'];
$citerne = null;
$porteur = null;
foreach ($trucks as $name) {
@@ -161,6 +168,7 @@ class SeedCommand extends Command
['label' => 'Foin', 'code' => 'FOIN'],
['label' => 'Paille', 'code' => 'PAILLE'],
['label' => 'Granule', 'code' => 'GRANULE'],
['label' => 'Autres', 'code' => 'AUTRES'],
];
foreach ($merchandiseTypes as $type) {
$this->upsertByCode(MerchandiseType::class, $type['code'], static function (MerchandiseType $entity) use ($type) {
@@ -223,6 +231,39 @@ class SeedCommand extends Command
}
}
private function seedBovineTypes(): void
{
$bovineTypes = [
['label' => 'Limousine', 'code' => '34'],
['label' => 'Charolaise', 'code' => '38'],
['label' => 'Parthenaise', 'code' => '71'],
];
foreach ($bovineTypes as $type) {
$this->upsertByCode(BovineType::class, $type['code'], static function (BovineType $entity) use ($type) {
$entity
->setLabel($type['label'])
->setCode($type['code'])
;
});
}
}
private function seedShipmentTypes(): void
{
$shipmentTypes = [
['label' => 'Boucherie', 'code' => 'BDB'],
['label' => 'Équarrissage', 'code' => 'BE'],
];
foreach ($shipmentTypes as $type) {
$this->upsertByCode(ShipmentType::class, $type['code'], static function (ShipmentType $entity) use ($type) {
$entity
->setLabel($type['label'])
->setCode($type['code'])
;
});
}
}
private function seedSuppliers(): void
{
$suppliers = [
@@ -458,6 +499,130 @@ class SeedCommand extends Command
}
}
private function seedCustomers(SymfonyStyle $io): void
{
$addressRepo = $this->entityManager->getRepository(Address::class);
$customers = [
[
'name' => 'ARNAULT EURL',
'phone' => '05.49.02.65.27',
'email' => 'eurl.arnault86@orange.fr',
'addresses' => [
[
'label' => 'ARNAULT EURL',
'street' => 'Moulin du Guéret',
'street2' => 'B.P 30425',
'postalCode' => '86100',
'city' => 'Antran',
'countryCode' => 'FR',
],
],
],
[
'name' => 'COVILIM',
'phone' => '05.55.30.03.10',
'email' => 'sandra.robineaux@covilim.com',
'addresses' => [
[
'label' => 'COVILIM',
'street' => 'Rue de Nexon',
'street2' => null,
'postalCode' => '87000',
'city' => 'LIMOGES',
'countryCode' => 'FR',
],
],
],
[
'name' => 'Les producteurs de la marche (LPM)',
'phone' => '05.55.63.04.53',
'email' => 'f.legalliard@lpmcoop.fr',
'addresses' => [
[
'label' => 'Les producteurs de la marche (LPM)',
'street' => 'Malonze',
'street2' => null,
'postalCode' => '23300',
'city' => 'LA SOUTERRAINE',
'countryCode' => 'FR',
],
],
],
[
'name' => 'LORTHOLARY BETAIL',
'phone' => '05.49.52.77.10',
'email' => 'contact86@lortholarybetail.com',
'addresses' => [
[
'label' => 'LORTHOLARY BETAIL',
'street' => 'FERME DE GENIEC',
'street2' => null,
'postalCode' => '86550',
'city' => 'MIGNALOUX BEAUVOIR',
'countryCode' => 'FR',
],
],
],
[
'name' => 'TERRENA',
'phone' => '02.40.98.90.00',
'email' => 'scouillaud@terrena.fr',
'addresses' => [
[
'label' => 'TERRENA',
'street' => 'LA NOELLE',
'street2' => 'BP 20199',
'postalCode' => '44155',
'city' => 'ANCENIS CEDEX',
'countryCode' => 'FR',
],
],
],
];
foreach ($customers as $customerData) {
$customerName = $customerData['name'] ?? $customerData['label'] ?? null;
if (!$customerName) {
$io->warning('Customer skipped: missing "name".');
continue;
}
$customer = $this->upsertByName(Customer::class, $customerName, static function (Customer $customer) use ($customerData, $customerName) {
$customer
->setName($customerName)
->setPhone($customerData['phone'] ?? null)
->setEmail($customerData['email'] ?? null)
;
});
$addresses = [];
if (isset($customerData['addresses']) && is_array($customerData['addresses'])) {
foreach ($customerData['addresses'] as $addressData) {
$addresses[] = $this->upsertAddress($addressData);
}
} else {
// Backward compatibility for older seed format with address ids.
$addressIds = $customerData['addressIds'] ?? (isset($customerData['addressId']) ? [$customerData['addressId']] : []);
foreach ($addressIds as $addressId) {
$address = $addressRepo->find($addressId);
if (!$address instanceof Address) {
$io->warning(sprintf(
'Customer "%s" skipped address id %d: not found.',
$customerName,
$addressId
));
continue;
}
$addresses[] = $address;
}
}
$customer->setAddresses($addresses);
$this->entityManager->persist($customer);
}
}
private function upsertByCode(string $entityClass, string $code, callable $apply): object
{
$repo = $this->entityManager->getRepository($entityClass);

View File

@@ -20,7 +20,6 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
return [
TransportFixtures::class,
ReferenceFixtures::class,
SupplierFixtures::class,
UserFixtures::class,
];
}

View File

@@ -5,10 +5,13 @@ declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Address;
use App\Entity\BovineType;
use App\Entity\Building;
use App\Entity\Customer;
use App\Entity\MerchandiseType;
use App\Entity\PelletType;
use App\Entity\ReceptionType;
use App\Entity\ShipmentType;
use App\Entity\Supplier;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
@@ -17,10 +20,13 @@ class ReferenceFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$addressIndex = [];
$merchandiseTypes = [
['label' => 'Foin', 'code' => 'FOIN'],
['label' => 'Paille', 'code' => 'PAILLE'],
['label' => 'Granule', 'code' => 'GRANULE'],
['label' => 'Autres', 'code' => 'AUTRES'],
];
foreach ($merchandiseTypes as $type) {
$merchandiseType = new MerchandiseType()
@@ -69,6 +75,31 @@ class ReferenceFixtures extends Fixture
$manager->persist($receptionType);
}
$bovineTypes = [
['label' => 'Limousine', 'code' => '34'],
['label' => 'Charolaise', 'code' => '38'],
['label' => 'Parthenaise', 'code' => '71'],
];
foreach ($bovineTypes as $type) {
$bovineType = new BovineType()
->setLabel($type['label'])
->setCode($type['code'])
;
$manager->persist($bovineType);
}
$shipmentTypes = [
['label' => 'Bovin de boucherie', 'code' => 'BDB'],
['label' => "Bovin d'équarrissage", 'code' => 'BE'],
];
foreach ($shipmentTypes as $type) {
$shipmentType = new ShipmentType()
->setLabel($type['label'])
->setCode($type['code'])
;
$manager->persist($shipmentType);
}
$suppliers = [
[
'name' => 'LIOT',
@@ -290,21 +321,129 @@ class ReferenceFixtures extends Fixture
;
foreach ($supplierData['addresses'] as $addressData) {
$address = new Address()
->setLabel($addressData['label'])
->setStreet($addressData['street'])
->setStreet2($addressData['street2'])
->setPostalCode($addressData['postalCode'])
->setCity($addressData['city'])
->setCountryCode($addressData['countryCode'])
;
$manager->persist($address);
$addressKey = sprintf('%s|%s', $addressData['label'], $addressData['postalCode']);
if (!isset($addressIndex[$addressKey])) {
$addressIndex[$addressKey] = new Address()
->setLabel($addressData['label'])
->setStreet($addressData['street'])
->setStreet2($addressData['street2'])
->setPostalCode($addressData['postalCode'])
->setCity($addressData['city'])
->setCountryCode($addressData['countryCode'])
;
$manager->persist($addressIndex[$addressKey]);
}
$address = $addressIndex[$addressKey];
$supplier->getAddresses()->add($address);
}
$manager->persist($supplier);
}
$customers = [
[
'name' => 'ARNAULT EURL',
'phone' => '05.49.02.65.27',
'email' => 'eurl.arnault86@orange.fr',
'addresses' => [
[
'label' => 'ARNAULT EURL',
'street' => 'Moulin du Guéret',
'street2' => 'B.P 30425',
'postalCode' => '86100',
'city' => 'Antran',
'countryCode' => 'FR',
],
],
],
[
'name' => 'COVILIM',
'phone' => '05.55.30.03.10',
'email' => 'sandra.robineaux@covilim.com',
'addresses' => [
[
'label' => 'COVILIM',
'street' => 'Rue de Nexon',
'street2' => null,
'postalCode' => '87000',
'city' => 'LIMOGES',
'countryCode' => 'FR',
],
],
],
[
'name' => 'Les producteurs de la marche (LPM)',
'phone' => '05.55.63.04.53',
'email' => 'f.legalliard@lpmcoop.fr',
'addresses' => [
[
'label' => 'Les producteurs de la marche (LPM)',
'street' => 'Malonze',
'street2' => null,
'postalCode' => '23300',
'city' => 'LA SOUTERRAINE',
'countryCode' => 'FR',
],
],
],
[
'name' => 'LORTHOLARY BETAIL',
'phone' => '05.49.52.77.10',
'email' => 'contact86@lortholarybetail.com',
'addresses' => [
[
'label' => 'LORTHOLARY BETAIL',
'street' => 'FERME DE GENIEC',
'street2' => null,
'postalCode' => '86550',
'city' => 'MIGNALOUX BEAUVOIR',
'countryCode' => 'FR',
],
],
],
[
'name' => 'TERRENA',
'phone' => '02.40.98.90.00',
'email' => 'scouillaud@terrena.fr',
'addresses' => [
[
'label' => 'TERRENA',
'street' => 'LA NOELLE',
'street2' => 'BP 20199',
'postalCode' => '44155',
'city' => 'ANCENIS CEDEX',
'countryCode' => 'FR',
],
],
],
];
foreach ($customers as $customerData) {
$customer = new Customer()
->setName($customerData['name'])
->setPhone($customerData['phone'])
->setEmail($customerData['email'])
;
foreach ($customerData['addresses'] as $addressData) {
$addressKey = sprintf('%s|%s', $addressData['label'], $addressData['postalCode']);
if (!isset($addressIndex[$addressKey])) {
$addressIndex[$addressKey] = new Address()
->setLabel($addressData['label'])
->setStreet($addressData['street'])
->setStreet2($addressData['street2'])
->setPostalCode($addressData['postalCode'])
->setCity($addressData['city'])
->setCountryCode($addressData['countryCode'])
;
$manager->persist($addressIndex[$addressKey]);
}
$customer->getAddresses()->add($addressIndex[$addressKey]);
}
$manager->persist($customer);
}
$manager->flush();
}
}

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Address;
use App\Entity\Supplier;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class SupplierFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$address = new Address()
->setLabel('LIOT CHATELLERAULT')
->setStreet("14 Allée d'Argenson")
->setStreet2('ZI Nord')
->setPostalCode('86100')
->setCity('CHATELLERAULT')
->setCountryCode('FR')
;
$supplier = new Supplier()
->setName('LIOT')
->setEmail('lpc.contacts@lpc-liot.fr')
->setPhone('05.49.20.09.10')
;
$supplier->getAddresses()->add($address);
$manager->persist($address);
$manager->persist($supplier);
$manager->flush();
}
}

View File

@@ -15,11 +15,17 @@ class TransportFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$citerne = new Truck()->setName('Citerne');
$porteur = new Truck()->setName('Porteur');
$citerne = new Truck()->setName('Citerne');
$porteur = new Truck()->setName('Porteur');
$plateau = new Truck()->setName('Plateau');
$remorque = new Truck()->setName('Remorque');
$benne = new Truck()->setName('Benne');
$manager->persist($citerne);
$manager->persist($porteur);
$manager->persist($plateau);
$manager->persist($remorque);
$manager->persist($benne);
$liot = new Carrier()
->setName('LIOT')

View File

@@ -12,11 +12,11 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
final readonly class PontBasculeReading
{
public function __construct(
#[Groups(['reception:weigh:read'])]
#[Groups(['reception:weigh:read', 'shipment:weigh:read'])]
private ?int $dsd,
#[Groups(['reception:weigh:read'])]
#[Groups(['reception:weigh:read', 'shipment:weigh:read'])]
private ?float $weight,
#[Groups(['reception:weigh:read'])]
#[Groups(['reception:weigh:read', 'shipment:weigh:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $weighedAt = null,
) {}

View File

@@ -7,6 +7,8 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -23,6 +25,16 @@ use Symfony\Component\Serializer\Attribute\Groups;
new GetCollection(
normalizationContext: ['groups' => ['address:read']],
),
new Post(
normalizationContext: ['groups' => ['address:read']],
denormalizationContext: ['groups' => ['address:write']],
security: "is_granted('ROLE_ADMIN')",
),
new Patch(
normalizationContext: ['groups' => ['address:read']],
denormalizationContext: ['groups' => ['address:write']],
security: "is_granted('ROLE_ADMIN')",
),
],
security: "is_granted('ROLE_USER')",
)]
@@ -31,31 +43,31 @@ class Address
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['address:read', 'supplier:read'])]
#[Groups(['address:read', 'supplier:read', 'customer:read', 'shipment:read'])]
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Groups(['address:read', 'supplier:read', 'reception:read'])]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])]
private string $label = '';
#[ORM\Column(length: 180)]
#[Groups(['address:read', 'supplier:read', 'reception:read'])]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])]
private string $street = '';
#[ORM\Column(name: 'street2', length: 180, nullable: true)]
#[Groups(['address:read', 'supplier:read', 'reception:read'])]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])]
private ?string $street2 = null;
#[ORM\Column(name: 'postal_code', length: 20)]
#[Groups(['address:read', 'supplier:read', 'reception:read'])]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])]
private string $postalCode = '';
#[ORM\Column(length: 120)]
#[Groups(['address:read', 'supplier:read', 'reception:read'])]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])]
private string $city = '';
#[ORM\Column(name: 'country_code', length: 2)]
#[Groups(['address:read', 'supplier:read'])]
#[Groups(['address:read', 'supplier:read', 'customer:read', 'address:write'])]
private string $countryCode = '';
/**
@@ -64,9 +76,16 @@ class Address
#[ORM\ManyToMany(targetEntity: Supplier::class, mappedBy: 'addresses')]
private Collection $suppliers;
/**
* @var Collection<int, Shipment>
*/
#[ORM\OneToMany(targetEntity: Shipment::class, mappedBy: 'address')]
private Collection $shipments;
public function __construct()
{
$this->suppliers = new ArrayCollection();
$this->shipments = new ArrayCollection();
}
public function getId(): ?int
@@ -146,7 +165,7 @@ class Address
return $this;
}
#[Groups(['address:read', 'supplier:read', 'reception:read'])]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'shipment:read', 'customer:read'])]
public function getFullAddress(): string
{
$parts = array_filter([
@@ -165,4 +184,34 @@ class Address
{
return $this->suppliers;
}
/**
* @return Collection<int, Shipment>
*/
public function getShipments(): Collection
{
return $this->shipments;
}
public function addShipment(Shipment $shipment): static
{
if (!$this->shipments->contains($shipment)) {
$this->shipments->add($shipment);
$shipment->setAddress($this);
}
return $this;
}
public function removeShipment(Shipment $shipment): static
{
if ($this->shipments->removeElement($shipment)) {
// set the owning side to null (unless already changed)
if ($shipment->getAddress() === $this) {
$shipment->setAddress(null);
}
}
return $this;
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ApiFilter(SearchFilter::class, properties: ['shipment' => 'exact'])]
#[ORM\UniqueConstraint(name: 'uniq_bovin_shipment_one_type', columns: ['shipment_id'])]
#[ORM\Table(name: 'bovin_shipment')]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['shipment-bovine:read']],
),
new GetCollection(
normalizationContext: ['groups' => ['shipment-bovine:read']],
),
new Post(
normalizationContext: ['groups' => ['shipment-bovine:read']],
denormalizationContext: ['groups' => ['shipment-bovine:write']],
),
new Patch(
normalizationContext: ['groups' => ['shipment-bovine:read']],
denormalizationContext: ['groups' => ['shipment-bovine:write']],
),
new Delete(),
],
security: "is_granted('ROLE_USER')",
)]
class BovinShipment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['shipment:read', 'shipment-bovine:read'])]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'bovinShipments')]
#[Groups(['shipment-bovine:read', 'shipment-bovine:write'])]
#[ApiProperty(readableLink: true)]
private ?Shipment $shipment = null;
#[ORM\ManyToOne]
#[Groups(['shipment:read', 'shipment-bovine:write', 'shipment-bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?ShipmentType $shipmentType = null;
#[ORM\Column]
#[Groups(['shipment:read', 'shipment-bovine:write', 'shipment-bovine:read'])]
private ?int $nbBovinSend = null;
public function getId(): ?int
{
return $this->id;
}
public function getShipment(): ?Shipment
{
return $this->shipment;
}
public function setShipment(?Shipment $shipment): void
{
$this->shipment = $shipment;
}
public function getShipmentType(): ?ShipmentType
{
return $this->shipmentType;
}
public function setShipmentType(?ShipmentType $shipmentType): void
{
$this->shipmentType = $shipmentType;
}
public function getNbBovinSend(): ?int
{
return $this->nbBovinSend;
}
public function setNbBovinSend(?int $nbBovinSend): void
{
$this->nbBovinSend = $nbBovinSend;
}
}

View File

@@ -7,6 +7,8 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -20,6 +22,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
new GetCollection(
normalizationContext: ['groups' => ['bovine-type:read']],
),
new Post(
normalizationContext: ['groups' => ['bovine-type:read']],
denormalizationContext: ['groups' => ['bovine-type:write']],
security: "is_granted('ROLE_ADMIN')",
),
new Patch(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['bovine-type:read']],
denormalizationContext: ['groups' => ['bovine-type:write']],
security: "is_granted('ROLE_ADMIN')",
),
],
security: "is_granted('ROLE_USER')",
)]
@@ -32,11 +45,11 @@ class BovineType
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Groups(['bovine-type:read', 'reception:read', 'reception-bovine:read'])]
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
private ?string $label = null;
#[ORM\Column(length: 50)]
#[Groups(['bovine-type:read', 'reception:read', 'reception-bovine:read'])]
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
private ?string $code = null;
public function getId(): ?int

View File

@@ -7,6 +7,8 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -17,9 +19,22 @@ use Symfony\Component\Serializer\Attribute\Groups;
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['carrier:read']],
security: "is_granted('ROLE_USER')"
),
new GetCollection(
normalizationContext: ['groups' => ['carrier:read']],
security: "is_granted('ROLE_USER')"
),
new Post(
normalizationContext: ['groups' => ['carrier:read']],
denormalizationContext: ['groups' => ['carrier:write']],
security: "is_granted('ROLE_ADMIN')"
),
new Patch(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['carrier:read']],
denormalizationContext: ['groups' => ['carrier:write']],
security: "is_granted('ROLE_ADMIN')"
),
],
security: "is_granted('ROLE_USER')",
@@ -29,15 +44,15 @@ class Carrier
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['carrier:read', 'driver:read', 'vehicle:read', 'reception:read'])]
#[Groups(['carrier:read', 'driver:read', 'vehicle:read', 'reception:read', 'shipment:read'])]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Groups(['carrier:read', 'driver:read', 'vehicle:read', 'reception:read'])]
#[Groups(['carrier:read', 'carrier:write', 'driver:read', 'vehicle:read', 'reception:read', 'shipment:read'])]
private string $name = '';
#[ORM\Column(length: 30, nullable: true)]
#[Groups(['carrier:read', 'driver:read', 'vehicle:read', 'reception:read'])]
#[Groups(['carrier:read', 'carrier:write', 'driver:read', 'vehicle:read', 'reception:read', 'shipment:read'])]
private ?string $code = null;
public function getId(): ?int

147
src/Entity/Customer.php Normal file
View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ORM\Table(name: 'customer')]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['customer:read']],
),
new GetCollection(
normalizationContext: ['groups' => ['customer:read']],
),
new Post(
normalizationContext: ['groups' => ['customer:read']],
denormalizationContext: ['groups' => ['customer:write']],
security: "is_granted('ROLE_ADMIN')",
),
new Patch(
normalizationContext: ['groups' => ['customer:read']],
denormalizationContext: ['groups' => ['customer:write']],
security: "is_granted('ROLE_ADMIN')",
),
],
security: "is_granted('ROLE_USER')",
)]
class Customer
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['shipment:read', 'customer:read'])]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Groups(['customer:read', 'customer:write', 'shipment:read'])]
private string $name = '';
#[ORM\Column(length: 180, nullable: true)]
#[Groups(['customer:read', 'customer:write', 'shipment:read'])]
private ?string $email = null;
#[ORM\Column(length: 40, nullable: true)]
#[Groups(['customer:read', 'customer:write', 'shipment:read'])]
private ?string $phone = null;
/**
* @var Collection<int, Address>
*/
#[ORM\ManyToMany(targetEntity: Address::class, inversedBy: 'customers')]
#[ORM\JoinTable(name: 'customer_address')]
#[Groups(['customer:read', 'customer:write'])]
#[ApiProperty(readableLink: true)]
private Collection $addresses;
public function __construct()
{
$this->addresses = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): self
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): self
{
$this->phone = $phone;
return $this;
}
public function getAddresses(): Collection
{
return $this->addresses;
}
public function setAddresses(iterable $addresses): self
{
$this->addresses->clear();
foreach ($addresses as $address) {
$this->addAddress($address);
}
return $this;
}
public function addAddress(Address $address): self
{
if (!$this->addresses->contains($address)) {
$this->addresses->add($address);
}
return $this;
}
public function removeAddress(Address $address): self
{
$this->addresses->removeElement($address);
return $this;
}
}

View File

@@ -30,11 +30,11 @@ class Driver
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['driver:read', 'reception:read'])]
#[Groups(['driver:read', 'reception:read', 'shipment:read'])]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Groups(['driver:read', 'reception:read'])]
#[Groups(['driver:read', 'reception:read', 'shipment:read'])]
private string $name = '';
#[ORM\ManyToOne]

369
src/Entity/Shipment.php Normal file
View File

@@ -0,0 +1,369 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\Dto\PontBasculeReading;
use App\State\ShipmentReceiptProvider;
use App\State\ShipmentWeighingProvider;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'shipment')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['shipment:read']],
),
new GetCollection(
normalizationContext: ['groups' => ['shipment:read']],
),
new Post(
normalizationContext: ['groups' => ['shipment:read']],
denormalizationContext: ['groups' => ['shipment:write']],
),
new Patch(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['shipment:read']],
denormalizationContext: ['groups' => ['shipment:write']],
),
new Get(
uriTemplate: '/shipments/weigh',
openapi: new OpenApiOperation(
summary: 'Fetch the current weight reading',
description: 'Queries the pont-bascule and returns the weight data.',
),
normalizationContext: ['groups' => ['shipment:weigh:read']],
output: PontBasculeReading::class,
provider: ShipmentWeighingProvider::class,
),
new Get(
uriTemplate: '/shipments/{id}/receipt',
requirements: ['id' => '\d+'],
openapi: new OpenApiOperation(
summary: 'Render a shipment receipt',
description: 'Returns a PDF receipt for the shipment.',
),
output: false,
provider: ShipmentReceiptProvider::class,
),
],
security: "is_granted('ROLE_USER')",
)]
class Shipment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['shipment:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['shipment:read', 'shipment:write'])]
private ?string $licencePlate = null;
#[ORM\Column(length: 20, unique: true, nullable: true)]
#[Groups(['shipment:read'])]
private ?string $identificationNumber = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['shipment:read', 'shipment:write'])]
private int $currentStep = 0;
#[ORM\Column]
#[Groups(['shipment:read', 'shipment:write'])]
private bool $isValid = false;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
#[Groups(['shipment:read', 'shipment:write'])]
#[ApiProperty(readableLink: true)]
private ?User $user = null;
#[ORM\Column(name: 'shipment_date', type: 'datetime_immutable')]
#[Groups(['shipment:read', 'shipment:write'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $shipmentDate = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
#[Groups(['shipment:read', 'shipment:write'])]
#[ApiProperty(readableLink: true)]
private ?Carrier $carrier = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
#[Groups(['shipment:read', 'shipment:write'])]
#[ApiProperty(readableLink: true)]
private ?Truck $truck = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
#[Groups(['shipment:read', 'shipment:write'])]
#[ApiProperty(readableLink: true)]
private ?Customer $customer = null;
/**
* @var Collection<int, BovinShipment>
*/
#[ORM\OneToMany(
targetEntity: BovinShipment::class,
mappedBy: 'shipment',
cascade: ['persist', 'remove'],
orphanRemoval: true
)]
#[Groups(['shipment:read', 'shipment:write'])]
private Collection $bovinShipments;
/**
* @var Collection<int, Weight>
*/
#[ORM\OneToMany(targetEntity: Weight::class, mappedBy: 'shipment', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['shipment:read'])]
private Collection $weights;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
#[Groups(['shipment:read', 'shipment:write'])]
#[ApiProperty(readableLink: true)]
private ?Driver $driver = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
#[Groups(['shipment:read', 'shipment:write'])]
#[ApiProperty(readableLink: true)]
private ?Address $address = null;
public function __construct()
{
$this->bovinShipments = new ArrayCollection();
$this->weights = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getLicencePlate(): ?string
{
return $this->licencePlate;
}
public function setLicencePlate(?string $licencePlate): void
{
$this->licencePlate = $licencePlate;
}
public function getIdentificationNumber(): ?string
{
return $this->identificationNumber;
}
public function setIdentificationNumber(?string $identificationNumber): void
{
$this->identificationNumber = $identificationNumber;
}
public function getCurrentStep(): int
{
return $this->currentStep;
}
public function setCurrentStep(int $currentStep): void
{
$this->currentStep = $currentStep;
}
public function getIsValid(): ?bool
{
return $this->isValid;
}
#[Groups(['shipment:read'])]
public function isValid(): bool
{
return $this->isValid;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): void
{
$this->user = $user;
}
public function setIsValid(?bool $isValid): void
{
$this->isValid = $isValid;
}
public function getShipmentDate(): ?DateTimeImmutable
{
return $this->shipmentDate;
}
public function setShipmentDate(?DateTimeImmutable $shipmentDate): void
{
$this->shipmentDate = $shipmentDate;
}
public function getCarrier(): ?Carrier
{
return $this->carrier;
}
public function setCarrier(?Carrier $carrier): void
{
$this->carrier = $carrier;
}
public function getTruck(): ?Truck
{
return $this->truck;
}
public function setTruck(?Truck $truck): void
{
$this->truck = $truck;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): void
{
$this->customer = $customer;
}
public function getBovinShipments(): Collection
{
return $this->bovinShipments;
}
public function setBovinShipments(Collection $bovinShipments): void
{
$this->bovinShipments = $bovinShipments;
}
public function addBovinShipment(BovinShipment $bovinShipment): self
{
if (!$this->bovinShipments->contains($bovinShipment)) {
$this->bovinShipments->add($bovinShipment);
$bovinShipment->setShipment($this);
}
return $this;
}
public function removeBovinShipment(BovinShipment $bovinShipment): self
{
if ($this->bovinShipments->removeElement($bovinShipment)) {
if ($bovinShipment->getShipment() === $this) {
$bovinShipment->setShipment(null);
}
}
return $this;
}
/**
* @return Collection<int, Weight>
*/
public function getWeights(): Collection
{
return $this->weights;
}
public function addWeight(Weight $weight): void
{
if (!$this->weights->contains($weight)) {
$this->weights->add($weight);
$weight->setShipment($this);
}
}
public function removeWeight(Weight $weight): void
{
if ($this->weights->removeElement($weight)) {
if ($weight->getShipment() === $this) {
$weight->setShipment(null);
}
}
}
#[ORM\PostPersist]
public function initializeIdentificationNumber(PostPersistEventArgs $args): void
{
if (null !== $this->identificationNumber) {
return;
}
if (null === $this->id) {
return;
}
$number = sprintf('P-BL-%04d', $this->id);
$this->identificationNumber = $number;
$args->getObjectManager()
->getConnection()
->executeStatement(
'UPDATE shipment SET identification_number = :number WHERE id = :id',
[
'number' => $number,
'id' => $this->id,
]
)
;
}
public function getDriver(): ?Driver
{
return $this->driver;
}
public function setDriver(?Driver $driver): static
{
$this->driver = $driver;
return $this;
}
public function getAddress(): ?Address
{
return $this->address;
}
public function setAddress(?Address $address): static
{
$this->address = $address;
return $this;
}
}

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