Compare commits

...

32 Commits

Author SHA1 Message Date
32fe51caaa feat : creation du composant datatable (WIP) 2026-02-18 14:54:18 +01:00
c229d0ab62 Merge branch 'refs/heads/develop' into feat/266-creation-composant-datatable 2026-02-18 08:05:12 +01:00
74de31721c feat : creation du composant datatable (WIP) 2026-02-17 14:52:23 +01:00
gitea-actions
0d258ae9c6 chore: bump version to v0.0.51
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-02-17 13:22:34 +00:00
7dd615ea34 Bovins Admin (!29)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #29
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: Matteo <matteo@yuno.malio.fr>
Co-committed-by: Matteo <matteo@yuno.malio.fr>
2026-02-17 13:22:29 +00:00
850e412840 Merge branch 'develop' into feat/266-creation-composant-datatable 2026-02-17 08:20:23 +01: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
dc69673a05 Merge branch 'develop' into feat/266-creation-composant-datatable
# Conflicts:
#	frontend/pages/reception/finish-reception.vue
#	frontend/pages/shipment/finish-shipment.vue
2026-02-17 08:11:41 +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
316a20c43a feat : creation du composant datatable (WIP) 2026-02-16 16:05:04 +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
d16a81630c Merge branch 'develop' into feat/266-creation-composant-datatable 2026-02-16 08:06:02 +01: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
d8c0a8b8e3 feat : creation du composant datatable (WIP) 2026-02-13 16:06:55 +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
86 changed files with 3280 additions and 866 deletions

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f407a514-c6b4-4b26-9555-445a85892502/console.sql" value="f407a514-c6b4-4b26-9555-445a85892502" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f407a514-c6b4-4b26-9555-445a85892502/console_1.sql" value="f407a514-c6b4-4b26-9555-445a85892502" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f407a514-c6b4-4b26-9555-445a85892502/console_2.sql" value="f407a514-c6b4-4b26-9555-445a85892502" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f407a514-c6b4-4b26-9555-445a85892502/console_3.sql" value="f407a514-c6b4-4b26-9555-445a85892502" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f407a514-c6b4-4b26-9555-445a85892502/console_4.sql" value="f407a514-c6b4-4b26-9555-445a85892502" />
</component>
</project>

216
.idea/workspace.xml generated
View File

@@ -4,28 +4,10 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)">
<change afterPath="$PROJECT_DIR$/frontend/components/shipment/shipment-form.vue" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/pages/shipment/[[id]].vue" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/bovin-shipment.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/customer.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/dto/bovin-shipment-data.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/dto/customer-data.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/dto/shipment-data.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/dto/shipment-type-data.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/shipment-type.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/shipment.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/stores/shipment.ts" afterDir="false" />
<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/components/ui/UiNumberInput.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/components/ui/UiNumberInput.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/constants/steps.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/constants/steps.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/i18n/locales/fr.json" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/i18n/locales/fr.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/index.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/index.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/services/reception.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/services/reception.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/Address.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/Address.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/BovinShipment.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/BovinShipment.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/Shipment.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/Shipment.php" 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" />
@@ -50,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="fix/makefile" />
<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 />
@@ -237,37 +223,42 @@
<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/271-expedition-etape-1",
"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\m-tristan\workspace\Ferme" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\templates" />
@@ -313,54 +304,10 @@
<workItem from="1770195718952" duration="215000" />
<workItem from="1770195959162" duration="18915000" />
<workItem from="1770274844804" duration="3940000" />
</task>
<task id="LOCAL-00001" summary="feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)">
<option name="closed" value="true" />
<created>1768237763998</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1768237763998</updated>
</task>
<task id="LOCAL-00002" summary="feat : Ajout de zod, création d'un composant de chargement loading-dots.vue et finalisation du flow d'une reception">
<option name="closed" value="true" />
<created>1768316052474</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1768316052474</updated>
</task>
<task id="LOCAL-00003" summary="feat : Ajout d'un composable pour la pesée qui sera réutilisable pour l'expédition, ajout de contrainte sur les entity de reception et weight pour plus de robustesse et correction de la class active des liens dans la nav">
<option name="closed" value="true" />
<created>1768316835575</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1768316835575</updated>
</task>
<task id="LOCAL-00004" summary="feat : update du fichier AGENTS.md">
<option name="closed" value="true" />
<created>1768316965511</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1768316965511</updated>
</task>
<task id="LOCAL-00005" summary="feat : update du fichier README.md et CHANGELOG.md">
<option name="closed" value="true" />
<created>1768317786187</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1768317786187</updated>
</task>
<task id="LOCAL-00006" summary="fix : correction du useApi pour qu'il n'y ait plus de retry lors d'une erreur 500 par exemple">
<option name="closed" value="true" />
<created>1768318875533</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1768318875533</updated>
<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" />
@@ -706,7 +653,55 @@
<option name="project" value="LOCAL" />
<updated>1770217875423</updated>
</task>
<option name="localTasksCounter" value="50" />
<task id="LOCAL-00050" summary="feat : creer une nouvelle expedtion (WIP)">
<option name="closed" value="true" />
<created>1770736570645</created>
<option name="number" value="00050" />
<option name="presentableId" value="LOCAL-00050" />
<option name="project" value="LOCAL" />
<updated>1770736570645</updated>
</task>
<task id="LOCAL-00051" summary="feat : ajout d'une page de creation d'une expedition">
<option name="closed" value="true" />
<created>1770880791564</created>
<option name="number" value="00051" />
<option name="presentableId" value="LOCAL-00051" />
<option name="project" value="LOCAL" />
<updated>1770880791565</updated>
</task>
<task id="LOCAL-00052" summary="feat : changelog">
<option name="closed" value="true" />
<created>1770881437439</created>
<option name="number" value="00052" />
<option name="presentableId" value="LOCAL-00052" />
<option name="project" value="LOCAL" />
<updated>1770881437439</updated>
</task>
<task id="LOCAL-00053" summary="feat : lister les expeditions terminees">
<option name="closed" value="true" />
<created>1770883114609</created>
<option name="number" value="00053" />
<option name="presentableId" value="LOCAL-00053" />
<option name="project" value="LOCAL" />
<updated>1770883114609</updated>
</task>
<task id="LOCAL-00054" summary="feat : lister les expeditions terminees">
<option name="closed" value="true" />
<created>1770884154297</created>
<option name="number" value="00054" />
<option name="presentableId" value="LOCAL-00054" />
<option name="project" value="LOCAL" />
<updated>1770884154297</updated>
</task>
<task id="LOCAL-00055" summary="fix : corrections diverses">
<option name="closed" value="true" />
<created>1770969471135</created>
<option name="number" value="00055" />
<option name="presentableId" value="LOCAL-00055" />
<option name="project" value="LOCAL" />
<updated>1770969471135</updated>
</task>
<option name="localTasksCounter" value="56" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -756,12 +751,6 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="fix : correction du path URI pour la création d'un poids dans une réception" />
<MESSAGE value="feat : Ajout du bundle Monolog pour la gestion des logs" />
<MESSAGE value="fix : affiche plus détail dans les logs en recette/prod" />
<MESSAGE value="fix : modification du script de déploiement pour corriger le problème d'écriture des logs de prod" />
<MESSAGE value="fix : doc de déploiement" />
<MESSAGE value="fix : doc et script de déploiement" />
<MESSAGE value="fix : gitea workflow" />
<MESSAGE value="fix : script de déploiement" />
<MESSAGE value="feat : ajout plus d'information sur la liste des réceptions côté front sur la page d'accueil" />
@@ -781,7 +770,13 @@
<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)" />
<option name="LAST_COMMIT_MESSAGE" value="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)" />
<MESSAGE value="feat : creer une nouvelle expedtion (WIP)" />
<MESSAGE value="feat : ajout d'une page de creation d'une expedition" />
<MESSAGE value="feat : changelog" />
<MESSAGE value="feat : lister les expeditions terminees" />
<MESSAGE value="fix: corrections diverses" />
<MESSAGE value="fix : corrections diverses" />
<option name="LAST_COMMIT_MESSAGE" value="fix : corrections diverses" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
@@ -791,10 +786,19 @@
<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/shipment.ts</url>
<properties lambdaOrdinal="-1" />
<option name="timeStamp" value="37" />
<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>

