Compare commits

...

10 Commits

Author SHA1 Message Date
gitea-actions 1c0cdeb085 chore: bump version to v0.0.74
Auto Tag Develop / tag (push) Failing after 1s
2026-03-18 16:53:27 +00:00
tristan 465339cdd6 fix : order navbar + modification création fournisseur et client 2026-03-18 17:53:17 +01:00
gitea-actions 2bc484574f chore: bump version to v0.0.73 2026-03-18 14:25:22 +00:00
tristan ea1e3b074c fix : script de déploiement + CI/CD build de l'app 2026-03-18 15:25:13 +01:00
gitea-actions 4944611088 chore: bump version to v0.0.72 2026-03-18 14:06:35 +00:00
tristan fbfc7acfe4 feat : ajout de l'api de l'état pour chercher les villes via le CP 2026-03-18 15:06:21 +01:00
gitea-actions 92f54f600f chore: bump version to v0.0.71 2026-03-18 13:47:14 +00:00
tristan a905c6a1de fix : correction des retours de la V0 2026-03-18 14:47:03 +01:00
gitea-actions 995e7de2cc chore: bump version to v0.0.70 2026-03-18 09:38:23 +00:00
tristan 2408ccab67 fix : on ne pèse plus automatiquement + fix message de création réception 2026-03-18 10:38:11 +01:00
71 changed files with 2168 additions and 1741 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npm run:*)",
"WebFetch(domain:geo.api.gouv.fr)"
]
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
run: | run: |
cd frontend cd frontend
npm ci npm ci
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ NUXT_PUBLIC_GEO_API_BASE=https://geo.api.gouv.fr npm run generate
test -f .output/public/index.html test -f .output/public/index.html
- name: Build artefact - name: Build artefact
+78 -77
View File
@@ -4,13 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : ajout de supplier dans la feed et fixtures"> <list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : script de ploiement + CI/CD build de l'app">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="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$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/layouts/default.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/layouts/default.vue" afterDir="false" /> <change beforePath="$PROJECT_DIR$/frontend/layouts/default.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/layouts/default.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/login.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/login.vue" afterDir="false" /> <change beforePath="$PROJECT_DIR$/frontend/pages/admin/customer/[[id]].vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/admin/customer/[[id]].vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Command/SeedCommand.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Command/SeedCommand.php" afterDir="false" /> <change beforePath="$PROJECT_DIR$/frontend/pages/admin/supplier/[[id]].vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/admin/supplier/[[id]].vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/DataFixtures/ReferenceFixtures.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/DataFixtures/ReferenceFixtures.php" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -226,36 +225,36 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"RunOnceActivity.MCP Project settings loaded": "true", &quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"git-widget-placeholder": "develop", &quot;git-widget-placeholder&quot;: &quot;develop&quot;,
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme", &quot;last_opened_file_path&quot;: &quot;//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"settings.editor.selected.configurable": "advanced.settings", &quot;settings.editor.selected.configurable&quot;: &quot;advanced.settings&quot;,
"ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external", &quot;ts.external.directory.path&quot;: &quot;/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external&quot;,
"vue.rearranger.settings.migration": "true" &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}, },
"keyToStringList": { &quot;keyToStringList&quot;: {
"DatabaseDriversLRU": [ &quot;DatabaseDriversLRU&quot;: [
"postgresql" &quot;postgresql&quot;
], ],
"com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File": [ &quot;com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File&quot;: [
"TEXT" &quot;TEXT&quot;
], ],
"vue.recent.templates": [ &quot;vue.recent.templates&quot;: [
"Vue Composition API Component" &quot;Vue Composition API Component&quot;
] ]
} }
}]]></component> }</component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" /> <recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
@@ -265,11 +264,11 @@
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\components\shipment" /> <recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\components\shipment" />
</key> </key>
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
<recent name="C:\Users\m-tristan\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" /> <recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\templates" /> <recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\templates" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" /> <recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches\Ferme_MCD\MCD_DOC" /> <recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches\Ferme_MCD\MCD_DOC" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\frontend\pages\reception" />
</key> </key>
</component> </component>
<component name="SharedIndexes"> <component name="SharedIndexes">
@@ -324,46 +323,8 @@
<workItem from="1773212999001" duration="652000" /> <workItem from="1773212999001" duration="652000" />
<workItem from="1773215356754" duration="5754000" /> <workItem from="1773215356754" duration="5754000" />
<workItem from="1773756072697" duration="5450000" /> <workItem from="1773756072697" duration="5450000" />
</task> <workItem from="1773766075191" duration="6202000" />
<task id="LOCAL-00022" summary="fix : correction du path URI pour la création d'un poids dans une réception"> <workItem from="1773824491213" duration="20110000" />
<option name="closed" value="true" />
<created>1769073690382</created>
<option name="number" value="00022" />
<option name="presentableId" value="LOCAL-00022" />
<option name="project" value="LOCAL" />
<updated>1769073690382</updated>
</task>
<task id="LOCAL-00023" summary="feat : Ajout du bundle Monolog pour la gestion des logs">
<option name="closed" value="true" />
<created>1769075990984</created>
<option name="number" value="00023" />
<option name="presentableId" value="LOCAL-00023" />
<option name="project" value="LOCAL" />
<updated>1769075990984</updated>
</task>
<task id="LOCAL-00024" summary="fix : affiche plus détail dans les logs en recette/prod">
<option name="closed" value="true" />
<created>1769077633390</created>
<option name="number" value="00024" />
<option name="presentableId" value="LOCAL-00024" />
<option name="project" value="LOCAL" />
<updated>1769077633390</updated>
</task>
<task id="LOCAL-00025" summary="fix : modification du script de déploiement pour corriger le problème d'écriture des logs de prod">
<option name="closed" value="true" />
<created>1769079030808</created>
<option name="number" value="00025" />
<option name="presentableId" value="LOCAL-00025" />
<option name="project" value="LOCAL" />
<updated>1769079030808</updated>
</task>
<task id="LOCAL-00026" summary="fix : doc de déploiement">
<option name="closed" value="true" />
<created>1769094376813</created>
<option name="number" value="00026" />
<option name="presentableId" value="LOCAL-00026" />
<option name="project" value="LOCAL" />
<updated>1769094376813</updated>
</task> </task>
<task id="LOCAL-00027" summary="fix : doc et script de déploiement"> <task id="LOCAL-00027" summary="fix : doc et script de déploiement">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -717,7 +678,47 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1773761787472</updated> <updated>1773761787472</updated>
</task> </task>
<option name="localTasksCounter" value="71" /> <task id="LOCAL-00071" summary="feat : ajout de bâtiment dans les fixtures et seed + organisation du menu">
<option name="closed" value="true" />
<created>1773766207721</created>
<option name="number" value="00071" />
<option name="presentableId" value="LOCAL-00071" />
<option name="project" value="LOCAL" />
<updated>1773766207721</updated>
</task>
<task id="LOCAL-00072" summary="fix : on ne pèse plus automatiquement + fix message de création réception">
<option name="closed" value="true" />
<created>1773826699115</created>
<option name="number" value="00072" />
<option name="presentableId" value="LOCAL-00072" />
<option name="project" value="LOCAL" />
<updated>1773826699115</updated>
</task>
<task id="LOCAL-00073" summary="fix : correction des retours de la V0">
<option name="closed" value="true" />
<created>1773841634554</created>
<option name="number" value="00073" />
<option name="presentableId" value="LOCAL-00073" />
<option name="project" value="LOCAL" />
<updated>1773841634554</updated>
</task>
<task id="LOCAL-00074" summary="feat : ajout de l'api de l'état pour chercher les villes via le CP">
<option name="closed" value="true" />
<created>1773842791819</created>
<option name="number" value="00074" />
<option name="presentableId" value="LOCAL-00074" />
<option name="project" value="LOCAL" />
<updated>1773842791819</updated>
</task>
<task id="LOCAL-00075" summary="fix : script de déploiement + CI/CD build de l'app">
<option name="closed" value="true" />
<created>1773843922376</created>
<option name="number" value="00075" />
<option name="presentableId" value="LOCAL-00075" />
<option name="project" value="LOCAL" />
<updated>1773843922377</updated>
</task>
<option name="localTasksCounter" value="76" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -767,11 +768,6 @@
</option> </option>
</component> </component>
<component name="VcsManagerConfiguration"> <component name="VcsManagerConfiguration">
<MESSAGE value="feat : update CHANGELOG.md" />
<MESSAGE value="feat : ajout de commentaire" />
<MESSAGE value="fix : correction de l'affichage de l'immatriculation sur une réception en cours + correction css étape 3 d'une réception" />
<MESSAGE value="feat : ajout de colonne pour les Supplier, Address et modification du numéro de réception" />
<MESSAGE value="feat : ajout de colonne pour les Supplier, Address. Modification du numéro de réception et ajout de fixtures" />
<MESSAGE value="feat : mise à jour du bon de réception" /> <MESSAGE value="feat : mise à jour du bon de réception" />
<MESSAGE value="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)" /> <MESSAGE value="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)" />
<MESSAGE value="feat : creer une nouvelle expedtion (WIP)" /> <MESSAGE value="feat : creer une nouvelle expedtion (WIP)" />
@@ -792,7 +788,12 @@
<MESSAGE value="feat : front page admin bovin et changelog" /> <MESSAGE value="feat : front page admin bovin et changelog" />
<MESSAGE value="fix : on ne bloque plus le poids max d'une pesée" /> <MESSAGE value="fix : on ne bloque plus le poids max d'une pesée" />
<MESSAGE value="feat : ajout de supplier dans la feed et fixtures" /> <MESSAGE value="feat : ajout de supplier dans la feed et fixtures" />
<option name="LAST_COMMIT_MESSAGE" value="feat : ajout de supplier dans la feed et fixtures" /> <MESSAGE value="feat : ajout de bâtiment dans les fixtures et seed + organisation du menu" />
<MESSAGE value="fix : on ne pèse plus automatiquement + fix message de création réception" />
<MESSAGE value="fix : correction des retours de la V0" />
<MESSAGE value="feat : ajout de l'api de l'état pour chercher les villes via le CP" />
<MESSAGE value="fix : script de déploiement + CI/CD build de l'app" />
<option name="LAST_COMMIT_MESSAGE" value="fix : script de déploiement + CI/CD build de l'app" />
</component> </component>
<component name="XDebuggerManager"> <component name="XDebuggerManager">
<breakpoint-manager> <breakpoint-manager>
+39 -6
View File
@@ -59,10 +59,18 @@ src/
frontend/ frontend/
├── components/ ├── components/
│ ├── ui/ # Composants réutilisables, auto-importés avec préfixe Ui (ex: UiLoadingDots) │ ├── ui/ # Composants réutilisables, auto-importés avec préfixe Ui (ex: UiLoadingDots)
── reception/ # Composants métier réception ── reception/ # Composants métier réception
├── composables/ # useApi, useWeighing, usePdfPrinter, useAppVersion │ ├── shipment/ # Composants métier expédition
│ ├── workflow/ # Composants partagés réception/expédition (workflow-weight, workflow-waiting-list, workflow-liot-fields)
│ └── commun/ # Composants communs (update-weight)
├── composables/ # useApi, useWeighing, usePdfPrinter, useAppVersion, useLiotHandling, useFormDataLoading, useAddressSync, useWorkflowSteps
│ └── steps/ # useWeighingStep (logique étape pesée)
├── config/ # reception.config.ts, shipment.config.ts (WorkflowConfig)
├── types/ # workflow.ts (interfaces partagées WorkflowEntity, WorkflowConfig, StepDefinition)
├── services/ # Couche service avec DTOs typés dans services/dto/ ├── services/ # Couche service avec DTOs typés dans services/dto/
│ └── workflow-service.ts # Factory service API (createWorkflowService)
├── stores/ # Pinia stores (reception, shipment, auth) ├── stores/ # Pinia stores (reception, shipment, auth)
│ └── workflow-store.ts # Factory store (useWorkflowStoreLogic)
├── pages/ # Pages Nuxt (file-based routing) ├── pages/ # Pages Nuxt (file-based routing)
├── layouts/ # Layout default : max-width 1050px ├── layouts/ # Layout default : max-width 1050px
├── i18n/locales/ # Traductions (défaut: fr) ├── i18n/locales/ # Traductions (défaut: fr)
@@ -78,6 +86,7 @@ frontend/
- `config/reference.php` est auto-généré — ne pas modifier à la main. - `config/reference.php` est auto-généré — ne pas modifier à la main.
- Endpoints toujours au pluriel (convention API Platform). - Endpoints toujours au pluriel (convention API Platform).
- Ne jamais créer de GET qui crée des ressources : utiliser POST + PATCH. - Ne jamais créer de GET qui crée des ressources : utiliser POST + PATCH.
- Les noms de `Supplier`, `Customer` et `Carrier` sont automatiquement mis en majuscule via `mb_strtoupper` dans `setName()`.
## Conventions frontend ## Conventions frontend
@@ -89,21 +98,44 @@ frontend/
- Nav active : `NuxtLink` avec slot `custom`. - Nav active : `NuxtLink` avec slot `custom`.
- PDFs : `usePdfPrinter` (receipt réception, rapport poids cases). - PDFs : `usePdfPrinter` (receipt réception, rapport poids cases).
### Validation required & erreurs visuelles
- Les champs `required` utilisent l'attribut HTML natif forwardé via `v-bind="attrs"` dans les composants UI.
- La bordure rouge n'apparaît qu'après soumission grâce à la classe CSS `submitted` ajoutée sur le `<form>` au clic sur le bouton Valider (`@click="submitted = true"`).
- Règles CSS globales dans `main.css` : `.submitted :invalid` (bordure + texte rouge), `.submitted :has(:invalid) > label` et `.submitted label:has(:invalid)` (labels rouges).
- Pour les validations manuelles (checkboxes, radio groups), les messages d'erreur utilisent `invisible` (pas `hidden`) pour garder l'espace réservé et éviter les décalages de layout.
- Les dates de l'API sont renvoyées au format `Y-m-d H:i` ; les formulaires utilisent `.slice(0, 10)` pour extraire la date seule (compatible `<input type="date">`).
### Workflow réception/expédition (mutualisé)
- Factory service `createWorkflowService` et factory store `useWorkflowStoreLogic` pour éviter la duplication.
- Composables partagés : `useLiotHandling` (logique LIOT), `useFormDataLoading` (users, trucks, carriers), `useAddressSync` (sync adresse fournisseur/client).
- `useWeighing` : une seule fonction paramétrée pour réception et expédition (remplace `useWeighing` + `useWeighingShipment`).
- Configs workflow dans `config/reception.config.ts` et `config/shipment.config.ts` (étapes, labels pesée, filename PDF).
- `WorkflowWeight` composant partagé pour les étapes de pesée (remplace `reception-weight.vue` et `shipment-weight.vue`).
- `WorkflowWaitingList` composant partagé pour les listes en attente, avec support colonnes dynamiques, slot `actions`, et prop `showActions`.
## Domaine métier clé ## Domaine métier clé
### Réception (pesée pont-bascule) ### Réception (pesée pont-bascule)
- Entité principale `Reception` : `date_reception` (DateTimeImmutable, format `Y-m-d`), `identification_number` (auto `N-BR-####`), `current_step` (défaut 0), `is_valid` (défaut false). - Entité principale `Reception` : `date_reception` (DateTimeImmutable, format lecture `Y-m-d H:i`, écriture `Y-m-d`), `identification_number` (auto `N-BR-####`), `current_step` (défaut 0), `is_valid` (défaut false).
- `Weight` (1-N avec Reception) : `type` (`gross`/`tare`), `dsd`, `weight`, `weighed_at`. - `Weight` (1-N avec Reception, cascade remove + orphanRemoval) : `type` (`gross`/`tare`), `dsd`, `weight`, `weighed_at`.
- Endpoint pesée : `/receptions/weigh``PontBasculeReading` (dsd, weight, weighedAt). - Endpoint pesée : `/receptions/weigh``PontBasculeReading` (dsd, weight, weighedAt).
- Endpoint suppression : `DELETE /receptions/{id}` — supprime en cascade weights, pelletBuildings, bovines.
- Parsing payload pont-bascule : `Service/PontBasculePayloadDecoder.php`. - Parsing payload pont-bascule : `Service/PontBasculePayloadDecoder.php`.
- Exception : `PontBasculeException` (messages en français, mappée 500). - Exception : `PontBasculeException` (messages en français, mappée 500).
- Store Pinia `reception.ts` = source de vérité pour la réception en cours. - Store Pinia `reception.ts` = source de vérité pour la réception en cours.
- UI multi-étapes dans `pages/reception/[[id]].vue` basée sur `currentStep`. - UI multi-étapes dans `pages/reception/[[id]].vue` basée sur `currentStep`.
- `PrePersist` : injecte l'heure courante sur `receptionDate` à la création ; `setReceptionDate` préserve l'heure existante au PATCH.
### Expédition
- Entité `Shipment` : même pattern que Reception, `shipment_date` (format lecture `Y-m-d H:i`, écriture `Y-m-d`).
- Endpoint suppression : `DELETE /shipments/{id}`.
- `PrePersist` : injecte l'heure courante sur `shipmentDate` ; `setShipmentDate` préserve l'heure au PATCH.
### LIOT (transport) ### LIOT (transport)
- Si carrier code = `LIOT` : afficher sélecteurs driver + vehicle, masquer saisie plaque manuelle. - Si carrier code = `LIOT` : afficher sélecteurs driver + vehicle, masquer saisie plaque manuelle.
- Liste véhicules filtrée par type de camion et transporteur. - Liste véhicules filtrée par type de camion et transporteur.
- Le véhicule sélectionné alimente `license_plate`. - Le véhicule sélectionné alimente `license_plate`.
- Logique mutualisée dans `composables/useLiotHandling.ts`.
### Bovins & infrastructure ### Bovins & infrastructure
- `Bovine` : `nationalNumber` (unique), `receivedWeight`, `arrivalDate`, `buildingCase` (ManyToOne). - `Bovine` : `nationalNumber` (unique), `receivedWeight`, `arrivalDate`, `buildingCase` (ManyToOne).
@@ -111,12 +143,13 @@ frontend/
- Rapport PDF cases : `GET /building_cases/{id}/weights-report` → template Twig, projection depuis `arrivalDate`, gain journalier fixe `1.3 kg/jour`. - Rapport PDF cases : `GET /building_cases/{id}/weights-report` → template Twig, projection depuis `arrivalDate`, gain journalier fixe `1.3 kg/jour`.
### Données de référence ### Données de référence
- `ReceptionType`, `MerchandiseType`, `PelletType`, `Building`, `Supplier` (avec `Address` via join table), `Truck`, `Carrier`, `Driver`, `Vehicle`. - `ReceptionType`, `MerchandiseType`, `PelletType`, `Building`, `Supplier` (avec `Address` via join table, `createdBy` → User), `Customer` (avec `Address` via join table, `createdBy` → User), `Truck`, `Carrier`, `Driver`, `Vehicle`.
- `Address` expose `fullAddress` via getter. - `Address` : champ `label` nullable (déprécié, retiré du front et du `address:write`), expose `fullAddress` via getter. `countryCode` par défaut `FR` côté front.
### Seed & fixtures ### Seed & fixtures
- Commande `app:seed` : seed infrastructure (statut, building_layout, building_case, building_case_position) puis bovins. - Commande `app:seed` : seed infrastructure (statut, building_layout, building_case, building_case_position) puis bovins.
- Utilise des flush intermédiaires pour que les queries find fonctionnent sur les records fraîchement créés. - Utilise des flush intermédiaires pour que les queries find fonctionnent sur les records fraîchement créés.
- `upsertAddress` cherche par `street|postalCode` (plus par `label`).
- Fixtures : `BuildingInfrastructureFixtures` + `BovineFixtures` (via dépendances `AppFixtures`). - Fixtures : `BuildingInfrastructureFixtures` + `BovineFixtures` (via dépendances `AppFixtures`).
## Environnement ## Environnement
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.0.69' app.version: '0.0.74'
+14
View File
@@ -7,3 +7,17 @@
@apply font-sans; @apply font-sans;
} }
} }
@layer utilities {
.submitted :invalid {
@apply border-red-500 text-red-500;
}
.submitted :has(:invalid) > label {
@apply text-red-500;
}
.submitted label:has(:invalid) {
@apply text-red-500;
}
}
+58 -10
View File
@@ -1,5 +1,5 @@
<template> <template>
<form @submit.prevent="validateForm"> <form :class="{ submitted }" @submit.prevent="validateForm">
<div class="flex items-center mb-11 justify-between relative"> <div class="flex items-center mb-11 justify-between relative">
<div class="flex flex-row absolute -left-[60px] "> <div class="flex flex-row absolute -left-[60px] ">
<Icon @click="goBack" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/> <Icon @click="goBack" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
@@ -10,11 +10,18 @@
</div> </div>
<div class="grid grid-cols-2 gap-y-16 gap-x-[200px] mb-16"> <div class="grid grid-cols-2 gap-y-16 gap-x-[200px] mb-16">
<UiTextInput id="address-label" v-model="form.label" label="Libellé" /> <UiTextInput id="address-street" v-model="form.street" label="Rue" required />
<UiTextInput id="address-street" v-model="form.street" label="Rue" />
<UiTextInput id="address-street2" v-model="form.street2" label="Complément" /> <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-postalCode" v-model="form.postalCode" label="Code postal" required />
<UiTextInput id="address-city" v-model="form.city" label="Ville" /> <UiSelect
id="address-city"
v-model="form.city"
label="Ville"
:options="communeOptions"
:loading="isLoadingCities"
:disabled="communes.length === 0"
required
/>
<UiTextInput id="address-country" v-model="form.countryCode" label="Pays (code)" /> <UiTextInput id="address-country" v-model="form.countryCode" label="Pays (code)" />
</div> </div>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
@@ -22,6 +29,7 @@
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit" type="submit"
:disabled="isLoading" :disabled="isLoading"
@click="submitted = true"
> >
Valider Valider
</UiButton> </UiButton>
@@ -31,7 +39,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AddressPayload } from "~/services/address" import type { AddressPayload } from "~/services/address"
import { getCommunesByPostalCode, type CommuneData } from "~/services/geo"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -42,14 +50,20 @@ const props = defineProps<{
}>() }>()
const isLoading = ref(false) const isLoading = ref(false)
const submitted = ref(false)
const communes = ref<CommuneData[]>([])
const isLoadingCities = ref(false)
const communeOptions = computed(() =>
communes.value.map(c => ({ value: c.nom, label: c.nom }))
)
const emptyForm = (): AddressPayload => ({ const emptyForm = (): AddressPayload => ({
label: "",
street: "", street: "",
street2: null, street2: null,
postalCode: "", postalCode: "",
city: "", city: "",
countryCode: "", countryCode: "FR",
}) })
const form = reactive<AddressPayload>(emptyForm()) const form = reactive<AddressPayload>(emptyForm())
@@ -70,12 +84,11 @@ const backPath = computed(() => {
const hydrateForm = (address?: AddressPayload | null) => { const hydrateForm = (address?: AddressPayload | null) => {
const data = address ?? emptyForm() const data = address ?? emptyForm()
form.label = data.label ?? ""
form.street = data.street ?? "" form.street = data.street ?? ""
form.street2 = data.street2 ?? null form.street2 = data.street2 ?? null
form.postalCode = data.postalCode ?? "" form.postalCode = data.postalCode ?? ""
form.city = data.city ?? "" form.city = data.city ?? ""
form.countryCode = data.countryCode ?? "" form.countryCode = data.countryCode || "FR"
} }
watch( watch(
@@ -86,6 +99,41 @@ watch(
{ immediate: true } { immediate: true }
) )
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(
() => form.postalCode,
(cp) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!cp || cp.length < 5) {
communes.value = []
form.city = ''
return
}
if (cp.length === 5) {
debounceTimer = setTimeout(async () => {
isLoadingCities.value = true
const previousCity = form.city
try {
communes.value = await getCommunesByPostalCode(cp)
if (communes.value.length === 1) {
form.city = communes.value[0].nom
} else if (communes.value.some(c => c.nom === previousCity)) {
form.city = previousCity
} else {
form.city = ''
}
} finally {
isLoadingCities.value = false
}
}, 300)
}
}
)
const validateForm = () => { const validateForm = () => {
if (isLoading.value) return if (isLoading.value) return
emit("validate", {...form}) emit("validate", {...form})
@@ -9,12 +9,14 @@
:disabled="!isAdmin" :disabled="!isAdmin"
:min="0" :min="0"
wrapper-class="flex-col" wrapper-class="flex-col"
required
/> />
<UiDateInput <UiDateInput
label="Date de pesée" label="Date de pesée"
v-model="localWeight.weighedAt" v-model="localWeight.weighedAt"
:disabled="!isAdmin" :disabled="!isAdmin"
required
/> />
<UiNumberInput <UiNumberInput
@@ -24,6 +26,7 @@
v-model="localWeight.dsd" v-model="localWeight.dsd"
:disabled="!isAdmin" :disabled="!isAdmin"
wrapper-class="flex-col" wrapper-class="flex-col"
required
/> />
</div> </div>
</form> </form>
@@ -1,7 +1,9 @@
<template> <template>
<div <form
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS" v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"
class="flex flex-col gap-16"> class="flex flex-col gap-16"
@submit.prevent="goNext"
>
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1> <h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1>
<div <div
class="flex flex-row gap-8 items-center w-full"> class="flex flex-row gap-8 items-center w-full">
@@ -31,15 +33,17 @@
/> />
</div> </div>
</div> </div>
<p class="text-red-500 text-sm" :class="showBovineError ? '' : 'invisible'">
Veuillez saisir au moins une race bovine.
</p>
<div class="flex justify-center"> <div class="flex justify-center">
<UiButton <UiButton
type="submit" type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="goNext"
>Valider >Valider
</UiButton> </UiButton>
</div> </div>
</div> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {BovineTypeData} from "~/services/dto/bovine-type-data"; import type {BovineTypeData} from "~/services/dto/bovine-type-data";
@@ -58,6 +62,7 @@ const toast = useToast()
const isLoadingBovineType = ref(false) const isLoadingBovineType = ref(false)
const bovineType = ref<BovineTypeData[]>([]) const bovineType = ref<BovineTypeData[]>([])
const receptionStore = useReceptionStore() const receptionStore = useReceptionStore()
const showBovineError = ref(false)
const bovineQuantities = reactive<Record<string, number | null>>({}) const bovineQuantities = reactive<Record<string, number | null>>({})
const otherQuantity = ref<number | null>(0) const otherQuantity = ref<number | null>(0)
const receptionId = computed(() => receptionStore.current?.id ?? null) const receptionId = computed(() => receptionStore.current?.id ?? null)
@@ -169,7 +174,13 @@ async function goNext() {
return return
} }
// @TODO Ajouter un composable pour le toaster qui gère les key i18n showBovineError.value = false
if (totalBovines.value === 0) {
showBovineError.value = true
return
}
if (totalBovines.value > 52) { if (totalBovines.value > 52) {
toast.error({ toast.error({
title: 'Erreur', title: 'Erreur',
+67 -321
View File
@@ -1,8 +1,7 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<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-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">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 <UiSelect
id="reception-user" id="reception-user"
v-model="form.userId" v-model="form.userId"
@@ -13,15 +12,15 @@
}))" }))"
:loading="isLoadingUsers" :loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2" wrapper-class="col-start-1 row-start-2"
required
/> />
<!-- Date de réception -->
<UiDateInput <UiDateInput
id="reception-date" id="reception-date"
v-model="form.receptionDate" v-model="form.receptionDate"
label="Date de réception" label="Date de réception"
wrapper-class="col-start-1 row-start-3" wrapper-class="col-start-1 row-start-3"
required
/> />
<!-- Type de réception -->
<UiSelect <UiSelect
id="reception-type" id="reception-type"
v-model="form.receptionTypeId" v-model="form.receptionTypeId"
@@ -31,8 +30,8 @@
label: type.label label: type.label
}))" }))"
wrapper-class="col-start-1 row-start-4" wrapper-class="col-start-1 row-start-4"
required
/> />
<!-- Fournisseur -->
<UiSelect <UiSelect
id="reception-supplier" id="reception-supplier"
v-model="form.supplierId" v-model="form.supplierId"
@@ -43,20 +42,17 @@
}))" }))"
:loading="isLoadingSuppliers" :loading="isLoadingSuppliers"
wrapper-class="col-start-1 row-start-5" wrapper-class="col-start-1 row-start-5"
required
/> />
<!-- Adresse fournisseur -->
<UiSelect <UiSelect
id="reception-address" id="reception-address"
v-model="form.addressId" v-model="form.addressId"
label="Adresse" label="Adresse"
:options="supplierAddresses.map((address) => ({ :options="addressOptions"
value: String(address.id), :disabled="isLoadingSuppliers || ownerAddresses.length === 0"
label: address.fullAddress
}))"
:disabled="isLoadingSuppliers || supplierAddresses.length === 0"
wrapper-class="col-start-2 row-start-1" wrapper-class="col-start-2 row-start-1"
required
/> />
<!-- Camion -->
<UiSelect <UiSelect
id="reception-truck" id="reception-truck"
v-model="form.truckId" v-model="form.truckId"
@@ -67,8 +63,8 @@
}))" }))"
:loading="isLoadingTrucks" :loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2" wrapper-class="col-start-2 row-start-2"
required
/> />
<!-- Transporteur -->
<UiSelect <UiSelect
id="reception-carrier" id="reception-carrier"
v-model="form.carrierId" v-model="form.carrierId"
@@ -80,15 +76,15 @@
:loading="isLoadingCarriers" :loading="isLoadingCarriers"
select-class="h-[34px]" select-class="h-[34px]"
wrapper-class="col-start-2 row-start-3" wrapper-class="col-start-2 row-start-3"
required
/> />
<!-- Plaque d'immatriculation -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4"> <div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput <UiLicensePlateInput
v-model="form.licensePlate" v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate" v-model:allowAny="allowAnyLicensePlate"
required
/> />
</div> </div>
<!-- Immatriculation (LIOT) -->
<UiSelect <UiSelect
v-if="isLiotCarrier" v-if="isLiotCarrier"
id="reception-vehicle" id="reception-vehicle"
@@ -101,8 +97,8 @@
:loading="isLoadingVehicles" :loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0" :disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4 h-[64px]" wrapper-class="col-start-2 row-start-4 h-[64px]"
required
/> />
<!-- Chauffeur (LIOT) -->
<UiSelect <UiSelect
id="reception-driver" id="reception-driver"
v-model="form.driverId" v-model="form.driverId"
@@ -114,42 +110,38 @@
:loading="isLoadingDrivers" :loading="isLoadingDrivers"
v-if="isLiotCarrier" v-if="isLiotCarrier"
wrapper-class="col-start-2 row-start-5" wrapper-class="col-start-2 row-start-5"
required
/> />
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<UiButton <UiButton
type="submit" type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="submitted = true"
>Valider >Valider
</UiButton> </UiButton>
</div> </div>
</form> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {useReceptionStore} from '~/stores/reception' import { useReceptionStore } from '~/stores/reception'
import type {ReceptionTypeData} from '~/services/dto/reception-type-data' import { useFormDataLoading } from '~/composables/useFormDataLoading'
import {getReceptionTypeList} from '~/services/reception-type' import { useLiotHandling } from '~/composables/useLiotHandling'
import type {UserData} from '~/services/dto/user-data' import { useAddressSync } from '~/composables/useAddressSync'
import {getUsers} from '~/services/auth' import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import {useAuthStore} from '~/stores/auth' import { getReceptionTypeList } from '~/services/reception-type'
import type {SupplierData} from '~/services/dto/supplier-data' import type { SupplierData } from '~/services/dto/supplier-data'
import {getSupplierList} from '~/services/supplier' import { getSupplierList } from '~/services/supplier'
import type {TruckData} from '~/services/dto/truck-data' import { RECEPTION_TYPE_CODES } from '~/utils/constants'
import {getTruckList} from '~/services/truck' import { deleteReceptionBovine, getReceptionBovineList } from '~/services/reception-bovine'
import type {CarrierData} from '~/services/dto/carrier-data' import type { ReceptionFormData } from '~/services/dto/reception-data'
import {getCarrierList} from '~/services/carrier'
import type {DriverData} from '~/services/dto/driver-data'
import {getDriverList} from '~/services/driver'
import type {VehicleData} from '~/services/dto/vehicle-data'
import {getVehicleList} from '~/services/vehicle'
import {RECEPTION_TYPE_CODES, SUPPLIER_CODE} from "~/utils/constants";
import {deleteReceptionBovine, getReceptionBovineList} from "~/services/reception-bovine";
import type {ReceptionFormData} from "~/services/dto/reception-data";
const router = useRouter() const router = useRouter()
const receptionStore = useReceptionStore() const receptionStore = useReceptionStore()
const isHydrating = ref(false)
const submitted = ref(false)
const form = reactive<ReceptionFormData>({ const form = reactive<ReceptionFormData>({
licensePlate: '', licensePlate: '',
receptionDate: new Date().toISOString().slice(0, 10), receptionDate: new Date().toISOString().slice(0, 10),
@@ -162,62 +154,27 @@ const form = reactive<ReceptionFormData>({
driverId: '', driverId: '',
vehicleId: '' vehicleId: ''
}) })
const allowAnyLicensePlate = ref(false)
const receptionTypes = ref<ReceptionTypeData[]>([]) const receptionTypes = ref<ReceptionTypeData[]>([])
const users = ref<UserData[]>([])
const isLoadingUsers = ref(false)
const suppliers = ref<SupplierData[]>([]) const suppliers = ref<SupplierData[]>([])
const isLoadingSuppliers = ref(false) const isLoadingSuppliers = ref(false)
const trucks = ref<TruckData[]>([])
const isLoadingTrucks = ref(false)
const carriers = ref<CarrierData[]>([])
const isLoadingCarriers = ref(false)
const drivers = ref<DriverData[]>([])
const isLoadingDrivers = ref(false)
const vehicles = ref<VehicleData[]>([])
const isLoadingVehicles = ref(false)
const authStore = useAuthStore()
// Empêche les watchers de reset des champs pendant le remplissage initial
const isHydrating = ref(false)
// Transporteur sélectionné dans le formulaire const { users, trucks, carriers, isLoadingUsers, isLoadingTrucks, isLoadingCarriers, loadCommonData } =
const selectedCarrier = computed(() => useFormDataLoading(form)
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
) const {
// Indique si le transporteur est LIOT isLiotCarrier, filteredDrivers, filteredVehicles,
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT) isLoadingDrivers, isLoadingVehicles, allowAnyLicensePlate,
// Adresses disponibles pour le fournisseur sélectionné loadDrivers, loadVehicles
const supplierAddresses = computed(() => { } = useLiotHandling(form, carriers, isHydrating)
const supplierId = Number(form.supplierId)
if (!Number.isFinite(supplierId)) { const supplierIdRef = computed(() => form.supplierId)
return [] const { ownerAddresses, addressOptions } = useAddressSync(form, supplierIdRef, suppliers)
}
return suppliers.value.find((supplier) => supplier.id === supplierId)?.addresses ?? []
})
// Chauffeurs filtrés par transporteur (LIOT)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) {
return []
}
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
// Véhicules filtrés par transporteur + type de camion
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) {
return []
}
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
const selectedReceptionType = computed(() => const selectedReceptionType = computed(() =>
receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null
) )
// Supprime les données bovines si on change de type de réception
const clearReceptionBovines = async (receptionIri: string) => { const clearReceptionBovines = async (receptionIri: string) => {
const existing = await getReceptionBovineList(receptionIri) const existing = await getReceptionBovineList(receptionIri)
for (const selection of existing) { for (const selection of existing) {
@@ -225,50 +182,6 @@ const clearReceptionBovines = async (receptionIri: string) => {
} }
} }
// Hydrate le formulaire depuis la réception en cours
watch(
() => receptionStore.current,
(reception) => {
isHydrating.value = true
form.licensePlate = reception?.licensePlate ?? ''
form.receptionDate = reception?.receptionDate ?? new Date().toISOString().slice(0, 10)
form.receptionTypeId = reception?.receptionType?.id
? String(reception.receptionType.id)
: ''
form.userId = reception?.user?.id
? String(reception.user.id)
: form.userId
form.supplierId = reception?.supplier?.id
? String(reception.supplier.id)
: ''
form.addressId = reception?.address?.id
? String(reception.address.id)
: ''
form.truckId = reception?.truck?.id
? String(reception.truck.id)
: ''
form.carrierId = reception?.carrier?.id
? String(reception.carrier.id)
: ''
form.driverId = reception?.driver?.id
? String(reception.driver.id)
: ''
isHydrating.value = false
},
{immediate: true}
)
// Charge la liste des users pour le select
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
// Charge la liste des fournisseurs pour le select
const loadSuppliers = async () => { const loadSuppliers = async () => {
isLoadingSuppliers.value = true isLoadingSuppliers.value = true
try { try {
@@ -278,185 +191,32 @@ const loadSuppliers = async () => {
} }
} }
// Charge la liste des camions pour le select watch(
const loadTrucks = async () => { () => receptionStore.current,
isLoadingTrucks.value = true (reception) => {
try { isHydrating.value = true
trucks.value = await getTruckList() form.licensePlate = reception?.licensePlate ?? ''
} finally { form.receptionDate = reception?.receptionDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)
isLoadingTrucks.value = false form.receptionTypeId = reception?.receptionType?.id ? String(reception.receptionType.id) : ''
} form.userId = reception?.user?.id ? String(reception.user.id) : form.userId
} form.supplierId = reception?.supplier?.id ? String(reception.supplier.id) : ''
form.addressId = reception?.address?.id ? String(reception.address.id) : ''
form.truckId = reception?.truck?.id ? String(reception.truck.id) : ''
form.carrierId = reception?.carrier?.id ? String(reception.carrier.id) : ''
form.driverId = reception?.driver?.id ? String(reception.driver.id) : ''
isHydrating.value = false
},
{ immediate: true }
)
// Charge la liste des transporteurs pour le select
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
// Charge la liste des chauffeurs pour le select
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
// Charge la liste des véhicules pour le select
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
// On met le user connecté par défaut dans le select
const setDefaultUser = () => {
if (form.userId) {
return
}
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
// On récupère toutes les données des selects au chargement du composant
onMounted(async () => { onMounted(async () => {
receptionTypes.value = await getReceptionTypeList() receptionTypes.value = await getReceptionTypeList()
await loadUsers()
await loadSuppliers() await loadSuppliers()
await loadTrucks() await loadCommonData()
await loadCarriers()
await loadDrivers() await loadDrivers()
await loadVehicles() await loadVehicles()
await authStore.ensureSession()
setDefaultUser()
}) })
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
watch(
() => [form.supplierId, form.addressId, suppliers.value],
() => {
if (!form.supplierId) {
form.addressId = ''
return
}
if (!form.addressId && supplierAddresses.value.length === 1) {
form.addressId = String(supplierAddresses.value[0].id)
return
}
if (!form.addressId) {
return
}
const matches = supplierAddresses.value.some(
(address) => String(address.id) === form.addressId
)
if (!matches) {
if (supplierAddresses.value.length === 1) {
form.addressId = String(supplierAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{immediate: true}
)
// Valide/auto-sélectionne le véhicule selon camion + transporteur (LIOT)
watch(
() => form.carrierId,
() => {
if (isHydrating.value) {
return
}
if (!form.carrierId) {
form.driverId = ''
form.vehicleId = ''
return
}
if (!isLiotCarrier.value) {
form.driverId = ''
form.vehicleId = ''
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
},
{immediate: true}
)
// Récupère la plaque depuis le véhicule choisi (LIOT)
watch(
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
return
}
if (!form.vehicleId) {
return
}
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
}
},
{immediate: true}
)
// Auto-renseigne le véhicule si la plaque correspond (LIOT)
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (isHydrating.value) {
return
}
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
watch(
() => [form.licensePlate, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) {
return
}
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
form.vehicleId = String(match.id)
}
}
)
// Valide le formulaire et crée/met à jour la réception
async function validate() { async function validate() {
const normalizedLicensePlate = form.licensePlate.trim() const normalizedLicensePlate = form.licensePlate.trim()
const normalizedReceptionDate = form.receptionDate.trim() const normalizedReceptionDate = form.receptionDate.trim()
@@ -467,27 +227,14 @@ async function validate() {
const normalizedTruckId = form.truckId.trim() const normalizedTruckId = form.truckId.trim()
const normalizedCarrierId = form.carrierId.trim() const normalizedCarrierId = form.carrierId.trim()
const normalizedDriverId = form.driverId.trim() const normalizedDriverId = form.driverId.trim()
const receptionTypeIri = normalizedReceptionTypeId
? `/api/reception_types/${normalizedReceptionTypeId}` const receptionTypeIri = normalizedReceptionTypeId ? `/api/reception_types/${normalizedReceptionTypeId}` : null
: null const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
const userIri = normalizedUserId const supplierIri = normalizedSupplierId ? `/api/suppliers/${normalizedSupplierId}` : null
? `/api/users/${normalizedUserId}` const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
: null const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
const supplierIri = normalizedSupplierId const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
? `/api/suppliers/${normalizedSupplierId}` const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
: null
const addressIri = normalizedAddressId
? `/api/addresses/${normalizedAddressId}`
: null
const truckIri = normalizedTruckId
? `/api/trucks/${normalizedTruckId}`
: null
const carrierIri = normalizedCarrierId
? `/api/carriers/${normalizedCarrierId}`
: null
const driverIri = normalizedDriverId
? `/api/drivers/${normalizedDriverId}`
: null
const basePayload = { const basePayload = {
licensePlate: normalizedLicensePlate, licensePlate: normalizedLicensePlate,
@@ -502,7 +249,7 @@ async function validate() {
const payload = { const payload = {
...basePayload, ...basePayload,
...(isLiotCarrier.value && driverIri ? {driver: driverIri} : {}) ...(isLiotCarrier.value && driverIri ? { driver: driverIri } : {})
} }
if (!receptionStore.current) { if (!receptionStore.current) {
@@ -532,5 +279,4 @@ async function validate() {
...payload ...payload
}) })
} }
</script> </script>
@@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col items-center gap-16"> <form :class="['flex flex-col items-center gap-16', { submitted }]" @submit.prevent="goNext">
<div <div
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES" v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"
class="flex flex-col gap-16 items-center w-full"> class="flex flex-col gap-16 items-center w-full">
@@ -10,6 +10,7 @@
label="Type de marchandises" label="Type de marchandises"
:options="merchandiseTypes.map((type) => ({ value: String(type.id), label: type.label }))" :options="merchandiseTypes.map((type) => ({ value: String(type.id), label: type.label }))"
wrapper-class="w-[550px]" wrapper-class="w-[550px]"
required
/> />
<div <div
v-if="selectedMerchandiseTypeId && isAutres" v-if="selectedMerchandiseTypeId && isAutres"
@@ -21,24 +22,30 @@
label="Préciser" label="Préciser"
placeholder="Précisions complémentaires" placeholder="Précisions complémentaires"
:maxlength="255" :maxlength="255"
required
/> />
</div> </div>
<div <div
v-if="selectedMerchandiseTypeId && !isGranule" v-if="selectedMerchandiseTypeId && !isGranule"
class="flex gap-4 w-[550px] justify-between" class="flex flex-col gap-4 w-[550px]"
> >
<div <div class="flex gap-4 justify-between">
v-for="building in buildings" <div
:key="building.id" v-for="building in buildings"
> :key="building.id"
<UiCheckbox >
v-model="selectedBuildingIds" <UiCheckbox
:value="String(building.id)" v-model="selectedBuildingIds"
:label="building.label" :value="String(building.id)"
label-class="text-xl" :label="building.label"
/> label-class="text-xl"
/>
</div>
</div> </div>
<p class="text-red-500 text-sm" :class="showBuildingError ? '' : 'invisible'">
Veuillez sélectionner au moins un bâtiment.
</p>
</div> </div>
<div <div
@@ -62,17 +69,20 @@
</div> </div>
</div> </div>
</div> </div>
<p class="text-red-500 text-sm" :class="showBuildingError ? '' : 'invisible'">
Veuillez sélectionner au moins un bâtiment.
</p>
</div> </div>
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<UiButton <UiButton
type="submit" type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="goNext" @click="submitted = true"
>Valider >Valider
</UiButton> </UiButton>
</div> </div>
</div> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -100,6 +110,9 @@ const selectedMerchandiseTypeId = ref('')
const selectedBuildingIds = ref<string[]>([]) const selectedBuildingIds = ref<string[]>([])
const selectedPelletBuildingIds = ref<Record<string, string[]>>({}) const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
const merchandiseDetail = ref('') const merchandiseDetail = ref('')
const submitted = ref(false)
const showBuildingError = ref(false)
const showPelletBuildingError = ref(false)
// Extrait l'ID d'une relation depuis un IRI ou un objet complet. // Extrait l'ID d'une relation depuis un IRI ou un objet complet.
const getRelationId = (value: unknown): string | null => { const getRelationId = (value: unknown): string | null => {
@@ -182,6 +195,23 @@ async function goNext() {
return return
} }
showBuildingError.value = false
showPelletBuildingError.value = false
if (!isGranule.value && !isAutres.value && selectedBuildingIds.value.length === 0) {
showBuildingError.value = true
return
}
if (isGranule.value) {
const hasAnyPelletBuilding = Object.values(selectedPelletBuildingIds.value)
.some((ids) => ids.length > 0)
if (!hasAnyPelletBuilding) {
showPelletBuildingError.value = true
return
}
}
const nextStep = receptionStore.current.currentStep + 1 const nextStep = receptionStore.current.currentStep + 1
const receptionIri = `/api/receptions/${receptionStore.current.id}` const receptionIri = `/api/receptions/${receptionStore.current.id}`
@@ -1,101 +0,0 @@
<template>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
<!--@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 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]">
<UiLoadingDots />
</div>
<div v-else-if="displayWeight !== null" class="w-full">
<div
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl text-primary-500">
{{ displayWeight }} kg
</div>
</div>
</div>
</div>
<div class="flex justify-center mt-[54px]">
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
<UiButton
v-if="displayWeight !== null && !showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="saveWeight"
>Valider la pesée</UiButton>
<UiButton
v-if="showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="printReceipt"
>Générer le bon</UiButton>
</div>
</template>
<script setup lang="ts">
import {computed, onMounted} from 'vue'
import { storeToRefs } from 'pinia'
import { useWeighing } from '~/composables/useWeighing'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
import { useReceptionStore } from '~/stores/reception'
const props = defineProps<{
mode: 'gross' | 'tare'
}>()
const router = useRouter()
const receptionStore = useReceptionStore()
const { current: storeReception } = storeToRefs(receptionStore)
const { printPdf } = usePdfPrinter()
const {
displayWeight,
title,
showLoadingBox,
fetchWeight,
saveWeight
} = useWeighing({
mode: props.mode,
reception: storeReception,
updateReception: receptionStore.updateReception,
loadReception: receptionStore.loadReception
})
// Affiche le bouton de génération du bon à l'étape tare
const showGenerateReceipt = computed(
() => props.mode === 'tare' && displayWeight.value !== null
)
// Génère le bon de réception, puis clôture la réception
const printReceipt = async () => {
if (!import.meta.client || !receptionStore.current) {
return
}
await saveWeight()
const reception = receptionStore.current
const filename = `${reception.identificationNumber ?? reception.id}_${reception.supplier?.name ?? 'fournisseur'}_${reception.licensePlate ?? 'immat'}.pdf`
await printPdf(`/receptions/${reception.id}/receipt`, filename)
// Laisse le temps a la boite de dialogue d'impression de s'ouvrir.
await new Promise((resolve) => setTimeout(resolve, 600))
const result = await receptionStore.updateReception(receptionStore.current.id, {
isValid: true
})
if (!result) {
return
}
receptionStore.clearCurrent()
await router.push('/')
}
// Récupère le poids dès l'arrivée sur l'écran
onMounted(() => {
if (displayWeight.value === null) {
fetchWeight()
}
})
</script>
@@ -1,7 +1,7 @@
<template> <template>
<form> <form>
<div class="flex flex-col"> <div class="flex flex-col">
<div class="w-full col-start-1 row-start-1"> <div class="w-full relative grid grid-cols-[1fr_200px]">
<UiRadioGroup <UiRadioGroup
id="merchandise-type" id="merchandise-type"
v-model="selectedMerchandiseTypeId" v-model="selectedMerchandiseTypeId"
@@ -13,15 +13,25 @@
input-class="accent-primary-700 focus:ring-primary-700" input-class="accent-primary-700 focus:ring-primary-700"
option-label-class="uppercase" option-label-class="uppercase"
wrapper-class="w-full uppercase" wrapper-class="w-full uppercase"
group-class="grid grid-cols-[336px_336px_355px_200px] w-[160px_160px_200px_180px] mt-9 mb-7" group-class="grid grid-cols-4 mt-9 mb-7"
:disabled="!isAdmin" :disabled="!isAdmin"
/> />
<UiTextInput
v-if="isAutres"
id="merchandise-detail"
:disabled="!isAdmin"
v-model="merchandiseDetail"
placeholder="Préciser"
:maxlength="255"
wrapper-class="w-[200px] mt-12 mb-7"
/>
</div> </div>
<div class="w-full grid grid-cols-[3fr_1fr] gap-12 col-start-2 row-start-1"> <div
<div v-if="selectedMerchandiseTypeId && !isGranule"
v-if="selectedMerchandiseTypeId && !isGranule" class="w-full grid grid-cols-[1fr_200px]"
class="flex gap-[218px]" >
<div class="grid grid-cols-4 gap-6"
> >
<div <div
v-for="building in buildings" v-for="building in buildings"
@@ -33,31 +43,17 @@
:label="building.label" :label="building.label"
:disabled="!isAdmin" :disabled="!isAdmin"
input-class="accent-primary-700 focus:ring-primary-700" input-class="accent-primary-700 focus:ring-primary-700"
label-class="text-xl uppercase" label-class="uppercase"
/> />
</div> </div>
</div> </div>
<div
v-if="selectedMerchandiseTypeId && isAutres"
class="flex flex-col justify-self-end max-w-[182px]"
>
<UiTextInput
id="merchandise-detail"
:disabled="!isAdmin"
v-model="merchandiseDetail"
placeholder="Préciser"
:maxlength="255"
class="h-6"
/>
</div>
</div> </div>
<div <div
v-if="selectedMerchandiseTypeId && isGranule" v-if="selectedMerchandiseTypeId && isGranule"
class="flex flex-col gap-10 w-full col-start-2 row-start-1" class="grid grid-cols-[1fr_200px] w-full col-start-2 row-start-1"
> >
<div class="grid grid-cols-1 md:grid-cols-[max-content_max-content_max-content_max-content] justify-between"> <div class="grid grid-cols-4 gap-6 justify-between">
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4"> <div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4">
<p class="mb-1 font-medium uppercase">{{ type.label }}</p> <p class="mb-1 font-medium uppercase">{{ type.label }}</p>
<div <div
+78 -321
View File
@@ -1,8 +1,7 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16"> <div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Expédition</h1> <h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Expédition</h1>
<!-- Nom de l'utilisateur -->
<UiSelect <UiSelect
id="shipment-user" id="shipment-user"
v-model="form.userId" v-model="form.userId"
@@ -13,15 +12,15 @@
}))" }))"
:loading="isLoadingUsers" :loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2" wrapper-class="col-start-1 row-start-2"
required
/> />
<!-- Date de l'éxpedition -->
<UiDateInput <UiDateInput
id="shipment-date" id="shipment-date"
v-model="form.shipmentDate" v-model="form.shipmentDate"
label="Date du jour" label="Date du jour"
wrapper-class="col-start-1 row-start-3" wrapper-class="col-start-1 row-start-3"
required
/> />
<!-- Type d'expédition -->
<div class="col-start-1 row-start-4 h-[64px]"> <div class="col-start-1 row-start-4 h-[64px]">
<div class="flex w-full items-end gap-[104px]"> <div class="flex w-full items-end gap-[104px]">
<UiRadioGroup <UiRadioGroup
@@ -36,6 +35,7 @@
value: String(type.id), value: String(type.id),
label: type.label label: type.label
}))" }))"
required
/> />
<UiNumberInput <UiNumberInput
id="shipment-type-quantity" id="shipment-type-quantity"
@@ -47,7 +47,6 @@
/> />
</div> </div>
</div> </div>
<!-- Client -->
<UiSelect <UiSelect
id="shipment-customer" id="shipment-customer"
v-model="form.customerId" v-model="form.customerId"
@@ -58,17 +57,17 @@
}))" }))"
:loading="isLoadingCustomers" :loading="isLoadingCustomers"
wrapper-class="col-start-1 row-start-5" wrapper-class="col-start-1 row-start-5"
required
/> />
<!-- Adresse du client -->
<UiSelect <UiSelect
id="shipment-address" id="shipment-address"
v-model="form.addressId" v-model="form.addressId"
:options="customerAddressOptions" :options="addressOptions"
:disabled="isLoadingCustomers || customerAddresses.length === 0" :disabled="isLoadingCustomers || ownerAddresses.length === 0"
label="Adresse" label="Adresse"
wrapper-class="col-start-2 row-start-1" wrapper-class="col-start-2 row-start-1"
required
/> />
<!-- Camion -->
<UiSelect <UiSelect
id="shipment-truck" id="shipment-truck"
v-model="form.truckId" v-model="form.truckId"
@@ -79,8 +78,8 @@
}))" }))"
:loading="isLoadingTrucks" :loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2" wrapper-class="col-start-2 row-start-2"
required
/> />
<!-- Transporteur -->
<UiSelect <UiSelect
id="shipment-carrier" id="shipment-carrier"
v-model="form.carrierId" v-model="form.carrierId"
@@ -90,15 +89,15 @@
label: carrier.name label: carrier.name
}))" }))"
wrapper-class="col-start-2 row-start-3" wrapper-class="col-start-2 row-start-3"
required
/> />
<!-- Plaque d'immatriculation (hors LIOT) -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4"> <div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput <UiLicensePlateInput
v-model="form.licensePlate" v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate" v-model:allowAny="allowAnyLicensePlate"
required
/> />
</div> </div>
<!-- Immatriculation (LIOT) -->
<UiSelect <UiSelect
v-if="isLiotCarrier" v-if="isLiotCarrier"
id="shipment-vehicle" id="shipment-vehicle"
@@ -111,8 +110,8 @@
:loading="isLoadingVehicles" :loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0" :disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4" wrapper-class="col-start-2 row-start-4"
required
/> />
<!-- Chauffeur (LIOT) -->
<UiSelect <UiSelect
id="shipment-driver" id="shipment-driver"
v-model="form.driverId" v-model="form.driverId"
@@ -124,67 +123,35 @@
:loading="isLoadingDrivers" :loading="isLoadingDrivers"
wrapper-class="col-start-2 row-start-5" wrapper-class="col-start-2 row-start-5"
v-if="isLiotCarrier" v-if="isLiotCarrier"
required
/> />
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<UiButton <UiButton
type="submit" type="submit"
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end" class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="submitted = true"
>Valider >Valider
</UiButton> </UiButton>
</div> </div>
</form> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useFormDataLoading } from '~/composables/useFormDataLoading'
import { useLiotHandling } from '~/composables/useLiotHandling'
import { useAddressSync } from '~/composables/useAddressSync'
import type { CustomerData } from '~/services/dto/customer-data'
import { getCustomerList } from '~/services/customer'
import type { ShipmentFormData } from '~/services/dto/shipment-data'
import { useShipmentStore } from '~/stores/shipment'
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import { getShipmentTypeList } from '~/services/shipment-type'
import type {UserData} from '~/services/dto/user-data'
import type {CustomerData} from '~/services/dto/customer-data'
import type {TruckData} from '~/services/dto/truck-data'
import type {CarrierData} from '~/services/dto/carrier-data'
import type {DriverData} from '~/services/dto/driver-data'
import type {VehicleData} from '~/services/dto/vehicle-data'
import type {AddressData} from '~/services/dto/address-data'
import {getUsers} from '~/services/auth'
import {getCustomerList} from '~/services/customer'
import {getTruckList} from '~/services/truck'
import {getCarrierList} from '~/services/carrier'
import {getVehicleList} from '~/services/vehicle'
import {getDriverList} from '~/services/driver'
import type {ShipmentFormData} from '~/services/dto/shipment-data'
import {SUPPLIER_CODE} from "~/utils/constants"
import {useAuthStore} from '~/stores/auth'
import {useShipmentStore} from '~/stores/shipment'
import {computed, reactive, ref, watch, onMounted} from 'vue'
import type {ShipmentTypeData} from "~/services/dto/shipment-type-data";
import {getShipmentTypeList} from "~/services/shipment-type";
const users = ref<UserData[]>([])
const customers = ref<CustomerData[]>([])
const trucks = ref<TruckData[]>([])
const carriers = ref<CarrierData[]>([])
const drivers = ref<DriverData[]>([])
const vehicles = ref<VehicleData[]>([])
const isLoadingUsers = ref(false)
const isLoadingShipmentTypes = ref(false)
const isLoadingCustomers = ref(false)
const isLoadingTrucks = ref(false)
const isLoadingCarriers = ref(false)
const isHydrating = ref(false)
const isLoadingVehicles = ref(false)
const allowAnyLicensePlate = ref(false)
const isLoadingDrivers = ref(false)
const authStore = useAuthStore()
const shipmentStore = useShipmentStore()
const router = useRouter() const router = useRouter()
const bovineShipment = ref<ShipmentTypeData[]>([]) const shipmentStore = useShipmentStore()
const selectedShipmentTypeId = ref('') const isHydrating = ref(false)
const shipmentQuantity = ref<number | null>(0) const submitted = ref(false)
// Transporteur sélectionné dans le formulaire
const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
const form = reactive<ShipmentFormData>({ const form = reactive<ShipmentFormData>({
userId: '', userId: '',
@@ -197,58 +164,24 @@ const form = reactive<ShipmentFormData>({
vehicleId: '', vehicleId: '',
licensePlate: '', licensePlate: '',
}) })
// Adresses liées au client sélectionné
const customerAddresses = computed<AddressData[]>(() => {
const customerId = Number(form.customerId)
if (!Number.isFinite(customerId)) {
return []
}
return customers.value.find((customer) => customer.id === customerId)?.addresses ?? []
})
// Options pour le select des adresses du client
const customerAddressOptions = computed(() =>
customerAddresses.value
.map((address) => ({
value: String(address.id),
label: address.fullAddress
}))
)
// Chauffeurs liés au transporteur sélectionné (LIOT)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) {
return []
}
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
// Véhicules liés au transporteur + camion sélectionnés (LIOT)
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) {
return []
}
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
// Chargement des données pour les selects
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
const loadShipmentType = async () => { const customers = ref<CustomerData[]>([])
isLoadingShipmentTypes.value = true const isLoadingCustomers = ref(false)
try { const bovineShipment = ref<ShipmentTypeData[]>([])
bovineShipment.value = await getShipmentTypeList() const selectedShipmentTypeId = ref('')
} finally { const shipmentQuantity = ref<number | null>(0)
isLoadingShipmentTypes.value = false
} const { users, trucks, carriers, isLoadingUsers, isLoadingTrucks, isLoadingCarriers, loadCommonData } =
} useFormDataLoading(form)
const {
isLiotCarrier, filteredDrivers, filteredVehicles,
isLoadingDrivers, isLoadingVehicles, allowAnyLicensePlate,
loadDrivers, loadVehicles
} = useLiotHandling(form, carriers, isHydrating)
const customerIdRef = computed(() => form.customerId)
const { ownerAddresses, addressOptions } = useAddressSync(form, customerIdRef, customers)
const loadCustomers = async () => { const loadCustomers = async () => {
isLoadingCustomers.value = true isLoadingCustomers.value = true
@@ -257,212 +190,51 @@ const loadCustomers = async () => {
} finally { } finally {
isLoadingCustomers.value = false isLoadingCustomers.value = false
} }
}
}
const loadTrucks = async () => {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
// On met le user connecté par défaut dans le select
const setDefaultUser = () => {
if (form.userId) {
return
}
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
// Chargement initial des données
onMounted(async () => {
await loadShipmentType()
await loadUsers()
await loadCustomers()
await loadTrucks()
await loadCarriers()
await loadVehicles()
await loadDrivers()
await authStore.ensureSession()
setDefaultUser()
})
// Hydrate le formulaire depuis l'expédition en cours
watch( watch(
() => shipmentStore.current, () => shipmentStore.current,
(shipment) => { (shipment) => {
isHydrating.value = true isHydrating.value = true
form.licensePlate = shipment?.licensePlate ?? '' form.licensePlate = shipment?.licensePlate ?? ''
form.shipmentDate = shipment?.shipmentDate ?? new Date().toISOString().slice(0, 10) form.shipmentDate = shipment?.shipmentDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)
form.userId = shipment?.user?.id ? String(shipment.user.id) : form.userId = shipment?.user?.id ? String(shipment.user.id) : form.userId
form.userId form.customerId = shipment?.customer?.id ? String(shipment.customer.id) : ''
form.customerId = shipment?.customer?.id ?
String(shipment.customer.id) : ''
form.addressId = shipment?.address?.id ? String(shipment.address.id) : '' form.addressId = shipment?.address?.id ? String(shipment.address.id) : ''
form.truckId = shipment?.truck?.id ? String(shipment.truck.id) : '' form.truckId = shipment?.truck?.id ? String(shipment.truck.id) : ''
form.carrierId = shipment?.carrier?.id ? String(shipment.carrier.id) : '' form.carrierId = shipment?.carrier?.id ? String(shipment.carrier.id) : ''
form.driverId = shipment?.driver?.id ? String(shipment.driver.id) : '' form.driverId = shipment?.driver?.id ? String(shipment.driver.id) : ''
form.vehicleId = shipment?.vehicle?.id ? String(shipment.vehicle.id) : '' form.vehicleId = shipment?.vehicle?.id ? String(shipment.vehicle.id) : ''
selectedShipmentTypeId.value = shipment?.shipmentType?.id ? String(shipment.shipmentType.id) : ''
selectedShipmentTypeId.value = shipment?.shipmentType?.id
? String(shipment.shipmentType.id)
: ''
shipmentQuantity.value = shipment?.nbBovinSend ?? 0 shipmentQuantity.value = shipment?.nbBovinSend ?? 0
isHydrating.value = false isHydrating.value = false
}, },
{immediate: true} { immediate: true }
)
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
watch(
() => [form.customerId, form.addressId, customers.value],
() => {
if (!form.customerId) {
form.addressId = ''
return
}
if (!form.addressId && customerAddresses.value.length === 1) {
form.addressId = String(customerAddresses.value[0].id)
return
}
if (!form.addressId) {
return
}
const matches = customerAddresses.value.some(
(address) => String(address.id) === form.addressId
)
if (!matches) {
if (customerAddresses.value.length === 1) {
form.addressId = String(customerAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{immediate: true}
)
// Valide/auto-sélectionne le véhicule selon camion + transporteur (LIOT)
const applyLiotDefaults = () => {
if (isHydrating.value) {
return
}
if (!form.carrierId) {
form.driverId = ''
form.vehicleId = ''
return
}
if (!isLiotCarrier.value) {
form.driverId = ''
form.vehicleId = ''
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
}
watch(
() => form.carrierId,
() => {
applyLiotDefaults()
},
{immediate: true}
) )
// Extra watcher for LIOT defaults after hydration
watch( watch(
() => isHydrating.value, () => isHydrating.value,
(value) => { (value) => {
if (!value) { if (!value && isLiotCarrier.value) {
applyLiotDefaults() if (filteredDrivers.value.length === 1 && !form.driverId) {
} form.driverId = String(filteredDrivers.value[0].id)
} }
) if (filteredVehicles.value.length === 1 && !form.vehicleId) {
// Récupère la plaque depuis le véhicule choisi (LIOT) form.vehicleId = String(filteredVehicles.value[0].id)
watch( }
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
return
}
if (!form.vehicleId) {
return
}
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
}
},
{immediate: true}
)
// Auto-renseigne le véhicule si la plaque correspond (LIOT)
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (isHydrating.value) {
return
}
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
watch(
() => [form.licensePlate, form.carrierId, form.vehicleId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) {
return
}
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
form.vehicleId = String(match.id)
} }
} }
) )
onMounted(async () => {
bovineShipment.value = await getShipmentTypeList()
await loadCustomers()
await loadCommonData()
await loadVehicles()
await loadDrivers()
})
const buildPayload = () => { const buildPayload = () => {
const normalizedLicensePlate = form.licensePlate.trim() const normalizedLicensePlate = form.licensePlate.trim()
const normalizedShipmentDate = form.shipmentDate.trim() const normalizedShipmentDate = form.shipmentDate.trim()
@@ -472,32 +244,18 @@ const buildPayload = () => {
const normalizedDriverId = form.driverId.trim() const normalizedDriverId = form.driverId.trim()
const normalizedUserId = form.userId.trim() const normalizedUserId = form.userId.trim()
const normalizedAddressId = form.addressId.trim() const normalizedAddressId = form.addressId.trim()
const customerIri = normalizedCustomerId
? `/api/customers/${normalizedCustomerId}` const customerIri = normalizedCustomerId ? `/api/customers/${normalizedCustomerId}` : null
: null const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
const truckIri = normalizedTruckId const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
? `/api/trucks/${normalizedTruckId}` const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
: null const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
const carrierIri = normalizedCarrierId const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
? `/api/carriers/${normalizedCarrierId}`
: null
const userIri = normalizedUserId
? `/api/users/${normalizedUserId}`
: null
const driverIri = normalizedDriverId
? `/api/drivers/${normalizedDriverId}`
: null
const addressIri = normalizedAddressId
? `/api/addresses/${normalizedAddressId}`
: null
const normalizedShipmentTypeId = selectedShipmentTypeId.value.trim() const normalizedShipmentTypeId = selectedShipmentTypeId.value.trim()
const shipmentTypeIri = normalizedShipmentTypeId const shipmentTypeIri = normalizedShipmentTypeId ? `/api/shipment_types/${normalizedShipmentTypeId}` : null
? `/api/shipment_types/${normalizedShipmentTypeId}`
: null
const rawQuantity = Number(shipmentQuantity.value ?? 0) const rawQuantity = Number(shipmentQuantity.value ?? 0)
const normalizedQuantity = Number.isFinite(rawQuantity) ? Math.max(0, const normalizedQuantity = Number.isFinite(rawQuantity) ? Math.max(0, Math.trunc(rawQuantity)) : 0
Math.trunc(rawQuantity)) : 0
return { return {
licensePlate: normalizedLicensePlate, licensePlate: normalizedLicensePlate,
@@ -522,15 +280,14 @@ const saveDraft = async () => {
}) })
return return
} }
await shipmentStore.updateShipment(shipmentStore.current.id, { await shipmentStore.updateShipment(shipmentStore.current.id, {
currentStep: shipmentStore.current.currentStep, currentStep: shipmentStore.current.currentStep,
...payload ...payload
}) })
} }
defineExpose({saveDraft}) defineExpose({ saveDraft })
// Valide le formulaire et crée/met à jour l'expédition
const validate = async () => { const validate = async () => {
const payload = buildPayload() const payload = buildPayload()
if (!shipmentStore.current) { if (!shipmentStore.current) {
@@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col items-center gap-[118px]"> <div class="flex flex-col items-center gap-[150px]">
<h1 class="font-bold text-5xl uppercase text-primary-500">Chargement des bovins</h1> <h1 class="font-bold text-5xl uppercase text-primary-500">Chargement des bovins</h1>
<div <div
class="w-full flex flex-col items-center justify-center"> class="w-full flex flex-col items-center justify-center">
@@ -1,101 +0,0 @@
<template>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
<!--@TODO Voir comment faire pour savoir si le pont-bascule et bien connecté + ajouter un icon comme sur la maquette-->
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
<div
v-if="showLoadingBox"
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
<UiLoadingDots />
</div>
<div v-else-if="displayWeight !== null" class="w-full">
<div
class="w-full flex flex-col items-center justify-center border border-primary-500 h-[90px] mt-12 mb-[25px] text-4xl text-primary-500">
{{ displayWeight }} kg
</div>
</div>
</div>
</div>
<div class="flex justify-center mt-[54px]">
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
<UiButton
v-if="displayWeight !== null && !showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="saveWeight"
>Valider la pesée</UiButton>
<UiButton
v-if="showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="printReceipt"
>Générer le bon</UiButton>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useWeighingShipment } from '~/composables/useWeighing'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
import { useShipmentStore } from '~/stores/shipment'
const props = defineProps<{
mode: 'gross' | 'tare'
}>()
const router = useRouter()
const shipmentStore = useShipmentStore()
const { current: storeShipment } = storeToRefs(shipmentStore)
const { printPdf } = usePdfPrinter()
const {
displayWeight,
title,
showLoadingBox,
fetchWeight,
saveWeight
} = useWeighingShipment({
modeShipment: props.mode,
shipment: storeShipment,
updateShipment: shipmentStore.updateShipment,
loadShipment: shipmentStore.loadShipment
})
// Affiche le bouton de génération du bon à l'étape tare
const showGenerateReceipt = computed(
() => props.mode === 'tare' && displayWeight.value !== null
)
// Génère le bon d'expédition, puis clôture l'expédition
const printReceipt = async () => {
if (!import.meta.client || !shipmentStore.current) {
return
}
await saveWeight()
const shipment = shipmentStore.current
const filename = `${shipment.identificationNumber ?? shipment.id}_${shipment.customer?.label ?? 'client'}_${shipment.licensePlate ?? 'immat'}.pdf`
await printPdf(`/shipments/${shipment.id}/receipt`, filename)
// Laisse le temps a la boite de dialogue d'impression de s'ouvrir.
await new Promise((resolve) => setTimeout(resolve, 600))
const result = await shipmentStore.updateShipment(shipmentStore.current.id, {
isValid: true
})
if (!result) {
return
}
shipmentStore.clearCurrent()
await router.push('/')
}
// Récupère le poids dès l'arrivée sur l'écran
onMounted(() => {
if (displayWeight.value === null) {
fetchWeight()
}
})
</script>
@@ -9,6 +9,7 @@
type="text" type="text"
:maxlength="maxLength" :maxlength="maxLength"
:placeholder="placeholderText" :placeholder="placeholderText"
:required="required"
class="border-b border-black flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]" class="border-b border-black flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
@input="handleInput" @input="handleInput"
/> />
@@ -32,12 +33,14 @@ type Props = {
allowAny?: boolean allowAny?: boolean
label?: string label?: string
id?: string id?: string
required?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
allowAny: false, allowAny: false,
label: 'Immatriculation', label: 'Immatriculation',
id: 'license-plate' id: 'license-plate',
required: false
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -0,0 +1,57 @@
<template>
<template v-if="!isLiotCarrier">
<div :class="wrapperClass">
<UiLicensePlateInput
v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate"
/>
</div>
</template>
<template v-if="isLiotCarrier">
<UiSelect
:id="`${idPrefix}-vehicle`"
v-model="form.vehicleId"
label="Immatriculation"
:options="filteredVehicles.map((vehicle) => ({
value: String(vehicle.id),
label: vehicle.plate
}))"
:loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
:wrapper-class="wrapperClass"
/>
<UiSelect
:id="`${idPrefix}-driver`"
v-model="form.driverId"
label="Nom du chauffeur si LIOT"
:options="filteredDrivers.map((driver) => ({
value: String(driver.id),
label: driver.name
}))"
:loading="isLoadingDrivers"
:wrapper-class="driverWrapperClass"
/>
</template>
</template>
<script setup lang="ts">
import type { DriverData } from '~/services/dto/driver-data'
import type { VehicleData } from '~/services/dto/vehicle-data'
defineProps<{
idPrefix: string
form: { licensePlate: string; vehicleId: string; driverId: string }
isLiotCarrier: boolean
allowAnyLicensePlate: boolean
filteredVehicles: VehicleData[]
filteredDrivers: DriverData[]
isLoadingVehicles: boolean
isLoadingDrivers: boolean
wrapperClass?: string
driverWrapperClass?: string
}>()
defineEmits<{
'update:allowAnyLicensePlate': [value: boolean]
}>()
</script>
@@ -0,0 +1,72 @@
<template>
<div class="flex items-center justify-between">
<div class="flex items-center gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
<h1 class="text-3xl font-bold uppercase text-primary-500">{{ title }}</h1>
</div>
</div>
<div class="px-[86px]">
<div class="mt-6 border border-slate-200 mb-16">
<div
class="grid gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
:style="{ gridTemplateColumns: gridCols }"
>
<div v-for="col in columns" :key="col.key">{{ col.label }}</div>
<div v-if="showActions">Actions</div>
</div>
<div
v-for="item in items"
:key="item.id"
class="grid gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
:style="{ gridTemplateColumns: gridCols }"
role="button"
tabindex="0"
@click="goToItem(item.id)"
@keydown.enter="goToItem(item.id)"
>
<div v-for="col in columns" :key="col.key">
<slot :name="`cell-${col.key}`" :item="item">
{{ getNestedValue(item, col.key) }}
</slot>
</div>
<div v-if="showActions" @click.stop>
<slot name="actions" :item="item" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Column {
key: string
label: string
}
const props = withDefaults(defineProps<{
title: string
columns: Column[]
items: any[]
routePrefix: string
showActions?: boolean
}>(), {
showActions: false
})
const router = useRouter()
const gridCols = computed(() => {
const dataCols = props.columns.map(() => '1fr').join(' ')
return props.showActions ? `${dataCols} 60px` : dataCols
})
const goToItem = (id: number) => {
router.push(`${props.routePrefix}/${id}`)
}
const getNestedValue = (obj: any, path: string): string => {
const value = path.split('.').reduce((acc, key) => acc?.[key], obj)
return value || '—'
}
</script>
@@ -0,0 +1,78 @@
<template>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
<div
v-if="!displayWeight"
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
<UiLoadingDots />
</div>
<div v-else class="w-full">
<div
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl text-primary-500">
{{ displayWeight }} kg
</div>
</div>
</div>
</div>
<div class="flex justify-center mt-[54px]">
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
<UiButton
v-if="displayWeight !== null && !showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="saveWeight"
>Valider la pesée</UiButton>
<UiButton
v-if="showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="printReceipt"
>Générer le bon</UiButton>
</div>
</template>
<script setup lang="ts">
import { toRef } from 'vue'
import { useWeighingStep } from '~/composables/steps/useWeighingStep'
import type { WeightData } from '~/services/dto/weight-data'
const props = defineProps<{
mode: 'gross' | 'tare'
entityName: 'reception' | 'shipment'
apiResource: string
titleLabel: string
isFinal: boolean
entity: any
getWeightFromScale: () => Promise<WeightData>
updateEntity: (id: number, payload: any) => Promise<any>
loadEntity: (id: number) => Promise<any>
clearEntity: () => void
buildReceiptFilename: (entity: any) => string
}>()
const entityRef = toRef(props, 'entity')
const {
displayWeight,
title,
fetchWeight,
saveWeight,
showGenerateReceipt,
printReceipt
} = useWeighingStep({
mode: props.mode,
entity: entityRef,
entityName: props.entityName,
apiResource: props.apiResource,
titleLabel: props.titleLabel,
isFinal: props.isFinal,
getWeightFromScale: props.getWeightFromScale,
updateEntity: props.updateEntity,
loadEntity: props.loadEntity,
clearEntity: props.clearEntity,
buildReceiptFilename: props.buildReceiptFilename
})
</script>
@@ -0,0 +1,77 @@
import type { Ref } from 'vue'
import { useWeighing } from '~/composables/useWeighing'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
import type { WeightData } from '~/services/dto/weight-data'
interface UseWeighingStepOptions {
mode: 'gross' | 'tare'
entity: Ref<any>
entityName: 'reception' | 'shipment'
apiResource: string
titleLabel: string
isFinal: boolean
getWeightFromScale: () => Promise<WeightData>
updateEntity: (id: number, payload: any) => Promise<any>
loadEntity: (id: number) => Promise<any>
clearEntity: () => void
buildReceiptFilename: (entity: any) => string
}
export const useWeighingStep = (options: UseWeighingStepOptions) => {
const router = useRouter()
const { printPdf } = usePdfPrinter()
const {
weightData,
currentWeightEntry,
displayWeight,
displayDsd,
title,
showLoadingBox,
fetchWeight,
saveWeight
} = useWeighing({
mode: options.mode,
entity: options.entity,
entityName: options.entityName,
apiResource: options.apiResource,
titleLabel: options.titleLabel,
getWeightFromScale: options.getWeightFromScale,
updateEntity: options.updateEntity,
loadEntity: options.loadEntity
})
const showGenerateReceipt = computed(
() => options.isFinal && displayWeight.value !== null
)
const printReceipt = async () => {
if (!import.meta.client || !options.entity.value) return
await saveWeight()
const entity = options.entity.value
const filename = options.buildReceiptFilename(entity)
await printPdf(`/${options.apiResource}/${entity.id}/receipt`, filename)
await new Promise((resolve) => setTimeout(resolve, 600))
const result = await options.updateEntity(entity.id, { isValid: true })
if (!result) return
options.clearEntity()
await router.push('/')
}
return {
weightData,
currentWeightEntry,
displayWeight,
displayDsd,
title,
showLoadingBox,
fetchWeight,
saveWeight,
showGenerateReceipt,
printReceipt
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { Ref } from 'vue'
import type { AddressData } from '~/services/dto/address-data'
interface AddressOwner {
id: number
addresses?: AddressData[]
}
export const useAddressSync = (
form: { addressId: string },
ownerId: Ref<string>,
owners: Ref<AddressOwner[]>
) => {
const ownerAddresses = computed<AddressData[]>(() => {
const id = Number(ownerId.value)
if (!Number.isFinite(id)) return []
return owners.value.find((owner) => owner.id === id)?.addresses ?? []
})
const addressOptions = computed(() =>
ownerAddresses.value.map((address) => ({
value: String(address.id),
label: address.fullAddress
}))
)
watch(
() => [ownerId.value, form.addressId, owners.value],
() => {
if (!ownerId.value) {
form.addressId = ''
return
}
if (!form.addressId && ownerAddresses.value.length === 1) {
form.addressId = String(ownerAddresses.value[0].id)
return
}
if (!form.addressId) return
const matches = ownerAddresses.value.some(
(address) => String(address.id) === form.addressId
)
if (!matches) {
if (ownerAddresses.value.length === 1) {
form.addressId = String(ownerAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{ immediate: true }
)
return { ownerAddresses, addressOptions }
}
@@ -0,0 +1,73 @@
import type { UserData } from '~/services/dto/user-data'
import type { TruckData } from '~/services/dto/truck-data'
import type { CarrierData } from '~/services/dto/carrier-data'
import { getUsers } from '~/services/auth'
import { getTruckList } from '~/services/truck'
import { getCarrierList } from '~/services/carrier'
import { useAuthStore } from '~/stores/auth'
export const useFormDataLoading = (form: { userId: string }) => {
const users = ref<UserData[]>([])
const trucks = ref<TruckData[]>([])
const carriers = ref<CarrierData[]>([])
const isLoadingUsers = ref(false)
const isLoadingTrucks = ref(false)
const isLoadingCarriers = ref(false)
const authStore = useAuthStore()
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
const loadTrucks = async () => {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
const setDefaultUser = () => {
if (form.userId) return
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
const loadCommonData = async () => {
await loadUsers()
await loadTrucks()
await loadCarriers()
await authStore.ensureSession()
setDefaultUser()
}
return {
users,
trucks,
carriers,
isLoadingUsers,
isLoadingTrucks,
isLoadingCarriers,
loadUsers,
loadTrucks,
loadCarriers,
setDefaultUser,
loadCommonData
}
}
+153
View File
@@ -0,0 +1,153 @@
import type { Ref } from 'vue'
import type { CarrierData } from '~/services/dto/carrier-data'
import type { DriverData } from '~/services/dto/driver-data'
import type { VehicleData } from '~/services/dto/vehicle-data'
import { getDriverList } from '~/services/driver'
import { getVehicleList } from '~/services/vehicle'
import { SUPPLIER_CODE } from '~/utils/constants'
interface LiotForm {
carrierId: string
truckId: string
driverId: string
vehicleId: string
licensePlate: string
}
export const useLiotHandling = (
form: LiotForm,
carriers: Ref<CarrierData[]>,
isHydrating: Ref<boolean>
) => {
const drivers = ref<DriverData[]>([])
const vehicles = ref<VehicleData[]>([])
const isLoadingDrivers = ref(false)
const isLoadingVehicles = ref(false)
const allowAnyLicensePlate = ref(false)
const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) return []
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) return []
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
// Auto-select driver/vehicle when carrier changes
watch(
() => form.carrierId,
() => {
if (isHydrating.value) return
if (!form.carrierId) {
form.driverId = ''
form.vehicleId = ''
return
}
if (!isLiotCarrier.value) {
form.driverId = ''
form.vehicleId = ''
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
},
{ immediate: true }
)
// Validate/auto-select vehicle when truck/carrier changes
watch(
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) return
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
return
}
if (!form.vehicleId) return
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
}
},
{ immediate: true }
)
// Sync license plate from selected vehicle
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) return
if (isHydrating.value) return
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
// Auto-select vehicle from license plate
watch(
() => [form.licensePlate, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) return
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
form.vehicleId = String(match.id)
}
}
)
return {
drivers,
vehicles,
isLoadingDrivers,
isLoadingVehicles,
allowAnyLicensePlate,
isLiotCarrier,
filteredDrivers,
filteredVehicles,
loadDrivers,
loadVehicles
}
}
+63 -112
View File
@@ -1,56 +1,64 @@
import {computed, ref} from 'vue' import { computed, ref } from 'vue'
import type {WeightEntryData} from '~/services/dto/reception-data' import type { Ref } from 'vue'
import type {WeightData} from '~/services/dto/weight-data' import type { WeightEntryData } from '~/services/dto/reception-data'
import {getWeight} from '~/services/reception' import type { WeightData } from '~/services/dto/weight-data'
import {getWeightShipment} from '~/services/shipment' import { createWeight, updateWeight } from '~/services/weight'
import {createWeight, updateWeight} from '~/services/weight'
import type {UseWeighingShipmentOptions, UseWeighingOptions} from '~/services/weight'
import type {WeightShipmentEntryData} from "~/services/dto/shipment-data";
export type WeighingMode = 'gross' | 'tare' export type WeighingMode = 'gross' | 'tare'
export interface UseWeighingOptions {
mode: WeighingMode
entity: Ref<{ id: number; currentStep: number; isValid: boolean; weights?: WeightEntryData[] | null } | null>
entityName: 'reception' | 'shipment'
apiResource: string
titleLabel: string
getWeightFromScale: () => Promise<WeightData>
updateEntity: (id: number, payload: any) => Promise<any>
loadEntity?: (id: number) => Promise<any>
}
export const useWeighing = ({ export const useWeighing = ({
mode, mode,
reception, entity,
updateReception, entityName,
loadReception apiResource,
}: UseWeighingOptions) => { titleLabel,
getWeightFromScale,
updateEntity,
loadEntity
}: UseWeighingOptions) => {
const weightData = ref<WeightData | null>(null) const weightData = ref<WeightData | null>(null)
const isFetching = ref(false) const isFetching = ref(false)
const currentWeightEntry = computed<WeightEntryData | null>(() => { const currentWeightEntry = computed<WeightEntryData | null>(() => {
const weights = reception.value?.weights ?? [] const weights = entity.value?.weights ?? []
return weights.find((entry) => entry.type === mode) ?? null return weights.find((entry) => entry.type === mode) ?? null
}) })
const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null) const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null)
const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-') const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-')
const title = computed(() => (mode === 'gross' ? 'Pesée à plein' : 'Pesée à vide')) const title = computed(() => titleLabel)
const showLoadingBox = computed( const showLoadingBox = computed(() => isFetching.value)
() => isFetching.value || (displayWeight.value === null && currentWeightEntry.value === null)
)
const fetchWeight = async () => { const fetchWeight = async () => {
isFetching.value = true isFetching.value = true
weightData.value = await getWeight().finally(() => { weightData.value = await getWeightFromScale().finally(() => {
isFetching.value = false isFetching.value = false
}) })
} }
const saveWeight = async () => { const saveWeight = async () => {
if (!reception.value) { if (!entity.value) return
return
}
const existingEntry = currentWeightEntry.value const existingEntry = currentWeightEntry.value
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
if (baseWeight === null) { if (baseWeight === null) return
return
} const relationPayload: Record<string, string> = {}
relationPayload[entityName] = `/api/${apiResource}/${entity.value.id}`
if (existingEntry?.id) { if (existingEntry?.id) {
await updateWeight(existingEntry.id, { await updateWeight(existingEntry.id, {
@@ -61,7 +69,7 @@ export const useWeighing = ({
}) })
} else { } else {
await createWeight({ await createWeight({
reception: `/api/receptions/${reception.value.id}`, ...relationPayload,
type: mode, type: mode,
dsd: baseDsd, dsd: baseDsd,
weight: baseWeight, weight: baseWeight,
@@ -70,15 +78,15 @@ export const useWeighing = ({
} }
const nextStep = mode === 'tare' const nextStep = mode === 'tare'
? reception.value.currentStep ? entity.value.currentStep
: reception.value.currentStep + 1 : entity.value.currentStep + 1
await updateReception(reception.value.id, { await updateEntity(entity.value.id, {
currentStep: nextStep, currentStep: nextStep,
isValid: reception.value.isValid isValid: entity.value.isValid
}) })
if (loadReception) { if (loadEntity) {
await loadReception(reception.value.id) await loadEntity(entity.value.id)
} }
} }
@@ -94,86 +102,29 @@ export const useWeighing = ({
} }
} }
// Backward-compatible aliases
export const useWeighingShipment = ({ export const useWeighingShipment = ({
modeShipment, modeShipment,
shipment, shipment,
updateShipment, updateShipment,
loadShipment loadShipment
}: UseWeighingShipmentOptions) => { }: {
const weightData = ref<WeightData | null>(null) modeShipment: WeighingMode
const isFetching = ref(false) shipment: Ref<any>
updateShipment: (id: number, payload: any) => Promise<any>
const currentWeightEntry = computed<WeightShipmentEntryData | null>(() => { loadShipment?: (id: number) => Promise<any>
const weights = shipment.value?.weights ?? [] }) => {
return weights.find((entry) => entry.type === modeShipment) ?? null return useWeighing({
mode: modeShipment,
entity: shipment,
entityName: 'shipment',
apiResource: 'shipments',
titleLabel: modeShipment === 'gross' ? 'Pesée à vide' : 'Pesée à plein',
getWeightFromScale: async () => {
const { getWeightShipment } = await import('~/services/shipment')
return getWeightShipment()
},
updateEntity: updateShipment,
loadEntity: loadShipment
}) })
const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null)
const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-')
const title = computed(() => (modeShipment === 'gross' ? 'Pesée à vide' : 'Pesée à plein'))
const showLoadingBox = computed(
() => isFetching.value || (displayWeight.value === null && currentWeightEntry.value === null)
)
const fetchWeight = async () => {
isFetching.value = true
weightData.value = await getWeightShipment().finally(() => {
isFetching.value = false
})
}
const saveWeight = async () => {
if (!shipment.value) {
return
}
const existingEntry = currentWeightEntry.value
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
if (baseWeight === null) {
return
}
if (existingEntry?.id) {
await updateWeight(existingEntry.id, {
type: modeShipment,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
} else {
await createWeight({
shipment: `/api/shipments/${shipment.value.id}`,
type: modeShipment,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
}
const nextStep = modeShipment === 'tare'
? shipment.value.currentStep
: shipment.value.currentStep + 1
await updateShipment(shipment.value.id, {
currentStep: nextStep,
isValid: shipment.value.isValid
})
if (loadShipment) {
await loadShipment(shipment.value.id)
}
}
return {
weightData,
currentWeightEntry,
displayWeight,
displayDsd,
title,
showLoadingBox,
fetchWeight,
saveWeight
}
} }
+83
View File
@@ -0,0 +1,83 @@
import type { Ref } from 'vue'
import type { WorkflowConfig } from '~/types/workflow'
interface WorkflowStore {
current: any
isLoading: boolean
clearCurrent: () => void
[key: string]: any
}
export const useWorkflowSteps = (config: WorkflowConfig, store: WorkflowStore) => {
const route = useRoute()
const router = useRouter()
const stepLabels = config.steps.map(s => s.label)
const currentStep = computed(() => store.current?.currentStep ?? 0)
const entity = computed(() => store.current)
const loadMethod = `load${config.entityName.charAt(0).toUpperCase() + config.entityName.slice(1)}`
const updateMethod = `update${config.entityName.charAt(0).toUpperCase() + config.entityName.slice(1)}`
const resolveId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) return null
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
const init = () => {
watch(
() => route.params.id,
async (param) => {
const id = resolveId(param)
if (id === null) {
store.clearCurrent()
return
}
await store[loadMethod](id)
},
{ immediate: true }
)
}
const saveAndHold = async () => {
if (!store.current) {
await router.push('/')
return
}
const datePayload: Record<string, any> = {}
datePayload[config.dateField] = store.current[config.dateField]
await store[updateMethod](store.current.id, {
currentStep: store.current.currentStep,
licensePlate: store.current.licensePlate,
...datePayload
})
await router.push('/')
}
const handleStepSelect = async (step: number) => {
if (!store.current) return
if (step === store.current.currentStep) return
await store[updateMethod](store.current.id, { currentStep: step })
await store[loadMethod](store.current.id)
}
const advanceStep = async () => {
if (!store.current) return
const nextStep = store.current.currentStep + 1
await store[updateMethod](store.current.id, { currentStep: nextStep })
await store[loadMethod](store.current.id)
}
return {
stepLabels,
currentStep,
entity,
init,
saveAndHold,
handleStepSelect,
advanceStep
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { WorkflowConfig, WorkflowEntity } from '~/types/workflow'
export const receptionConfig: WorkflowConfig = {
entityName: 'reception',
apiResource: 'receptions',
steps: [
{ label: 'Réception' },
{ label: 'Pesée à plein', weighingMode: 'gross' },
{ label: 'Sélection réception' },
{ label: 'Pesée à vide', weighingMode: 'tare', isFinal: true }
],
weighingLabels: {
gross: 'Pesée à plein',
tare: 'Pesée à vide'
},
buildReceiptFilename: (entity: WorkflowEntity) => {
const rec = entity as any
return `${rec.identificationNumber ?? rec.id}_${rec.supplier?.name ?? 'fournisseur'}_${rec.licensePlate ?? 'immat'}.pdf`
},
routePrefix: '/reception',
toastPrefix: 'reception',
dateField: 'receptionDate'
}
export const RECEPTION_STEP_LABELS = receptionConfig.steps.map(s => s.label)
+25
View File
@@ -0,0 +1,25 @@
import type { WorkflowConfig, WorkflowEntity } from '~/types/workflow'
export const shipmentConfig: WorkflowConfig = {
entityName: 'shipment',
apiResource: 'shipments',
steps: [
{ label: 'Expédition' },
{ label: 'Pesée à vide', weighingMode: 'gross' },
{ label: 'Chargement' },
{ label: 'Pesée à plein', weighingMode: 'tare', isFinal: true }
],
weighingLabels: {
gross: 'Pesée à vide',
tare: 'Pesée à plein'
},
buildReceiptFilename: (entity: WorkflowEntity) => {
const ship = entity as any
return `${ship.identificationNumber ?? ship.id}_${ship.customer?.label ?? 'client'}_${ship.licensePlate ?? 'immat'}.pdf`
},
routePrefix: '/shipment',
toastPrefix: 'shipment',
dateField: 'shipmentDate'
}
export const SHIPMENT_STEP_LABELS = shipmentConfig.steps.map(s => s.label)
-22
View File
@@ -1,22 +0,0 @@
export enum StepLabel {
Reception = 'Réception',
GrossWeighing = 'Pesée à plein',
Selection = 'Sélection réception',
TareWeighing = 'Pesée à vide',
Shipment = 'Expédition',
ShipmentLoading = 'Chargement',
}
export const RECEPTION_STEP_LABELS = [
StepLabel.Reception,
StepLabel.GrossWeighing,
StepLabel.Selection,
StepLabel.TareWeighing
]
export const SHIPMENT_STEP_LABELS = [
StepLabel.Shipment,
StepLabel.TareWeighing,
StepLabel.ShipmentLoading,
StepLabel.GrossWeighing,
]
+8 -2
View File
@@ -12,6 +12,7 @@
"fetch": "Impossible de récupérer la réception.", "fetch": "Impossible de récupérer la réception.",
"create": "Impossible de créer la réception.", "create": "Impossible de créer la réception.",
"update": "Impossible de mettre à jour la réception.", "update": "Impossible de mettre à jour la réception.",
"delete": "Impossible de supprimer la réception.",
"weight": "Impossible de récupérer la pesée." "weight": "Impossible de récupérer la pesée."
}, },
"weight": { "weight": {
@@ -22,6 +23,7 @@
"fetch": "Impossible de récupérer l'éxpeditions.", "fetch": "Impossible de récupérer l'éxpeditions.",
"create": "Impossible de créer l'éxpeditions.", "create": "Impossible de créer l'éxpeditions.",
"update": "Impossible de mettre à jour l'éxpeditions.", "update": "Impossible de mettre à jour l'éxpeditions.",
"delete": "Impossible de supprimer l'expédition.",
"weigh": "Impossible de récupérer la pesée." "weigh": "Impossible de récupérer la pesée."
}, },
"shipmentBovine": { "shipmentBovine": {
@@ -109,10 +111,14 @@
}, },
"success": { "success": {
"reception": { "reception": {
"update": "Réception mise à jour avec succès." "create": "Réception créée avec succès",
"update": "Réception mise à jour avec succès.",
"delete": "Réception supprimée avec succès."
}, },
"shipment": { "shipment": {
"update": "Éxpedition mise à jour avec succès." "create": "Éxpedition créée avec succès",
"update": "Éxpedition mise à jour avec succès.",
"delete": "Expédition supprimée avec succès."
}, },
"supplier": { "supplier": {
"create": "Fournisseur créé avec succès.", "create": "Fournisseur créé avec succès.",
+19 -18
View File
@@ -72,23 +72,6 @@
</a> </a>
</NuxtLink> </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 <NuxtLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
to="/admin/customer/customer-list" to="/admin/customer/customer-list"
@@ -106,6 +89,23 @@
</a> </a>
</NuxtLink> </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 <NuxtLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
to="/admin/bovin/bovin-list" to="/admin/bovin/bovin-list"
@@ -134,7 +134,8 @@
class="inline-flex items-center py-2 -my-2 text-xl leading-none transition hover:opacity-80" class="inline-flex items-center py-2 -my-2 text-xl leading-none transition hover:opacity-80"
aria-haspopup="true" aria-haspopup="true"
> >
<span class="capitalize font-bold">{{ userDisplayName }}</span> <Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
<span class="capitalize font-bold ml-4">{{ userDisplayName }}</span>
<span <span
class="ml-[6px] inline-flex items-center font-bold transition-transform group-hover:rotate-180 group-focus-within:rotate-180"> 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"/> <Icon name="mdi:chevron-down" size="20"/>
+2 -1
View File
@@ -15,7 +15,8 @@ export default defineNuxtConfig({
css: ['~/assets/css/main.css', '~/assets/css/toast.css'], css: ['~/assets/css/main.css', '~/assets/css/toast.css'],
runtimeConfig: { runtimeConfig: {
public: { public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE apiBase: process.env.NUXT_PUBLIC_API_BASE,
geoApiBase: ''
} }
}, },
toast: { toast: {
+5 -3
View File
@@ -1,5 +1,5 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center justify-between relative"> <div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]"> <div class="flex flex-row absolute -left-[60px]">
<Icon <Icon
@@ -15,14 +15,15 @@
</div> </div>
<div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]"> <div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" /> <UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" required />
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" /> <UiTextInput label="Code bovin" id="code-id" v-model="form.code" required />
</div> </div>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<UiButton <UiButton
type="submit" type="submit"
:disabled="isLoading || isHydrating" :disabled="isLoading || isHydrating"
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
@click="submitted = true"
> >
Valider Valider
</UiButton> </UiButton>
@@ -37,6 +38,7 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const isLoading = ref(false) const isLoading = ref(false)
const isHydrating = ref(false) const isHydrating = ref(false)
const submitted = ref(false)
const idBovin = computed(() => resolveId(route.params.id)) const idBovin = computed(() => resolveId(route.params.id))
const isEdit = computed(() => idBovin.value !== null) const isEdit = computed(() => idBovin.value !== null)
+5 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center justify-between relative"> <div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]"> <div class="flex flex-row absolute -left-[60px]">
<Icon <Icon
@@ -20,18 +20,21 @@
label="Nom du transporteur" label="Nom du transporteur"
id="carrier-name" id="carrier-name"
v-model="form.name" v-model="form.name"
required
/> />
<UiTextInput <UiTextInput
label="Code transporteur" label="Code transporteur"
id="code-id" id="code-id"
v-model="form.code" v-model="form.code"
required
/> />
</div> </div>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<UiButton <UiButton
type="submit" type="submit"
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
@click="submitted = true"
> >
Valider Valider
</UiButton> </UiButton>
@@ -49,6 +52,7 @@ const route = useRoute()
const idCarrier = computed(() => resolveId(route.params.id)) const idCarrier = computed(() => resolveId(route.params.id))
const isLoading = ref(false) const isLoading = ref(false)
const isHydrating = ref(false) const isHydrating = ref(false)
const submitted = ref(false)
const resolveId = (param: unknown) => { const resolveId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param const idStr = Array.isArray(param) ? param[0] : param
+55 -8
View File
@@ -1,5 +1,5 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative"> <div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px] "> <div class="flex flex-row absolute -left-[60px] ">
<Icon @click="router.push('/admin/customer/customer-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/> <Icon @click="router.push('/admin/customer/customer-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
@@ -10,21 +10,36 @@
</div> </div>
<div class="flex flex-cols-3 justify-between mb-11 pt-7"> <div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput id="customer-name" v-model="form.name" label="Nom du client" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/> <UiTextInput id="customer-name" v-model="form.name" label="Nom du client" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/> <UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="customer-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/> <UiTextInput id="customer-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
</div> </div>
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-street" v-model="addressForm.street" label="Rue" wrapper-class="w-[280px]" required />
<UiTextInput id="address-street2" v-model="addressForm.street2" label="Complément" wrapper-class="w-[280px]" />
<UiTextInput id="address-country" v-model="addressForm.countryCode" label="Pays (code)" wrapper-class="w-[280px]" />
</div>
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-postalCode" v-model="addressForm.postalCode" label="Code postal" wrapper-class="w-[280px]" required />
<UiSelect id="address-city" v-model="addressForm.city" label="Ville"
:options="communeOptions" :loading="isLoadingCities"
wrapper-class="w-[280px]" required />
<div class="w-[280px]" />
</div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<UiButton <UiButton
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit" type="submit"
:disabled="isLoading || !auth.isAdmin" :disabled="isLoading || !auth.isAdmin"
@click="submitted = true"
> >
<Icon :name="customerId ? '' : 'mdi:plus'" size="28" /> <Icon :name="customerId ? '' : 'mdi:plus'" size="28" />
{{ customerId ? "Valider" : "Ajouter" }} {{ customerId ? "Valider" : "Ajouter" }}
</UiButton> </UiButton>
</div> </div>
<template v-if="customerId">
<div class="flex items-center justify-between mb-7"> <div class="flex items-center justify-between mb-7">
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du client</h2> <h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du client</h2>
</div> </div>
@@ -32,7 +47,6 @@
<table class="w-full border-collapse text-primary-700"> <table class="w-full border-collapse text-primary-700">
<thead> <thead>
<tr class="text-left border bg-slate-100 border-gray-200"> <tr class="text-left border bg-slate-100 border-gray-200">
<th class="py-3 px-4 text-sm uppercase">Libellé</th>
<th class="py-3 px-4 text-sm uppercase">Rue</th> <th class="py-3 px-4 text-sm uppercase">Rue</th>
<th class="py-3 px-4 text-sm uppercase">Complément</th> <th class="py-3 px-4 text-sm uppercase">Complément</th>
<th class="py-3 px-4 text-sm uppercase">Code postal</th> <th class="py-3 px-4 text-sm uppercase">Code postal</th>
@@ -43,7 +57,7 @@
<tbody> <tbody>
<template v-if="form.addresses.length === 0"> <template v-if="form.addresses.length === 0">
<tr> <tr>
<td colspan="6" class="py-4 text-slate-400"> <td colspan="5" class="py-4 text-slate-400">
Aucune adresse. Aucune adresse.
</td> </td>
</tr> </tr>
@@ -56,7 +70,6 @@
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'" :class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)" @click="goToEditAddress(address.id ?? null)"
> >
<td class="py-3 px-4">{{ address.label || "—" }}</td>
<td class="py-3 px-4">{{ address.street || "—" }}</td> <td class="py-3 px-4">{{ address.street || "—" }}</td>
<td class="py-3 px-4">{{ address.street2 || "—" }}</td> <td class="py-3 px-4">{{ address.street2 || "—" }}</td>
<td class="py-3 px-4">{{ address.postalCode || "—" }}</td> <td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
@@ -78,6 +91,7 @@
Ajouter Ajouter
</UiButton> </UiButton>
</div> </div>
</template>
</form> </form>
</template> </template>
@@ -85,6 +99,8 @@
import {computed, reactive, ref, watch} from "vue" import {computed, reactive, ref, watch} from "vue"
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer" import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data" import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
import {createAddress, type AddressPayload} from "~/services/address"
import {getCommunesByPostalCode, type CommuneData} from "~/services/geo"
import {useAuthStore} from "~/stores/auth" import {useAuthStore} from "~/stores/auth"
const route = useRoute() const route = useRoute()
@@ -99,6 +115,7 @@ const resolveId = (param: unknown) => {
} }
const customerId = computed(() => resolveId(route.params.id)) const customerId = computed(() => resolveId(route.params.id))
const isLoading = ref(false) const isLoading = ref(false)
const submitted = ref(false)
const form = reactive<CustomerFormData>({ const form = reactive<CustomerFormData>({
name: "", name: "",
phone: "", phone: "",
@@ -106,6 +123,30 @@ const form = reactive<CustomerFormData>({
addresses: [], addresses: [],
}) })
// Address form (creation mode only)
const addressForm = reactive<AddressPayload>({
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
})
const communes = ref<CommuneData[]>([])
const isLoadingCities = ref(false)
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => addressForm.postalCode, (cp) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
if (cp.length === 5) {
debounceTimer = setTimeout(async () => {
isLoadingCities.value = true
try {
communes.value = await getCommunesByPostalCode(cp)
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
else addressForm.city = ''
} finally { isLoadingCities.value = false }
}, 300)
}
})
const goToAddAddress = () => { const goToAddAddress = () => {
if (customerId.value === null || !auth.isAdmin) return if (customerId.value === null || !auth.isAdmin) return
router.push({ router.push({
@@ -143,7 +184,6 @@ const hydrateFromCustomer = (customer: CustomerData | null) => {
form.addresses = customer.addresses.map((address) => ({ form.addresses = customer.addresses.map((address) => ({
id: address.id ?? null, id: address.id ?? null,
label: address.label ?? "",
street: address.street ?? "", street: address.street ?? "",
street2: address.street2 ?? null, street2: address.street2 ?? null,
postalCode: address.postalCode ?? "", postalCode: address.postalCode ?? "",
@@ -188,7 +228,14 @@ async function validate() {
await updateCustomer(customerId.value, customerPayload) await updateCustomer(customerId.value, customerPayload)
targetId = customerId.value targetId = customerId.value
} else { } else {
const created = await createCustomer(customerPayload) const addressData = await createAddress({ ...addressForm })
const addressIRI = `/api/addresses/${addressData.id}`
const creationPayload = {
...customerPayload,
addresses: [addressIRI],
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
}
const created = await createCustomer(creationPayload)
targetId = created.id targetId = created.id
} }
+13 -57
View File
@@ -6,72 +6,28 @@
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11"> <div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
<div class="max-h-96 overflow-y-auto"> <div class="max-h-96 overflow-y-auto">
<div <div
class="sticky text-primary-700 top-0 z-10 grid grid-cols-8 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide" class="sticky text-primary-700 top-0 z-10 grid grid-cols-4 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
> >
<div>Nom</div> <div>Nom</div>
<div>Téléphone</div> <div>Téléphone</div>
<div>Email</div> <div>Mail</div>
<div>Rue</div> <div>Créé par</div>
<div>Complément</div>
<div>Code Postal</div>
<div>Ville</div>
<div>Pays</div>
</div> </div>
<div v-if="customerList.length === 0" class="px-4 py-6 text-slate-400"> <div v-if="customerList.length === 0" class="px-4 py-6 text-slate-400">
Aucun client. Aucun client.
</div> </div>
<div v-for="customer in customerList" :key="customer.id"> <div
<div v-for="customer in customerList"
v-if="!customer.addresses || customer.addresses.length === 0" :key="customer.id"
class="grid text-primary-700 grid-cols-8 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer" class="grid grid-cols-4 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
@click="goToCustomer(customer.id)" @click="goToCustomer(customer.id)"
> >
<div class="truncate">{{ customer.name || "—" }}</div> <div class="truncate">{{ customer.name || "—" }}</div>
<div class="truncate">{{ customer.phone || "—" }}</div> <div class="truncate">{{ customer.phone || "—" }}</div>
<div class="truncate">{{ customer.email || "—" }}</div> <div class="truncate">{{ customer.email || "—" }}</div>
<div class="col-span-1">Pas d'adresse</div> <div class="truncate">{{ customer.createdBy?.username || "—" }}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
</div>
<template v-else-if="customer.addresses.length > 0">
<div
v-for="(address, idx) in customer.addresses"
:key="address.id ?? `${customer.id}-${idx}-${address.street}-${address.postalCode}`"
class="grid grid-cols-8 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
:class="idx > 0 ? 'pl-4 border-l-4 border-l-slate-200 bg-slate-50' : ''"
@click="goToCustomer(customer.id)"
>
<div class="truncate">
{{ idx === 0 ? (customer.name || "—") : "↳" }}
</div>
<div class="truncate">{{ idx === 0 ? (customer.phone || "—") : "" }}</div>
<div class="truncate">{{ idx === 0 ? (customer.email || "—") : "" }}</div>
<div class="truncate">{{ address.street || "—" }}</div>
<div class="truncate">{{ address.street2 || "—" }}</div>
<div>{{ address.postalCode || "—" }}</div>
<div class="uppercase truncate">{{ address.city || "—" }}</div>
<div class="uppercase truncate">{{ address.countryCode || "—" }}</div>
</div>
</template>
<template v-else>
<div
class="grid grid-cols-8 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
@click="goToCustomer(customer.id)"
>
<div class="truncate">{{ customer.name || "—" }}</div>
<div class="truncate">{{ customer.phone || "—" }}</div>
<div class="truncate">{{ customer.email || "—" }}</div>
<div class="col-span-5 text-slate-400">
Adresses non chargées
</div>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
+55 -8
View File
@@ -1,5 +1,5 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative"> <div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px] "> <div class="flex flex-row absolute -left-[60px] ">
@@ -11,21 +11,36 @@
</div> </div>
<div class="flex flex-cols-3 justify-between mb-11 pt-7"> <div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput id="supplier-name" v-model="form.name" label="Nom du fournisseur" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/> <UiTextInput id="supplier-name" v-model="form.name" label="Nom du fournisseur" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="supplier-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="supplier-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/> <UiTextInput id="supplier-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
<UiTextInput id="supplier-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
</div> </div>
<div v-if="!supplierId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-street" v-model="addressForm.street" label="Rue" wrapper-class="w-[280px]" required />
<UiTextInput id="address-street2" v-model="addressForm.street2" label="Complément" wrapper-class="w-[280px]" />
<UiTextInput id="address-country" v-model="addressForm.countryCode" label="Pays (code)" wrapper-class="w-[280px]" />
</div>
<div v-if="!supplierId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-postalCode" v-model="addressForm.postalCode" label="Code postal" wrapper-class="w-[280px]" required />
<UiSelect id="address-city" v-model="addressForm.city" label="Ville"
:options="communeOptions" :loading="isLoadingCities"
wrapper-class="w-[280px]" required />
<div class="w-[280px]" />
</div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<UiButton <UiButton
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit" type="submit"
:disabled="isLoading || !auth.isAdmin" :disabled="isLoading || !auth.isAdmin"
@click="submitted = true"
> >
<Icon :name="supplierId ? '' : 'mdi:plus'" size="28" /> <Icon :name="supplierId ? '' : 'mdi:plus'" size="28" />
{{ supplierId ? "Valider" : "Ajouter" }} {{ supplierId ? "Valider" : "Ajouter" }}
</UiButton> </UiButton>
</div> </div>
<template v-if="supplierId">
<div class="flex items-center justify-between mb-7"> <div class="flex items-center justify-between mb-7">
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du fournisseur</h2> <h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du fournisseur</h2>
</div> </div>
@@ -33,7 +48,6 @@
<table class="w-full border-collapse"> <table class="w-full border-collapse">
<thead> <thead>
<tr class="text-left border bg-slate-100 border-gray-200"> <tr class="text-left border bg-slate-100 border-gray-200">
<th class="py-3 px-4 text-sm uppercase">Libellé</th>
<th class="py-3 px-4 text-sm uppercase">Rue</th> <th class="py-3 px-4 text-sm uppercase">Rue</th>
<th class="py-3 px-4 text-sm uppercase">Complément</th> <th class="py-3 px-4 text-sm uppercase">Complément</th>
<th class="py-3 px-4 text-sm uppercase">Code postal</th> <th class="py-3 px-4 text-sm uppercase">Code postal</th>
@@ -44,7 +58,7 @@
<tbody> <tbody>
<template v-if="form.addresses.length === 0"> <template v-if="form.addresses.length === 0">
<tr> <tr>
<td colspan="6" class="py-4 text-slate-400"> <td colspan="5" class="py-4 text-slate-400">
Aucune adresse. Aucune adresse.
</td> </td>
</tr> </tr>
@@ -57,7 +71,6 @@
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'" :class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)" @click="goToEditAddress(address.id ?? null)"
> >
<td class="py-3 px-4">{{ address.label || "—" }}</td>
<td class="py-3 px-4">{{ address.street || "—" }}</td> <td class="py-3 px-4">{{ address.street || "—" }}</td>
<td class="py-3 px-4">{{ address.street2 || "—" }}</td> <td class="py-3 px-4">{{ address.street2 || "—" }}</td>
<td class="py-3 px-4">{{ address.postalCode || "—" }}</td> <td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
@@ -79,6 +92,7 @@
Ajouter Ajouter
</UiButton> </UiButton>
</div> </div>
</template>
</form> </form>
</template> </template>
@@ -86,6 +100,8 @@
import {computed, reactive, ref, watch} from "vue" import {computed, reactive, ref, watch} from "vue"
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier" import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data" import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
import {createAddress, type AddressPayload} from "~/services/address"
import {getCommunesByPostalCode, type CommuneData} from "~/services/geo"
import {useAuthStore} from "~/stores/auth" import {useAuthStore} from "~/stores/auth"
const route = useRoute() const route = useRoute()
@@ -100,6 +116,7 @@ const resolveId = (param: unknown) => {
} }
const supplierId = computed(() => resolveId(route.params.id)) const supplierId = computed(() => resolveId(route.params.id))
const isLoading = ref(false) const isLoading = ref(false)
const submitted = ref(false)
const form = reactive<SupplierFormData>({ const form = reactive<SupplierFormData>({
name: "", name: "",
email: "", email: "",
@@ -107,6 +124,30 @@ const form = reactive<SupplierFormData>({
addresses: [], addresses: [],
}) })
// Address form (creation mode only)
const addressForm = reactive<AddressPayload>({
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
})
const communes = ref<CommuneData[]>([])
const isLoadingCities = ref(false)
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => addressForm.postalCode, (cp) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
if (cp.length === 5) {
debounceTimer = setTimeout(async () => {
isLoadingCities.value = true
try {
communes.value = await getCommunesByPostalCode(cp)
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
else addressForm.city = ''
} finally { isLoadingCities.value = false }
}, 300)
}
})
const goToAddAddress = () => { const goToAddAddress = () => {
if (supplierId.value === null || !auth.isAdmin) return if (supplierId.value === null || !auth.isAdmin) return
router.push({ router.push({
@@ -146,7 +187,6 @@ const hydrateFromSupplier = (supplier: SupplierData | null) => {
form.addresses = supplier.addresses.map((address) => ({ form.addresses = supplier.addresses.map((address) => ({
id: address.id ?? null, id: address.id ?? null,
label: address.label ?? "",
street: address.street ?? "", street: address.street ?? "",
street2: address.street2 ?? null, street2: address.street2 ?? null,
postalCode: address.postalCode ?? "", postalCode: address.postalCode ?? "",
@@ -191,7 +231,14 @@ async function validate() {
await updateSupplier(supplierId.value, supplierPayload) await updateSupplier(supplierId.value, supplierPayload)
targetId = supplierId.value targetId = supplierId.value
} else { } else {
const created = await createSupplier(supplierPayload) const addressData = await createAddress({ ...addressForm })
const addressIRI = `/api/addresses/${addressData.id}`
const creationPayload = {
...supplierPayload,
addresses: [addressIRI],
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
}
const created = await createSupplier(creationPayload)
targetId = created.id targetId = created.id
} }
+13 -53
View File
@@ -6,68 +6,28 @@
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11"> <div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
<div class="max-h-96 overflow-y-auto"> <div class="max-h-96 overflow-y-auto">
<div <div
class="sticky text-primary-700 top-0 z-10 grid grid-cols-7 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide" class="sticky text-primary-700 top-0 z-10 grid grid-cols-4 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
> >
<div>Nom</div> <div>Nom</div>
<div>Téléphone</div>
<div>Mail</div> <div>Mail</div>
<div>Rue</div> <div>Créé par</div>
<div>Complément</div>
<div>Code Postal</div>
<div>Ville</div>
<div>Pays</div>
</div> </div>
<div v-if="supplierList.length === 0" class="px-4 py-6 text-slate-400"> <div v-if="supplierList.length === 0" class="px-4 py-6 text-slate-400">
Aucun fournisseur. Aucun fournisseur.
</div> </div>
<div v-for="supplier in supplierList" :key="supplier.id"> <div
<div v-for="supplier in supplierList"
v-if="!supplier.addresses || supplier.addresses.length === 0" :key="supplier.id"
class="grid text-primary-700 grid-cols-7 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer" class="grid grid-cols-4 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
@click="goToSupplier(supplier.id)" @click="goToSupplier(supplier.id)"
> >
<div class="truncate">{{ supplier.name }}</div> <div class="truncate">{{ supplier.name || "—" }}</div>
<div class="truncate">{{ supplier.email }}</div> <div class="truncate">{{ supplier.phone || "—" }}</div>
<div class="col-span-1">Pas d'adresse</div> <div class="truncate">{{ supplier.email || "—" }}</div>
<div class="uppercase truncate">{{"—"}}</div> <div class="truncate">{{ supplier.createdBy?.username || "—" }}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
</div>
<template v-else-if="supplier.addresses.length > 0">
<div
v-for="(address, idx) in supplier.addresses"
:key="address.id ?? `${supplier.id}-${idx}-${address.street}-${address.postalCode}`"
class="grid grid-cols-7 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
:class="idx > 0 ? 'pl-4 border-l-4 border-l-slate-200 bg-slate-50' : ''"
@click="goToSupplier(supplier.id)"
>
<div class="truncate">
{{ idx === 0 ? supplier.name : "↳" }}
</div>
<div class="truncate">{{ idx === 0 ? supplier.email : "" }}</div>
<div class="truncate">{{ address.street || "—" }}</div>
<div class="truncate">{{ address.street2 || "—" }}</div>
<div>{{ address.postalCode || "—" }}</div>
<div class="uppercase truncate">{{ address.city || "—" }}</div>
<div class="uppercase truncate">{{ address.countryCode || "—" }}</div>
</div>
</template>
<template v-else>
<div
class="grid grid-cols-7 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
@click="goToSupplier(supplier.id)"
>
<div class="truncate">{{ supplier.name }}</div>
<div class="truncate">{{ supplier.email }}</div>
<div class="col-span-5 text-slate-400">
Adresses non chargées
</div>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
+6 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative"> <div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px]"> <div class="flex flex-row absolute -left-[60px]">
<Icon <Icon
@@ -21,6 +21,7 @@
label="Nom de l'utilisateur" label="Nom de l'utilisateur"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
wrapper-class="w-[280px]" wrapper-class="w-[280px]"
required
/> />
<UiSelect <UiSelect
@@ -30,6 +31,7 @@
:options="ROLE" :options="ROLE"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
wrapper-class="w-[280px]" wrapper-class="w-[280px]"
required
/> />
<UiTextInput <UiTextInput
@@ -39,6 +41,7 @@
type="password" type="password"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
wrapper-class="w-[280px]" wrapper-class="w-[280px]"
required
/> />
</div> </div>
@@ -47,6 +50,7 @@
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit" type="submit"
:disabled="isLoading || isHydrating || !auth.isAdmin" :disabled="isLoading || isHydrating || !auth.isAdmin"
@click="submitted = true"
> >
<Icon :name="userId ? '' : 'mdi:plus'" size="28" /> <Icon :name="userId ? '' : 'mdi:plus'" size="28" />
{{ userId ? 'Valider' : 'Ajouter' }} {{ userId ? 'Valider' : 'Ajouter' }}
@@ -68,6 +72,7 @@ const auth = useAuthStore()
const userId = computed(() => resolveUserId(route.params.id)) const userId = computed(() => resolveUserId(route.params.id))
const isLoading = ref(false) const isLoading = ref(false)
const isHydrating = ref(false) const isHydrating = ref(false)
const submitted = ref(false)
const resolveUserId = (param: unknown) => { const resolveUserId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param const idStr = Array.isArray(param) ? param[0] : param
+46 -52
View File
@@ -2,7 +2,7 @@
<div class="flex justify-between h-[52px] mb-[80px]"> <div class="flex justify-between h-[52px] mb-[80px]">
<div class="flex flex-1 mr-16"> <div class="flex flex-1 mr-16">
<UiStepper <UiStepper
:labels="RECEPTION_STEP_LABELS" :labels="stepLabels"
:current-step="storeReception?.currentStep ?? 0" :current-step="storeReception?.currentStep ?? 0"
@select="handleStepSelect" @select="handleStepSelect"
/> />
@@ -15,76 +15,70 @@
</UiButton> </UiButton>
</div> </div>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/> <ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/>
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/> <WorkflowWeight
v-if="storeReception?.currentStep === 1"
mode="gross"
entity-name="reception"
api-resource="receptions"
:title-label="receptionConfig.weighingLabels.gross"
:is-final="false"
:entity="storeReception"
:get-weight-from-scale="getWeight"
:update-entity="receptionStore.updateReception"
:load-entity="receptionStore.loadReception"
:clear-entity="receptionStore.clearCurrent"
:build-receipt-filename="receptionConfig.buildReceiptFilename"
/>
<ReceptionProductReceived <ReceptionProductReceived
v-if="storeReception?.currentStep === 2 && v-if="storeReception?.currentStep === 2 &&
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"/> receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"/>
<ReceptionBovineReceived <ReceptionBovineReceived
v-if="storeReception?.currentStep === 2 && v-if="storeReception?.currentStep === 2 &&
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/> receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/>
<ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/> <WorkflowWeight
v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3"
mode="tare"
entity-name="reception"
api-resource="receptions"
:title-label="receptionConfig.weighingLabels.tare"
:is-final="true"
:entity="storeReception"
:get-weight-from-scale="getWeight"
:update-entity="receptionStore.updateReception"
:load-entity="receptionStore.loadReception"
:clear-entity="receptionStore.clearCurrent"
:build-receipt-filename="receptionConfig.buildReceiptFilename"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {useReceptionStore} from '~/stores/reception' import { useReceptionStore } from '~/stores/reception'
import {storeToRefs} from 'pinia' import { storeToRefs } from 'pinia'
import {RECEPTION_STEP_LABELS} from '~/constants/steps' import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
import {RECEPTION_TYPE_CODES} from "~/utils/constants"; import { receptionConfig } from '~/config/reception.config'
import { getWeight } from '~/services/reception'
const route = useRoute() import { RECEPTION_TYPE_CODES } from '~/utils/constants'
const router = useRouter()
const receptionStore = useReceptionStore() const receptionStore = useReceptionStore()
const {current: storeReception} = storeToRefs(receptionStore) const { current: storeReception } = storeToRefs(receptionStore)
const resolveReceptionId = (param: unknown) => { const { stepLabels, saveAndHold, handleStepSelect } = useWorkflowSteps(receptionConfig, receptionStore)
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) {
return null
}
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
// Init route watcher
const route = useRoute()
watch( watch(
() => route.params.id, () => route.params.id,
async (param) => { async (param) => {
const id = resolveReceptionId(param) const idStr = Array.isArray(param) ? param[0] : param
if (id === null) { if (!idStr) {
receptionStore.clearCurrent() receptionStore.clearCurrent()
return return
} }
await receptionStore.loadReception(id) const id = Number(idStr)
if (Number.isFinite(id)) {
await receptionStore.loadReception(id)
}
}, },
{immediate: true} { immediate: true }
) )
const saveAndHold = async () => {
if (!receptionStore.current) {
await router.push('/')
return
}
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: receptionStore.current.currentStep,
licensePlate: receptionStore.current.licensePlate,
receptionDate: receptionStore.current.receptionDate
})
await router.push('/')
}
const handleStepSelect = async (step: number) => {
if (!receptionStore.current) {
return
}
if (step === receptionStore.current.currentStep) {
return
}
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: step
})
await receptionStore.loadReception(receptionStore.current.id)
}
</script> </script>
+15 -2
View File
@@ -8,7 +8,7 @@
<div class="mt-6 border border-slate-200 mb-16 "> <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 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>Numéro</div>
<div>Date</div> <div>Date et heure</div>
<div>Fournisseur</div> <div>Fournisseur</div>
<div>Adresse</div> <div>Adresse</div>
<div>Type réception</div> <div>Type réception</div>
@@ -23,7 +23,7 @@
@click="goToReception(reception.id)" @click="goToReception(reception.id)"
> >
<div>{{ reception.identificationNumber}}</div> <div>{{ reception.identificationNumber}}</div>
<div>{{ reception.receptionDate}}</div> <div>{{ formatDate(reception.receptionDate) }}</div>
<div>{{ reception.supplier?.name }}</div> <div>{{ reception.supplier?.name }}</div>
<div>{{ reception.address?.fullAddress }}</div> <div>{{ reception.address?.fullAddress }}</div>
<div>{{ reception.receptionType?.label }}</div> <div>{{ reception.receptionType?.label }}</div>
@@ -41,6 +41,19 @@ import type {ShipmentData} from "~/services/dto/shipment-data";
const receptionList = ref<ReceptionData[]>() const receptionList = ref<ReceptionData[]>()
const router = useRouter() const router = useRouter()
const formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date.replace(' ', 'T'))
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const formatWeighing = (reception: ReceptionData) => { const formatWeighing = (reception: ReceptionData) => {
const gross = reception.weights?.find((weight) => weight.type === 'gross')?.weight const gross = reception.weights?.find((weight) => weight.type === 'gross')?.weight
const tare = reception.weights?.find((weight) => weight.type === 'tare')?.weight const tare = reception.weights?.find((weight) => weight.type === 'tare')?.weight
+100 -10
View File
@@ -1,13 +1,15 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-[60px]"> <div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-[60px]">
<div class="flex items-center justify-between gap-10 relative"> <div class="flex items-center justify-between gap-10 relative">
<div class="flex flex-row absolute -left-[60px] justify-between"> <div class="flex flex-row absolute -left-[60px] justify-between">
<Icon @click="router.push('/reception/finish-reception')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/> <Icon @click="router.push('/reception/finish-reception')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
</div> </div>
<h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Réception {{ form.identificationNumber }}</h1> <h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Réception {{ form.identificationNumber }}</h1>
<Icon @click="printReceipt" name="mdi:printer-outline" size="44" class="cursor-pointer text-primary-500"/> <div class="bg-primary-500 p-1 rounded-md flex items-center" title="Imprimer" @click="printReceipt">
<Icon name="mdi:printer-outline" size="32" class="cursor-pointer text-white"/>
</div>
</div> </div>
<!-- Nom de l'utilisateur --> <!-- Nom de l'utilisateur -->
<UiSelect <UiSelect
@@ -21,6 +23,7 @@
}))" }))"
:loading="isLoadingUsers" :loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2" wrapper-class="col-start-1 row-start-2"
required
/> />
<!-- Date de réception --> <!-- Date de réception -->
<UiDateInput <UiDateInput
@@ -29,6 +32,7 @@
v-model="form.receptionDate" v-model="form.receptionDate"
label="Date de réception" label="Date de réception"
wrapper-class="col-start-1 row-start-3" wrapper-class="col-start-1 row-start-3"
required
/> />
<!-- type de reception --> <!-- type de reception -->
<UiSelect <UiSelect
@@ -42,6 +46,7 @@
}))" }))"
:loading="isLoadingSuppliers" :loading="isLoadingSuppliers"
wrapper-class="col-start-1 row-start-4" wrapper-class="col-start-1 row-start-4"
required
/> />
<!-- Fournisseur --> <!-- Fournisseur -->
<UiSelect <UiSelect
@@ -55,6 +60,7 @@
}))" }))"
:loading="isLoadingSuppliers" :loading="isLoadingSuppliers"
wrapper-class="col-start-1 row-start-5" wrapper-class="col-start-1 row-start-5"
required
/> />
<!-- Adresse fournisseur --> <!-- Adresse fournisseur -->
<UiSelect <UiSelect
@@ -67,6 +73,7 @@
}))" }))"
:disabled="(isLoadingSuppliers || supplierAddresses.length === 0) && !auth.isAdmin" :disabled="(isLoadingSuppliers || supplierAddresses.length === 0) && !auth.isAdmin"
wrapper-class="col-start-2 row-start-1" wrapper-class="col-start-2 row-start-1"
required
/> />
<!-- Camion --> <!-- Camion -->
<UiSelect <UiSelect
@@ -80,6 +87,7 @@
}))" }))"
:loading="isLoadingTrucks" :loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2" wrapper-class="col-start-2 row-start-2"
required
/> />
<!-- Transporteur --> <!-- Transporteur -->
<UiSelect <UiSelect
@@ -94,6 +102,7 @@
:loading="isLoadingCarriers" :loading="isLoadingCarriers"
select-class="h-[34px]" select-class="h-[34px]"
wrapper-class="col-start-2 row-start-3" wrapper-class="col-start-2 row-start-3"
required
/> />
<!-- Chauffeur (LIOT) --> <!-- Chauffeur (LIOT) -->
<UiSelect <UiSelect
@@ -108,6 +117,7 @@
:loading="isLoadingDrivers" :loading="isLoadingDrivers"
wrapper-class="col-start-2 row-start-5" wrapper-class="col-start-2 row-start-5"
v-if="isLiotCarrier" v-if="isLiotCarrier"
required
/> />
<!-- Plaque d'immatriculation --> <!-- Plaque d'immatriculation -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4"> <div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
@@ -115,6 +125,7 @@
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
v-model="form.licensePlate" v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate" v-model:allowAny="allowAnyLicensePlate"
required
/> />
</div> </div>
<!-- Immatriculation (LIOT) --> <!-- Immatriculation (LIOT) -->
@@ -130,27 +141,37 @@
:loading="isLoadingVehicles" :loading="isLoadingVehicles"
:disabled="(isLoadingVehicles || filteredVehicles.length === 0) && !auth.isAdmin" :disabled="(isLoadingVehicles || filteredVehicles.length === 0) && !auth.isAdmin"
wrapper-class="col-start-2 row-start-4" wrapper-class="col-start-2 row-start-4"
required
/> />
</div> </div>
<div v-if="formIsLoading"> <div v-if="formIsLoading">
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60"> <div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1 <h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer " class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50'" :class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'" @click="activeTab = 'weights'"
> >
pesée à plein pesée à plein
</h1> </h1>
<h1 <h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer " class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500 ' : 'text-primary-500/50'" :class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weightsEmpty'" @click="activeTab = 'weightsEmpty'"
> >
pesée à vide pesée à vide
</h1> </h1>
<h1 <h1
class="font-bold text-3xl uppercase px-12 col-start-2 row-start-1 cursor-pointer " class="font-bold text-3xl uppercase px-12 col-start-2 row-start-1 cursor-pointer"
:class="activeTab === 'merchandise' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50'" :class="[
activeTab === 'merchandise' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasMerchandiseTabError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'merchandise'" @click="activeTab = 'merchandise'"
> >
{{ isMerchandise ? "Marchandise" : "Bovins" }} {{ isMerchandise ? "Marchandise" : "Bovins" }}
@@ -176,6 +197,9 @@
v-model="merchandiseForm" v-model="merchandiseForm"
:isAdmin="auth.isAdmin" :isAdmin="auth.isAdmin"
/> />
<p v-if="activeTab === 'merchandise' && isMerchandise" class="text-red-500 text-sm mt-2" :class="showMerchandiseError ? '' : 'invisible'">
{{ merchandiseErrorMessage || '&nbsp;' }}
</p>
<update-bovin <update-bovin
v-if="activeTab === 'merchandise' && !isMerchandise" v-if="activeTab === 'merchandise' && !isMerchandise"
@@ -183,12 +207,16 @@
v-model:otherQuantity="bovineOtherQuantity" v-model:otherQuantity="bovineOtherQuantity"
:isAdmin="auth.isAdmin" :isAdmin="auth.isAdmin"
/> />
<p v-if="activeTab === 'merchandise' && !isMerchandise" class="text-red-500 text-sm mt-2" :class="showMerchandiseError ? '' : 'invisible'">
{{ merchandiseErrorMessage || '&nbsp;' }}
</p>
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<UiButton <UiButton
v-if="auth.isAdmin" v-if="auth.isAdmin"
type="submit" type="submit"
class="inline-flex mb-16 items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2 justify-self-end" class="inline-flex mb-16 items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2 justify-self-end"
@click="submitted = true"
> >
Valider Valider
</UiButton> </UiButton>
@@ -239,7 +267,9 @@ import { getVehicleList } from '~/services/vehicle'
import { createWeight, updateWeight } from '~/services/weight' import { createWeight, updateWeight } from '~/services/weight'
import { useAuthStore } from '~/stores/auth' import { useAuthStore } from '~/stores/auth'
import { useReceptionStore } from '~/stores/reception' import { useReceptionStore } from '~/stores/reception'
import { RECEPTION_TYPE_CODES, SUPPLIER_CODE } from '~/utils/constants' import { MERCHANDISE_TYPE_CODES, RECEPTION_TYPE_CODES, SUPPLIER_CODE } from '~/utils/constants'
import { getMerchandiseTypeList } from '~/services/merchandise-type'
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -260,6 +290,17 @@ const merchandiseForm = ref<MerchandiseEntryData>({
selectedPelletBuildingIds: {} selectedPelletBuildingIds: {}
}) })
const allowAnyLicensePlate = ref(false) const allowAnyLicensePlate = ref(false)
const submitted = ref(false)
const showMerchandiseError = ref(false)
const merchandiseErrorMessage = ref('')
const hasGrossWeightError = computed(() =>
submitted.value && (grossWeight.value.weight === null || grossWeight.value.weighedAt === null || grossWeight.value.dsd === null)
)
const hasTareWeightError = computed(() =>
submitted.value && (tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null)
)
const hasMerchandiseTabError = computed(() => submitted.value && showMerchandiseError.value)
const isLoading = ref(false) const isLoading = ref(false)
const users = ref<UserData[]>([]) const users = ref<UserData[]>([])
const isLoadingUsers = ref(false) const isLoadingUsers = ref(false)
@@ -277,6 +318,7 @@ const vehicles = ref<VehicleData[]>([])
const isLoadingVehicles = ref(false) const isLoadingVehicles = ref(false)
const formIsLoading = ref(false) const formIsLoading = ref(false)
const isMerchandise = ref(false) const isMerchandise = ref(false)
const merchandiseTypesList = ref<MerchandiseTypeData[]>([])
const isHydrating = ref(false) const isHydrating = ref(false)
const vehicleSyncLock = ref(false) const vehicleSyncLock = ref(false)
@@ -436,7 +478,7 @@ function hydrateFromReception(reception: ReceptionData | null) {
isHydrating.value = true isHydrating.value = true
form.identificationNumber = reception?.identificationNumber ?? '' form.identificationNumber = reception?.identificationNumber ?? ''
form.licensePlate = reception?.licensePlate ?? '' form.licensePlate = reception?.licensePlate ?? ''
form.receptionDate = reception?.receptionDate ?? new Date().toISOString().slice(0, 10) form.receptionDate = reception?.receptionDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)
form.userId = reception?.user?.id form.userId = reception?.user?.id
? String(reception.user.id) ? String(reception.user.id)
: form.userId : form.userId
@@ -776,6 +818,53 @@ async function validate() {
} }
if (idReception) { if (idReception) {
const hasInvalidWeights =
grossWeight.value.weight === null || grossWeight.value.weighedAt === null || grossWeight.value.dsd === null ||
tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null
if (hasInvalidWeights) {
return
}
showMerchandiseError.value = false
merchandiseErrorMessage.value = ''
if (!isMerchandise.value && getTotalBovines() === 0) {
showMerchandiseError.value = true
merchandiseErrorMessage.value = 'Veuillez saisir au moins une race bovine.'
return
}
if (isMerchandise.value) {
const selectedType = merchandiseTypesList.value.find(
(t) => String(t.id) === merchandiseForm.value.merchandiseTypeId
)
const isAutresType = selectedType?.code === MERCHANDISE_TYPE_CODES.AUTRES
const isGranuleType = selectedType?.code === MERCHANDISE_TYPE_CODES.GRANULE
if (isAutresType && !merchandiseForm.value.merchandiseDetail.trim()) {
showMerchandiseError.value = true
merchandiseErrorMessage.value = 'Veuillez préciser le type de marchandise.'
return
}
if (!isAutresType && !isGranuleType && merchandiseForm.value.selectedBuildingIds.length === 0) {
showMerchandiseError.value = true
merchandiseErrorMessage.value = 'Veuillez sélectionner au moins un bâtiment.'
return
}
if (isGranuleType) {
const hasAny = Object.values(merchandiseForm.value.selectedPelletBuildingIds)
.some((ids) => ids.length > 0)
if (!hasAny) {
showMerchandiseError.value = true
merchandiseErrorMessage.value = 'Veuillez sélectionner au moins un bâtiment.'
return
}
}
}
await receptionStore.updateReception(idReception, { await receptionStore.updateReception(idReception, {
...payload ...payload
}) })
@@ -820,6 +909,7 @@ async function validate() {
onMounted(async () => { onMounted(async () => {
await loadTypes() await loadTypes()
merchandiseTypesList.value = await getMerchandiseTypeList()
syncMerchandiseFlag() syncMerchandiseFlag()
formIsLoading.value = true formIsLoading.value = true
await loadUsers() await loadUsers()
+51 -38
View File
@@ -1,48 +1,61 @@
<template> <template>
<div class="flex items-center justify-between"> <WorkflowWaitingList
<div class="flex items-center gap-10"> title="listes des réceptions en attente"
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/> :columns="columns"
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions en attente</h1> :items="receptionList ?? []"
</div> route-prefix="/reception"
</div> show-actions
>
<div class="px-[86px]"> <template #cell-receptionDate="{ item }">
<div class="mt-6 border border-slate-200 mb-16"> {{ formatDate(item.receptionDate) }}
<div class="grid grid-cols-5 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"> </template>
<div>Fournisseur</div> <template #actions="{ item }">
<div>Adresse</div> <Icon
<div>Type réception</div> name="mdi:delete-outline"
<div>Transporteur</div> size="24"
<div>Immatriculation</div> class="cursor-pointer text-red-500 hover:text-red-700"
</div> @click="confirmDelete(item)"
<div />
v-for="reception in receptionList" </template>
:key="reception.id" </WorkflowWaitingList>
class="grid grid-cols-5 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToReception(reception.id)"
@keydown.enter="goToReception(reception.id)"
>
<div>{{ reception.supplier?.name }}</div>
<div>{{ reception.address?.fullAddress }}</div>
<div>{{ reception.receptionType?.label }}</div>
<div>{{ reception.carrier?.name }}</div>
<div>{{ reception.licensePlate }}</div>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {ReceptionData} from "~/services/dto/reception-data"; import type { ReceptionData } from '~/services/dto/reception-data'
import {getReceptionList} from "~/services/reception"; import { getReceptionList, deleteReception } from '~/services/reception'
const columns = [
{ key: 'receptionDate', label: 'Date et heure' },
{ key: 'supplier.name', label: 'Fournisseur' },
{ key: 'address.fullAddress', label: 'Adresse' },
{ key: 'receptionType.label', label: 'Type réception' },
{ key: 'carrier.name', label: 'Transporteur' },
{ key: 'licensePlate', label: 'Immatriculation' }
]
const receptionList = ref<ReceptionData[]>() const receptionList = ref<ReceptionData[]>()
const router = useRouter()
const goToReception = (id: number) => { const formatDate = (date: string | null) => {
router.push(`/reception/${id}`) if (!date) return '—'
const d = new Date(date.replace(' ', 'T'))
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const confirmDelete = async (reception: ReceptionData) => {
const confirmed = window.confirm(
`Êtes-vous sûr de vouloir supprimer la réception ${reception.identificationNumber ?? `#${reception.id}`} ? Toutes les données liées seront supprimées.`
)
if (!confirmed) return
await deleteReception(reception.id)
receptionList.value = receptionList.value?.filter(r => r.id !== reception.id)
} }
onMounted(async () => { onMounted(async () => {
+47 -40
View File
@@ -3,10 +3,9 @@
<div class="flex justify-between h-[52px] mb-[80px]"> <div class="flex justify-between h-[52px] mb-[80px]">
<div class="flex flex-1 mr-16"> <div class="flex flex-1 mr-16">
<UiStepper <UiStepper
:labels="SHIPMENT_STEP_LABELS" :labels="stepLabels"
:current-step="storeShipment?.currentStep ?? 0" :current-step="storeShipment?.currentStep ?? 0"
@select="handleStepSelect" @select="handleStepSelect"
/> />
</div> </div>
<UiButton <UiButton
@@ -17,45 +16,69 @@
</UiButton> </UiButton>
</div> </div>
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/> <ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
<ShipmentWeight v-if="storeShipment?.currentStep === 1" mode="gross"/> <WorkflowWeight
v-if="storeShipment?.currentStep === 1"
mode="gross"
entity-name="shipment"
api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.gross"
:is-final="false"
:entity="storeShipment"
:get-weight-from-scale="getWeightShipment"
:update-entity="shipmentStore.updateShipment"
:load-entity="shipmentStore.loadShipment"
:clear-entity="shipmentStore.clearCurrent"
:build-receipt-filename="shipmentConfig.buildReceiptFilename"
/>
<ShipmentLoading v-if="storeShipment?.currentStep === 2"/> <ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
<ShipmentWeight v-if="storeShipment?.currentStep === 3" mode="tare"/> <WorkflowWeight
v-if="storeShipment?.currentStep === 3"
mode="tare"
entity-name="shipment"
api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.tare"
:is-final="true"
:entity="storeShipment"
:get-weight-from-scale="getWeightShipment"
:update-entity="shipmentStore.updateShipment"
:load-entity="shipmentStore.loadShipment"
:clear-entity="shipmentStore.clearCurrent"
:build-receipt-filename="shipmentConfig.buildReceiptFilename"
/>
</div> </div>
</template> </template>
<script setup lang="ts">
import {SHIPMENT_STEP_LABELS} from "~/constants/steps"; <script setup lang="ts">
import {storeToRefs} from "pinia"; import { storeToRefs } from 'pinia'
import {useShipmentStore} from "~/stores/shipment"; import { useShipmentStore } from '~/stores/shipment'
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
import { shipmentConfig } from '~/config/shipment.config'
import { getWeightShipment } from '~/services/shipment'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
const shipmentStore = useShipmentStore() const shipmentStore = useShipmentStore()
const {current: storeShipment} = storeToRefs(shipmentStore) const { current: storeShipment } = storeToRefs(shipmentStore)
const shipmentFormRef = ref<{ saveDraft: () => Promise<void> } | null>(null) const shipmentFormRef = ref<{ saveDraft: () => Promise<void> } | null>(null)
const { stepLabels, handleStepSelect } = useWorkflowSteps(shipmentConfig, shipmentStore)
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const resolveShipmentId = (param: unknown) => { watch(
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) {
return null
}
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
watch (
() => route.params.id, () => route.params.id,
async (param) => { async (param) => {
const id = resolveShipmentId(param) const idStr = Array.isArray(param) ? param[0] : param
if (id === null) { if (!idStr) {
shipmentStore.clearCurrent() shipmentStore.clearCurrent()
return return
} }
await shipmentStore.loadShipment(id) const id = Number(idStr)
if (Number.isFinite(id)) {
await shipmentStore.loadShipment(id)
}
}, },
{immediate: true} { immediate: true }
) )
const saveAndHold = async () => { const saveAndHold = async () => {
@@ -64,20 +87,4 @@ const saveAndHold = async () => {
} }
await router.push('/') await router.push('/')
} }
const handleStepSelect = async (step: number) => {
if (!shipmentStore.current) {
return
}
if (step === shipmentStore.current.currentStep) {
return
}
await shipmentStore.updateShipment(shipmentStore.current.id, {
currentStep: step
})
await shipmentStore.loadShipment(shipmentStore.current.id)
}
</script> </script>
+41 -5
View File
@@ -1,12 +1,14 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16"> <div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
<div class="flex items-center justify-between gap-10 relative col-start-1 row-start-1"> <div class="flex items-center justify-between gap-10 relative col-start-1 row-start-1">
<div class="flex flex-row absolute -left-[60px] justify-between"> <div class="flex flex-row absolute -left-[60px] justify-between">
<Icon @click="router.push('/shipment/finish-shipment')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/> <Icon @click="router.push('/shipment/finish-shipment')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
</div> </div>
<h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Expédition {{ form.identificationNumber }}</h1> <h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Expédition {{ form.identificationNumber }}</h1>
<Icon @click="printReceipt" name="mdi:printer-outline" size="44" class="cursor-pointer text-primary-500"/> <div class="bg-primary-500 p-1 rounded-md flex items-center" title="Imprimer" @click="printReceipt">
<Icon name="mdi:printer-outline" size="32" class="cursor-pointer text-white"/>
</div>
</div> </div>
<UiSelect <UiSelect
@@ -19,6 +21,7 @@
}))" }))"
:loading="isLoadingUsers" :loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2" wrapper-class="col-start-1 row-start-2"
required
/> />
<UiDateInput <UiDateInput
@@ -26,6 +29,7 @@
v-model="form.shipmentDate" v-model="form.shipmentDate"
label="Date d'expédition" label="Date d'expédition"
wrapper-class="col-start-1 row-start-3" wrapper-class="col-start-1 row-start-3"
required
/> />
<div class="col-start-1 row-start-4 h-[64px]"> <div class="col-start-1 row-start-4 h-[64px]">
@@ -41,6 +45,7 @@
value: String(type.id), value: String(type.id),
label: type.label label: type.label
}))" }))"
required
/> />
<UiNumberInput <UiNumberInput
id="shipment-type-quantity" id="shipment-type-quantity"
@@ -63,6 +68,7 @@
}))" }))"
:loading="isLoadingCustomers" :loading="isLoadingCustomers"
wrapper-class="col-start-1 row-start-5" wrapper-class="col-start-1 row-start-5"
required
/> />
<UiSelect <UiSelect
@@ -72,6 +78,7 @@
:disabled="isLoadingCustomers || customerAddresses.length === 0" :disabled="isLoadingCustomers || customerAddresses.length === 0"
label="Adresse" label="Adresse"
wrapper-class="col-start-2 row-start-1" wrapper-class="col-start-2 row-start-1"
required
/> />
<UiSelect <UiSelect
@@ -84,6 +91,7 @@
}))" }))"
:loading="isLoadingTrucks" :loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2" wrapper-class="col-start-2 row-start-2"
required
/> />
<UiSelect <UiSelect
@@ -95,12 +103,14 @@
label: carrier.name label: carrier.name
}))" }))"
wrapper-class="col-start-2 row-start-3" wrapper-class="col-start-2 row-start-3"
required
/> />
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4"> <div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput <UiLicensePlateInput
v-model="form.licensePlate" v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate" v-model:allowAny="allowAnyLicensePlate"
required
/> />
</div> </div>
@@ -116,6 +126,7 @@
:loading="isLoadingVehicles" :loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0" :disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4" wrapper-class="col-start-2 row-start-4"
required
/> />
<div class="col-start-2 row-start-5 min-h-[72px]"> <div class="col-start-2 row-start-5 min-h-[72px]">
@@ -129,6 +140,7 @@
label: driver.name label: driver.name
}))" }))"
:loading="isLoadingDrivers" :loading="isLoadingDrivers"
required
/> />
</div> </div>
</div> </div>
@@ -137,14 +149,20 @@
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60"> <div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1 <h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer" class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50'" :class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'" @click="activeTab = 'weights'"
> >
pesée à plein pesée à plein
</h1> </h1>
<h1 <h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer" class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50'" :class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weightsEmpty'" @click="activeTab = 'weightsEmpty'"
> >
pesée à vide pesée à vide
@@ -170,6 +188,7 @@
<UiButton <UiButton
type="submit" type="submit"
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end" class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="submitted = true"
> >
Valider Valider
</UiButton> </UiButton>
@@ -220,6 +239,15 @@ const currentShipment = ref<ShipmentData | null>(null)
const selectedShipmentTypeId = ref('') const selectedShipmentTypeId = ref('')
const shipmentQuantity = ref<number | null>(0) const shipmentQuantity = ref<number | null>(0)
const allowAnyLicensePlate = ref(false) const allowAnyLicensePlate = ref(false)
const submitted = ref(false)
const hasGrossWeightError = computed(() =>
submitted.value && (grossWeight.value.weight === null || grossWeight.value.weighedAt === null || grossWeight.value.dsd === null)
)
const hasTareWeightError = computed(() =>
submitted.value && (tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null)
)
const activeTab = ref<'weightsEmpty' | 'weights'>('weights') const activeTab = ref<'weightsEmpty' | 'weights'>('weights')
const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross')) const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross'))
const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare')) const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare'))
@@ -376,7 +404,7 @@ function hydrateFromShipment(shipment: ShipmentData | null) {
isHydrating.value = true isHydrating.value = true
form.identificationNumber = shipment.identificationNumber ?? null form.identificationNumber = shipment.identificationNumber ?? null
form.licensePlate = shipment.licensePlate ?? '' form.licensePlate = shipment.licensePlate ?? ''
form.shipmentDate = shipment.shipmentDate ?? new Date().toISOString().slice(0, 10) form.shipmentDate = shipment.shipmentDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)
form.userId = shipment.user?.id ? String(shipment.user.id) : form.userId form.userId = shipment.user?.id ? String(shipment.user.id) : form.userId
form.customerId = shipment.customer?.id ? String(shipment.customer.id) : '' form.customerId = shipment.customer?.id ? String(shipment.customer.id) : ''
form.addressId = shipment.address?.id ? String(shipment.address.id) : '' form.addressId = shipment.address?.id ? String(shipment.address.id) : ''
@@ -613,6 +641,14 @@ async function validate() {
return return
} }
const hasInvalidWeights =
grossWeight.value.weight === null || grossWeight.value.weighedAt === null || grossWeight.value.dsd === null ||
tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null
if (hasInvalidWeights) {
return
}
await updateShipment(shipmentId.value, { await updateShipment(shipmentId.value, {
currentStep: currentShipment.value?.currentStep ?? 0, currentStep: currentShipment.value?.currentStep ?? 0,
...buildPayload() ...buildPayload()
+61 -49
View File
@@ -1,73 +1,85 @@
<template> <template>
<div class="flex items-center justify-between"> <WorkflowWaitingList
<div class="flex items-center gap-10"> title="listes des expéditions en attente"
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/> :columns="columns"
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions en attente</h1> :items="shipmentList ?? []"
</div> route-prefix="/shipment"
</div> show-actions
>
<div class="px-[86px]"> <template #cell-shipmentDate="{ item }">
<div class="mt-6 border border-slate-200 mb-16 "> {{ formatDate(item.shipmentDate) }}
<div class="grid grid-cols-5 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"> </template>
<div>Client</div> <template #cell-shipmentType="{ item }">
<div>Adresse</div> <template v-if="formatShipmentLines(item).length">
<div>Type d'expéditions</div> <div
<div>Transporteur</div> v-for="(line, index) in formatShipmentLines(item)"
<div>Immatriculation</div> :key="index"
</div> class="leading-5"
<div >
v-for="shipment in shipmentList" {{ line }}
:key="shipment.id"
class="grid grid-cols-5 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToShipment(shipment.id)"
@keydown.enter="goToShipment(shipment.id)"
>
<div>{{ shipment.customer?.name }}</div>
<div>{{ shipment.address?.fullAddress }}</div>
<div>
<template v-if="formatShipmentLines(shipment).length">
<div
v-for="(line, index) in formatShipmentLines(shipment)"
:key="index"
class="leading-5"
>
{{ line }}
</div>
</template>
</div> </div>
<div>{{ shipment.carrier?.name }}</div> </template>
<div>{{ shipment.licensePlate }}</div> <template v-else></template>
</div> </template>
</div> <template #actions="{ item }">
</div> <Icon
name="mdi:delete-outline"
size="24"
class="cursor-pointer text-red-500 hover:text-red-700"
@click="confirmDelete(item)"
/>
</template>
</WorkflowWaitingList>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ShipmentData } from '~/services/dto/shipment-data'
import { getShipmentList, deleteShipment } from '~/services/shipment'
import type {ShipmentData} from "~/services/dto/shipment-data"; const columns = [
import {getShipmentList} from "~/services/shipment"; { key: 'shipmentDate', label: 'Date et heure' },
{ key: 'customer.name', label: 'Client' },
{ key: 'address.fullAddress', label: 'Adresse' },
{ key: 'shipmentType', label: "Type d'expé." },
{ key: 'carrier.name', label: 'Transporteur' },
{ key: 'licensePlate', label: 'Immatriculation' }
]
const shipmentList = ref<ShipmentData[]>() const shipmentList = ref<ShipmentData[]>()
const router = useRouter()
const goToShipment = (id: number) => { const formatDate = (date: string | null) => {
router.push(`/shipment/${id}`) if (!date) return '—'
const d = new Date(date.replace(' ', 'T'))
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} }
const formatShipmentLines = (shipment: ShipmentData) => { const formatShipmentLines = (shipment: ShipmentData) => {
if (!shipment.shipmentType && shipment.nbBovinSend == null) { if (!shipment.shipmentType && shipment.nbBovinSend == null) {
return [] return []
} }
const label = typeof shipment.shipmentType === 'string' const label = typeof shipment.shipmentType === 'string'
? shipment.shipmentType ? shipment.shipmentType
: shipment.shipmentType?.label : shipment.shipmentType?.label
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`] return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
} }
const confirmDelete = async (shipment: ShipmentData) => {
const confirmed = window.confirm(
`Êtes-vous sûr de vouloir supprimer l'expédition ${shipment.identificationNumber ?? `#${shipment.id}`} ? Toutes les données liées seront supprimées.`
)
if (!confirmed) return
await deleteShipment(shipment.id)
shipmentList.value = shipmentList.value?.filter(s => s.id !== shipment.id)
}
onMounted(async () => { onMounted(async () => {
shipmentList.value = await getShipmentList(false) shipmentList.value = await getShipmentList(false)
}) })
+1 -2
View File
@@ -1,7 +1,6 @@
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import type { AddressData } from '~/services/dto/address-data'
export interface AddressPayload { export interface AddressPayload {
label: string
street: string street: string
street2?: string | null street2?: string | null
postalCode: string postalCode: string
+2 -2
View File
@@ -1,6 +1,6 @@
export interface AddressData { export interface AddressData {
id: number id: number
label: string label?: string | null
street: string street: string
street2?: string | null street2?: string | null
postalCode: string postalCode: string
@@ -11,7 +11,7 @@ export interface AddressData {
export interface AddressFormData { export interface AddressFormData {
id?: number | null id?: number | null
label: string label?: string | null
street: string street: string
street2?: string | null street2?: string | null
postalCode: string postalCode: string
+2
View File
@@ -1,4 +1,5 @@
import type { AddressFormData } from "~/services/dto/address-data" import type { AddressFormData } from "~/services/dto/address-data"
import type { UserData } from "~/services/dto/user-data"
export type CustomerAddresses = AddressFormData[] | string[] export type CustomerAddresses = AddressFormData[] | string[]
@@ -7,6 +8,7 @@ export interface CustomerData {
name: string name: string
phone?: string | null phone?: string | null
email?: string | null email?: string | null
createdBy?: UserData | null
addresses: CustomerAddresses addresses: CustomerAddresses
} }
+2
View File
@@ -1,4 +1,5 @@
import type { AddressFormData } from "~/services/dto/address-data" import type { AddressFormData } from "~/services/dto/address-data"
import type { UserData } from "~/services/dto/user-data"
export type SupplierAddresses = AddressFormData[] | string[] export type SupplierAddresses = AddressFormData[] | string[]
@@ -7,6 +8,7 @@ export interface SupplierData {
name: string name: string
email?: string | null email?: string | null
phone?: string | null phone?: string | null
createdBy?: UserData | null
addresses: SupplierAddresses addresses: SupplierAddresses
} }
+16
View File
@@ -0,0 +1,16 @@
export interface CommuneData {
nom: string
code: string
}
export async function getCommunesByPostalCode(postalCode: string): Promise<CommuneData[]> {
const config = useRuntimeConfig()
const base = config.public.geoApiBase
try {
return await $fetch<CommuneData[]>(`${base}/communes`, {
params: { codePostal: postalCode, fields: 'nom', format: 'json' }
})
} catch {
return []
}
}
+17 -36
View File
@@ -1,41 +1,22 @@
import {useApi} from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import type {ReceptionData, ReceptionPayload} from '~/services/dto/reception-data' import { createWorkflowService } from '~/services/workflow-service'
import type {WeightData} from '~/services/dto/weight-data' import type { ReceptionData, ReceptionPayload } from '~/services/dto/reception-data'
import type { WeightData } from '~/services/dto/weight-data'
export async function getReceptionList(isValid: boolean|null = null) { const service = createWorkflowService<ReceptionData, ReceptionPayload>('receptions', 'reception')
export const getReceptionList = service.getList
export const getReception = service.get
export const createReception = service.create
export const updateReception = service.update
export const getWeight = service.getWeight as () => Promise<WeightData>
export async function deleteReception(id: number) {
const api = useApi() const api = useApi()
const query = isValid !== null ? { isValid: isValid} : {} return api.delete(`receptions/${id}`, {}, {
return api.get<ReceptionData[]>('receptions', query, { toastSuccessKey: 'success.reception.delete',
toastErrorKey: 'errors.reception.list' toastErrorKey: 'errors.reception.delete'
}) })
} }
export { service as receptionService }
export async function getReception(id: number) {
const api = useApi()
return api.get<ReceptionData>(`receptions/${id}`, {}, {
toastErrorKey: 'errors.reception.fetch'
})
}
export async function createReception(payload: ReceptionPayload = {}) {
const api = useApi()
return api.post<ReceptionData>('receptions', payload, {
toastErrorKey: 'errors.reception.create'
})
}
export async function updateReception(id: number, payload: ReceptionPayload) {
const api = useApi()
return api.patch<ReceptionData>(`receptions/${id}`, payload, {
toastErrorKey: 'errors.reception.update',
toastSuccessKey: 'success.reception.update'
})
}
export async function getWeight(): Promise<WeightData> {
const api = useApi()
return api.get<WeightData>('receptions/weigh', {}, {
toastErrorKey: 'errors.reception.weigh'
})
}
+17 -35
View File
@@ -1,40 +1,22 @@
import {useApi} from '~/composables/useApi' import { createWorkflowService } from '~/services/workflow-service'
import type {ShipmentData, ShipmentPayload} from '~/services/dto/shipment-data' import type { ShipmentData, ShipmentPayload } from '~/services/dto/shipment-data'
import type {WeightData} from '~/services/dto/weight-data' import type { WeightData } from '~/services/dto/weight-data'
export async function getShipmentList(isValid: boolean|null = null) { const service = createWorkflowService<ShipmentData, ShipmentPayload>('shipments', 'shipment')
export const getShipmentList = service.getList
export const getShipment = service.get
export const createShipment = service.create
export const updateShipment = service.update
export const getWeightShipment = service.getWeight as () => Promise<WeightData>
export async function deleteShipment(id: number) {
const { useApi } = await import('~/composables/useApi')
const api = useApi() const api = useApi()
const query = isValid !== null ? { isValid: isValid} : {} return api.delete(`shipments/${id}`, {}, {
return api.get<ShipmentData[]>('shipments', query, { toastSuccessKey: 'success.shipment.delete',
toastErrorKey: 'errors.shipment.list' toastErrorKey: 'errors.shipment.delete'
}) })
} }
export async function getShipment(id: number) { export { service as shipmentService }
const api = useApi()
return api.get<ShipmentData>(`shipments/${id}`, {}, {
toastErrorKey: 'errors.shipment.fetch'
})
}
export async function createShipment(payload: ShipmentPayload = {}) {
const api = useApi()
return api.post<ShipmentData>('shipments', payload, {
toastErrorKey: 'errors.shipment.create'
})
}
export async function updateShipment(id: number, payload: ShipmentPayload) {
const api = useApi()
return api.patch<ShipmentData>(`shipments/${id}`, payload, {
toastErrorKey: 'errors.shipment.update',
toastSuccessKey: 'success.shipment.update'
})
}
export async function getWeightShipment(): Promise<WeightData> {
const api = useApi()
return api.get<WeightData>('shipments/weigh', {}, {
toastErrorKey: 'errors.shipment.weigh'
})
}
+5 -22
View File
@@ -1,8 +1,5 @@
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import type {ReceptionData, ReceptionPayload, WeightEntryData} from '~/services/dto/reception-data' import type { WeightEntryData } from '~/services/dto/reception-data'
import type {Ref} from "vue";
import type {ShipmentData, ShipmentPayload} from "~/services/dto/shipment-data";
import type {WeighingMode} from "~/composables/useWeighing";
export type WeightPayload = { export type WeightPayload = {
reception?: string reception?: string
@@ -13,29 +10,15 @@ export type WeightPayload = {
weighedAt: string | null weighedAt: string | null
} }
export async function createWeight(payload: WeightPayload) { export async function createWeight(payload: WeightPayload | Record<string, any>) {
const api = useApi() const api = useApi()
return api.post<WeightEntryData>('weights', payload) return api.post<WeightEntryData>('weights', payload)
} }
export async function updateWeight(id: number, payload: Partial<WeightPayload>) { export async function updateWeight(id: number, payload: Partial<WeightPayload>) {
const api = useApi() const api = useApi()
return api.patch<WeightEntryData>(`weights/${id}`, payload,{ return api.patch<WeightEntryData>(`weights/${id}`, payload, {
toastErrorKey: 'errors.weight.update', toastErrorKey: 'errors.weight.update',
toastSuccessKey: 'success.weight.update' toastSuccessKey: 'success.weight.update'
}) })
} }
export type UseWeighingShipmentOptions = {
modeShipment: WeighingMode
shipment: Ref<ShipmentData | null>
updateShipment: (id: number, payload: ShipmentPayload) => Promise<ShipmentData | null>
loadShipment?: (id: number) => Promise<ShipmentData | null>
}
export type UseWeighingOptions = {
mode: WeighingMode
reception: Ref<ReceptionData | null>
updateReception: (id: number, payload: ReceptionPayload) => Promise<ReceptionData | null>
loadReception?: (id: number) => Promise<ReceptionData | null>
}
+55
View File
@@ -0,0 +1,55 @@
import { useApi } from '~/composables/useApi'
import type { WeightData } from '~/services/dto/weight-data'
export interface WorkflowService<TEntity, TPayload> {
getList: (isValid?: boolean | null) => Promise<TEntity[]>
get: (id: number) => Promise<TEntity>
create: (payload: TPayload) => Promise<TEntity>
update: (id: number, payload: TPayload) => Promise<TEntity>
getWeight: () => Promise<WeightData>
}
export function createWorkflowService<TEntity, TPayload>(
apiResource: string,
toastPrefix: string
): WorkflowService<TEntity, TPayload> {
const getList = async (isValid: boolean | null = null): Promise<TEntity[]> => {
const api = useApi()
const query = isValid !== null ? { isValid } : {}
return api.get<TEntity[]>(apiResource, query, {
toastErrorKey: `errors.${toastPrefix}.list`
})
}
const get = async (id: number): Promise<TEntity> => {
const api = useApi()
return api.get<TEntity>(`${apiResource}/${id}`, {}, {
toastErrorKey: `errors.${toastPrefix}.fetch`
})
}
const create = async (payload: TPayload): Promise<TEntity> => {
const api = useApi()
return api.post<TEntity>(apiResource, payload, {
toastSuccessKey: `success.${toastPrefix}.create`,
toastErrorKey: `errors.${toastPrefix}.create`
})
}
const update = async (id: number, payload: TPayload): Promise<TEntity> => {
const api = useApi()
return api.patch<TEntity>(`${apiResource}/${id}`, payload, {
toastErrorKey: `errors.${toastPrefix}.update`,
toastSuccessKey: `success.${toastPrefix}.update`
})
}
const getWeight = async (): Promise<WeightData> => {
const api = useApi()
return api.get<WeightData>(`${apiResource}/weigh`, {}, {
toastErrorKey: `errors.${toastPrefix}.weigh`
})
}
return { getList, get, create, update, getWeight }
}
+13 -53
View File
@@ -1,59 +1,19 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useWorkflowStoreLogic } from '~/stores/workflow-store'
import { receptionService } from '~/services/reception'
import type { ReceptionData, ReceptionPayload } from '~/services/dto/reception-data' import type { ReceptionData, ReceptionPayload } from '~/services/dto/reception-data'
import { createReception, getReception, updateReception } from '~/services/reception'
const isReceptionData = (value: unknown): value is ReceptionData => { export const useReceptionStore = defineStore('reception', () => {
return Boolean(value && typeof value === 'object' && 'id' in value) const { current, isLoading, setCurrent, clearCurrent, load, create, update } =
} useWorkflowStoreLogic<ReceptionData, ReceptionPayload>(receptionService)
export const useReceptionStore = defineStore('reception', { return {
state: () => ({ current,
current: null as ReceptionData | null, isLoading,
isLoading: false setCurrent,
}), clearCurrent,
actions: { loadReception: load,
setCurrent(reception: ReceptionData | null) { createReception: create,
this.current = reception updateReception: update
},
clearCurrent() {
this.current = null
},
async loadReception(id: number) {
this.isLoading = true
const result = await getReception(id).finally(() => {
this.isLoading = false
})
if (!isReceptionData(result)) {
this.current = null
return null
}
this.current = result
return result
},
async createReception(payload: ReceptionPayload = {}) {
this.isLoading = true
const result = await createReception(payload).finally(() => {
this.isLoading = false
})
if (!isReceptionData(result)) {
return null
}
this.current = result
return result
},
async updateReception(id: number, payload: ReceptionPayload) {
this.isLoading = true
const result = await updateReception(id, payload).finally(() => {
this.isLoading = false
})
if (!isReceptionData(result)) {
return null
}
this.current = result
return result
} }
}
}) })
+14 -53
View File
@@ -1,58 +1,19 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type {ShipmentData, ShipmentPayload} from "~/services/dto/shipment-data"; import { useWorkflowStoreLogic } from '~/stores/workflow-store'
import {createShipment, getShipment, updateShipment} from "~/services/shipment"; import { shipmentService } from '~/services/shipment'
import type { ShipmentData, ShipmentPayload } from '~/services/dto/shipment-data'
const isShipmentData = (value: unknown): value is ShipmentData => { export const useShipmentStore = defineStore('shipment', () => {
return Boolean(value && typeof value === 'object' && 'id' in value) const { current, isLoading, setCurrent, clearCurrent, load, create, update } =
} useWorkflowStoreLogic<ShipmentData, ShipmentPayload>(shipmentService)
export const useShipmentStore = defineStore('shipment', {
state: () => ({
current: null as ShipmentData | null,
isLoading: false
}),
actions: {
setCurrent(shipment: ShipmentData | null) {
this.current = shipment
},
clearCurrent() {
this.current = null
},
async loadShipment(id: number) {
this.isLoading = true
const result = await getShipment(id).finally(() => {
this.isLoading = false
})
if (!isShipmentData(result)) {
this.current = null
return null
}
this.current = result return {
return result current,
}, isLoading,
async createShipment(payload: ShipmentPayload = {}) { setCurrent,
this.isLoading = true clearCurrent,
const result = await createShipment(payload).finally(() => { loadShipment: load,
this.isLoading = false createShipment: create,
}) updateShipment: update
if (!isShipmentData(result)) {
return null
}
this.current = result
return result
},
async updateShipment(id: number, payload: ShipmentPayload) {
this.isLoading = true
const result = await updateShipment(id, payload).finally(() => {
this.isLoading = false
})
if (!isShipmentData(result)) {
return null
}
this.current = result
return result
}
} }
}) })
+59
View File
@@ -0,0 +1,59 @@
import type { WorkflowService } from '~/services/workflow-service'
const isEntityData = (value: unknown): value is { id: number } => {
return Boolean(value && typeof value === 'object' && 'id' in value)
}
export function useWorkflowStoreLogic<TEntity extends { id: number }, TPayload>(
service: WorkflowService<TEntity, TPayload>
) {
const current = ref<TEntity | null>(null)
const isLoading = ref(false)
const setCurrent = (entity: TEntity | null) => {
current.value = entity
}
const clearCurrent = () => {
current.value = null
}
const load = async (id: number) => {
isLoading.value = true
const result = await service.get(id).finally(() => {
isLoading.value = false
})
if (!isEntityData(result)) {
current.value = null
return null
}
current.value = result as any
return result
}
const create = async (payload: TPayload) => {
isLoading.value = true
const result = await service.create(payload).finally(() => {
isLoading.value = false
})
if (!isEntityData(result)) {
return null
}
current.value = result as any
return result
}
const update = async (id: number, payload: TPayload) => {
isLoading.value = true
const result = await service.update(id, payload).finally(() => {
isLoading.value = false
})
if (!isEntityData(result)) {
return null
}
current.value = result as any
return result
}
return { current, isLoading, setCurrent, clearCurrent, load, create, update }
}
+48
View File
@@ -0,0 +1,48 @@
import type { WeightEntryData } from '~/services/dto/reception-data'
import type { CarrierData } from '~/services/dto/carrier-data'
import type { TruckData } from '~/services/dto/truck-data'
import type { UserData } from '~/services/dto/user-data'
import type { AddressData } from '~/services/dto/address-data'
import type { DriverData } from '~/services/dto/driver-data'
export interface WorkflowEntity {
id: number
identificationNumber?: string | null
licensePlate: string | null
currentStep: number
isValid: boolean
weights?: WeightEntryData[] | null
carrier?: CarrierData | null
truck?: TruckData | null
user?: UserData | null
address?: AddressData | null
driver?: DriverData | null
}
export interface WorkflowPayload {
licensePlate?: string | null
currentStep?: number
isValid?: boolean
carrier?: string | null
truck?: string | null
user?: string | null
address?: string | null
driver?: string | null
}
export interface StepDefinition {
label: string
weighingMode?: 'gross' | 'tare'
isFinal?: boolean
}
export interface WorkflowConfig {
entityName: 'reception' | 'shipment'
apiResource: string
steps: StepDefinition[]
weighingLabels: Record<'gross' | 'tare', string>
buildReceiptFilename: (entity: WorkflowEntity) => string
routePrefix: string
toastPrefix: string
dateField: string
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260318103644 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 ALTER label DROP NOT NULL');
$this->addSql('ALTER INDEX idx_ea9e2a42f8d859df RENAME TO IDX_2068337FF8D859DF');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE address ALTER label SET NOT NULL');
$this->addSql('ALTER INDEX idx_2068337ff8d859df RENAME TO idx_ea9e2a42f8d859df');
}
}
+41
View File
@@ -0,0 +1,41 @@
<?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 Version20260318104205 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 customer ADD created_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE customer ADD CONSTRAINT FK_81398E09DE12AB56 FOREIGN KEY (created_by) REFERENCES public."user" (id) NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_81398E09DE12AB56 ON customer (created_by)');
$this->addSql('ALTER TABLE supplier ADD created_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE supplier ADD CONSTRAINT FK_9B2A6C7EDE12AB56 FOREIGN KEY (created_by) REFERENCES public."user" (id) NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_9B2A6C7EDE12AB56 ON supplier (created_by)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE customer DROP CONSTRAINT FK_81398E09DE12AB56');
$this->addSql('DROP INDEX IDX_81398E09DE12AB56');
$this->addSql('ALTER TABLE customer DROP created_by');
$this->addSql('ALTER TABLE supplier DROP CONSTRAINT FK_9B2A6C7EDE12AB56');
$this->addSql('DROP INDEX IDX_9B2A6C7EDE12AB56');
$this->addSql('ALTER TABLE supplier DROP created_by');
}
}
+2 -6
View File
@@ -75,9 +75,5 @@ chmod o+rx "$(dirname "$DEPLOY_DIR")" "$DEPLOY_DIR" 2>/dev/null || true
echo "Release ${TAG} deployed to ${DEPLOY_DIR}" echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
if [ -f "${DEPLOY_DIR}/.env.local" ]; then echo "Running migrations (if any)..."
echo "Running migrations (if any)..." php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod
php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod
else
echo "Skip migrations: ${DEPLOY_DIR}/.env.local not found" >&2
fi
+4 -26
View File
@@ -545,7 +545,6 @@ class SeedCommand extends Command
'phone' => '05.49.20.09.10', 'phone' => '05.49.20.09.10',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LIOT CHATELLERAULT',
'street' => "14 Allée d'Argenson", 'street' => "14 Allée d'Argenson",
'street2' => 'ZI Nord', 'street2' => 'ZI Nord',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -560,7 +559,6 @@ class SeedCommand extends Command
'phone' => '05.49.02.65.27', 'phone' => '05.49.02.65.27',
'addresses' => [ 'addresses' => [
[ [
'label' => 'ARNAULT EURL',
'street' => 'Moulin du Guéret', 'street' => 'Moulin du Guéret',
'street2' => 'B.P 30425', 'street2' => 'B.P 30425',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -575,7 +573,6 @@ class SeedCommand extends Command
'phone' => '06.80.14.18.82', 'phone' => '06.80.14.18.82',
'addresses' => [ 'addresses' => [
[ [
'label' => 'EARL DES GONNIERES',
'street' => "27 Route d'Ingrandes", 'street' => "27 Route d'Ingrandes",
'street2' => 'Les Gonnières', 'street2' => 'Les Gonnières',
'postalCode' => '86220', 'postalCode' => '86220',
@@ -590,7 +587,6 @@ class SeedCommand extends Command
'phone' => '05.49.86.17.95', 'phone' => '05.49.86.17.95',
'addresses' => [ 'addresses' => [
[ [
'label' => 'EARL LESIGNY BABY',
'street' => '2 Lieu Dit Les Bouquins', 'street' => '2 Lieu Dit Les Bouquins',
'street2' => null, 'street2' => null,
'postalCode' => '86270', 'postalCode' => '86270',
@@ -605,7 +601,6 @@ class SeedCommand extends Command
'phone' => '03.85.24.25.50', 'phone' => '03.85.24.25.50',
'addresses' => [ 'addresses' => [
[ [
'label' => 'FEDER',
'street' => 'Molaise', 'street' => 'Molaise',
'street2' => null, 'street2' => null,
'postalCode' => '71120', 'postalCode' => '71120',
@@ -620,7 +615,6 @@ class SeedCommand extends Command
'phone' => '05.49.86.57.24', 'phone' => '05.49.86.57.24',
'addresses' => [ 'addresses' => [
[ [
'label' => "GAEC DE L'ESPOIR",
'street' => 'La Moujonnerie', 'street' => 'La Moujonnerie',
'street2' => null, 'street2' => null,
'postalCode' => '86450', 'postalCode' => '86450',
@@ -635,7 +629,6 @@ class SeedCommand extends Command
'phone' => '05.49.23.51.66', 'phone' => '05.49.23.51.66',
'addresses' => [ 'addresses' => [
[ [
'label' => 'GRAVELEAU',
'street' => '3, Le Jeu', 'street' => '3, Le Jeu',
'street2' => null, 'street2' => null,
'postalCode' => '86220', 'postalCode' => '86220',
@@ -650,7 +643,6 @@ class SeedCommand extends Command
'phone' => '05.49.52.77.10', 'phone' => '05.49.52.77.10',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LORTHOLARY',
'street' => 'Ferme de Geniec', 'street' => 'Ferme de Geniec',
'street2' => null, 'street2' => null,
'postalCode' => '86550', 'postalCode' => '86550',
@@ -665,7 +657,6 @@ class SeedCommand extends Command
'phone' => '05.65.67.89.46', 'phone' => '05.65.67.89.46',
'addresses' => [ 'addresses' => [
[ [
'label' => 'NATERA',
'street' => 'Bd des Balquières', 'street' => 'Bd des Balquières',
'street2' => 'BP 3220', 'street2' => 'BP 3220',
'postalCode' => '12032', 'postalCode' => '12032',
@@ -675,12 +666,11 @@ class SeedCommand extends Command
], ],
], ],
[ [
'name' => 'SCEA des Bariollières', 'name' => 'SCEA DES BARIOLLIÈRES',
'email' => 'elisregnier@gmail.com', 'email' => 'elisregnier@gmail.com',
'phone' => '06.09.37.65.61', 'phone' => '06.09.37.65.61',
'addresses' => [ 'addresses' => [
[ [
'label' => 'SCEA des Bariollières',
'street' => '2 rue des Barriollières', 'street' => '2 rue des Barriollières',
'street2' => null, 'street2' => null,
'postalCode' => '86220', 'postalCode' => '86220',
@@ -695,7 +685,6 @@ class SeedCommand extends Command
'phone' => null, 'phone' => null,
'addresses' => [ 'addresses' => [
[ [
'label' => 'SCEA SENE',
'street' => '3 Route de la Roche Posay', 'street' => '3 Route de la Roche Posay',
'street2' => 'Les Girouettes', 'street2' => 'Les Girouettes',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -710,7 +699,6 @@ class SeedCommand extends Command
'phone' => '02.51.67.17.98', 'phone' => '02.51.67.17.98',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TERRENA',
'street' => 'La Blanchardière', 'street' => 'La Blanchardière',
'street2' => null, 'street2' => null,
'postalCode' => '44522', 'postalCode' => '44522',
@@ -725,7 +713,6 @@ class SeedCommand extends Command
'phone' => '05.49.19.44.33', 'phone' => '05.49.19.44.33',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TRICHERIE COOPERATIVE',
'street' => 'B.P n°2', 'street' => 'B.P n°2',
'street2' => null, 'street2' => null,
'postalCode' => '86490', 'postalCode' => '86490',
@@ -735,12 +722,11 @@ class SeedCommand extends Command
], ],
], ],
[ [
'name' => 'TURPAULT Muriel', 'name' => 'TURPAULT MURIEL',
'email' => null, 'email' => null,
'phone' => null, 'phone' => null,
'addresses' => [ 'addresses' => [
[ [
'label' => 'TURPAULT Muriel',
'street' => '23Bis Rue Marcel Pagnol', 'street' => '23Bis Rue Marcel Pagnol',
'street2' => null, 'street2' => null,
'postalCode' => '86100', 'postalCode' => '86100',
@@ -755,7 +741,6 @@ class SeedCommand extends Command
'phone' => '0675446004', 'phone' => '0675446004',
'addresses' => [ 'addresses' => [
[ [
'label' => 'EARL DE LA MENAUDIERE',
'street' => '1 la menaudière', 'street' => '1 la menaudière',
'street2' => null, 'street2' => null,
'postalCode' => '86450', 'postalCode' => '86450',
@@ -770,7 +755,6 @@ class SeedCommand extends Command
'phone' => '0675030304', 'phone' => '0675030304',
'addresses' => [ 'addresses' => [
[ [
'label' => 'SARL ERBS',
'street' => 'les rodières ', 'street' => 'les rodières ',
'street2' => null, 'street2' => null,
'postalCode' => '86230', 'postalCode' => '86230',
@@ -811,7 +795,6 @@ class SeedCommand extends Command
'email' => 'eurl.arnault86@orange.fr', 'email' => 'eurl.arnault86@orange.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'ARNAULT EURL',
'street' => 'Moulin du Guéret', 'street' => 'Moulin du Guéret',
'street2' => 'B.P 30425', 'street2' => 'B.P 30425',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -826,7 +809,6 @@ class SeedCommand extends Command
'email' => 'sandra.robineaux@covilim.com', 'email' => 'sandra.robineaux@covilim.com',
'addresses' => [ 'addresses' => [
[ [
'label' => 'COVILIM',
'street' => 'Rue de Nexon', 'street' => 'Rue de Nexon',
'street2' => null, 'street2' => null,
'postalCode' => '87000', 'postalCode' => '87000',
@@ -836,12 +818,11 @@ class SeedCommand extends Command
], ],
], ],
[ [
'name' => 'Les producteurs de la marche (LPM)', 'name' => 'LES PRODUCTEURS DE LA MARCHE (LPM)',
'phone' => '05.55.63.04.53', 'phone' => '05.55.63.04.53',
'email' => 'f.legalliard@lpmcoop.fr', 'email' => 'f.legalliard@lpmcoop.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'Les producteurs de la marche (LPM)',
'street' => 'Malonze', 'street' => 'Malonze',
'street2' => null, 'street2' => null,
'postalCode' => '23300', 'postalCode' => '23300',
@@ -856,7 +837,6 @@ class SeedCommand extends Command
'email' => 'contact86@lortholarybetail.com', 'email' => 'contact86@lortholarybetail.com',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LORTHOLARY BETAIL',
'street' => 'FERME DE GENIEC', 'street' => 'FERME DE GENIEC',
'street2' => null, 'street2' => null,
'postalCode' => '86550', 'postalCode' => '86550',
@@ -871,7 +851,6 @@ class SeedCommand extends Command
'email' => 'scouillaud@terrena.fr', 'email' => 'scouillaud@terrena.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TERRENA',
'street' => 'LA NOELLE', 'street' => 'LA NOELLE',
'street2' => 'BP 20199', 'street2' => 'BP 20199',
'postalCode' => '44155', 'postalCode' => '44155',
@@ -961,7 +940,7 @@ class SeedCommand extends Command
{ {
$addressRepo = $this->entityManager->getRepository(Address::class); $addressRepo = $this->entityManager->getRepository(Address::class);
$address = $addressRepo->findOneBy([ $address = $addressRepo->findOneBy([
'label' => $addressData['label'], 'street' => $addressData['street'],
'postalCode' => $addressData['postalCode'], 'postalCode' => $addressData['postalCode'],
]); ]);
if (!$address) { if (!$address) {
@@ -971,7 +950,6 @@ class SeedCommand extends Command
++$this->updated; ++$this->updated;
} }
$address $address
->setLabel($addressData['label'])
->setStreet($addressData['street']) ->setStreet($addressData['street'])
->setStreet2($addressData['street2']) ->setStreet2($addressData['street2'])
->setPostalCode($addressData['postalCode']) ->setPostalCode($addressData['postalCode'])
+5 -28
View File
@@ -108,7 +108,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.20.09.10', 'phone' => '05.49.20.09.10',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LIOT CHATELLERAULT',
'street' => "14 Allée d'Argenson", 'street' => "14 Allée d'Argenson",
'street2' => 'ZI Nord', 'street2' => 'ZI Nord',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -123,7 +122,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.02.65.27', 'phone' => '05.49.02.65.27',
'addresses' => [ 'addresses' => [
[ [
'label' => 'ARNAULT EURL',
'street' => 'Moulin du Guéret', 'street' => 'Moulin du Guéret',
'street2' => 'B.P 30425', 'street2' => 'B.P 30425',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -138,7 +136,6 @@ class ReferenceFixtures extends Fixture
'phone' => '06.80.14.18.82', 'phone' => '06.80.14.18.82',
'addresses' => [ 'addresses' => [
[ [
'label' => 'EARL DES GONNIERES',
'street' => "27 Route d'Ingrandes", 'street' => "27 Route d'Ingrandes",
'street2' => 'Les Gonnières', 'street2' => 'Les Gonnières',
'postalCode' => '86220', 'postalCode' => '86220',
@@ -153,7 +150,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.86.17.95', 'phone' => '05.49.86.17.95',
'addresses' => [ 'addresses' => [
[ [
'label' => 'EARL LESIGNY BABY',
'street' => '2 Lieu Dit Les Bouquins', 'street' => '2 Lieu Dit Les Bouquins',
'street2' => null, 'street2' => null,
'postalCode' => '86270', 'postalCode' => '86270',
@@ -168,7 +164,6 @@ class ReferenceFixtures extends Fixture
'phone' => '03.85.24.25.50', 'phone' => '03.85.24.25.50',
'addresses' => [ 'addresses' => [
[ [
'label' => 'FEDER',
'street' => 'Molaise', 'street' => 'Molaise',
'street2' => null, 'street2' => null,
'postalCode' => '71120', 'postalCode' => '71120',
@@ -183,7 +178,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.86.57.24', 'phone' => '05.49.86.57.24',
'addresses' => [ 'addresses' => [
[ [
'label' => "GAEC DE L'ESPOIR",
'street' => 'La Moujonnerie', 'street' => 'La Moujonnerie',
'street2' => null, 'street2' => null,
'postalCode' => '86450', 'postalCode' => '86450',
@@ -198,7 +192,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.23.51.66', 'phone' => '05.49.23.51.66',
'addresses' => [ 'addresses' => [
[ [
'label' => 'GRAVELEAU',
'street' => '3, Le Jeu', 'street' => '3, Le Jeu',
'street2' => null, 'street2' => null,
'postalCode' => '86220', 'postalCode' => '86220',
@@ -213,7 +206,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.52.77.10', 'phone' => '05.49.52.77.10',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LORTHOLARY',
'street' => 'Ferme de Geniec', 'street' => 'Ferme de Geniec',
'street2' => null, 'street2' => null,
'postalCode' => '86550', 'postalCode' => '86550',
@@ -228,7 +220,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.65.67.89.46', 'phone' => '05.65.67.89.46',
'addresses' => [ 'addresses' => [
[ [
'label' => 'NATERA',
'street' => 'Bd des Balquières', 'street' => 'Bd des Balquières',
'street2' => 'BP 3220', 'street2' => 'BP 3220',
'postalCode' => '12032', 'postalCode' => '12032',
@@ -238,12 +229,11 @@ class ReferenceFixtures extends Fixture
], ],
], ],
[ [
'name' => 'SCEA des Bariollières', 'name' => 'SCEA DES BARIOLLIÈRES',
'email' => 'elisregnier@gmail.com', 'email' => 'elisregnier@gmail.com',
'phone' => '06.09.37.65.61', 'phone' => '06.09.37.65.61',
'addresses' => [ 'addresses' => [
[ [
'label' => 'SCEA des Bariollières',
'street' => '2 rue des Barriollières', 'street' => '2 rue des Barriollières',
'street2' => null, 'street2' => null,
'postalCode' => '86220', 'postalCode' => '86220',
@@ -258,7 +248,6 @@ class ReferenceFixtures extends Fixture
'phone' => null, 'phone' => null,
'addresses' => [ 'addresses' => [
[ [
'label' => 'SCEA SENE',
'street' => '3 Route de la Roche Posay', 'street' => '3 Route de la Roche Posay',
'street2' => 'Les Girouettes', 'street2' => 'Les Girouettes',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -273,7 +262,6 @@ class ReferenceFixtures extends Fixture
'phone' => '02.51.67.17.98', 'phone' => '02.51.67.17.98',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TERRENA',
'street' => 'La Blanchardière', 'street' => 'La Blanchardière',
'street2' => null, 'street2' => null,
'postalCode' => '44522', 'postalCode' => '44522',
@@ -288,7 +276,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.19.44.33', 'phone' => '05.49.19.44.33',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TRICHERIE COOPERATIVE',
'street' => 'B.P n°2', 'street' => 'B.P n°2',
'street2' => null, 'street2' => null,
'postalCode' => '86490', 'postalCode' => '86490',
@@ -298,12 +285,11 @@ class ReferenceFixtures extends Fixture
], ],
], ],
[ [
'name' => 'TURPAULT Muriel', 'name' => 'TURPAULT MURIEL',
'email' => null, 'email' => null,
'phone' => null, 'phone' => null,
'addresses' => [ 'addresses' => [
[ [
'label' => 'TURPAULT Muriel',
'street' => '23Bis Rue Marcel Pagnol', 'street' => '23Bis Rue Marcel Pagnol',
'street2' => null, 'street2' => null,
'postalCode' => '86100', 'postalCode' => '86100',
@@ -318,7 +304,6 @@ class ReferenceFixtures extends Fixture
'phone' => '0675446004', 'phone' => '0675446004',
'addresses' => [ 'addresses' => [
[ [
'label' => 'EARL DE LA MENAUDIERE',
'street' => '1 la menaudière', 'street' => '1 la menaudière',
'street2' => null, 'street2' => null,
'postalCode' => '86450', 'postalCode' => '86450',
@@ -333,7 +318,6 @@ class ReferenceFixtures extends Fixture
'phone' => '0675030304', 'phone' => '0675030304',
'addresses' => [ 'addresses' => [
[ [
'label' => 'SARL ERBS',
'street' => 'les rodières ', 'street' => 'les rodières ',
'street2' => null, 'street2' => null,
'postalCode' => '86230', 'postalCode' => '86230',
@@ -352,10 +336,9 @@ class ReferenceFixtures extends Fixture
; ;
foreach ($supplierData['addresses'] as $addressData) { foreach ($supplierData['addresses'] as $addressData) {
$addressKey = sprintf('%s|%s', $addressData['label'], $addressData['postalCode']); $addressKey = sprintf('%s|%s', $addressData['street'], $addressData['postalCode']);
if (!isset($addressIndex[$addressKey])) { if (!isset($addressIndex[$addressKey])) {
$addressIndex[$addressKey] = new Address() $addressIndex[$addressKey] = new Address()
->setLabel($addressData['label'])
->setStreet($addressData['street']) ->setStreet($addressData['street'])
->setStreet2($addressData['street2']) ->setStreet2($addressData['street2'])
->setPostalCode($addressData['postalCode']) ->setPostalCode($addressData['postalCode'])
@@ -378,7 +361,6 @@ class ReferenceFixtures extends Fixture
'email' => 'eurl.arnault86@orange.fr', 'email' => 'eurl.arnault86@orange.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'ARNAULT EURL',
'street' => 'Moulin du Guéret', 'street' => 'Moulin du Guéret',
'street2' => 'B.P 30425', 'street2' => 'B.P 30425',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -393,7 +375,6 @@ class ReferenceFixtures extends Fixture
'email' => 'sandra.robineaux@covilim.com', 'email' => 'sandra.robineaux@covilim.com',
'addresses' => [ 'addresses' => [
[ [
'label' => 'COVILIM',
'street' => 'Rue de Nexon', 'street' => 'Rue de Nexon',
'street2' => null, 'street2' => null,
'postalCode' => '87000', 'postalCode' => '87000',
@@ -403,12 +384,11 @@ class ReferenceFixtures extends Fixture
], ],
], ],
[ [
'name' => 'Les producteurs de la marche (LPM)', 'name' => 'LES PRODUCTEURS DE LA MARCHE (LPM)',
'phone' => '05.55.63.04.53', 'phone' => '05.55.63.04.53',
'email' => 'f.legalliard@lpmcoop.fr', 'email' => 'f.legalliard@lpmcoop.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'Les producteurs de la marche (LPM)',
'street' => 'Malonze', 'street' => 'Malonze',
'street2' => null, 'street2' => null,
'postalCode' => '23300', 'postalCode' => '23300',
@@ -423,7 +403,6 @@ class ReferenceFixtures extends Fixture
'email' => 'contact86@lortholarybetail.com', 'email' => 'contact86@lortholarybetail.com',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LORTHOLARY BETAIL',
'street' => 'FERME DE GENIEC', 'street' => 'FERME DE GENIEC',
'street2' => null, 'street2' => null,
'postalCode' => '86550', 'postalCode' => '86550',
@@ -438,7 +417,6 @@ class ReferenceFixtures extends Fixture
'email' => 'scouillaud@terrena.fr', 'email' => 'scouillaud@terrena.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TERRENA',
'street' => 'LA NOELLE', 'street' => 'LA NOELLE',
'street2' => 'BP 20199', 'street2' => 'BP 20199',
'postalCode' => '44155', 'postalCode' => '44155',
@@ -457,10 +435,9 @@ class ReferenceFixtures extends Fixture
; ;
foreach ($customerData['addresses'] as $addressData) { foreach ($customerData['addresses'] as $addressData) {
$addressKey = sprintf('%s|%s', $addressData['label'], $addressData['postalCode']); $addressKey = sprintf('%s|%s', $addressData['street'], $addressData['postalCode']);
if (!isset($addressIndex[$addressKey])) { if (!isset($addressIndex[$addressKey])) {
$addressIndex[$addressKey] = new Address() $addressIndex[$addressKey] = new Address()
->setLabel($addressData['label'])
->setStreet($addressData['street']) ->setStreet($addressData['street'])
->setStreet2($addressData['street2']) ->setStreet2($addressData['street2'])
->setPostalCode($addressData['postalCode']) ->setPostalCode($addressData['postalCode'])
+5 -5
View File
@@ -46,9 +46,9 @@ class Address
#[Groups(['address:read', 'supplier:read', 'customer:read', 'shipment:read'])] #[Groups(['address:read', 'supplier:read', 'customer:read', 'shipment:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 120)] #[ORM\Column(length: 120, nullable: true)]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])] #[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read'])]
private string $label = ''; private ?string $label = null;
#[ORM\Column(length: 180)] #[ORM\Column(length: 180)]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])] #[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])]
@@ -93,12 +93,12 @@ class Address
return $this->id; return $this->id;
} }
public function getLabel(): string public function getLabel(): ?string
{ {
return $this->label; return $this->label;
} }
public function setLabel(string $label): self public function setLabel(?string $label): self
{ {
$this->label = $label; $this->label = $label;
+1 -1
View File
@@ -67,7 +67,7 @@ class Carrier
public function setName(string $name): self public function setName(string $name): self
{ {
$this->name = $name; $this->name = mb_strtoupper($name);
return $this; return $this;
} }
+18 -1
View File
@@ -59,6 +59,11 @@ class Customer
#[Groups(['customer:read', 'customer:write', 'shipment:read'])] #[Groups(['customer:read', 'customer:write', 'shipment:read'])]
private ?string $phone = null; private ?string $phone = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'created_by', nullable: true)]
#[Groups(['customer:read', 'customer:write'])]
private ?User $createdBy = null;
/** /**
* @var Collection<int, Address> * @var Collection<int, Address>
*/ */
@@ -85,7 +90,7 @@ class Customer
public function setName(string $name): self public function setName(string $name): self
{ {
$this->name = $name; $this->name = mb_strtoupper($name);
return $this; return $this;
} }
@@ -114,6 +119,18 @@ class Customer
return $this; return $this;
} }
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
public function setCreatedBy(?User $createdBy): self
{
$this->createdBy = $createdBy;
return $this;
}
public function getAddresses(): Collection public function getAddresses(): Collection
{ {
return $this->addresses; return $this->addresses;
+25 -2
View File
@@ -8,6 +8,7 @@ use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
@@ -47,6 +48,9 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
normalizationContext: ['groups' => ['reception:read']], normalizationContext: ['groups' => ['reception:read']],
denormalizationContext: ['groups' => ['reception:write']], denormalizationContext: ['groups' => ['reception:write']],
), ),
new Delete(
requirements: ['id' => '\d+'],
),
new Get( new Get(
uriTemplate: '/receptions/weigh', uriTemplate: '/receptions/weigh',
openapi: new OpenApiOperation( openapi: new OpenApiOperation(
@@ -96,7 +100,10 @@ class Reception
#[ORM\Column(name: 'date_reception', type: 'datetime_immutable')] #[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])] #[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] #[Context(
normalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i'],
denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'],
)]
private ?DateTimeImmutable $receptionDate = null; private ?DateTimeImmutable $receptionDate = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
@@ -259,6 +266,14 @@ class Reception
public function setReceptionDate(?DateTimeImmutable $receptionDate): self public function setReceptionDate(?DateTimeImmutable $receptionDate): self
{ {
if (null !== $receptionDate && null !== $this->receptionDate) {
// Préserve l'heure existante quand seule la date est mise à jour
$receptionDate = $receptionDate->setTime(
(int) $this->receptionDate->format('H'),
(int) $this->receptionDate->format('i'),
(int) $this->receptionDate->format('s'),
);
}
$this->receptionDate = $receptionDate; $this->receptionDate = $receptionDate;
return $this; return $this;
@@ -457,8 +472,16 @@ class Reception
#[ORM\PrePersist] #[ORM\PrePersist]
public function initializeReceptionDate(): void public function initializeReceptionDate(): void
{ {
$now = new DateTimeImmutable();
if (null === $this->receptionDate) { if (null === $this->receptionDate) {
$this->receptionDate = new DateTimeImmutable(); $this->receptionDate = $now;
} else {
// Préserve la date choisie mais utilise l'heure courante
$this->receptionDate = $this->receptionDate->setTime(
(int) $now->format('H'),
(int) $now->format('i'),
(int) $now->format('s'),
);
} }
} }
+30 -1
View File
@@ -8,6 +8,7 @@ use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
@@ -47,6 +48,9 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
normalizationContext: ['groups' => ['shipment:read']], normalizationContext: ['groups' => ['shipment:read']],
denormalizationContext: ['groups' => ['shipment:write']], denormalizationContext: ['groups' => ['shipment:write']],
), ),
new Delete(
requirements: ['id' => '\d+'],
),
new Get( new Get(
uriTemplate: '/shipments/weigh', uriTemplate: '/shipments/weigh',
openapi: new OpenApiOperation( openapi: new OpenApiOperation(
@@ -102,7 +106,10 @@ class Shipment
#[ORM\Column(name: 'shipment_date', type: 'datetime_immutable')] #[ORM\Column(name: 'shipment_date', type: 'datetime_immutable')]
#[Groups(['shipment:read', 'shipment:write'])] #[Groups(['shipment:read', 'shipment:write'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] #[Context(
normalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i'],
denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'],
)]
private ?DateTimeImmutable $shipmentDate = null; private ?DateTimeImmutable $shipmentDate = null;
#[ORM\ManyToOne] #[ORM\ManyToOne]
@@ -245,6 +252,13 @@ class Shipment
public function setShipmentDate(?DateTimeImmutable $shipmentDate): void public function setShipmentDate(?DateTimeImmutable $shipmentDate): void
{ {
if (null !== $shipmentDate && null !== $this->shipmentDate) {
$shipmentDate = $shipmentDate->setTime(
(int) $this->shipmentDate->format('H'),
(int) $this->shipmentDate->format('i'),
(int) $this->shipmentDate->format('s'),
);
}
$this->shipmentDate = $shipmentDate; $this->shipmentDate = $shipmentDate;
} }
@@ -303,6 +317,21 @@ class Shipment
} }
} }
#[ORM\PrePersist]
public function initializeShipmentDate(): void
{
$now = new DateTimeImmutable();
if (null === $this->shipmentDate) {
$this->shipmentDate = $now;
} else {
$this->shipmentDate = $this->shipmentDate->setTime(
(int) $now->format('H'),
(int) $now->format('i'),
(int) $now->format('s'),
);
}
}
#[ORM\PostPersist] #[ORM\PostPersist]
public function initializeIdentificationNumber(PostPersistEventArgs $args): void public function initializeIdentificationNumber(PostPersistEventArgs $args): void
{ {
+18 -1
View File
@@ -61,6 +61,11 @@ class Supplier
#[Groups(['supplier:read', 'reception:read', 'supplier:write'])] #[Groups(['supplier:read', 'reception:read', 'supplier:write'])]
private ?string $phone = null; private ?string $phone = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'created_by', nullable: true)]
#[Groups(['supplier:read', 'supplier:write'])]
private ?User $createdBy = null;
/** /**
* @var Collection<int, Address> * @var Collection<int, Address>
*/ */
@@ -87,7 +92,7 @@ class Supplier
public function setName(string $name): self public function setName(string $name): self
{ {
$this->name = $name; $this->name = mb_strtoupper($name);
return $this; return $this;
} }
@@ -116,6 +121,18 @@ class Supplier
return $this; return $this;
} }
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
public function setCreatedBy(?User $createdBy): self
{
$this->createdBy = $createdBy;
return $this;
}
/** /**
* @return Collection<int, Address> * @return Collection<int, Address>
*/ */
+2 -2
View File
@@ -61,11 +61,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
#[Groups(['user:read', 'user-login:read', 'reception:read', 'shipment:read'])] #[Groups(['user:read', 'user-login:read', 'reception:read', 'shipment:read', 'supplier:read', 'customer:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 180, unique: true)] #[ORM\Column(length: 180, unique: true)]
#[Groups(['user:read', 'user:write', 'user-login:read', 'reception:read', 'shipment:read'])] #[Groups(['user:read', 'user:write', 'user-login:read', 'reception:read', 'shipment:read', 'supplier:read', 'customer:read'])]
private string $username = ''; private string $username = '';
#[ORM\Column(type: 'json')] #[ORM\Column(type: 'json')]