View File

@@ -40,7 +40,15 @@ Ajouter dans le fichier .env du frontend
* [#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

@@ -3,6 +3,8 @@ api_platform:
version: 1.0.0
defaults:
stateless: true
pagination_client_items_per_page: true
pagination_maximum_items_per_page: 100
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
formats:

View File

@@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
@@ -1472,7 +1470,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* mercure?: bool|array{
* enabled?: bool|Param, // Default: false
* hub_url?: scalar|Param|null, // The URL sent in the Link HTTP header. If not set, will default to the URL for MercureBundle's default hub. // Default: null
* include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false
* include_type?: bool|Param, // Always include @type in updates (including delete ones). // Default: false
* },
* messenger?: bool|array{
* enabled?: bool|Param, // Default: false

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.39'
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,15 +100,28 @@
}))"
: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>
@@ -342,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 = ''
@@ -359,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'
@@ -94,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

@@ -27,12 +27,12 @@
/>
</div>
</div>
<button
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!auth.isAdmin"
>Valider
</button>
</UiButton>
</div>
</form>
</template>
@@ -83,14 +83,14 @@ onMounted(async () => {
})
watch(
() => receptionId,
async (id) => {
if (!id || !receptionIri.value) {
[() => receptionId, () => 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
}
@@ -105,7 +105,7 @@ watch(
}
Object.assign(bovineQuantities, selectionMap)
const existingOther = await reception.bovineDetail
const existingOther = reception.bovineDetail
const parsedOther =
typeof existingOther === 'string' && existingOther.trim() !== ''
? Number(existingOther)

View File

@@ -1,79 +1,80 @@
<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 class="flex flex-col items-center gap-16">
<div
v-if="merchandiseTypeId && isAutres"
class="flex flex-col w-full max-w-[550px]"
>
class="flex flex-col gap-16 items-center w-full">
<UiTextInput
id="merchandise-detail"
:disabled="!auth.isAdmin"
v-model="merchandiseDetail"
label="Préciser"
placeholder="Précisions complémentaires"
:maxlength="255"
id="merchandise-type"
v-model="selectedMerchandiseTypeId"
label="Type de marchandises"
:value="reception.merchandiseType?.label"
wrapper-class="w-[550px]"
:disabled="true"
/>
</div>
<div
v-if="merchandiseTypeId && !isGranule"
class="flex gap-4 w-[550px] justify-evenly"
>
<div
v-for="building in buildings"
:key="building.id"
v-if="merchandiseTypeId && isAutres"
class="flex flex-col w-full max-w-[550px]"
>
<UiCheckbox
v-model="selectedBuildingIds"
:value="String(building.id)"
:label="building.label"
<UiTextInput
id="merchandise-detail"
:disabled="!auth.isAdmin"
label-class="text-xl"
v-model="merchandiseDetail"
label="Préciser"
placeholder="Précisions complémentaires"
:maxlength="255"
/>
</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
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>
<button
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!auth.isAdmin"
>Valider
</button>
</div>
</form>
</template>

View File

@@ -1,30 +1,43 @@
<template>
<form @submit.prevent="validate">
<div class="grid grid-cols-2 gap-x-40 gap-y-8 mb-16">
<div class="grid grid-cols-2 gap-x-40 gap-y-8 mb-8">
<UiNumberInput
label="Pesée à vide"
v-model="form.weights[0].weight"
label="Dsd"
class="col-start-2"
labelClass="font-bold uppercase"
v-model="sharedWeightMeta.dsd"
:disabled="!auth.isAdmin"
:min="0"
/>
<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
label="Pesée à plein"
v-model="form.weights[1].weight"
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">
<button
<UiButton
v-if="auth.isAdmin"
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!auth.isAdmin"
>
Valider
</button>
</UiButton>
</div>
</form>
@@ -32,7 +45,7 @@
<script setup lang="ts">
import type {ReceptionFormWeight} from '~/services/dto/reception-data'
import { getReception } from '~/services/reception'
import {getReception} from '~/services/reception'
import {updateWeight} from "~/services/weight";
import {useAuthStore} from "~/stores/auth";
@@ -45,17 +58,42 @@ const auth = useAuthStore()
const form = reactive({
weights: [
{ id: 0, type: 'tare' as const, weight: 0 },
{ id: 0, type: 'gross' as const, weight: 0 }
{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) => {
const tare = reception.weights.find(weight => weight.type === 'tare')
const gross = reception.weights.find(weight => weight.type === 'gross')
// 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)
}
}
if (tare) form.weights[0] = { ...tare }
if (gross) form.weights[1] = { ...gross }
// 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 () => {
@@ -64,11 +102,23 @@ onMounted(async () => {
})
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})
await updateWeight(weight.id, {
weight: weight.weight,
dsd: Number.isFinite(sharedDsd) ? sharedDsd : null,
weighedAt: sharedWeighedAt
})
}
}
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<form @submit.prevent="validate">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1">Expédition</h1>
<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"
@@ -22,24 +22,27 @@
wrapper-class="col-start-1 row-start-3"
/>
<!-- Type d'expédition -->
<div class="col-start-1 row-start-4">
<label class="font-bold uppercase text-xl mb-2 block">
Type d'expédition
</label>
<div class="grid grid-cols-2 gap-x-8">
<div
v-for="type in bovineShipment"
:key="type.id"
class="mt-8 flex flex-row gap-6"
>
<UiNumberInput
:label="type.label"
v-model="bovineQuantities[String(type.id)]"
:placeholder="0"
:min="0"
:max="10"
/>
</div>
<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 -->
@@ -49,7 +52,7 @@
label="Client"
:options="customers.map((customer) => ({
value: String(customer.id),
label: customer.label
label: customer.name || `Client #${customer.id}`
}))"
:loading="isLoadingCustomers"
wrapper-class="col-start-1 row-start-5"
@@ -86,20 +89,8 @@
}))"
wrapper-class="col-start-2 row-start-3"
/>
<!-- 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-4"
/>
<!-- Plaque d'immatriculation (hors LIOT) -->
<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.licencePlate"
v-model:allowAny="allowAnyLicensePlate"
@@ -117,15 +108,28 @@
}))"
: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">
<button
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Valider
</button>
</UiButton>
</div>
</form>
</template>
@@ -177,8 +181,9 @@ const isLoadingDrivers = ref(false)
const authStore = useAuthStore()
const shipmentStore = useShipmentStore()
const router = useRouter()
const bovineQuantities = ref<Record<string, number | null>>({})
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
@@ -328,15 +333,21 @@ watch(
form.driverId = shipment?.driver?.id ? String(shipment.driver.id) : ''
form.vehicleId = shipment?.vehicle?.id ? String(shipment.vehicle.id) : ''
if (!shipment || !shipment.bovinShipments) {
bovineQuantities.value = {}
selectedShipmentTypeId.value = ''
shipmentQuantity.value = 0
} else {
const next: Record<string, number | null> = {}
for (const entry of shipment.bovinShipments) {
const selectedEntry = shipment.bovinShipments.find((entry) => {
const typeId = entry.shipmentType?.id
if (!typeId) continue
next[String(typeId)] = entry.nbBovinSend ?? null
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
}
bovineQuantities.value = next
}
isHydrating.value = false
},
@@ -344,7 +355,7 @@ watch(
)
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
watch(
() => [form.customerId, customers.value],
() => [form.customerId, form.addressId, customers.value],
() => {
if (!form.customerId) {
form.addressId = ''
@@ -361,7 +372,11 @@ watch(
(address) => String(address.id) === form.addressId
)
if (!matches) {
form.addressId = ''
if (customerAddresses.value.length === 1) {
form.addressId = String(customerAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{immediate: true}
@@ -460,16 +475,22 @@ watch(
}
)
const buildDesiredBovinShipments = () => {
return bovineShipment.value
.map((type) => {
const raw = bovineQuantities.value[String(type.id)]
const quantity = raw === null || raw === undefined ? 0 : Number(raw)
return {
type,
quantity: Number.isFinite(quantity) ? Math.max(0, Math.trunc(quantity)) : 0
}
})
.filter((entry) => entry.quantity > 0)
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,

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

@@ -1,7 +1,7 @@
<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>
<div
@@ -11,27 +11,27 @@
</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-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]">
<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>

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

@@ -0,0 +1,436 @@
<template>
<div class="mt-6 mx-[6px]">
<table class="w-full border border-slate-300 table-fixed">
<thead class="bg-slate-100 capitalize tracking-wide">
<tr>
<th
v-for="column in normalizedColumns"
:key="column.key"
class="border border-slate-300 px-2 py-1"
>
<div class="flex flex-col gap-1">
<UiSelect
v-if="column.isSearchable && column.type === 'selectTypeReception'"
v-model="searchValues[column.key]"
:placeholder="column.label"
select-class="w-full !text-sm !py-1"
:options="[
{ value: '__all__', label: 'Tous' },
...receptionTypes.map((type) => ({
value: type.label,
label: type.label
}))
]"
/>
<UiSelect
v-else-if="column.isSearchable && column.type === 'selectTypeShipment'"
v-model="searchValues[column.key]"
:placeholder="column.label"
select-class="w-full !text-sm !py-1"
:options="[
{ value: '__all__', label: 'Tous' },
...shipmentTypes.map((type) => ({
value: type.label,
label: type.label
}))
]"
/>
<div v-else-if="column.isSearchable" class="relative">
<UiTextInput
v-model="searchValues[column.key]"
:placeholder="column.label"
input-class="min-w-full !text-sm !py-1 !pr-7"
/>
<Icon
name="gg:search"
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-slate-400"
/>
</div>
<span v-else>{{ column.label }}</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td
class="border border-slate-300 px-2 py-2 whitespace-pre-line"
:colspan="normalizedColumns.length || 1"
>
Chargement...
</td>
</tr>
<tr v-else-if="displayedRows.length === 0">
<td
class="border border-slate-300 px-3 py-2 text-left text-slate-500"
:colspan="normalizedColumns.length || 1"
>
Aucune donnée
</td>
</tr>
<template v-else>
<tr
v-for="(row, rowIndex) in displayedRows"
class="hover:bg-primary-500 hover:bg-opacity-15"
:key="rowIndex"
:class="props.rowClickable ? 'cursor-pointer' : ''"
@click="props.rowClickable ? onRowClick(row) : null"
>
<td
v-for="column in normalizedColumns"
:key="column.key"
class="border border-slate-300 px-2 py-2 whitespace-pre-line "
>
{{ formatColumnValue(row, column) }}
</td>
</tr>
</template>
</tbody>
</table>
<div class="flex items-center justify-between mt-4">
<p class="text-slate-600">
{{ pageLabel }}
</p>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded border border-slate-300 px-2 py-1 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="currentPage <= 1 || loading"
@click="currentPage = currentPage - 1"
>
Précédent
</button>
<button
v-for="(item, index) in paginationItems"
:key="`${item}-${index}`"
type="button"
class="min-w-9 rounded border px-2 py-1"
:class="item === currentPage
? 'border-primary-500 bg-primary-500 text-white'
: 'border-slate-300'"
:disabled="loading || item === '...'"
@click="typeof item === 'number' ? (currentPage = item) : null"
>
{{ item }}
</button>
<button
type="button"
class="rounded border border-slate-300 px-2 py-1 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="currentPage >= totalPages || loading"
@click="currentPage = currentPage + 1"
>
Suivant
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {Row, ColumnConfig, AnyCollection, PaginationItem} from '~/services/dto/datatable-data'
import {useApi} from '~/composables/useApi'
import type {ReceptionTypeData} from '~/services/dto/reception-type-data'
import {getReceptionTypeList} from '~/services/reception-type'
import type {ShipmentTypeData} from "~/services/dto/shipment-data";
import {getShipmentTypeList} from "~/services/shipment-type";
const api = useApi()
const receptionTypes = ref<ReceptionTypeData[]>([])
const shipmentTypes = ref<ShipmentTypeData[]>([])
const loading = ref(false)
const currentPage = ref(1)
const rows = ref<Row[]>([])
const total = ref(0)
const searchValues = reactive<Record<string, string>>({})
const isNestedMode = computed(() => Boolean(props.responsePath))
const effectiveTotal = computed(() => total.value)
const emit = defineEmits<{
rowClick: [row: Row]
}>()
const props = withDefaults(defineProps<{
url?: string
responsePath?: string
columns?: ColumnConfig[]
query?: Record<string, unknown>
itemsPerPage?: number
rowClickable?: boolean
}>(), {
url: '',
responsePath: '',
columns: () => [],
query: () => ({}),
itemsPerPage: 10,
rowClickable: true
})
const displayedRows = computed<Row[]>(() => {
if (!isNestedMode.value) return rows.value
const startIndex = (currentPage.value - 1) * props.itemsPerPage
const endIndex = startIndex + props.itemsPerPage
return rows.value.slice(startIndex, endIndex)
})
onMounted(async () => {
receptionTypes.value = await getReceptionTypeList()
shipmentTypes.value = await getShipmentTypeList()
})
const normalizedColumns = computed(() => {
if (props.columns.length > 0) {
return props.columns.map((column) => ({
key: column.key,
label: column.label ?? column.key,
format: column.format,
isSearchable: column.isSearchable ?? false,
type: column.type
}))
}
if (displayedRows.value.length === 0) {
return []
}
return Object.keys(displayedRows.value[0])
.filter((key) => !key.startsWith('@'))
.map((key) => ({
key,
label: key
}))
})
const totalPages = computed(() => Math.max(1, Math.ceil(effectiveTotal.value / props.itemsPerPage)))
function getVisiblePages(page: number, lastPage: number): number[] {
const candidates = new Set([1, page - 1, page, page + 1, lastPage])
return Array.from(candidates)
.filter((p) => p >= 1 && p <= lastPage)
.sort((a, b) => a - b)
}
function insertEllipses(sortedPages: number[]): PaginationItem[] {
const items: PaginationItem[] = []
for (let i = 0; i < sortedPages.length; i++) {
const current = sortedPages[i]
const previous = sortedPages[i - 1]
if (previous != null && current - previous > 1) {
items.push('...')
}
items.push(current)
}
return items
}
const paginationItems = computed<PaginationItem[]>(() => {
const pages = getVisiblePages(currentPage.value, totalPages.value)
return insertEllipses(pages)
})
const pageLabel = computed(() => {
if (!effectiveTotal.value) return '0 résultat'
const start = (currentPage.value - 1) * props.itemsPerPage + 1
const end = Math.min(currentPage.value * props.itemsPerPage, effectiveTotal.value)
return `${start}-${end} sur ${effectiveTotal.value}`
})
watch(
() => [props.url, props.itemsPerPage, JSON.stringify(props.query ?? {}), props.responsePath],
async () => {
if (currentPage.value !== 1) {
currentPage.value = 1
if (!isNestedMode.value) return
}
await loadPage()
},
{immediate: true}
)
let timeout: ReturnType<typeof setTimeout>
watch(
() => ({...searchValues}),
() => {
clearTimeout(timeout)
timeout = setTimeout(() => {
currentPage.value = 1
if (!isNestedMode.value) loadPage()
}, 750)
},
{deep: true}
)
watch(
() => currentPage.value,
async () => {
if (isNestedMode.value) return
await loadPage()
}
)
watch(
() => [totalPages.value, currentPage.value],
() => {
if (currentPage.value > totalPages.value) {
currentPage.value = totalPages.value
}
},
{immediate: true}
)
function buildDateInterval(value: string): { after: string; before: string } | null {
const trimmed = value.trim()
// YYYY
if (/^\d{4}$/.test(trimmed)) {
const year = Number(trimmed)
return {
after: `${year}-01-01`,
before: `${year + 1}-01-01`
}
}
// YYYY-MM
if (/^\d{4}-\d{2}$/.test(trimmed)) {
const [year, month] = trimmed.split('-').map(Number)
const nextMonth = month === 12 ? 1 : month + 1
const nextYear = month === 12 ? year + 1 : year
return {
after: `${year}-${String(month).padStart(2, '0')}-01`,
before: `${nextYear}-${String(nextMonth).padStart(2, '0')}-01`
}
}
// YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const date = new Date(`${trimmed}T00:00:00`)
const nextDay = new Date(date)
nextDay.setDate(date.getDate() + 1)
const yyyy = nextDay.getFullYear()
const mm = String(nextDay.getMonth() + 1).padStart(2, '0')
const dd = String(nextDay.getDate()).padStart(2, '0')
return {
after: trimmed,
before: `${yyyy}-${mm}-${dd}`
}
}
return null
}
// Construit la requête, charge les données et normalise la réponse, puis met à jour rows et total
async function loadPage(): Promise<void> {
if (!props.url) {
rows.value = []
total.value = 0
return
}
loading.value = true
try {
if (isNestedMode.value) {
const response = await api.get<Row>(props.url, props.query, {
headers: {
Accept: 'application/ld+json'
}
})
const nestedRows = readPath(response, props.responsePath)
rows.value = Array.isArray(nestedRows) ? nestedRows as Row[] : []
total.value = rows.value.length
return
}
const searchQuery: Record<string, string> = {}
for (const column of normalizedColumns.value) {
if (!column.isSearchable) continue
const rawValue = searchValues[column.key] ?? ''
const raw = rawValue === '__all__' ? '' : rawValue.trim()
if (!raw) continue
const paramBase = column.key
if (column.type === 'date') {
const interval = buildDateInterval(raw)
if (interval) {
searchQuery[`${paramBase}[after]`] = interval.after
searchQuery[`${paramBase}[before]`] = interval.before
}
continue
}
searchQuery[paramBase] = raw
}
const requestQuery: Record<string, unknown> = {
...props.query,
...searchQuery,
page: currentPage.value,
itemsPerPage: props.itemsPerPage,
}
const response = await api.get<AnyCollection<Row> | Row[]>(props.url, requestQuery, {
headers: {
Accept: 'application/ld+json'
}
})
if (Array.isArray(response)) {
rows.value = response
total.value = response.length
return
}
const mappedRows = response['hydra:member'] ?? response.member ?? response.items ?? []
rows.value = Array.isArray(mappedRows) ? mappedRows : []
total.value = Number(response['hydra:totalItems'] ?? response.totalItems ?? rows.value.length)
} finally {
loading.value = false
}
}
function onRowClick(row: Row): void {
emit('rowClick', row)
}
// Lit une valeur imbriquée dans une ligne à partir d'un chemin de type "objet.sousObjet.cle".
function readPath(source: Row, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => (acc as Row | undefined)?.[key], source)
}
// Formate une valeur brute pour l'affichage dans une cellule (vide, tableau, objet ou valeur simple).
function formatCell(value: unknown): string {
if (value == null || value === '') return '-'
if (Array.isArray(value)) return value.length ? value.map(formatCell).join(', ') : '-'
if (typeof value === 'object') {
const objectValue = value as Row
return String(objectValue.label ?? objectValue.name ?? objectValue.code ?? objectValue.id ?? '[object]')
}
return String(value)
}
function formatColumnValue(
row: Row,
column: { key: string; format?: (value: unknown, row: Row) => string }
): string {
const value = readPath(row, column.key)
if (column.format) {
return column.format(value, row)
}
return formatCell(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 }}
@@ -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"
class="text-xl flex items-center gap-2 text-primary-500"
:class="labelClass"
>
<span
@@ -25,7 +25,7 @@
:step="step"
:disabled="disabled"
v-bind="attrs"
class="border-b border-black text-xl bg-transparent w-12"
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',
@@ -74,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,9 +1,10 @@
export enum StepLabel {
Reception = 'Réception',
GrossWeighing = 'Pesée à plein',
Selection = 'Sélection réceptionnées',
Selection = 'Sélection réception',
TareWeighing = 'Pesée à vide',
Shipment = 'Expédition',
ShipmentLoading = 'Chargement',
}
export const RECEPTION_STEP_LABELS = [
@@ -16,5 +17,6 @@ export const RECEPTION_STEP_LABELS = [
export const SHIPMENT_STEP_LABELS = [
StepLabel.Shipment,
StepLabel.TareWeighing,
StepLabel.ShipmentLoading,
StepLabel.GrossWeighing,
]

View File

@@ -56,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."
@@ -91,6 +111,18 @@
"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.",

View File

@@ -1,74 +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>
<NuxtLink to="/admin/user/list">
Utilisateurs
</NuxtLink>
</div>
<div class="p-4">
<p class="font-bold text-white text-left">v{{ version }}</p>
<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">
import {useAuthStore} from '~/stores/auth'
const auth = useAuthStore()
const { version } = useAppVersion()
const handleLogout = async () => {
try {
await auth.logout()
} finally {
await navigateTo('/login')
}
}
</script>

View File

@@ -1,53 +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 }"
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"
@@ -62,6 +174,8 @@
@click="closeMenu"
/>
</transition>
<!-- Drawer (mobile) -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="-translate-x-full"
@@ -72,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>
@@ -87,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
@@ -100,10 +230,10 @@
</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>
@@ -114,9 +244,12 @@ import {useAuthStore} from '~/stores/auth'
const route = useRoute()
const auth = useAuthStore()
const isMenuOpen = ref(false)
const {version} = useAppVersion()
const isMenuOpen = ref(false)
const userDisplayName = computed(() => auth.user?.username ?? 'Utilisateur')
const closeMenu = () => {
isMenuOpen.value = false
}

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

@@ -1,19 +1,19 @@
<template>
<form @submit.prevent="validate">
<div class="flex items-center justify-between ">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase">
{{ route.params.id ? 'Modifier transporteur' : 'Ajout transporteur' }}
</h1>
<button
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Enregistrer
</button>
</UiButton>
</div>
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 py-12">
<UiTextInput
label = "nom du fournisseur"
id="carrier-name"
@@ -33,19 +33,27 @@
<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 = Number(route.params.id)
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: 'admin'
layout: 'default'
})
const hydrateFromUser = (carrier: CarrierData | null) => {
@@ -59,7 +67,7 @@ const hydrateFromUser = (carrier: CarrierData | null) => {
}
watch(
() => idCarrier,
() => idCarrier.value,
async (id) => {
if (id === null) {
return
@@ -85,8 +93,8 @@ async function validate() {
}
if(idCarrier){
await updateCarrier(idCarrier, basePayload)
if(idCarrier.value){
await updateCarrier(idCarrier.value, basePayload)
navigate()
return
}

View File

@@ -1,51 +1,43 @@
<template>
<div class="flex items-center justify-between ">
<h1 class="text-3xl font-bold uppercase">listes des transporteurs</h1>
<NuxtLink
to="/admin/carrier"
class="flex items-center justify-center text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
>Ajouter
</NuxtLink>
<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 ">
<div class="grid grid-cols-2 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Label</div>
<div>Code</div>
</div>
<div
v-for="carrier in carrierList"
:key="carrier.id"
class="grid grid-cols-2 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToCarrier(carrier.id)"
@keydown.enter="goToCarrier(carrier.id)"
>
<div>{{ carrier.name}}</div>
<div>{{ carrier.code }}</div>
</div>
</div>
<UiDataTable
:columns="columns"
url="carriers"
@row-click="onCarrierRowClick"
/>
</template>
<script setup lang="ts">
import type {CarrierData} from "~/services/dto/carrier-data";
import {getCarrierList} from "~/services/carrier";
import type {ColumnConfig, Row} from "~/services/dto/datatable-data";
const carrierList = ref<CarrierData[]>()
const router = useRouter()
const columns: ColumnConfig[] = [
{key: "name", label: "Label"},
{key: "code", label: "Code"},
]
const goToCarrier = (id: number) => {
router.push(`/admin/carrier/${id}`)
}
definePageMeta({
layout: 'admin'
})
const onCarrierRowClick = (row: Row) => {
const id = Number(row.id)
if (!Number.isFinite(id)) return
goToCarrier(id)
}
onMounted(async () => {
carrierList.value = await getCarrierList(false)
definePageMeta({
layout: 'default'
})
</script>

View File

@@ -0,0 +1,163 @@
<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>
<UiDataTable
class="mb-10"
:columns="addressColumns"
:url="customerId !== null ? `customers/${customerId}` : ''"
response-path="addresses"
:items-per-page="5"
:row-clickable="auth.isAdmin"
@row-click="onAddressRowClick"
/>
</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 type {ColumnConfig, Row} from "~/services/dto/datatable-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 addressColumns: ColumnConfig[] = [
{key: "label", label: "Libellé"},
{key: "street", label: "Rue"},
{key: "street2", label: "Complément"},
{key: "postalCode", label: "Code postal"},
{key: "city", label: "Ville"},
{key: "countryCode", label: "Pays"},
]
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 onAddressRowClick = (row: Row) => {
const id = Number(row.id)
goToEditAddress(Number.isFinite(id) ? id : null)
}
const hydrateFromCustomer = (customer: CustomerData | null) => {
if (!customer) return
form.name = customer.name ?? ""
form.phone = customer.phone ?? ""
form.email = customer.email ?? ""
}
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,59 @@
<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>
<UiDataTable
v-if="auth.isAdmin"
:columns="columns"
url="customers"
@row-click="onCustomerRowClick"
/>
<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 { ColumnConfig, Row } from "~/services/dto/datatable-data"
import { formatAddresses } from "~/utils/datatable-formatters"
import { useAuthStore } from "~/stores/auth"
definePageMeta({ layout: "default" })
const router = useRouter()
const auth = useAuthStore()
const columns: ColumnConfig[] = [
{ key: "name", label: "Nom", isSearchable:true},
{ key: "phone", label: "Téléphone" },
{ key: "email", label: "Email" },
{ key: "addresses", label: "Adresses", format: formatAddresses },
]
const goToCustomer = (id: number) => {
if (!auth.isAdmin) return
router.push(`/admin/customer/${id}`)
}
const onCustomerRowClick = (row: Row) => {
const id = Number(row.id)
if (!Number.isFinite(id)) return
goToCustomer(id)
}
const handleAddClick = (event: Event) => {
if (auth.isAdmin) return
event.preventDefault()
}
</script>

View File

@@ -2,6 +2,6 @@
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin'
layout: 'default'
})
</script>

View File

@@ -0,0 +1,163 @@
<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>
<UiDataTable
class="mb-10"
:columns="addressColumns"
:url="supplierId !== null ? `suppliers/${supplierId}` : ''"
response-path="addresses"
:items-per-page="5"
:row-clickable="auth.isAdmin"
@row-click="onAddressRowClick"
/>
</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 type {ColumnConfig, Row} from "~/services/dto/datatable-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 addressColumns: ColumnConfig[] = [
{key: "label", label: "Libellé"},
{key: "street", label: "Rue"},
{key: "street2", label: "Complément"},
{key: "postalCode", label: "Code postal"},
{key: "city", label: "Ville"},
{key: "countryCode", label: "Pays"},
]
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 onAddressRowClick = (row: Row) => {
const id = Number(row.id)
goToEditAddress(Number.isFinite(id) ? id : null)
}
const hydrateFromSupplier = (supplier: SupplierData | null) => {
if (!supplier) return
form.name = supplier.name ?? ""
form.email = supplier.email ?? ""
form.phone = supplier.phone ?? ""
}
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,74 +1,58 @@
<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 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"
>
<div>Nom</div>
<div>Mail</div>
<div>Rue</div>
<div>Complément</div>
<div>Code Postal</div>
<div>Ville</div>
</div>
<div v-for="supplier in supplierList" :key="supplier.id">
<template v-if="supplier.addresses?.length">
<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"
@click="goToSupplier(supplier.id)"
>
<div class="truncate">
{{ 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>
</div>
</template>
</div>
</div>
<UiDataTable
v-if="auth.isAdmin"
:columns="columns"
url="suppliers"
@row-click="onSupplierRowClick"
/>
<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 type { ColumnConfig, Row } from "~/services/dto/datatable-data"
import {formatAddresses} from "~/utils/datatable-formatters"
import { useAuthStore } from "~/stores/auth"
definePageMeta({layout: "admin"})
definePageMeta({ layout: "default" })
const supplierList = ref<SupplierData[]>([])
const router = useRouter()
const auth = useAuthStore()
const columns: ColumnConfig[] = [
{ key: "name", label: "Nom", isSearchable:true },
{ key: "email", label: "Mail" },
{ key: "addresses", label: "Adresses", format: formatAddresses },
]
const goToSupplier = (id: number) => {
if (!auth.isAdmin) return
router.push(`/admin/supplier/${id}`)
}
onMounted(async () => {
supplierList.value = (await getSupplierList(false)) ?? []
})
const onSupplierRowClick = (row: Row) => {
const id = Number(row.id)
if (!Number.isFinite(id)) return
goToSupplier(id)
}
const handleAddClick = (event: Event) => {
if (auth.isAdmin) return
event.preventDefault()
}
</script>

View File

@@ -5,15 +5,15 @@
<h1 class="text-3xl font-bold uppercase">
{{ userId ? "Modifications de l'utilisateur" : "Ajout d'un utilisateur" }}
</h1>
<button
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
type="submit"
>
{{ userId ? 'Sauvegarder' : 'Ajouter' }}
</button>
</UiButton>
</div>
<div class="grid gap-y-16 gap-x-40 mb-16">
<div class="grid gap-y-16 gap-x-40 py-12">
<UiTextInput
id="user-name"
v-model="form.username"
@@ -39,13 +39,13 @@
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin'
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} from '~/services/dto/user-data'
import type {UserData, UserFormData, UserPayload} from '~/services/dto/user-data'
const route = useRoute()
const router = useRouter()
@@ -105,10 +105,12 @@ async function validate() {
const normalizedRole = form.role.trim()
const normalizedPassword = form.password.trim()
const basePayload = {
const basePayload: UserPayload = {
username: normalizedUsername,
roles: normalizedRole ? [normalizedRole] : undefined,
password: normalizedPassword || undefined
}
if (normalizedPassword) {
basePayload.password = normalizedPassword
}
if (userId.value) {

View File

@@ -1,57 +1,44 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase">Liste des utilisateurs</h1>
<h1 class="text-3xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
<NuxtLink
class="flex items-center justify-center text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
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>
{{ user.roles?.join(', ') || ' ---' }}
</div>
</div>
</div>
</div>
<UiDataTable
:columns="columns"
url="admin/users"
@row-click="onUserRowClick"
/>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin'
layout: 'default'
})
import type {UserData} from "~/services/dto/user-data";
import {getAdminUsers, getUsers} from "~/services/auth";
import {ROLE} from "~/utils/constants";
import type {ColumnConfig, Row} from "~/services/dto/datatable-data";
import {formatRoleLabels} from "~/utils/datatable-formatters";
const userList = ref<UserData[]>([])
const router = useRouter()
const roleLabelByValue = new Map(ROLE.map((role) => [role.value, role.label]))
const goToUser = (id: number) => {
const columns: ColumnConfig[] = [
{ key: "username", label: "Username" },
{ key: "roles", label: "Role", format: (value) => formatRoleLabels(value, roleLabelByValue) },
]
const onUserRowClick = (row: Row) => {
const id = Number(row.id)
if (!Number.isFinite(id)) return
router.push(`/admin/user/${id}`)
}
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="/shipment" 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="PLAN DE SITE" link="/" iconName="material-symbols:warehouse-outline-rounded" />
<card-link label="" link="/reception/waiting-reception" iconName="mdi:truck-remove-outline">
<template #label>
Réceptions<br>EN ATTENTE
</template>
</card-link>
<card-link label="" 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 label="" 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,58 +1,39 @@
<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="mt-6 border border-slate-200 mb-16 ">
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Numéro</div>
<div>Date</div>
<div>Fournisseur</div>
<div>Adresse</div>
<div>Type réception</div>
<div>Poids</div>
</div>
<div
v-for="reception in receptionList"
:key="reception.id"
class="grid grid-cols-6 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToReception(reception.id)"
>
<div>{{ reception.identificationNumber}}</div>
<div>{{ reception.receptionDate}}</div>
<div>{{ reception.supplier?.name }}</div>
<div>{{ reception.address?.fullAddress }}</div>
<div>{{ reception.receptionType?.label }}</div>
<div>{{ formatWeighing(reception, 'gross') }} | {{ formatWeighing(reception, 'tare') }}</div>
</div>
</div>
</div>
<UiDataTable
:columns="columns"
url="receptions"
class="ps-20"
:query="{ isValid: true }"
@row-click="goToReception"
/>
</template>
<script setup lang="ts">
import type {ReceptionData} from "~/services/dto/reception-data";
import {getReceptionList} from "~/services/reception";
const receptionList = ref<ReceptionData[]>()
const router = useRouter()
import {formatWeights} from "~/utils/datatable-formatters";
const formatWeighing = (reception: ReceptionData, type: 'gross' | 'tare') => {
const entry = reception.weights?.find((weight) => weight.type === type)
if (!entry || entry.weight == null || entry.dsd == null) {
return '—'
}
return `${entry.weight} kg`
type ReceptionRow = {
id?: number | string
}
const goToReception = (id: number) => {
const router = useRouter()
const columns = [
{ key: 'identificationNumber', label: 'Numero', isSearchable:true },
{ key: 'receptionDate', label: 'Date de livraison', isSearchable: true, type: 'date' },
{ key: 'supplier.name', label: 'Fournisseur', isSearchable: true },
{ key: 'address.fullAddress', label: 'Adresse', isSearchable: true },
{ key: 'receptionType.label', label: 'Type', isSearchable: true, type:'selectTypeReception' },
{ key: 'weights', label: 'Poids', format: formatWeights }
]
const goToReception = (row: ReceptionRow) => {
const id = Number(row?.id)
if (!Number.isFinite(id)) return
router.push(`/reception/update/${id}`)
}
onMounted(async () => {
receptionList.value = await getReceptionList(true)
})
</script>

View File

@@ -1,16 +1,10 @@
<template>
<form @submit.prevent="validate">
<div class="flex items-center justify-between mt-8 mb-8 ">
<h1 class="font-bold text-5xl uppercase">Réception {{receptionLoad?.identificationNumber}}</h1>
<button
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
:disabled="!auth.isAdmin"
>Enregistrer
</button>
<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-16">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-12">
<!-- Nom de l'utilisateur -->
<UiSelect
id="reception-user"
@@ -120,28 +114,50 @@
wrapper-class="col-start-2 row-start-4"
/>
</div>
<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" @click="isBtWeight = true" >pesées</h1>
<h1 class="font-bold text-5xl uppercase col-start-2 row-start-1" @click="isBtWeight = false">{{isMerchandise ? "Marchandises" : "Bovins"}}</h1>
<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>
<update-weight
v-if="isBtWeight"
:idReception="idReception"
:disabled="!auth.isAdmin"
/>
<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-merchandise
v-else-if="isMerchandise"
:idReception="idReception"
:disabled="!auth.isAdmin"
/>
<update-weight
v-if="activeTab === 'weights'"
:idReception="idReception"
:disabled="!auth.isAdmin"
/>
<update-bovin
v-else
: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>
@@ -168,6 +184,7 @@ 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>({
@@ -249,7 +266,7 @@ const clearReceptionBovines = async (receptionIri: string) => {
}
}
const hydrateFromUser = (reception: ReceptionData | null)=> {
const hydrateFromUser = (reception: ReceptionData | null) => {
if (!reception) {
return
}
@@ -378,7 +395,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 = ''
@@ -395,7 +412,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}
@@ -532,7 +553,7 @@ async function validate() {
}
if (idReception) {
const updated = await receptionStore.updateReception(idReception,{
const updated = await receptionStore.updateReception(idReception, {
...payload
})
if (updated) {

View File

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

View File

@@ -9,16 +9,17 @@
/>
</div>
<button
<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
</button>
</UiButton>
</div>
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
<ShipmentWeight v-if="storeShipment?.currentStep === 1" mode="gross"/>
<ShipmentWeight v-if="storeShipment?.currentStep >= 2" mode="tare"/>
<ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
<ShipmentWeight v-if="storeShipment?.currentStep === 3" mode="tare"/>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<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>
<UiDataTable
:columns="columns"
url="shipments"
:query="{ isValid: true }"
@row-click="goToShipment"
/>
</template>
<script setup lang="ts">
import {formatBovinShipments, formatWeights} from "~/utils/datatable-formatters";
const router = useRouter()
const columns = [
{key: 'identificationNumber', label: 'Numero',isSearchable:true},
{key: 'shipmentDate', label: 'Date de livraison',isSearchable:true, type:'date'},
{key: 'customer.name', label: 'Client',isSearchable:true},
{key: 'address.fullAddress', label: 'Adresse',isSearchable:true},
{key: 'bovinShipments', label: 'Type', format:formatBovinShipments},
{key: 'weights', label: 'Poids', format: formatWeights}
]
type ReceptionRow = {
id?: number | string
}
const goToShipment = (row: ReceptionRow) => {
const id = Number(row?.id)
if (!Number.isFinite(id)) return
router.push(`/shipment/update/${id}`)
}
</script>

View File

@@ -0,0 +1,38 @@
<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>
<UiDataTable
:columns="columns"
url="shipments"
:query="{ isValid: false }"
@row-click="goToShipment"
/>
</template>
<script setup lang="ts">
import {formatBovinShipments} from "~/utils/datatable-formatters";
const router = useRouter()
const columns = [
{key: 'customer.name', label: 'Client', isSearchable:true},
{key: 'address.fullAddress', label: 'Adresse', isSearchable:true},
{key: 'carrier.name', label: 'Transporteur', isSearchable:true},
{key: 'bovinShipments', label: 'Type', format:formatBovinShipments},
{key: 'licencePlate', label: 'Immatriculation', isSearchable:true},
]
type ReceptionRow = {
id?: number | string
}
const goToShipment = (row: ReceptionRow) => {
const id = Number(row?.id)
if (!Number.isFinite(id)) return
router.push(`/shipment/${id}`)
}
</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,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,23 +1,43 @@
import { useApi } from '~/composables/useApi'
import type { CustomerData } from '~/services/dto/customer-data'
import { useApi } from "~/composables/useApi"
import type { CustomerData, CustomerPayload } from "~/services/dto/customer-data"
export type CustomerListResponse =
| CustomerData[]
| { 'hydra:member'?: CustomerData[] }
| { "hydra:member"?: CustomerData[] }
export async function getCustomerList(): Promise<CustomerData[]> {
const api = useApi()
const response = await api.get<CustomerListResponse>('customers', {}, {
toastErrorKey: 'errors.customer.list'
const response = await api.get<CustomerListResponse>("customers", {}, {
toastErrorKey: "errors.customer.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 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

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

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

View File

@@ -0,0 +1,20 @@
export type Row = Record<string, unknown>
export type ColumnConfig = {
key: string
label?: string
format?: (value: unknown, row: Row) => string
isSearchable?: boolean
type?: string
}
type HydraCollection<T> = {
'hydra:member': T[]
'hydra:totalItems': number
}
export type AnyCollection<T> = HydraCollection<T> & {
member?: T[]
items?: T[]
totalItems?: number
}
export type PaginationItem = number | '...'

View File

@@ -1,6 +1,7 @@
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
@@ -21,6 +22,7 @@ export type ShipmentData = {
shipmentDate: string
currentStep: number
isValid: boolean
address?: AddressData | null
carrier?: CarrierData | null
truck?: TruckData | null
customer?: CustomerData | null

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

@@ -2,4 +2,5 @@ export interface WeightData {
weight: number | null
dsd: number | null
weighedAt: string | null
type : string | null
}

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

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

@@ -0,0 +1,70 @@
export const formatBovinShipments = (value: unknown): string => {
if (!Array.isArray(value) || value.length === 0) return '-'
return value.map((item: any) => {
const label = item?.shipmentType?.label ?? item?.shipmentType?.code ??
'Type inconnu'
const qty = item?.nbBovinSend ?? '-'
return `${label} : ${qty}`
}).join(', ')
}
export const formatWeights = (value: unknown): string => {
if (!Array.isArray(value) || value.length === 0) return '-'
let gross = 0
let tare = 0
for (const item of value as Array<{ type?: string; weight?:
unknown }>) {
const w = Number(item.weight)
if (!Number.isFinite(w)) continue
if (item.type === 'gross') gross += w
else if (item.type === 'tare') tare += w
}
return `${gross - tare} kg`
}
export const formatRoleLabels = (
value: unknown,
roleLabelByValue: Map<string, string>,
): string => {
if (!Array.isArray(value) || value.length === 0) {
return ' - '
}
return value
.map((role) => {
const key = String(role)
return roleLabelByValue.get(key) ?? key
})
.join(', ')
}
export const formatAddresses = (value: unknown): string => {
if (!Array.isArray(value) || value.length === 0) {
return " - "
}
if (typeof value[0] === 'string') {
return 'Adresses non chargées'
}
return value
.map((item) => {
if (!item || typeof item !== 'object') return '-'
const address = item as Record<string, unknown>
const street = String(address.street ?? '').trim()
const street2 = String(address.street2 ?? '').trim()
const postalCode = String(address.postalCode ?? '').trim()
const city = String(address.city ?? '').trim()
const countryCode = String(address.countryCode ?? '').trim().toUpperCase()
const firstLine = [street, street2].filter(Boolean).join(', ')
const secondLine = [postalCode, city].filter(Boolean).join(' ')
const finalLine = [firstLine, secondLine, countryCode].filter(Boolean).join(', ')
return finalLine || '-'
})
.join('\n')
}

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

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260218093842 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 address ADD full_address VARCHAR(400)');
$this->addSql('DROP INDEX idx_7049f4507be036fc');
$this->addSql('DROP INDEX uniq_weight_shipment_type');
$this->addSql('DROP INDEX uniq_weight_reception_type');
$this->addSql('ALTER INDEX idx_weight_shipment RENAME TO IDX_7CD55417BE036FC');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE address DROP full_address');
$this->addSql('CREATE INDEX idx_7049f4507be036fc ON bovin_shipment (shipment_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_weight_shipment_type ON weight (shipment_id, type)');
$this->addSql('CREATE UNIQUE INDEX uniq_weight_reception_type ON weight (reception_id, type)');
$this->addSql('ALTER INDEX idx_7cd55417be036fc RENAME TO idx_weight_shipment');
}
}

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

@@ -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;
@@ -14,6 +16,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ORM\Table(name: 'address')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
operations: [
new Get(
@@ -23,6 +26,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')",
)]
@@ -35,27 +48,31 @@ class Address
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment: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', 'customer:read', 'shipment: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', 'customer:read', 'shipment: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', 'customer:read', 'shipment: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', 'customer:read', 'shipment:read'])]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])]
private string $city = '';
#[ORM\Column(length: 400)]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])]
private string $fullAddress = '';
#[ORM\Column(name: 'country_code', length: 2)]
#[Groups(['address:read', 'supplier:read', 'customer:read'])]
#[Groups(['address:read', 'supplier:read', 'customer:read', 'address:write'])]
private string $countryCode = '';
/**
@@ -153,16 +170,21 @@ class Address
return $this;
}
#[Groups(['address:read', 'supplier:read', 'reception:read', 'shipment:read', 'customer:read'])]
public function getFullAddress(): string
{
$parts = array_filter([
$this->street,
$this->street2,
trim(sprintf('%s %s', $this->postalCode, $this->city)),
]);
return $this->fullAddress;
}
return implode(', ', $parts);
#[ORM\PrePersist]
#[ORM\PreUpdate]
public function updateFullAddress(): void
{
$this->fullAddress = trim(sprintf(
'%s %s %s',
$this->street ?? '',
$this->postalCode ?? '',
$this->city ?? ''
));
}
/**

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ApiFilter(SearchFilter::class, properties: ['shipment' => 'exact'])]
#[ORM\UniqueConstraint(name: 'uniq_bovin_shipment', columns: ['shipment_id', 'shipment_type_id'])]
#[ORM\UniqueConstraint(name: 'uniq_bovin_shipment_one_type', columns: ['shipment_id'])]
#[ORM\Table(name: 'bovin_shipment')]
#[ApiResource(
operations: [

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

@@ -19,9 +19,11 @@ 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']],

View File

@@ -4,10 +4,14 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
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;
@@ -15,6 +19,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ORM\Table(name: 'customer')]
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'ipartial',
])]
#[ApiResource(
operations: [
new Get(
@@ -24,6 +31,16 @@ use Symfony\Component\Serializer\Attribute\Groups;
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')",
)]
@@ -35,20 +52,24 @@ class Customer
#[Groups(['shipment:read', 'customer:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['customer:read', 'shipment:read'])]
private ?string $label = null;
#[ORM\Column(length: 180)]
#[Groups(['customer:read', 'customer:write', 'shipment:read'])]
private string $name = '';
#[ORM\Column(length: 255)]
#[Groups(['customer:read', 'shipment:read'])]
private ?string $code = null;
#[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'])]
#[Groups(['customer:read', 'customer:write'])]
#[ApiProperty(readableLink: true)]
private Collection $addresses;
@@ -62,24 +83,40 @@ class Customer
return $this->id;
}
public function getLabel(): ?string
public function getName(): string
{
return $this->label;
return $this->name;
}
public function setLabel(?string $label): void
public function setName(string $name): self
{
$this->label = $label;
$this->name = $name;
return $this;
}
public function getCode(): ?string
public function getEmail(): ?string
{
return $this->code;
return $this->email;
}
public function setCode(?string $code): void
public function setEmail(?string $email): self
{
$this->code = $code;
$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
@@ -87,8 +124,29 @@ class Customer
return $this->addresses;
}
public function setAddresses(Collection $addresses): void
public function setAddresses(iterable $addresses): self
{
$this->addresses = $addresses;
$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

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
@@ -29,6 +31,15 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'reception')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiFilter(SearchFilter::class, properties: [
'identificationNumber' => 'ipartial',
'supplier.name' => 'ipartial',
'carrier.name' => 'ipartial',
'licensePlate' => 'ipartial',
'receptionType.label' => 'ipartial',
'address.fullAddress' => 'ipartial',
])]
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
#[ApiResource(
operations: [
new Get(

View File

@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
@@ -26,6 +30,16 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'shipment')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiFilter(SearchFilter::class, properties: [
'identificationNumber' => 'ipartial',
'customer.name' => 'ipartial',
'carrier.name' => 'ipartial',
'licencePlate' => 'ipartial',
'bovinShipments' => 'ipartial',
'address.fullAddress' => 'ipartial',
])]
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
#[ApiResource(
operations: [
new Get(
@@ -325,7 +339,7 @@ class Shipment
return;
}
$number = sprintf('P-BR-%04d', $this->id);
$number = sprintf('P-BL-%04d', $this->id);
$this->identificationNumber = $number;
$args->getObjectManager()

View File

@@ -4,10 +4,14 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
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;
@@ -15,14 +19,29 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ORM\Table(name: 'supplier')]
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'ipartial',
])]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['supplier:read']],
security: "is_granted('ROLE_USER')"
),
new GetCollection(
normalizationContext: ['groups' => ['supplier:read']],
security: "is_granted('ROLE_USER')"
),
new Post(
normalizationContext: ['groups' => ['supplier:read']],
denormalizationContext: ['groups' => ['supplier:write']],
security: "is_granted('ROLE_ADMIN')",
),
new Patch(
normalizationContext: ['groups' => ['supplier:read']],
denormalizationContext: ['groups' => ['supplier:write']],
security: "is_granted('ROLE_ADMIN')",
),
],
security: "is_granted('ROLE_USER')",
@@ -36,15 +55,15 @@ class Supplier
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Groups(['supplier:read', 'reception:read'])]
#[Groups(['supplier:read', 'reception:read', 'supplier:write'])]
private string $name = '';
#[ORM\Column(length: 180, nullable: true)]
#[Groups(['supplier:read', 'reception:read'])]
#[Groups(['supplier:read', 'reception:read', 'supplier:write'])]
private ?string $email = null;
#[ORM\Column(length: 40, nullable: true)]
#[Groups(['supplier:read', 'reception:read'])]
#[Groups(['supplier:read', 'reception:read', 'supplier:write'])]
private ?string $phone = null;
/**
@@ -52,7 +71,7 @@ class Supplier
*/
#[ORM\ManyToMany(targetEntity: Address::class, inversedBy: 'suppliers')]
#[ORM\JoinTable(name: 'supplier_address')]
#[Groups(['supplier:read'])]
#[Groups(['supplier:read', 'supplier:write'])]
#[ApiProperty(readableLink: true)]
private Collection $addresses;
@@ -109,4 +128,30 @@ class Supplier
{
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

@@ -21,8 +21,12 @@ final class UserPasswordProcessor implements ProcessorInterface
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($data instanceof User) {
$plain = $data->getPassword();
if ('' !== $plain) {
$plain = $data->getPassword();
$previous = $context['previous_data'] ?? null;
if ($previous instanceof User && $plain === $previous->getPassword()) {
// Password not changed in payload: keep existing hash.
$data->setPassword($previous->getPassword());
} elseif ('' !== $plain) {
$data->setPassword($this->hasher->hashPassword(
$data,
$plain

View File

@@ -174,7 +174,7 @@
<td style="width:30%; text-align:right; vertical-align:top; font-size: 14px;">
<div style="display:inline-block; width:75mm; line-height:1.3;">
<strong>{{ shipment.customer ? shipment.customer.label : '-' }}</strong><br>
<strong>{{ shipment.customer ? shipment.customer.name : '-' }}</strong><br>
<span>{{ shipment.address ? shipment.address.street : '' }}</span><br>
{% if shipment.address and shipment.address.street2 %}
<span>{{ shipment.address.street2 }}</span><br>
@@ -198,7 +198,7 @@
</tr>
<tr>
<td style="width:55%; text-align:center;">
{{ shipment.customer ? shipment.customer.code : '-' }}
{{ shipment.customer ? shipment.customer.name : '-' }}
</td>
<td style="width:20%; text-align:center; white-space:nowrap;">
{{ shipment.shipmentDate|date('d/m/Y') }}
@@ -276,7 +276,7 @@
<td style="width:60%; padding-right:8mm; vertical-align:top;">
<div class="meta">
<p>Transporteur : {{ shipment.carrier ? shipment.carrier.name : '-' }}</p>
<p>Mode de livraison : {{ shipment.truck ? shipment.truck.name : '-' }}</p>
<p>Mode d'expédition : {{ shipment.truck ? shipment.truck.name : '-' }}</p>
<p>Immatriculation : {{ shipment.licencePlate ?? '-' }}</p>
</div>
</td>