Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 726be37ccf |
+18
-13
@@ -78,6 +78,23 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
// Section "Transport" (M4, ERP-153) : pole logistique, porte le repertoire
|
||||||
|
// transporteurs. L'item est gate par `transport.carriers.view` ; la section
|
||||||
|
// disparait automatiquement (SidebarProvider) si le module `transport` est
|
||||||
|
// desactive ou si l'user n'a pas la permission (Compta / Usine).
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.transport.section',
|
||||||
|
'icon' => 'mdi:truck-outline',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.transport.carriers',
|
||||||
|
'to' => '/carriers',
|
||||||
|
'icon' => 'mdi:truck-outline',
|
||||||
|
'module' => 'transport',
|
||||||
|
'permission' => 'transport.carriers.view',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
// Section "Administration" : regroupe toutes les pages de configuration
|
// Section "Administration" : regroupe toutes les pages de configuration
|
||||||
// applicative (RBAC, users, sites, audit log).
|
// applicative (RBAC, users, sites, audit log).
|
||||||
//
|
//
|
||||||
@@ -100,20 +117,8 @@ return [
|
|||||||
// individuelles"), ajouter : 'permission' => 'core.admin.access'.
|
// individuelles"), ajouter : 'permission' => 'core.admin.access'.
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.administration.section',
|
'label' => 'sidebar.administration.section',
|
||||||
'icon' => 'mdi:file-settings-cog-outline',
|
'icon' => 'mdi:cog-outline',
|
||||||
'items' => [
|
'items' => [
|
||||||
// Transport — Repertoire transporteurs (M4, ERP-164). Rattache a
|
|
||||||
// l'Administration (premier item) plutot qu'a une section dediee :
|
|
||||||
// referentiel global de configuration applicative, sans cloisonnement
|
|
||||||
// par site. Reste gate par sa propre permission `transport.carriers.view`
|
|
||||||
// (Admin / Bureau / Commerciale) et son module owner `transport`.
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.transport.carriers',
|
|
||||||
'to' => '/carriers',
|
|
||||||
'icon' => 'mdi:truck-outline',
|
|
||||||
'module' => 'transport',
|
|
||||||
'permission' => 'transport.carriers.view',
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.core.roles',
|
'label' => 'sidebar.core.roles',
|
||||||
'to' => '/admin/roles',
|
'to' => '/admin/roles',
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.129'
|
app.version: '0.1.130'
|
||||||
|
|||||||
+14
-208
@@ -495,200 +495,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"transport": {
|
|
||||||
"carriers": {
|
|
||||||
"title": "Répertoire transporteurs",
|
|
||||||
"add": "Ajouter",
|
|
||||||
"export": "Exporter",
|
|
||||||
"empty": "Aucun transporteur pour l'instant.",
|
|
||||||
"column": {
|
|
||||||
"name": "Nom",
|
|
||||||
"certification": "Certification",
|
|
||||||
"validityDate": "Date de validité",
|
|
||||||
"lastActivity": "Dernière activité"
|
|
||||||
},
|
|
||||||
"certification": {
|
|
||||||
"QUALIMAT": "QUALIMAT",
|
|
||||||
"GMP_PLUS": "GMP+",
|
|
||||||
"OVOCOM": "OVOCOM",
|
|
||||||
"COMPTE_PROPRE": "Compte-propre",
|
|
||||||
"AUTRE": "Autre"
|
|
||||||
},
|
|
||||||
"filters": {
|
|
||||||
"title": "Filtres",
|
|
||||||
"search": "Recherche",
|
|
||||||
"certification": "Certification",
|
|
||||||
"status": "Statut",
|
|
||||||
"archivedOnly": "Voir les archivés",
|
|
||||||
"apply": "Voir les résultats",
|
|
||||||
"reset": "Réinitialiser"
|
|
||||||
},
|
|
||||||
"toast": {
|
|
||||||
"error": "Une erreur est survenue. Réessayez.",
|
|
||||||
"exportError": "L'export du répertoire transporteurs a échoué. Réessayez.",
|
|
||||||
"createSuccess": "Transporteur créé avec succès",
|
|
||||||
"integrateSuccess": "Transporteur QUALIMAT intégré",
|
|
||||||
"addressSaved": "Adresse enregistrée",
|
|
||||||
"contactSaved": "Contact enregistré",
|
|
||||||
"priceSaved": "Prix enregistré",
|
|
||||||
"updateSuccess": "Transporteur mis à jour avec succès",
|
|
||||||
"archiveSuccess": "Transporteur archivé avec succès",
|
|
||||||
"restoreSuccess": "Transporteur restauré avec succès"
|
|
||||||
},
|
|
||||||
"action": {
|
|
||||||
"edit": "Modifier",
|
|
||||||
"archive": "Archiver",
|
|
||||||
"restore": "Restaurer"
|
|
||||||
},
|
|
||||||
"consultation": {
|
|
||||||
"title": "Consultation transporteur",
|
|
||||||
"back": "Retour au répertoire",
|
|
||||||
"loading": "Chargement du transporteur…",
|
|
||||||
"notFound": "Transporteur introuvable.",
|
|
||||||
"confirmArchive": {
|
|
||||||
"title": "Archiver le transporteur",
|
|
||||||
"message": "Ce transporteur n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
|
|
||||||
},
|
|
||||||
"confirmRestore": {
|
|
||||||
"title": "Restaurer le transporteur",
|
|
||||||
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
|
||||||
},
|
|
||||||
"price": {
|
|
||||||
"group": "Contenant",
|
|
||||||
"carrier": "Transporteurs",
|
|
||||||
"aproOrSite": "Adresse sites",
|
|
||||||
"delivery": "Adresse livraisons",
|
|
||||||
"forfait": "Forfait €",
|
|
||||||
"tonne": "Tonne €",
|
|
||||||
"indexation": "Indexation",
|
|
||||||
"state": "État du prix",
|
|
||||||
"export": "Exporter",
|
|
||||||
"empty": "Aucun prix pour ce transporteur."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"edit": {
|
|
||||||
"title": "Modifier le transporteur",
|
|
||||||
"back": "Retour à la consultation",
|
|
||||||
"loading": "Chargement du transporteur…",
|
|
||||||
"notFound": "Transporteur introuvable.",
|
|
||||||
"save": "Enregistrer"
|
|
||||||
},
|
|
||||||
"containerType": {
|
|
||||||
"BENNE": "Benne",
|
|
||||||
"FOND_MOUVANT": "Fond mouvant"
|
|
||||||
},
|
|
||||||
"tab": {
|
|
||||||
"qualimat": "Qualimat",
|
|
||||||
"addresses": "Adresses",
|
|
||||||
"contacts": "Contacts",
|
|
||||||
"prices": "Prix"
|
|
||||||
},
|
|
||||||
"form": {
|
|
||||||
"title": "Ajouter un transporteur",
|
|
||||||
"back": "Retour au répertoire",
|
|
||||||
"submit": "Valider",
|
|
||||||
"comingSoon": "À venir",
|
|
||||||
"duplicateName": "Un transporteur actif portant ce nom existe déjà.",
|
|
||||||
"main": {
|
|
||||||
"name": "Nom",
|
|
||||||
"certificationType": "Certification transport",
|
|
||||||
"isChartered": "Affréter",
|
|
||||||
"indexationRate": "Indexation %",
|
|
||||||
"containerType": "Benne / Fond mouvant",
|
|
||||||
"volumeM3": "Volume m³",
|
|
||||||
"discharge": "Décharge",
|
|
||||||
"liotPlates": "Immatriculations LIOT",
|
|
||||||
"liotPlatesHint": "Séparées par « ; »"
|
|
||||||
},
|
|
||||||
"qualimat": {
|
|
||||||
"empty": "Aucun transporteur QUALIMAT trouvé.",
|
|
||||||
"searchHint": "Saisissez le nom du transporteur pour lancer la recherche.",
|
|
||||||
"columns": {
|
|
||||||
"name": "Nom",
|
|
||||||
"address": "Adresse",
|
|
||||||
"validityDate": "Date de validité"
|
|
||||||
},
|
|
||||||
"confirm": {
|
|
||||||
"title": "Intégration QUALIMAT",
|
|
||||||
"message": "Êtes-vous sûr de vouloir intégrer ce transporteur ?",
|
|
||||||
"cancel": "Annuler",
|
|
||||||
"confirm": "Intégrer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"nameRequired": "Le nom du transporteur est obligatoire.",
|
|
||||||
"certificationRequired": "Le type de certification est obligatoire.",
|
|
||||||
"dischargeRequired": "La décharge est obligatoire pour une certification « Autre ».",
|
|
||||||
"indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.",
|
|
||||||
"containerTypeRequired": "Le type de contenant est obligatoire pour un transporteur affrété.",
|
|
||||||
"volumeRequired": "Le volume est obligatoire pour un transporteur affrété."
|
|
||||||
},
|
|
||||||
"address": {
|
|
||||||
"country": "Pays",
|
|
||||||
"postalCode": "Code postal",
|
|
||||||
"city": "Ville",
|
|
||||||
"street": "Adresse",
|
|
||||||
"streetComplement": "Adresse complémentaire",
|
|
||||||
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
|
||||||
"add": "Nouvelle adresse",
|
|
||||||
"remove": "Supprimer l'adresse",
|
|
||||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
|
||||||
},
|
|
||||||
"contact": {
|
|
||||||
"lastName": "Nom",
|
|
||||||
"firstName": "Prénom",
|
|
||||||
"jobTitle": "Fonction",
|
|
||||||
"phonePrimary": "Téléphone",
|
|
||||||
"phoneSecondary": "Téléphone (2)",
|
|
||||||
"addPhone": "Ajouter un numéro",
|
|
||||||
"email": "Email",
|
|
||||||
"add": "Nouveau contact",
|
|
||||||
"remove": "Supprimer le contact"
|
|
||||||
},
|
|
||||||
"confirmDelete": {
|
|
||||||
"title": "Supprimer ce bloc",
|
|
||||||
"message": "Cette suppression est définitive. Confirmer ?",
|
|
||||||
"cancel": "Annuler",
|
|
||||||
"confirm": "Supprimer"
|
|
||||||
},
|
|
||||||
"price": {
|
|
||||||
"direction": "Sens",
|
|
||||||
"directionClient": "Client",
|
|
||||||
"directionSupplier": "Fournisseur",
|
|
||||||
"client": "Client",
|
|
||||||
"clientDeliveryAddress": "Adresse de livraison",
|
|
||||||
"departureSite": "Adresse de départ",
|
|
||||||
"supplier": "Fournisseur",
|
|
||||||
"supplierSupplyAddress": "Adresse d'approvisionnement",
|
|
||||||
"deliverySite": "Adresse de livraison",
|
|
||||||
"containerType": "Benne / Fond mouvant",
|
|
||||||
"pricingUnit": "Forfait / Tonne",
|
|
||||||
"pricingForfait": "Forfait",
|
|
||||||
"pricingTonne": "Tonne",
|
|
||||||
"price": "Prix",
|
|
||||||
"priceState": "État du prix",
|
|
||||||
"stateEnCours": "En cours",
|
|
||||||
"stateValide": "Validé",
|
|
||||||
"stateNonValide": "Non validé",
|
|
||||||
"add": "Nouveau prix",
|
|
||||||
"remove": "Supprimer le prix",
|
|
||||||
"errors": {
|
|
||||||
"direction": "Le sens du prix est obligatoire.",
|
|
||||||
"client": "Le client est obligatoire pour un prix client.",
|
|
||||||
"clientDeliveryAddress": "L'adresse de livraison du client est obligatoire pour un prix client.",
|
|
||||||
"departureSite": "Le site de départ est obligatoire pour un prix client.",
|
|
||||||
"supplier": "Le fournisseur est obligatoire pour un prix fournisseur.",
|
|
||||||
"supplierSupplyAddress": "L'adresse d'approvisionnement est obligatoire pour un prix fournisseur.",
|
|
||||||
"deliverySite": "Le site de livraison est obligatoire pour un prix fournisseur.",
|
|
||||||
"containerType": "Le type de contenant est obligatoire.",
|
|
||||||
"pricingUnit": "L'unité de tarification est obligatoire.",
|
|
||||||
"price": "Le prix est obligatoire.",
|
|
||||||
"priceState": "L'état du prix est obligatoire."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Connexion",
|
"login": "Connexion",
|
||||||
"logout": "Deconnexion",
|
"logout": "Deconnexion",
|
||||||
@@ -731,27 +537,27 @@
|
|||||||
"delete": "Suppression"
|
"delete": "Suppression"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"core_user": "Utilisateur",
|
"core_user": "Utilisateur",
|
||||||
"core_role": "Rôle",
|
"core_role": "Rôle",
|
||||||
"core_permission": "Permission",
|
"core_permission": "Permission",
|
||||||
"sites_site": "Site",
|
"sites_site": "Site",
|
||||||
"catalog_category": "Catégorie",
|
"catalog_category": "Catégorie",
|
||||||
"commercial_client": "Client",
|
"commercial_client": "Client",
|
||||||
"commercial_clientaddress": "Adresse client",
|
"commercial_clientaddress": "Adresse client",
|
||||||
"commercial_clientcontact": "Contact client",
|
"commercial_clientcontact": "Contact client",
|
||||||
"commercial_clientrib": "RIB client",
|
"commercial_clientrib": "RIB client",
|
||||||
"commercial_supplier": "Fournisseur",
|
"commercial_supplier": "Fournisseur",
|
||||||
"commercial_supplieraddress": "Adresse fournisseur",
|
"commercial_supplieraddress": "Adresse fournisseur",
|
||||||
"commercial_suppliercontact": "Contact fournisseur",
|
"commercial_suppliercontact": "Contact fournisseur",
|
||||||
"commercial_supplierrib": "RIB fournisseur",
|
"commercial_supplierrib": "RIB fournisseur",
|
||||||
"technique_provider": "Prestataire",
|
"technique_provider": "Prestataire",
|
||||||
"technique_provideraddress": "Adresse prestataire",
|
"technique_provideraddress": "Adresse prestataire",
|
||||||
"technique_providercontact": "Contact prestataire",
|
"technique_providercontact": "Contact prestataire",
|
||||||
"technique_providerrib": "RIB prestataire",
|
"technique_providerrib": "RIB prestataire",
|
||||||
"transport_carrier": "Transporteur",
|
"transport_carrier": "Transporteur",
|
||||||
"transport_carrieraddress": "Adresse transporteur",
|
"transport_carrieraddress": "Adresse transporteur",
|
||||||
"transport_carriercontact": "Contact transporteur",
|
"transport_carriercontact": "Contact transporteur",
|
||||||
"transport_carrierprice": "Prix transporteur"
|
"transport_carrierprice": "Prix transporteur"
|
||||||
},
|
},
|
||||||
"empty": "Aucune activité enregistrée",
|
"empty": "Aucune activité enregistrée",
|
||||||
"no_results": "Aucun résultat pour ces filtres",
|
"no_results": "Aucun résultat pour ces filtres",
|
||||||
|
|||||||
@@ -41,10 +41,9 @@ export interface Supplier {
|
|||||||
* sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais
|
* sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais
|
||||||
* de chargement integral en memoire). Miroir de `useClientsRepository` (M1).
|
* de chargement integral en memoire). Miroir de `useClientsRepository` (M1).
|
||||||
*
|
*
|
||||||
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
|
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
|
||||||
* via `setFilters` du composable partage — la remise en page 1 est garantie.
|
* par la page via `setFilters` du composable partage — la remise en page 1 est
|
||||||
* Cocher « Voir les archivés » envoie `archivedOnly=true` → seules les archives
|
* garantie.
|
||||||
* sont listees (aligne sur Client).
|
|
||||||
*
|
*
|
||||||
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
||||||
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
|
||||||
<!-- Suppression : modal de confirmation cote parent. -->
|
|
||||||
<MalioButtonIcon
|
|
||||||
v-if="removable && !readonly"
|
|
||||||
icon="mdi:delete-outline"
|
|
||||||
variant="ghost"
|
|
||||||
button-class="absolute top-3 right-3"
|
|
||||||
v-bind="{ ariaLabel: t('transport.carriers.form.address.remove') }"
|
|
||||||
@click="$emit('remove')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Pays : prerempli « France » (RG-4.05). -->
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.country"
|
|
||||||
:options="countryOptions"
|
|
||||||
:label="t('transport.carriers.form.address.country')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:required="true"
|
|
||||||
:error="errors?.country"
|
|
||||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
|
|
||||||
<MalioInputText
|
|
||||||
:model-value="model.postalCode"
|
|
||||||
:label="t('transport.carriers.form.address.postalCode')"
|
|
||||||
:mask="POSTAL_CODE_MASK"
|
|
||||||
:readonly="readonly"
|
|
||||||
:required="true"
|
|
||||||
:error="errors?.postalCode"
|
|
||||||
@update:model-value="onPostalCodeChange"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
|
||||||
<MalioSelect
|
|
||||||
v-if="!degraded"
|
|
||||||
:model-value="model.city"
|
|
||||||
:options="cityOptions"
|
|
||||||
:label="t('transport.carriers.form.address.city')"
|
|
||||||
:readonly="readonly"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:error="errors?.city"
|
|
||||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
<MalioInputText
|
|
||||||
v-else
|
|
||||||
:model-value="model.city"
|
|
||||||
:label="t('transport.carriers.form.address.city')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:required="true"
|
|
||||||
:error="errors?.city"
|
|
||||||
@update:model-value="(v: string) => update('city', v)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Filler : aligne le debut de la ligne suivante sur la grille. -->
|
|
||||||
<div aria-hidden="true" />
|
|
||||||
|
|
||||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
|
||||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
|
||||||
<div class="col-span-2">
|
|
||||||
<MalioInputAutocomplete
|
|
||||||
v-if="!readonly"
|
|
||||||
:model-value="model.street"
|
|
||||||
:options="addressOptions"
|
|
||||||
:loading="addressLoading"
|
|
||||||
:min-search-length="3"
|
|
||||||
:label="t('transport.carriers.form.address.street')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:required="true"
|
|
||||||
:error="errors?.street"
|
|
||||||
:allow-create="true"
|
|
||||||
:no-results-text="t('transport.carriers.form.address.streetNotFound')"
|
|
||||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
|
||||||
@search="onAddressSearch"
|
|
||||||
@select="onAddressSelect"
|
|
||||||
/>
|
|
||||||
<MalioInputText
|
|
||||||
v-else
|
|
||||||
:model-value="model.street"
|
|
||||||
:label="t('transport.carriers.form.address.street')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:required="true"
|
|
||||||
:error="errors?.street"
|
|
||||||
@update:model-value="(v: string) => update('street', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MalioInputText
|
|
||||||
:model-value="model.streetComplement"
|
|
||||||
:label="t('transport.carriers.form.address.streetComplement')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.streetComplement"
|
|
||||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
|
||||||
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
|
|
||||||
|
|
||||||
interface RefOption {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Masque code postal FR : 5 chiffres.
|
|
||||||
const POSTAL_CODE_MASK = '#####'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
/** Brouillon de l'adresse (v-model). */
|
|
||||||
modelValue: CarrierAddressFormDraft
|
|
||||||
/** Pays disponibles (France par defaut). */
|
|
||||||
countryOptions: RefOption[]
|
|
||||||
removable?: boolean
|
|
||||||
readonly?: boolean
|
|
||||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
|
||||||
errors?: Record<string, string>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: CarrierAddressFormDraft]
|
|
||||||
'remove': []
|
|
||||||
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
|
||||||
'degraded': []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const autocomplete = useAddressAutocomplete()
|
|
||||||
|
|
||||||
const model = computed(() => props.modelValue)
|
|
||||||
|
|
||||||
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
|
|
||||||
const degraded = ref(false)
|
|
||||||
let unavailableNotified = false
|
|
||||||
const banCityOptions = ref<RefOption[]>([])
|
|
||||||
const banAddressOptions = ref<RefOption[]>([])
|
|
||||||
|
|
||||||
// Options ville effectives : on garantit que la ville courante figure toujours
|
|
||||||
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
|
|
||||||
const cityOptions = computed<RefOption[]>(() => {
|
|
||||||
const current = props.modelValue.city
|
|
||||||
if (current && !banCityOptions.value.some(o => o.value === current)) {
|
|
||||||
return [{ value: current, label: current }, ...banCityOptions.value]
|
|
||||||
}
|
|
||||||
return banCityOptions.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
|
|
||||||
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
|
|
||||||
const addressOptions = computed<RefOption[]>(() => {
|
|
||||||
const current = props.modelValue.street
|
|
||||||
if (current && !banAddressOptions.value.some(o => o.value === current)) {
|
|
||||||
return [{ value: current, label: current }, ...banAddressOptions.value]
|
|
||||||
}
|
|
||||||
return banAddressOptions.value
|
|
||||||
})
|
|
||||||
const addressLoading = ref(false)
|
|
||||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
|
||||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
|
||||||
|
|
||||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
|
||||||
function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void {
|
|
||||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
|
||||||
function notifyUnavailable(): void {
|
|
||||||
if (!unavailableNotified) {
|
|
||||||
unavailableNotified = true
|
|
||||||
emit('degraded')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
|
||||||
async function onPostalCodeChange(value: string): Promise<void> {
|
|
||||||
update('postalCode', value)
|
|
||||||
|
|
||||||
const digits = (value ?? '').replace(/\D/g, '')
|
|
||||||
if (digits.length < 5) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const suggestions = await autocomplete.searchCity(digits)
|
|
||||||
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
|
||||||
degraded.value = false
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
degraded.value = true
|
|
||||||
notifyUnavailable()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
|
||||||
async function onAddressSearch(query: string): Promise<void> {
|
|
||||||
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
|
|
||||||
if (query.trim().length < 3) {
|
|
||||||
banAddressOptions.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
addressLoading.value = true
|
|
||||||
try {
|
|
||||||
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
|
|
||||||
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
|
||||||
lastAddressSuggestions = suggestions
|
|
||||||
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
|
|
||||||
banAddressOptions.value = []
|
|
||||||
notifyUnavailable()
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
addressLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
|
|
||||||
function onAddressSelect(option: { label: string, value: string | number } | null): void {
|
|
||||||
if (option === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
|
|
||||||
if (!suggestion) {
|
|
||||||
update('street', String(option.value))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
emit('update:modelValue', {
|
|
||||||
...props.modelValue,
|
|
||||||
street: suggestion.street,
|
|
||||||
city: suggestion.city,
|
|
||||||
postalCode: suggestion.postalCode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
|
||||||
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
|
|
||||||
non supprimable (1er bloc) ou en lecture seule. -->
|
|
||||||
<MalioButtonIcon
|
|
||||||
v-if="removable && !readonly"
|
|
||||||
icon="mdi:delete-outline"
|
|
||||||
variant="ghost"
|
|
||||||
button-class="absolute top-3 right-3"
|
|
||||||
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
|
|
||||||
@click="$emit('remove')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MalioInputText
|
|
||||||
:model-value="model.lastName"
|
|
||||||
:label="t('transport.carriers.form.contact.lastName')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.lastName"
|
|
||||||
@update:model-value="(v: string) => update('lastName', v)"
|
|
||||||
/>
|
|
||||||
<MalioInputText
|
|
||||||
:model-value="model.firstName"
|
|
||||||
:label="t('transport.carriers.form.contact.firstName')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.firstName"
|
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
|
||||||
/>
|
|
||||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
|
|
||||||
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
|
|
||||||
<div class="col-span-2">
|
|
||||||
<MalioInputText
|
|
||||||
:model-value="model.jobTitle"
|
|
||||||
:label="t('transport.carriers.form.contact.jobTitle')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.jobTitle"
|
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<MalioInputEmail
|
|
||||||
:model-value="model.email"
|
|
||||||
:label="t('transport.carriers.form.contact.email')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:lowercase="true"
|
|
||||||
:error="errors?.email"
|
|
||||||
@update:model-value="(v: string) => update('email', v)"
|
|
||||||
/>
|
|
||||||
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
|
|
||||||
<MalioInputPhone
|
|
||||||
:model-value="model.phonePrimary"
|
|
||||||
:label="t('transport.carriers.form.contact.phonePrimary')"
|
|
||||||
:mask="PHONE_MASK"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.phonePrimary"
|
|
||||||
:addable="!model.hasSecondaryPhone && !readonly"
|
|
||||||
:add-button-label="t('transport.carriers.form.contact.addPhone')"
|
|
||||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
|
||||||
@add="revealSecondaryPhone"
|
|
||||||
/>
|
|
||||||
<!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). -->
|
|
||||||
<MalioInputPhone
|
|
||||||
v-if="model.hasSecondaryPhone"
|
|
||||||
:model-value="model.phoneSecondary"
|
|
||||||
:label="t('transport.carriers.form.contact.phoneSecondary')"
|
|
||||||
:mask="PHONE_MASK"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.phoneSecondary"
|
|
||||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
|
|
||||||
|
|
||||||
// Masque téléphone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
|
||||||
const PHONE_MASK = '## ## ## ## ##'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
/** Brouillon du contact (v-model). */
|
|
||||||
modelValue: CarrierContactFormDraft
|
|
||||||
/** Affiche l'icône de suppression (1er bloc non supprimable). */
|
|
||||||
removable?: boolean
|
|
||||||
/** Bloc en lecture seule (onglet validé). */
|
|
||||||
readonly?: boolean
|
|
||||||
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
|
|
||||||
errors?: Record<string, string>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: CarrierContactFormDraft]
|
|
||||||
'remove': []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
// Alias local pour la lisibilité du template.
|
|
||||||
const model = computed(() => props.modelValue)
|
|
||||||
|
|
||||||
/** Émet un nouveau brouillon avec le champ modifié (immutabilité). */
|
|
||||||
function update<K extends keyof CarrierContactFormDraft>(field: K, value: CarrierContactFormDraft[K]): void {
|
|
||||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Révèle le 2e numéro (max 1 secondaire, le « + » disparaît). */
|
|
||||||
function revealSecondaryPhone(): void {
|
|
||||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
|
||||||
<!-- Suppression : modal de confirmation côté parent. -->
|
|
||||||
<MalioButtonIcon
|
|
||||||
v-if="removable && !readonly"
|
|
||||||
icon="mdi:delete-outline"
|
|
||||||
variant="ghost"
|
|
||||||
button-class="absolute top-3 right-3"
|
|
||||||
v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }"
|
|
||||||
@click="$emit('remove')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios
|
|
||||||
EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la
|
|
||||||
case « Affréter ». Pas de label de groupe. -->
|
|
||||||
<div>
|
|
||||||
<div class="flex h-12 items-center gap-6">
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="model.direction"
|
|
||||||
:name="`price-direction-${uid}`"
|
|
||||||
value="CLIENT"
|
|
||||||
:label="t('transport.carriers.form.price.directionClient')"
|
|
||||||
:disabled="readonly"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="onDirectionChange"
|
|
||||||
/>
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="model.direction"
|
|
||||||
:name="`price-direction-${uid}`"
|
|
||||||
value="FOURNISSEUR"
|
|
||||||
:label="t('transport.carriers.form.price.directionSupplier')"
|
|
||||||
:disabled="readonly"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="onDirectionChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="errors?.direction" class="ml-[2px] text-xs text-m-danger">{{ errors.direction }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Branche CLIENT (RG-4.10). -->
|
|
||||||
<template v-if="model.direction === 'CLIENT'">
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.clientIri"
|
|
||||||
:options="clientOptions"
|
|
||||||
:label="t('transport.carriers.form.price.client')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.client"
|
|
||||||
@update:model-value="onClientChange"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.clientDeliveryAddressIri"
|
|
||||||
:options="clientAddressOptions"
|
|
||||||
:label="t('transport.carriers.form.price.clientDeliveryAddress')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.clientDeliveryAddress"
|
|
||||||
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.departureSiteIri"
|
|
||||||
:options="siteOptions"
|
|
||||||
:label="t('transport.carriers.form.price.departureSite')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.departureSite"
|
|
||||||
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Branche FOURNISSEUR (RG-4.11). -->
|
|
||||||
<template v-else-if="model.direction === 'FOURNISSEUR'">
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.supplierIri"
|
|
||||||
:options="supplierOptions"
|
|
||||||
:label="t('transport.carriers.form.price.supplier')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.supplier"
|
|
||||||
@update:model-value="onSupplierChange"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.supplierSupplyAddressIri"
|
|
||||||
:options="supplierAddressOptions"
|
|
||||||
:label="t('transport.carriers.form.price.supplierSupplyAddress')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.supplierSupplyAddress"
|
|
||||||
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.deliverySiteIri"
|
|
||||||
:options="siteOptions"
|
|
||||||
:label="t('transport.carriers.form.price.deliverySite')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.deliverySite"
|
|
||||||
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Communs (visibles dès qu'un sens est choisi). -->
|
|
||||||
<template v-if="model.direction !== null">
|
|
||||||
<!-- Contenant : Benne / Fond mouvant (radios centrés h-12, pas de label). -->
|
|
||||||
<div>
|
|
||||||
<div class="flex h-12 items-center gap-4">
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="model.containerType"
|
|
||||||
:name="`price-container-${uid}`"
|
|
||||||
value="BENNE"
|
|
||||||
:label="t('transport.carriers.containerType.BENNE')"
|
|
||||||
:disabled="readonly"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="model.containerType"
|
|
||||||
:name="`price-container-${uid}`"
|
|
||||||
value="FOND_MOUVANT"
|
|
||||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
|
||||||
:disabled="readonly"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="errors?.containerType" class="ml-[2px] text-xs text-m-danger">{{ errors.containerType }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). -->
|
|
||||||
<div>
|
|
||||||
<div class="flex h-12 items-center gap-4">
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="model.pricingUnit"
|
|
||||||
:name="`price-unit-${uid}`"
|
|
||||||
value="FORFAIT"
|
|
||||||
:label="t('transport.carriers.form.price.pricingForfait')"
|
|
||||||
:disabled="readonly"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="model.pricingUnit"
|
|
||||||
:name="`price-unit-${uid}`"
|
|
||||||
value="TONNE"
|
|
||||||
:label="t('transport.carriers.form.price.pricingTonne')"
|
|
||||||
:disabled="readonly"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MalioInputAmount
|
|
||||||
:model-value="model.price"
|
|
||||||
:label="t('transport.carriers.form.price.price')"
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.price"
|
|
||||||
@update:model-value="(v: string) => update('price', v)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.priceState"
|
|
||||||
:options="priceStateOptions"
|
|
||||||
:label="t('transport.carriers.form.price.priceState')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.priceState"
|
|
||||||
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, useId, watch } from 'vue'
|
|
||||||
import type { CarrierPriceFormDraft } from '~/modules/transport/types/carrierForm'
|
|
||||||
|
|
||||||
interface SelectOption {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
/** Brouillon du prix (v-model). */
|
|
||||||
modelValue: CarrierPriceFormDraft
|
|
||||||
/** Clients disponibles (IRI en value). */
|
|
||||||
clientOptions: SelectOption[]
|
|
||||||
/** Fournisseurs disponibles (IRI en value). */
|
|
||||||
supplierOptions: SelectOption[]
|
|
||||||
/** Sites Starseed (3 sites — IRI en value). */
|
|
||||||
siteOptions: SelectOption[]
|
|
||||||
removable?: boolean
|
|
||||||
readonly?: boolean
|
|
||||||
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
|
|
||||||
errors?: Record<string, string>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: CarrierPriceFormDraft]
|
|
||||||
'remove': []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const api = useApi()
|
|
||||||
|
|
||||||
// Identifiant unique par instance : les groupes de radios (sens / contenant / tarif)
|
|
||||||
// doivent avoir un `name` PROPRE à chaque bloc prix, sinon plusieurs blocs partagent
|
|
||||||
// le même groupe HTML et leurs radios se désélectionnent mutuellement.
|
|
||||||
const uid = useId()
|
|
||||||
|
|
||||||
const model = computed(() => props.modelValue)
|
|
||||||
|
|
||||||
const priceStateOptions = computed<SelectOption[]>(() => [
|
|
||||||
{ value: 'EN_COURS', label: t('transport.carriers.form.price.stateEnCours') },
|
|
||||||
{ value: 'VALIDE', label: t('transport.carriers.form.price.stateValide') },
|
|
||||||
{ value: 'NON_VALIDE', label: t('transport.carriers.form.price.stateNonValide') },
|
|
||||||
])
|
|
||||||
|
|
||||||
// Adresses chargées à la volée pour le client / fournisseur sélectionné (par bloc).
|
|
||||||
const clientAddressOptions = ref<SelectOption[]>([])
|
|
||||||
const supplierAddressOptions = ref<SelectOption[]>([])
|
|
||||||
|
|
||||||
/** Émet un nouveau brouillon avec le champ modifié (immutabilité). */
|
|
||||||
function update<K extends keyof CarrierPriceFormDraft>(field: K, value: CarrierPriceFormDraft[K]): void {
|
|
||||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Changement de sens : réinitialise les DEUX branches (cohérence CHECK BDD). */
|
|
||||||
function onDirectionChange(value: string | number | boolean | null): void {
|
|
||||||
const direction = value === 'CLIENT' || value === 'FOURNISSEUR' ? value : null
|
|
||||||
emit('update:modelValue', {
|
|
||||||
...props.modelValue,
|
|
||||||
direction,
|
|
||||||
clientIri: null,
|
|
||||||
clientDeliveryAddressIri: null,
|
|
||||||
departureSiteIri: null,
|
|
||||||
supplierIri: null,
|
|
||||||
supplierSupplyAddressIri: null,
|
|
||||||
deliverySiteIri: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sélection d'un client → maj IRI + reset de l'adresse de livraison (autre client). */
|
|
||||||
function onClientChange(value: string | number | null): void {
|
|
||||||
emit('update:modelValue', {
|
|
||||||
...props.modelValue,
|
|
||||||
clientIri: value === null ? null : String(value),
|
|
||||||
clientDeliveryAddressIri: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sélection d'un fournisseur → maj IRI + reset de l'adresse d'appro. */
|
|
||||||
function onSupplierChange(value: string | number | null): void {
|
|
||||||
emit('update:modelValue', {
|
|
||||||
...props.modelValue,
|
|
||||||
supplierIri: value === null ? null : String(value),
|
|
||||||
supplierSupplyAddressIri: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Réponse détail (client / fournisseur) embarquant ses adresses. */
|
|
||||||
interface ParentWithAddresses {
|
|
||||||
addresses?: { '@id': string, street?: string | null, postalCode?: string | null, city?: string | null }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mappe les adresses embarquées en options (IRI en value, voie · CP · ville en label). */
|
|
||||||
function toAddressOptions(parent: ParentWithAddresses): SelectOption[] {
|
|
||||||
return (parent.addresses ?? []).map(a => ({
|
|
||||||
value: a['@id'],
|
|
||||||
label: [a.street, a.postalCode, a.city].filter(Boolean).join(' · ') || a['@id'],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Charge les adresses d'un parent (client/fournisseur) à la volée via son détail
|
|
||||||
* (GET de l'IRI, qui embarque `addresses`). Échec → liste vide (non bloquant).
|
|
||||||
*/
|
|
||||||
async function loadAddresses(iri: string | null, target: typeof clientAddressOptions): Promise<void> {
|
|
||||||
if (!iri) {
|
|
||||||
target.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// L'IRI est absolue (/api/clients/5) ; useApi préfixe déjà /api → on le retire.
|
|
||||||
const path = iri.replace(/^\/api/, '')
|
|
||||||
const data = await api.get<ParentWithAddresses>(path, {}, { headers: { Accept: 'application/ld+json' }, toast: false })
|
|
||||||
target.value = toAddressOptions(data)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
target.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recharge les adresses quand le client / fournisseur change (immediate pour le
|
|
||||||
// pré-remplissage en édition).
|
|
||||||
watch(() => props.modelValue.clientIri, iri => loadAddresses(iri, clientAddressOptions), { immediate: true })
|
|
||||||
watch(() => props.modelValue.supplierIri, iri => loadAddresses(iri, supplierAddressOptions), { immediate: true })
|
|
||||||
</script>
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { mount, flushPromises } from '@vue/test-utils'
|
|
||||||
import { defineComponent, h, ref, computed } from 'vue'
|
|
||||||
import { emptyCarrierAddress } from '~/modules/transport/types/carrierForm'
|
|
||||||
import CarrierAddressBlock from '../CarrierAddressBlock.vue'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests de l'autocomplétion BAN du bloc Adresse transporteur (ERP-167) — réutilise
|
|
||||||
* `useAddressAutocomplete` (M1/M2/M3). On vérifie le NOMINAL (CP → ville) et le
|
|
||||||
* DÉGRADÉ (BAN indisponible → saisie libre + event `degraded`).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
|
|
||||||
searchCityMock: vi.fn(),
|
|
||||||
searchAddressMock: vi.fn(),
|
|
||||||
}))
|
|
||||||
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
|
||||||
useAddressAutocomplete: () => ({
|
|
||||||
searchCity: searchCityMock,
|
|
||||||
searchAddress: searchAddressMock,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
|
||||||
vi.stubGlobal('ref', ref)
|
|
||||||
vi.stubGlobal('computed', computed)
|
|
||||||
|
|
||||||
const MalioInputTextStub = defineComponent({
|
|
||||||
name: 'MalioInputText',
|
|
||||||
props: { modelValue: { default: null }, label: { type: String, default: '' }, error: { type: String, default: '' } },
|
|
||||||
emits: ['update:modelValue'],
|
|
||||||
setup(props) {
|
|
||||||
return () => h('div', { 'data-testid': 'input-text', 'data-label': props.label, 'data-error': props.error })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const MalioSelectStub = defineComponent({
|
|
||||||
name: 'MalioSelect',
|
|
||||||
props: { modelValue: { default: null }, options: { type: Array as () => { value: string }[], default: () => [] }, label: { type: String, default: '' }, error: { type: String, default: '' } },
|
|
||||||
emits: ['update:modelValue'],
|
|
||||||
setup(props) {
|
|
||||||
return () => h('div', { 'data-testid': 'select', 'data-label': props.label, 'data-options': JSON.stringify(props.options.map(o => o.value)) })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const MalioInputAutocompleteStub = defineComponent({
|
|
||||||
name: 'MalioInputAutocomplete',
|
|
||||||
props: { modelValue: { default: null }, options: { type: Array as () => { value: string }[], default: () => [] }, loading: { type: Boolean, default: false }, allowCreate: { type: Boolean, default: false } },
|
|
||||||
emits: ['update:modelValue', 'search', 'select'],
|
|
||||||
setup(props) {
|
|
||||||
return () => h('div', { 'data-testid': 'addr-autocomplete', 'data-options': JSON.stringify(props.options.map(o => o.value)) })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function mountBlock(overrides: Record<string, unknown> = {}) {
|
|
||||||
return mount(CarrierAddressBlock, {
|
|
||||||
props: {
|
|
||||||
modelValue: { ...emptyCarrierAddress(), ...overrides },
|
|
||||||
countryOptions: [{ value: 'France', label: 'France' }],
|
|
||||||
},
|
|
||||||
global: {
|
|
||||||
stubs: {
|
|
||||||
MalioButtonIcon: true,
|
|
||||||
MalioInputText: MalioInputTextStub,
|
|
||||||
MalioSelect: MalioSelectStub,
|
|
||||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Récupère le composant MalioInputText d'un label donné. */
|
|
||||||
function inputTextByLabel(wrapper: ReturnType<typeof mountBlock>, label: string) {
|
|
||||||
return wrapper.findAllComponents(MalioInputTextStub).find(c => c.props('label') === label)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('CarrierAddressBlock — autocomplétion ville (BAN) NOMINAL', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
searchCityMock.mockReset()
|
|
||||||
searchAddressMock.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('saisie d\'un CP à 5 chiffres → searchCity + peuple le select Ville', async () => {
|
|
||||||
searchCityMock.mockResolvedValueOnce([{ city: 'Poitiers', postalCode: '86000' }])
|
|
||||||
const wrapper = mountBlock()
|
|
||||||
|
|
||||||
const cp = inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')
|
|
||||||
cp?.vm.$emit('update:modelValue', '86000')
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(searchCityMock).toHaveBeenCalledWith('86000')
|
|
||||||
const citySelect = wrapper.findAllComponents(MalioSelectStub).find(c => c.props('label') === 'transport.carriers.form.address.city')
|
|
||||||
const options = JSON.parse(citySelect?.attributes('data-options') ?? '[]')
|
|
||||||
expect(options).toContain('Poitiers')
|
|
||||||
expect(wrapper.emitted('degraded')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('n\'interroge pas la BAN sous 5 chiffres', async () => {
|
|
||||||
const wrapper = mountBlock()
|
|
||||||
inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')?.vm.$emit('update:modelValue', '860')
|
|
||||||
await flushPromises()
|
|
||||||
expect(searchCityMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('CarrierAddressBlock — autocomplétion DÉGRADÉE', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
searchCityMock.mockReset()
|
|
||||||
searchAddressMock.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('BAN ville indisponible → bascule en saisie libre + émet « degraded »', async () => {
|
|
||||||
searchCityMock.mockRejectedValueOnce(new Error('BAN indisponible'))
|
|
||||||
const wrapper = mountBlock()
|
|
||||||
|
|
||||||
inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')?.vm.$emit('update:modelValue', '86000')
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.emitted('degraded')).toHaveLength(1)
|
|
||||||
// En dégradé, la Ville devient un MalioInputText (plus de MalioSelect ville).
|
|
||||||
const citySelect = wrapper.findAllComponents(MalioSelectStub).find(c => c.props('label') === 'transport.carriers.form.address.city')
|
|
||||||
expect(citySelect).toBeUndefined()
|
|
||||||
expect(inputTextByLabel(wrapper, 'transport.carriers.form.address.city')).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('autocomplétion adresse : pas d\'appel BAN sous 3 caractères', async () => {
|
|
||||||
const wrapper = mountBlock()
|
|
||||||
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
|
|
||||||
await flushPromises()
|
|
||||||
expect(searchAddressMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('autocomplétion adresse : émet « degraded » une seule fois malgré plusieurs erreurs', async () => {
|
|
||||||
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
|
|
||||||
const wrapper = mountBlock()
|
|
||||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
|
||||||
|
|
||||||
auto.vm.$emit('search', 'rue de la paix')
|
|
||||||
await flushPromises()
|
|
||||||
auto.vm.$emit('search', 'rue de la paixx')
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.emitted('degraded')).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
|
|
||||||
const wrapper = mountBlock()
|
|
||||||
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,964 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests du workflow « Ajouter un transporteur » (M4 Transport, ERP-165).
|
|
||||||
*
|
|
||||||
* `useCarrierForm` porte le formulaire principal (Nom + Certification + Affréter)
|
|
||||||
* et l'orchestration des onglets de création. On vérifie ici le CONTRAT propre à
|
|
||||||
* la création :
|
|
||||||
* - pré-check front : nom requis → POST bloqué, erreur inline, aucun appel réseau ;
|
|
||||||
* - POST /carriers (groupe carrier:write:main) : payload + Accept ld+json +
|
|
||||||
* toast:false ; au succès, verrouillage + bascule sur l'onglet Qualimat +
|
|
||||||
* réaffichage du nom normalisé ;
|
|
||||||
* - 409 doublon (RG-4.12) → erreur inline dédiée sur `name` ;
|
|
||||||
* - 422 → mapping inline par champ (propertyPath) ;
|
|
||||||
* - onglets : 4 onglets (Qualimat/Adresses/Contacts/Prix), completeTab
|
|
||||||
* déverrouille/avance et signale le dernier onglet ;
|
|
||||||
* - patchCarrier : PATCH partiel, no-op avant création.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const mockPost = vi.hoisted(() => vi.fn())
|
|
||||||
const mockPatch = vi.hoisted(() => vi.fn())
|
|
||||||
const mockDelete = vi.hoisted(() => vi.fn())
|
|
||||||
|
|
||||||
vi.stubGlobal('useApi', () => ({
|
|
||||||
get: vi.fn(),
|
|
||||||
post: mockPost,
|
|
||||||
put: vi.fn(),
|
|
||||||
patch: mockPatch,
|
|
||||||
delete: mockDelete,
|
|
||||||
}))
|
|
||||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
|
||||||
vi.stubGlobal('useToast', () => ({
|
|
||||||
success: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
warning: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
|
|
||||||
const { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } = await import('../../utils/forms/carrierContact')
|
|
||||||
const { buildCarrierPricePayload, isCarrierPriceValid } = await import('../../utils/forms/carrierPrice')
|
|
||||||
const { emptyCarrierContact, emptyCarrierPrice } = await import('../../types/carrierForm')
|
|
||||||
|
|
||||||
describe('useCarrierForm', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPost.mockReset()
|
|
||||||
mockPatch.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('front : nom vide → erreur inline sur name, pas de POST', async () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
|
|
||||||
const created = await form.submitMain()
|
|
||||||
|
|
||||||
expect(created).toBe(false)
|
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
|
||||||
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired')
|
|
||||||
expect(form.mainLocked.value).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('front : nom en espaces uniquement → erreur inline, pas de POST', async () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = ' '
|
|
||||||
|
|
||||||
const created = await form.submitMain()
|
|
||||||
|
|
||||||
expect(created).toBe(false)
|
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
|
||||||
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('front : certification vide (hors LIOT) → erreur inline sur certificationType, pas de POST', async () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'Acme'
|
|
||||||
// certificationType laissé null → bloqué côté front (RG-4.01).
|
|
||||||
|
|
||||||
const created = await form.submitMain()
|
|
||||||
|
|
||||||
expect(created).toBe(false)
|
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
|
||||||
expect(form.mainErrors.errors.certificationType).toBe('transport.carriers.form.errors.certificationRequired')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('front : cas LIOT → certification non requise (aucune erreur de certification)', async () => {
|
|
||||||
mockPost.mockResolvedValueOnce({ id: 5, name: 'LIOT', certificationType: null })
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'LIOT'
|
|
||||||
form.main.liotPlates = 'AA-123-BB'
|
|
||||||
|
|
||||||
const created = await form.submitMain()
|
|
||||||
|
|
||||||
expect(created).toBe(true)
|
|
||||||
expect(form.mainErrors.errors.certificationType).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('front RG-4.02 : certification AUTRE sans décharge → erreur inline, pas de POST', async () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'Acme'
|
|
||||||
form.main.certificationType = 'AUTRE'
|
|
||||||
// dischargeDocumentIri null (upload non fourni).
|
|
||||||
|
|
||||||
const created = await form.submitMain()
|
|
||||||
|
|
||||||
expect(created).toBe(false)
|
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
|
||||||
expect(form.mainErrors.errors.dischargeDocument).toBe('transport.carriers.form.errors.dischargeRequired')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('front RG-4.03 : affrété sans indexation / contenant / volume → 3 erreurs inline, pas de POST', async () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'Acme'
|
|
||||||
form.main.certificationType = 'GMP_PLUS'
|
|
||||||
form.main.isChartered = true
|
|
||||||
// Annule le défaut « BENNE » pour vérifier la garde « contenant obligatoire ».
|
|
||||||
form.main.containerType = null
|
|
||||||
|
|
||||||
const created = await form.submitMain()
|
|
||||||
|
|
||||||
expect(created).toBe(false)
|
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
|
||||||
expect(form.mainErrors.errors.indexationRate).toBe('transport.carriers.form.errors.indexationRequired')
|
|
||||||
expect(form.mainErrors.errors.containerType).toBe('transport.carriers.form.errors.containerTypeRequired')
|
|
||||||
expect(form.mainErrors.errors.volumeM3).toBe('transport.carriers.form.errors.volumeRequired')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('front RG-4.03 : affrété avec tous les champs remplis → POST envoyé', async () => {
|
|
||||||
mockPost.mockResolvedValueOnce({ id: 8, name: 'ACME', certificationType: 'GMP_PLUS' })
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'Acme'
|
|
||||||
form.main.certificationType = 'GMP_PLUS'
|
|
||||||
form.main.isChartered = true
|
|
||||||
form.main.indexationRate = '5'
|
|
||||||
form.main.containerType = 'BENNE'
|
|
||||||
form.main.volumeM3 = '30'
|
|
||||||
|
|
||||||
const created = await form.submitMain()
|
|
||||||
|
|
||||||
expect(created).toBe(true)
|
|
||||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
|
||||||
expect(mockPost.mock.calls[0]?.[1]).toMatchObject({
|
|
||||||
isChartered: true,
|
|
||||||
indexationRate: '5',
|
|
||||||
containerType: 'BENNE',
|
|
||||||
volumeM3: '30',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('POST /carriers avec Accept ld+json, verrouille et bascule sur Qualimat', async () => {
|
|
||||||
mockPost.mockResolvedValueOnce({ id: 42, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS' })
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'Transports Acme'
|
|
||||||
form.main.certificationType = 'GMP_PLUS'
|
|
||||||
|
|
||||||
const created = await form.submitMain()
|
|
||||||
|
|
||||||
expect(created).toBe(true)
|
|
||||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
|
||||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
|
||||||
expect(url).toBe('/carriers')
|
|
||||||
expect(body).toEqual({
|
|
||||||
name: 'Transports Acme',
|
|
||||||
certificationType: 'GMP_PLUS',
|
|
||||||
isChartered: false,
|
|
||||||
})
|
|
||||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
|
||||||
|
|
||||||
expect(form.carrierId.value).toBe(42)
|
|
||||||
// RG-4.13 : réaffiche le nom normalisé (UPPERCASE) renvoyé par le serveur.
|
|
||||||
expect(form.main.name).toBe('TRANSPORTS ACME')
|
|
||||||
expect(form.mainLocked.value).toBe(true)
|
|
||||||
// L'onglet Qualimat était déjà accessible (saisie assistée) ; le POST
|
|
||||||
// déverrouille Adresses (index 1) et bascule dessus.
|
|
||||||
expect(form.activeTab.value).toBe('addresses')
|
|
||||||
expect(form.unlockedIndex.value).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('buildMainPayload : omet certificationType vide, garde isChartered', () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'X'
|
|
||||||
|
|
||||||
const body = form.buildMainPayload()
|
|
||||||
expect(body).toEqual({ name: 'X', isChartered: false })
|
|
||||||
expect('certificationType' in body).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('409 doublon (RG-4.12) : erreur inline dédiée sur name, pas de verrouillage', async () => {
|
|
||||||
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'Doublon'
|
|
||||||
form.main.certificationType = 'GMP_PLUS'
|
|
||||||
|
|
||||||
const created = await form.submitMain()
|
|
||||||
|
|
||||||
expect(created).toBe(false)
|
|
||||||
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.duplicateName')
|
|
||||||
expect(form.mainLocked.value).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('422 : mappe les violations serveur inline par champ', async () => {
|
|
||||||
// Contrainte re-validée uniquement back (ex. longueur du nom) : le pré-check
|
|
||||||
// front passe (nom rempli, certif choisie, non affrété), la 422 mappe inline
|
|
||||||
// sur le champ via son propertyPath.
|
|
||||||
mockPost.mockRejectedValueOnce({
|
|
||||||
response: {
|
|
||||||
status: 422,
|
|
||||||
_data: { violations: [{ propertyPath: 'name', message: 'Le nom du transporteur ne peut dépasser 255 caractères.' }] },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'Acme'
|
|
||||||
form.main.certificationType = 'GMP_PLUS'
|
|
||||||
|
|
||||||
const created = await form.submitMain()
|
|
||||||
|
|
||||||
expect(created).toBe(false)
|
|
||||||
expect(form.mainErrors.errors.name).toBe('Le nom du transporteur ne peut dépasser 255 caractères.')
|
|
||||||
expect(form.mainLocked.value).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('onglets : 4 clés Qualimat/Adresses/Contacts/Prix', () => {
|
|
||||||
expect(CARRIER_TAB_KEYS).toEqual(['qualimat', 'addresses', 'contacts', 'prices'])
|
|
||||||
const form = useCarrierForm()
|
|
||||||
expect(form.tabKeys.value).toEqual(['qualimat', 'addresses', 'contacts', 'prices'])
|
|
||||||
// L'onglet Qualimat (index 0) est accessible dès le départ (saisie assistée) ;
|
|
||||||
// Adresses / Contacts / Prix restent verrouillés jusqu'au POST principal.
|
|
||||||
expect(form.unlockedIndex.value).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('completeTab : déverrouille/avance, et signale le dernier onglet du flux', () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
|
|
||||||
// Qualimat → Adresses (pas le dernier).
|
|
||||||
expect(form.completeTab('qualimat')).toBe(false)
|
|
||||||
expect(form.isValidated('qualimat')).toBe(true)
|
|
||||||
expect(form.activeTab.value).toBe('addresses')
|
|
||||||
expect(form.unlockedIndex.value).toBe(1)
|
|
||||||
|
|
||||||
expect(form.completeTab('addresses')).toBe(false)
|
|
||||||
expect(form.activeTab.value).toBe('contacts')
|
|
||||||
|
|
||||||
expect(form.completeTab('contacts')).toBe(false)
|
|
||||||
expect(form.activeTab.value).toBe('prices')
|
|
||||||
|
|
||||||
// Prix = dernier onglet → true (création terminée).
|
|
||||||
expect(form.completeTab('prices')).toBe(true)
|
|
||||||
expect(form.isValidated('prices')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.editMode.value = true
|
|
||||||
form.activeTab.value = 'qualimat'
|
|
||||||
|
|
||||||
expect(form.completeTab('qualimat')).toBe(false)
|
|
||||||
expect(form.isValidated('qualimat')).toBe(false)
|
|
||||||
expect(form.activeTab.value).toBe('qualimat')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('patchCarrier : PATCH /carriers/{id} en mode strict, no-op avant création', async () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
|
|
||||||
await form.patchCarrier({ liotPlates: 'AA-123-BB' })
|
|
||||||
expect(mockPatch).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
mockPost.mockResolvedValueOnce({ id: 9, name: 'ACME', certificationType: 'OVOCOM' })
|
|
||||||
form.main.name = 'Acme'
|
|
||||||
form.main.certificationType = 'OVOCOM'
|
|
||||||
await form.submitMain()
|
|
||||||
|
|
||||||
await form.patchCarrier({ liotPlates: 'AA-123-BB' })
|
|
||||||
expect(mockPatch).toHaveBeenCalledWith('/carriers/9', { liotPlates: 'AA-123-BB' }, { toast: false })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useCarrierForm — champs conditionnels (ERP-166)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPost.mockReset()
|
|
||||||
mockPatch.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('cas LIOT (insensible à la casse) : masque la certification, payload réduit', () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'liot'
|
|
||||||
|
|
||||||
expect(form.isLiot.value).toBe(true)
|
|
||||||
expect(form.showCertification.value).toBe(false)
|
|
||||||
|
|
||||||
form.main.liotPlates = 'AA-123-BB ; CC-456-DD'
|
|
||||||
expect(form.buildMainPayload()).toEqual({
|
|
||||||
name: 'liot',
|
|
||||||
isChartered: false,
|
|
||||||
liotPlates: 'AA-123-BB ; CC-456-DD',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('LIOT masque les champs conditionnels (affrètement / décharge)', () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'LIOT'
|
|
||||||
form.main.isChartered = true
|
|
||||||
form.main.certificationType = 'AUTRE'
|
|
||||||
|
|
||||||
expect(form.showCharteredFields.value).toBe(false)
|
|
||||||
expect(form.showDischarge.value).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('RG-4.03 affrété : indexation / contenant / volume visibles et dans le payload', () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'Acme'
|
|
||||||
form.main.certificationType = 'GMP_PLUS'
|
|
||||||
form.main.isChartered = true
|
|
||||||
|
|
||||||
expect(form.showCharteredFields.value).toBe(true)
|
|
||||||
|
|
||||||
form.main.indexationRate = '5'
|
|
||||||
form.main.containerType = 'BENNE'
|
|
||||||
form.main.volumeM3 = '30'
|
|
||||||
|
|
||||||
expect(form.buildMainPayload()).toEqual({
|
|
||||||
name: 'Acme',
|
|
||||||
certificationType: 'GMP_PLUS',
|
|
||||||
isChartered: true,
|
|
||||||
indexationRate: '5',
|
|
||||||
containerType: 'BENNE',
|
|
||||||
volumeM3: '30',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('RG-4.03 affrété, indexation/volume vides : omis du payload (containerType garde son défaut BENNE)', () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'Acme'
|
|
||||||
form.main.certificationType = 'GMP_PLUS'
|
|
||||||
form.main.isChartered = true
|
|
||||||
|
|
||||||
// indexation / volume vides → omis (422 NotBlank back) ; containerType défaut « BENNE » envoyé.
|
|
||||||
expect(form.buildMainPayload()).toEqual({
|
|
||||||
name: 'Acme',
|
|
||||||
certificationType: 'GMP_PLUS',
|
|
||||||
isChartered: true,
|
|
||||||
containerType: 'BENNE',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('RG-4.02 AUTRE : décharge visible + dischargeDocument dans le payload si IRI résolu', () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'Acme'
|
|
||||||
form.main.certificationType = 'AUTRE'
|
|
||||||
|
|
||||||
expect(form.showDischarge.value).toBe(true)
|
|
||||||
|
|
||||||
form.main.dischargeDocumentIri = '/api/uploaded_documents/7'
|
|
||||||
expect(form.buildMainPayload()).toMatchObject({ dischargeDocument: '/api/uploaded_documents/7' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
|
|
||||||
const QUALIMAT_ROW = {
|
|
||||||
'@id': '/api/qualimat_carriers/42',
|
|
||||||
id: '42',
|
|
||||||
name: 'TRANSPORTS QUALIMAT',
|
|
||||||
siret: '12345678900012',
|
|
||||||
address: '1 rue du Port',
|
|
||||||
postalCode: '86000',
|
|
||||||
city: 'Poitiers',
|
|
||||||
validityDate: '2027-01-15',
|
|
||||||
status: 'VALIDE',
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPost.mockReset()
|
|
||||||
mockPatch.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('copie name + certificationType=QUALIMAT (readonly) + IRI + adresse, sans PATCH avant création', async () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
|
|
||||||
const ok = await form.applyQualimatSelection(QUALIMAT_ROW)
|
|
||||||
|
|
||||||
expect(ok).toBe(true)
|
|
||||||
expect(mockPatch).not.toHaveBeenCalled()
|
|
||||||
expect(form.main.name).toBe('TRANSPORTS QUALIMAT')
|
|
||||||
expect(form.main.certificationType).toBe('QUALIMAT')
|
|
||||||
expect(form.main.qualimatCarrierIri).toBe('/api/qualimat_carriers/42')
|
|
||||||
expect(form.isQualimat.value).toBe(true)
|
|
||||||
expect(form.certificationReadonly.value).toBe(true)
|
|
||||||
expect(form.qualimatAddress.value).toEqual({
|
|
||||||
country: 'France',
|
|
||||||
postalCode: '86000',
|
|
||||||
city: 'Poitiers',
|
|
||||||
street: '1 rue du Port',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('après création : PATCH /carriers/{id} avec qualimatCarrier + name + certification', async () => {
|
|
||||||
mockPost.mockResolvedValueOnce({ id: 9, name: 'X', certificationType: 'GMP_PLUS' })
|
|
||||||
mockPatch.mockResolvedValueOnce({})
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'X'
|
|
||||||
form.main.certificationType = 'GMP_PLUS'
|
|
||||||
await form.submitMain()
|
|
||||||
|
|
||||||
const ok = await form.applyQualimatSelection(QUALIMAT_ROW)
|
|
||||||
|
|
||||||
expect(ok).toBe(true)
|
|
||||||
expect(mockPatch).toHaveBeenCalledWith(
|
|
||||||
'/carriers/9',
|
|
||||||
{
|
|
||||||
qualimatCarrier: '/api/qualimat_carriers/42',
|
|
||||||
name: 'TRANSPORTS QUALIMAT',
|
|
||||||
certificationType: 'QUALIMAT',
|
|
||||||
},
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('après création : PATCH en échec → pas de copie locale (rollback) et retour false', async () => {
|
|
||||||
mockPost.mockResolvedValueOnce({ id: 9, name: 'X', certificationType: 'GMP_PLUS' })
|
|
||||||
mockPatch.mockRejectedValueOnce({ response: { status: 500, _data: {} } })
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'X'
|
|
||||||
form.main.certificationType = 'GMP_PLUS'
|
|
||||||
await form.submitMain()
|
|
||||||
|
|
||||||
const ok = await form.applyQualimatSelection(QUALIMAT_ROW)
|
|
||||||
|
|
||||||
// Échec serveur : l'UI ne doit pas refléter une intégration QUALIMAT non persistée.
|
|
||||||
expect(ok).toBe(false)
|
|
||||||
expect(form.main.name).toBe('X')
|
|
||||||
expect(form.main.certificationType).toBe('GMP_PLUS')
|
|
||||||
expect(form.main.qualimatCarrierIri).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('buildMainPayload inclut qualimatCarrier + certificationType QUALIMAT après intégration', async () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'Acme'
|
|
||||||
await form.applyQualimatSelection(QUALIMAT_ROW)
|
|
||||||
|
|
||||||
expect(form.buildMainPayload()).toMatchObject({
|
|
||||||
qualimatCarrier: '/api/qualimat_carriers/42',
|
|
||||||
certificationType: 'QUALIMAT',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applyQualimatSelection pré-remplit le 1er bloc d\'adresse (RG-4.05)', async () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.main.name = 'Acme'
|
|
||||||
|
|
||||||
await form.applyQualimatSelection(QUALIMAT_ROW)
|
|
||||||
|
|
||||||
expect(form.addresses.value).toHaveLength(1)
|
|
||||||
expect(form.addresses.value[0]).toEqual({
|
|
||||||
id: null,
|
|
||||||
country: 'France',
|
|
||||||
postalCode: '86000',
|
|
||||||
city: 'Poitiers',
|
|
||||||
street: '1 rue du Port',
|
|
||||||
streetComplement: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useCarrierForm — onglet Adresses (ERP-167)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPost.mockReset()
|
|
||||||
mockPatch.mockReset()
|
|
||||||
mockDelete.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Transporteur créé, onglet Adresses accessible. */
|
|
||||||
function createdForm() {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.carrierId.value = 7
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remplit un bloc adresse complet (CP + ville + rue). */
|
|
||||||
function fillAddress(form: ReturnType<typeof useCarrierForm>, index = 0): void {
|
|
||||||
const a = form.addresses.value[index]
|
|
||||||
if (a) {
|
|
||||||
a.postalCode = '86100'
|
|
||||||
a.city = 'Châtellerault'
|
|
||||||
a.street = '1 rue du Test'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('canAddAddress : désactivé tant que la dernière adresse est incomplète', () => {
|
|
||||||
const form = createdForm()
|
|
||||||
expect(form.canAddAddress.value).toBe(false)
|
|
||||||
|
|
||||||
form.addAddress()
|
|
||||||
expect(form.addresses.value).toHaveLength(1) // no-op tant qu'incomplète
|
|
||||||
|
|
||||||
fillAddress(form)
|
|
||||||
expect(form.canAddAddress.value).toBe(true)
|
|
||||||
form.addAddress()
|
|
||||||
expect(form.addresses.value).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('submitAddresses : POST des nouvelles adresses, capture l\'id, finalise l\'onglet', async () => {
|
|
||||||
mockPost.mockResolvedValueOnce({ id: 88 })
|
|
||||||
const form = createdForm()
|
|
||||||
fillAddress(form)
|
|
||||||
|
|
||||||
const ok = await form.submitAddresses(vi.fn())
|
|
||||||
|
|
||||||
expect(ok).toBe(true)
|
|
||||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
|
||||||
expect(url).toBe('/carriers/7/addresses')
|
|
||||||
expect(body).toEqual({
|
|
||||||
country: 'France',
|
|
||||||
postalCode: '86100',
|
|
||||||
city: 'Châtellerault',
|
|
||||||
street: '1 rue du Test',
|
|
||||||
streetComplement: null,
|
|
||||||
})
|
|
||||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
|
||||||
expect(form.addresses.value[0]?.id).toBe(88)
|
|
||||||
expect(form.isValidated('addresses')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('submitAddresses : PATCH des adresses existantes sur /carrier_addresses/{id}', async () => {
|
|
||||||
mockPatch.mockResolvedValueOnce({})
|
|
||||||
const form = createdForm()
|
|
||||||
fillAddress(form)
|
|
||||||
const first = form.addresses.value[0]
|
|
||||||
if (first) first.id = 88
|
|
||||||
|
|
||||||
await form.submitAddresses(vi.fn())
|
|
||||||
|
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
|
||||||
expect(mockPatch).toHaveBeenCalledWith('/carrier_addresses/88', expect.objectContaining({ city: 'Châtellerault' }), { toast: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('submitAddresses : mappe les 422 PAR LIGNE et ne finalise pas l\'onglet (RG-4.05)', async () => {
|
|
||||||
mockPost.mockRejectedValueOnce({
|
|
||||||
response: {
|
|
||||||
status: 422,
|
|
||||||
_data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire pour un transporteur affrété.' }] },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const form = createdForm()
|
|
||||||
fillAddress(form)
|
|
||||||
|
|
||||||
const ok = await form.submitAddresses(vi.fn())
|
|
||||||
|
|
||||||
expect(ok).toBe(false)
|
|
||||||
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire pour un transporteur affrété.')
|
|
||||||
expect(form.isValidated('addresses')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('removeAddress : DELETE /carrier_addresses/{id} puis retrait du bloc', async () => {
|
|
||||||
mockDelete.mockResolvedValueOnce({})
|
|
||||||
const form = createdForm()
|
|
||||||
fillAddress(form)
|
|
||||||
const first = form.addresses.value[0]
|
|
||||||
if (first) first.id = 88
|
|
||||||
form.addAddress()
|
|
||||||
fillAddress(form, 1)
|
|
||||||
|
|
||||||
await form.removeAddress(0)
|
|
||||||
|
|
||||||
expect(mockDelete).toHaveBeenCalledWith('/carrier_addresses/88', {}, { toast: false })
|
|
||||||
expect(form.addresses.value).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléphones', () => {
|
|
||||||
it('isCarrierContactBlank : vrai si vide, faux dès un champ comptant rempli (phoneSecondary exclu)', () => {
|
|
||||||
expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true)
|
|
||||||
expect(isCarrierContactBlank({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
|
|
||||||
expect(isCarrierContactBlank({ ...emptyCarrierContact(), phonePrimary: '0102030405' })).toBe(false)
|
|
||||||
// phoneSecondary seul ne compte pas (aligné M1/M2/M3).
|
|
||||||
expect(isCarrierContactBlank({ ...emptyCarrierContact(), phoneSecondary: '0605040302', hasSecondaryPhone: true })).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('isCarrierContactNamed : nommé seulement avec un prénom OU un nom', () => {
|
|
||||||
expect(isCarrierContactNamed(emptyCarrierContact())).toBe(false)
|
|
||||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), firstName: 'Jean' })).toBe(true)
|
|
||||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), lastName: 'Doe' })).toBe(true)
|
|
||||||
// Fonction / téléphone / email seuls ne « nomment » pas (≠ RG-4.08 large).
|
|
||||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
|
|
||||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), email: 'a@b.fr' })).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => {
|
|
||||||
const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' })
|
|
||||||
expect(body.phones).toEqual(['0102030405'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('buildCarrierContactPayload : phones = 2 numéros si secondaire révélé', () => {
|
|
||||||
const body = buildCarrierContactPayload({
|
|
||||||
...emptyCarrierContact(),
|
|
||||||
phonePrimary: '0102030405',
|
|
||||||
phoneSecondary: '0605040302',
|
|
||||||
hasSecondaryPhone: true,
|
|
||||||
})
|
|
||||||
expect(body.phones).toEqual(['0102030405', '0605040302'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('buildCarrierContactPayload : 2e numéro ignoré tant que non révélé', () => {
|
|
||||||
const body = buildCarrierContactPayload({
|
|
||||||
...emptyCarrierContact(),
|
|
||||||
phonePrimary: '0102030405',
|
|
||||||
phoneSecondary: '0605040302',
|
|
||||||
hasSecondaryPhone: false,
|
|
||||||
})
|
|
||||||
expect(body.phones).toEqual(['0102030405'])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPost.mockReset()
|
|
||||||
mockPatch.mockReset()
|
|
||||||
mockDelete.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Transporteur créé, onglet Contacts accessible. */
|
|
||||||
function createdForm() {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.carrierId.value = 7
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
it('« + Nouveau contact » désactivé tant que le bloc n\'est pas nommé (prénom OU nom, aligné M1/M2/M3)', () => {
|
|
||||||
const form = createdForm()
|
|
||||||
expect(form.canAddContact.value).toBe(false)
|
|
||||||
|
|
||||||
// addContact est un no-op tant que le bloc n'est pas nommé.
|
|
||||||
form.addContact()
|
|
||||||
expect(form.contacts.value).toHaveLength(1)
|
|
||||||
|
|
||||||
// Fonction seule ne suffit PAS à ajouter un nouveau bloc (≠ RG-4.08 large).
|
|
||||||
const first = form.contacts.value[0]
|
|
||||||
if (first) first.jobTitle = 'Acheteur'
|
|
||||||
expect(form.canAddContact.value).toBe(false)
|
|
||||||
form.addContact()
|
|
||||||
expect(form.contacts.value).toHaveLength(1)
|
|
||||||
|
|
||||||
// Un nom (ou prénom) débloque l'ajout.
|
|
||||||
if (first) first.lastName = 'Doe'
|
|
||||||
expect(form.canAddContact.value).toBe(true)
|
|
||||||
form.addContact()
|
|
||||||
expect(form.contacts.value).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('submitContacts : POST des nouveaux contacts (phones tableau), capture id, finalise', async () => {
|
|
||||||
mockPost.mockResolvedValueOnce({ id: 55 })
|
|
||||||
const form = createdForm()
|
|
||||||
const c = form.contacts.value[0]
|
|
||||||
if (c) { c.firstName = 'Jean'; c.phonePrimary = '0102030405' }
|
|
||||||
|
|
||||||
const ok = await form.submitContacts(vi.fn())
|
|
||||||
|
|
||||||
expect(ok).toBe(true)
|
|
||||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
|
||||||
expect(url).toBe('/carriers/7/contacts')
|
|
||||||
expect(body).toMatchObject({ firstName: 'Jean', phones: ['0102030405'] })
|
|
||||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
|
||||||
expect(form.contacts.value[0]?.id).toBe(55)
|
|
||||||
expect(form.isValidated('contacts')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('submitContacts : PATCH des contacts existants sur /carrier_contacts/{id}', async () => {
|
|
||||||
mockPatch.mockResolvedValueOnce({})
|
|
||||||
const form = createdForm()
|
|
||||||
const c = form.contacts.value[0]
|
|
||||||
if (c) { c.id = 55; c.lastName = 'Doe' }
|
|
||||||
|
|
||||||
await form.submitContacts(vi.fn())
|
|
||||||
|
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
|
||||||
expect(mockPatch).toHaveBeenCalledWith('/carrier_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('RG-4.08 : onglet vide → soumet l\'amorce pour déclencher la 422 inline', async () => {
|
|
||||||
mockPost.mockRejectedValueOnce({
|
|
||||||
response: {
|
|
||||||
status: 422,
|
|
||||||
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const form = createdForm()
|
|
||||||
|
|
||||||
const ok = await form.submitContacts(vi.fn())
|
|
||||||
|
|
||||||
expect(ok).toBe(false)
|
|
||||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
|
||||||
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
|
|
||||||
expect(form.isValidated('contacts')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => {
|
|
||||||
mockDelete.mockResolvedValueOnce({})
|
|
||||||
const form = createdForm()
|
|
||||||
const c = form.contacts.value[0]
|
|
||||||
if (c) { c.id = 90; c.lastName = 'Doe' }
|
|
||||||
form.addContact()
|
|
||||||
const c2 = form.contacts.value[1]
|
|
||||||
if (c2) c2.firstName = 'Jean'
|
|
||||||
|
|
||||||
await form.removeContact(0)
|
|
||||||
|
|
||||||
expect(mockDelete).toHaveBeenCalledWith('/carrier_contacts/90', {}, { toast: false })
|
|
||||||
expect(form.contacts.value).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('carrierPrice (util) — bascule CLIENT/FOURNISSEUR + champs requis par branche', () => {
|
|
||||||
const CLIENT = '/api/clients/3'
|
|
||||||
const CLIENT_ADDR = '/api/client_addresses/8'
|
|
||||||
const SUPPLIER = '/api/suppliers/5'
|
|
||||||
const SUPPLIER_ADDR = '/api/supplier_addresses/9'
|
|
||||||
const SITE = '/api/sites/1'
|
|
||||||
|
|
||||||
it('buildCarrierPricePayload CLIENT : branche client envoyée, branche fournisseur à null', () => {
|
|
||||||
const body = buildCarrierPricePayload({
|
|
||||||
...emptyCarrierPrice(),
|
|
||||||
direction: 'CLIENT',
|
|
||||||
clientIri: CLIENT,
|
|
||||||
clientDeliveryAddressIri: CLIENT_ADDR,
|
|
||||||
departureSiteIri: SITE,
|
|
||||||
containerType: 'BENNE',
|
|
||||||
pricingUnit: 'FORFAIT',
|
|
||||||
price: '120.00',
|
|
||||||
priceState: 'EN_COURS',
|
|
||||||
})
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
direction: 'CLIENT',
|
|
||||||
client: CLIENT,
|
|
||||||
clientDeliveryAddress: CLIENT_ADDR,
|
|
||||||
departureSite: SITE,
|
|
||||||
supplier: null,
|
|
||||||
supplierSupplyAddress: null,
|
|
||||||
deliverySite: null,
|
|
||||||
containerType: 'BENNE',
|
|
||||||
pricingUnit: 'FORFAIT',
|
|
||||||
price: '120.00',
|
|
||||||
priceState: 'EN_COURS',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('buildCarrierPricePayload FOURNISSEUR : branche fournisseur envoyée, branche client à null', () => {
|
|
||||||
const body = buildCarrierPricePayload({
|
|
||||||
...emptyCarrierPrice(),
|
|
||||||
direction: 'FOURNISSEUR',
|
|
||||||
supplierIri: SUPPLIER,
|
|
||||||
supplierSupplyAddressIri: SUPPLIER_ADDR,
|
|
||||||
deliverySiteIri: SITE,
|
|
||||||
containerType: 'FOND_MOUVANT',
|
|
||||||
pricingUnit: 'TONNE',
|
|
||||||
price: '45',
|
|
||||||
priceState: 'VALIDE',
|
|
||||||
})
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
direction: 'FOURNISSEUR',
|
|
||||||
supplier: SUPPLIER,
|
|
||||||
supplierSupplyAddress: SUPPLIER_ADDR,
|
|
||||||
deliverySite: SITE,
|
|
||||||
client: null,
|
|
||||||
clientDeliveryAddress: null,
|
|
||||||
departureSite: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('isCarrierPriceValid : faux si branche incomplète, vrai si branche complète + communs', () => {
|
|
||||||
const base = { ...emptyCarrierPrice(), containerType: 'BENNE', pricingUnit: 'FORFAIT', price: '10', priceState: 'EN_COURS' }
|
|
||||||
// Direction non choisie → invalide.
|
|
||||||
expect(isCarrierPriceValid({ ...base, direction: null })).toBe(false)
|
|
||||||
// Sens CLIENT par défaut mais branche incomplète → invalide.
|
|
||||||
expect(isCarrierPriceValid(base)).toBe(false)
|
|
||||||
// CLIENT sans adresse/site → invalide.
|
|
||||||
expect(isCarrierPriceValid({ ...base, direction: 'CLIENT', clientIri: CLIENT })).toBe(false)
|
|
||||||
// CLIENT complet → valide.
|
|
||||||
expect(isCarrierPriceValid({ ...base, direction: 'CLIENT', clientIri: CLIENT, clientDeliveryAddressIri: CLIENT_ADDR, departureSiteIri: SITE })).toBe(true)
|
|
||||||
// FOURNISSEUR complet → valide.
|
|
||||||
expect(isCarrierPriceValid({ ...base, direction: 'FOURNISSEUR', supplierIri: SUPPLIER, supplierSupplyAddressIri: SUPPLIER_ADDR, deliverySiteIri: SITE })).toBe(true)
|
|
||||||
// Prix manquant → invalide même branche complète.
|
|
||||||
expect(isCarrierPriceValid({ ...emptyCarrierPrice(), direction: 'CLIENT', clientIri: CLIENT, clientDeliveryAddressIri: CLIENT_ADDR, departureSiteIri: SITE, containerType: 'BENNE', pricingUnit: 'FORFAIT', priceState: 'EN_COURS' })).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useCarrierForm — onglet Prix (ERP-169)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPost.mockReset()
|
|
||||||
mockPatch.mockReset()
|
|
||||||
mockDelete.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
function createdForm() {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.carrierId.value = 7
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
it('démarre avec un bloc CLIENT par défaut ; « + Nouveau prix » bloqué tant qu\'il est incomplet', () => {
|
|
||||||
const form = createdForm()
|
|
||||||
// Un bloc présent d'office, sens CLIENT pré-sélectionné.
|
|
||||||
expect(form.prices.value).toHaveLength(1)
|
|
||||||
expect(form.prices.value[0]?.direction).toBe('CLIENT')
|
|
||||||
// Bloc incomplet → on ne peut pas en ajouter un autre.
|
|
||||||
expect(form.canAddPrice.value).toBe(false)
|
|
||||||
form.addPrice()
|
|
||||||
expect(form.prices.value).toHaveLength(1)
|
|
||||||
|
|
||||||
// Une fois le bloc complété, l'ajout est autorisé.
|
|
||||||
const p = form.prices.value[0]
|
|
||||||
if (p) {
|
|
||||||
p.clientIri = '/api/clients/3'
|
|
||||||
p.clientDeliveryAddressIri = '/api/client_addresses/8'
|
|
||||||
p.departureSiteIri = '/api/sites/1'
|
|
||||||
p.price = '120'
|
|
||||||
p.priceState = 'EN_COURS'
|
|
||||||
}
|
|
||||||
expect(form.canAddPrice.value).toBe(true)
|
|
||||||
form.addPrice()
|
|
||||||
expect(form.prices.value).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('submitPrices : POST des nouveaux prix (branche CLIENT), capture l\'id, finalise', async () => {
|
|
||||||
mockPost.mockResolvedValueOnce({ id: 50 })
|
|
||||||
const form = createdForm()
|
|
||||||
form.addPrice()
|
|
||||||
const p = form.prices.value[0]
|
|
||||||
if (p) {
|
|
||||||
p.direction = 'CLIENT'
|
|
||||||
p.clientIri = '/api/clients/3'
|
|
||||||
p.clientDeliveryAddressIri = '/api/client_addresses/8'
|
|
||||||
p.departureSiteIri = '/api/sites/1'
|
|
||||||
p.containerType = 'BENNE'
|
|
||||||
p.pricingUnit = 'FORFAIT'
|
|
||||||
p.price = '120'
|
|
||||||
p.priceState = 'EN_COURS'
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await form.submitPrices(vi.fn())
|
|
||||||
|
|
||||||
expect(ok).toBe(true)
|
|
||||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
|
||||||
expect(url).toBe('/carriers/7/prices')
|
|
||||||
expect(body).toMatchObject({ direction: 'CLIENT', client: '/api/clients/3', supplier: null })
|
|
||||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
|
||||||
expect(form.prices.value[0]?.id).toBe(50)
|
|
||||||
expect(form.isValidated('prices')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('submitPrices : PATCH des prix existants sur /carrier_prices/{id}', async () => {
|
|
||||||
mockPatch.mockResolvedValueOnce({})
|
|
||||||
const form = createdForm()
|
|
||||||
const p = form.prices.value[0]
|
|
||||||
if (p) {
|
|
||||||
p.id = 50
|
|
||||||
p.direction = 'FOURNISSEUR'
|
|
||||||
p.supplierIri = '/api/suppliers/5'
|
|
||||||
p.supplierSupplyAddressIri = '/api/supplier_addresses/9'
|
|
||||||
p.deliverySiteIri = '/api/sites/1'
|
|
||||||
p.price = '10'
|
|
||||||
p.priceState = 'VALIDE'
|
|
||||||
}
|
|
||||||
|
|
||||||
await form.submitPrices(vi.fn())
|
|
||||||
|
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
|
||||||
expect(mockPatch).toHaveBeenCalledWith('/carrier_prices/50', expect.objectContaining({ direction: 'FOURNISSEUR' }), { toast: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('front : bloc prix incomplet → erreurs inline sous chaque champ requis, pas d\'appel back', async () => {
|
|
||||||
const form = createdForm()
|
|
||||||
// Bloc CLIENT par défaut, rien d'autre rempli.
|
|
||||||
const ok = await form.submitPrices(vi.fn())
|
|
||||||
|
|
||||||
expect(ok).toBe(false)
|
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
|
||||||
const errs = form.priceErrors.value[0]
|
|
||||||
expect(errs?.client).toBeTruthy()
|
|
||||||
expect(errs?.clientDeliveryAddress).toBeTruthy()
|
|
||||||
expect(errs?.departureSite).toBeTruthy()
|
|
||||||
expect(errs?.price).toBeTruthy()
|
|
||||||
expect(errs?.priceState).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('submitPrices : mappe les 422 back par ligne (appartenance adresse) et ne finalise pas', async () => {
|
|
||||||
mockPost.mockRejectedValueOnce({
|
|
||||||
response: { status: 422, _data: { violations: [{ propertyPath: 'clientDeliveryAddress', message: 'L\'adresse de livraison doit appartenir au client selectionne.' }] } },
|
|
||||||
})
|
|
||||||
const form = createdForm()
|
|
||||||
// Tous les champs requis remplis (le pré-check front passe) ; le back 422 sur
|
|
||||||
// une RG qu'il est seul à connaître (appartenance de l'adresse au client).
|
|
||||||
const p = form.prices.value[0]
|
|
||||||
if (p) {
|
|
||||||
p.direction = 'CLIENT'
|
|
||||||
p.clientIri = '/api/clients/3'
|
|
||||||
p.clientDeliveryAddressIri = '/api/client_addresses/8'
|
|
||||||
p.departureSiteIri = '/api/sites/1'
|
|
||||||
p.price = '10'
|
|
||||||
p.priceState = 'EN_COURS'
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await form.submitPrices(vi.fn())
|
|
||||||
|
|
||||||
expect(ok).toBe(false)
|
|
||||||
expect(form.priceErrors.value[0]?.clientDeliveryAddress).toBe('L\'adresse de livraison doit appartenir au client selectionne.')
|
|
||||||
expect(form.isValidated('prices')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('removePrice : DELETE /carrier_prices/{id} puis retrait du bloc', async () => {
|
|
||||||
mockDelete.mockResolvedValueOnce({})
|
|
||||||
const form = createdForm()
|
|
||||||
form.addPrice()
|
|
||||||
const p = form.prices.value[0]
|
|
||||||
if (p) { p.id = 77; p.direction = 'CLIENT'; p.clientIri = '/api/clients/3'; p.clientDeliveryAddressIri = '/api/client_addresses/8'; p.departureSiteIri = '/api/sites/1'; p.containerType = 'BENNE'; p.pricingUnit = 'FORFAIT'; p.price = '10'; p.priceState = 'EN_COURS' }
|
|
||||||
form.addPrice()
|
|
||||||
|
|
||||||
await form.removePrice(0)
|
|
||||||
|
|
||||||
expect(mockDelete).toHaveBeenCalledWith('/carrier_prices/77', {}, { toast: false })
|
|
||||||
expect(form.prices.value).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useCarrierForm — édition (ERP-170)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPost.mockReset()
|
|
||||||
mockPatch.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prefillFrom : peuple carrierId + principal + sous-collections, passe en editMode', () => {
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.prefillFrom({
|
|
||||||
'@id': '/api/carriers/7',
|
|
||||||
id: 7,
|
|
||||||
name: 'TRANSPORTS ACME',
|
|
||||||
certificationType: 'GMP_PLUS',
|
|
||||||
addresses: [{ '@id': '/api/carrier_addresses/3', id: 3, city: 'Poitiers' }],
|
|
||||||
contacts: [{ '@id': '/api/carrier_contacts/9', id: 9, lastName: 'Doe', phonePrimary: '0102030405' }],
|
|
||||||
prices: [{ '@id': '/api/carrier_prices/5', id: 5, direction: 'CLIENT', client: { '@id': '/api/clients/3' }, containerType: 'BENNE', pricingUnit: 'FORFAIT', price: '120', priceState: 'EN_COURS' }],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(form.carrierId.value).toBe(7)
|
|
||||||
expect(form.editMode.value).toBe(true)
|
|
||||||
expect(form.main.name).toBe('TRANSPORTS ACME')
|
|
||||||
expect(form.main.certificationType).toBe('GMP_PLUS')
|
|
||||||
expect(form.addresses.value).toHaveLength(1)
|
|
||||||
expect(form.addresses.value[0]?.id).toBe(3)
|
|
||||||
expect(form.contacts.value[0]?.id).toBe(9)
|
|
||||||
expect(form.prices.value[0]?.clientIri).toBe('/api/clients/3')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updateMain : PATCH /carriers/{id} (pas de POST), réaffiche le nom normalisé', async () => {
|
|
||||||
mockPatch.mockResolvedValueOnce({ id: 7, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS' })
|
|
||||||
const form = useCarrierForm()
|
|
||||||
form.prefillFrom({ '@id': '/api/carriers/7', id: 7, name: 'Transports Acme', certificationType: 'GMP_PLUS' })
|
|
||||||
|
|
||||||
const ok = await form.updateMain()
|
|
||||||
|
|
||||||
expect(ok).toBe(true)
|
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
|
||||||
expect(mockPatch).toHaveBeenCalledWith(
|
|
||||||
'/carriers/7',
|
|
||||||
expect.objectContaining({ name: 'Transports Acme', certificationType: 'GMP_PLUS' }),
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
expect(form.main.name).toBe('TRANSPORTS ACME')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { useCarriersRepository, type Carrier } from '../useCarriersRepository'
|
|
||||||
|
|
||||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
|
||||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests du repertoire transporteurs (ERP-164).
|
|
||||||
*
|
|
||||||
* `useCarriersRepository` est une fine enveloppe de `usePaginatedList<Carrier>`
|
|
||||||
* sur `/carriers`. Les invariants generiques de pagination sont deja couverts par
|
|
||||||
* `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire :
|
|
||||||
* - la ressource ciblee est bien `/carriers` ;
|
|
||||||
* - l'enveloppe Hydra (member / totalItems) est consommee ;
|
|
||||||
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
|
|
||||||
* renvoie un tableau plat sans pagination) ;
|
|
||||||
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `archivedOnly` n'est envoye
|
|
||||||
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
|
|
||||||
* archives) ; le filtre « Voir les archivés » est bien transmis une fois
|
|
||||||
* applique (aligne sur Client / Fournisseur / Prestataire).
|
|
||||||
*/
|
|
||||||
describe('useCarriersRepository', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockApiGet.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Une page de transporteurs Hydra, avec qualimatCarrier embarque (RG-4.04). */
|
|
||||||
const PAGE: Carrier[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'TRANSPORTS ACME',
|
|
||||||
certificationType: 'QUALIMAT',
|
|
||||||
qualimatCarrier: {
|
|
||||||
id: '42',
|
|
||||||
name: 'TRANSPORTS ACME',
|
|
||||||
validityDate: '2027-01-15',
|
|
||||||
status: 'VALIDE',
|
|
||||||
},
|
|
||||||
updatedAt: '2026-06-15T08:12:01+02:00',
|
|
||||||
isArchived: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
it('cible /carriers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
|
|
||||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
|
||||||
const repo = useCarriersRepository()
|
|
||||||
|
|
||||||
await repo.fetch()
|
|
||||||
|
|
||||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
|
||||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
|
||||||
expect(url).toBe('/carriers')
|
|
||||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
|
||||||
expect(opts).toMatchObject({
|
|
||||||
toast: false,
|
|
||||||
headers: { Accept: 'application/ld+json' },
|
|
||||||
})
|
|
||||||
expect(repo.items.value).toEqual(PAGE)
|
|
||||||
expect(repo.totalItems.value).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('exclut les archives par defaut : aucun archivedOnly au premier fetch', async () => {
|
|
||||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
|
||||||
const repo = useCarriersRepository()
|
|
||||||
|
|
||||||
await repo.fetch()
|
|
||||||
|
|
||||||
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
|
||||||
expect(query.archivedOnly).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('transmet archivedOnly une fois le filtre applique (retour page 1)', async () => {
|
|
||||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
|
||||||
const repo = useCarriersRepository()
|
|
||||||
await repo.fetch()
|
|
||||||
|
|
||||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
|
||||||
await repo.setFilters({ archivedOnly: true })
|
|
||||||
|
|
||||||
expect(repo.currentPage.value).toBe(1)
|
|
||||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
|
||||||
expect(query.archivedOnly).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('transmet les certifications multiples + la recherche', async () => {
|
|
||||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
|
||||||
const repo = useCarriersRepository()
|
|
||||||
await repo.fetch()
|
|
||||||
|
|
||||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
|
||||||
await repo.setFilters({ search: 'acme', 'certificationType[]': ['QUALIMAT', 'AUTRE'] })
|
|
||||||
|
|
||||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
|
||||||
expect(query.search).toBe('acme')
|
|
||||||
expect(query['certificationType[]']).toEqual(['QUALIMAT', 'AUTRE'])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { useQualimatSearch, type QualimatCarrierRow } from '../useQualimatSearch'
|
|
||||||
|
|
||||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
|
||||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests de la saisie assistée QUALIMAT (M4 Transport, ERP-166 — RG-4.01).
|
|
||||||
*
|
|
||||||
* `useQualimatSearch` est une fine enveloppe de `usePaginatedList<QualimatCarrierRow>`
|
|
||||||
* sur `/qualimat_carriers`. La pagination générique est couverte par
|
|
||||||
* `usePaginatedList.test.ts` ; on vérifie ici le CONTRAT propre à la recherche :
|
|
||||||
* - ressource ciblée `/qualimat_carriers` + enveloppe Hydra + `Accept: application/ld+json` ;
|
|
||||||
* - le filtre `search` (branché sur le nom du transporteur) est transmis et
|
|
||||||
* retombe en page 1.
|
|
||||||
*/
|
|
||||||
describe('useQualimatSearch', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockApiGet.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
const PAGE: QualimatCarrierRow[] = [
|
|
||||||
{
|
|
||||||
'@id': '/api/qualimat_carriers/1',
|
|
||||||
id: '1',
|
|
||||||
name: 'TRANSPORTS ACME',
|
|
||||||
siret: '12345678900012',
|
|
||||||
address: '1 rue du Port',
|
|
||||||
postalCode: '86000',
|
|
||||||
city: 'Poitiers',
|
|
||||||
validityDate: '2027-01-15',
|
|
||||||
status: 'VALIDE',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
it('cible /qualimat_carriers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
|
|
||||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
|
||||||
const repo = useQualimatSearch()
|
|
||||||
|
|
||||||
await repo.fetch()
|
|
||||||
|
|
||||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
|
||||||
expect(url).toBe('/qualimat_carriers')
|
|
||||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
|
||||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
|
||||||
expect(repo.items.value).toEqual(PAGE)
|
|
||||||
expect(repo.totalItems.value).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('transmet le filtre `search` (nom du transporteur) et retombe en page 1', async () => {
|
|
||||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
|
||||||
const repo = useQualimatSearch()
|
|
||||||
await repo.fetch()
|
|
||||||
|
|
||||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
|
||||||
await repo.setFilters({ search: 'acme' })
|
|
||||||
|
|
||||||
expect(repo.currentPage.value).toBe(1)
|
|
||||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
|
||||||
expect(query.search).toBe('acme')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import type { CarrierDetail } from '~/modules/transport/utils/forms/carrierMappers'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chargement et actions d'archivage d'un transporteur unique (écrans Consultation /
|
|
||||||
* Modification, ERP-170). Miroir de `useProvider` (M3) / `useSupplier` (M2). Lit le
|
|
||||||
* détail embarqué via `GET /api/carriers/{id}` (qualimatCarrier + addresses /
|
|
||||||
* contacts / prices sous `carrier:item:read`, relations cross-module via leurs
|
|
||||||
* read-groups) — une SEULE requête peuple les deux écrans (embed borné, pas de N+1).
|
|
||||||
*
|
|
||||||
* L'en-tête `Accept: application/ld+json` est imposé pour obtenir le payload Hydra
|
|
||||||
* complet (avec les `@id` des relations embarquées, indispensables au préremplissage).
|
|
||||||
*
|
|
||||||
* État 100 % local à l'instance (refs). Les erreurs d'archivage / restauration
|
|
||||||
* (notamment le 409 d'homonyme actif à la restauration) sont PROPAGÉES à l'appelant.
|
|
||||||
*/
|
|
||||||
export function useCarrier(id: number | string) {
|
|
||||||
const api = useApi()
|
|
||||||
|
|
||||||
const carrier = ref<CarrierDetail | null>(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref(false)
|
|
||||||
|
|
||||||
/** Récupère le détail complet (embed qualimatCarrier + addresses / contacts / prices). */
|
|
||||||
function fetchDetail(): Promise<CarrierDetail> {
|
|
||||||
return api.get<CarrierDetail>(
|
|
||||||
`/carriers/${id}`,
|
|
||||||
{},
|
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Charge le détail du transporteur. En cas d'échec : `error = true`, `carrier = null`. */
|
|
||||||
async function load(): Promise<void> {
|
|
||||||
loading.value = true
|
|
||||||
error.value = false
|
|
||||||
try {
|
|
||||||
carrier.value = await fetchDetail()
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
error.value = true
|
|
||||||
carrier.value = null
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bascule l'archivage (PATCH `isArchived` SEUL — groupe carrier:write:archive ;
|
|
||||||
* tout autre champ → 422, security archive = Admin seul), puis RECHARGE le détail
|
|
||||||
* complet (la réponse du PATCH ne porte pas l'embed des sous-collections). Toute
|
|
||||||
* erreur est propagée à l'appelant AVANT le rechargement.
|
|
||||||
*/
|
|
||||||
async function setArchived(isArchived: boolean): Promise<void> {
|
|
||||||
await api.patch(`/carriers/${id}`, { isArchived }, { toast: false })
|
|
||||||
carrier.value = await fetchDetail()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
carrier,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
load,
|
|
||||||
archive: () => setArchived(true),
|
|
||||||
restore: () => setArchived(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,781 +0,0 @@
|
|||||||
import { computed, reactive, ref, type Ref } from 'vue'
|
|
||||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
|
||||||
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
|
||||||
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
|
||||||
import {
|
|
||||||
emptyCarrierAddress,
|
|
||||||
emptyCarrierAddressCopy,
|
|
||||||
emptyCarrierContact,
|
|
||||||
emptyCarrierMain,
|
|
||||||
emptyCarrierPrice,
|
|
||||||
type CarrierAddressCopy,
|
|
||||||
type CarrierAddressFormDraft,
|
|
||||||
type CarrierContactFormDraft,
|
|
||||||
type CarrierMainDraft,
|
|
||||||
type CarrierMainResponse,
|
|
||||||
type CarrierPriceFormDraft,
|
|
||||||
} from '~/modules/transport/types/carrierForm'
|
|
||||||
import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress'
|
|
||||||
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
|
|
||||||
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
|
|
||||||
import {
|
|
||||||
mapAddressToDraft,
|
|
||||||
mapContactToDraft,
|
|
||||||
mapMainToDraft,
|
|
||||||
mapPriceToDraft,
|
|
||||||
type CarrierDetail,
|
|
||||||
} from '~/modules/transport/utils/forms/carrierMappers'
|
|
||||||
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
|
||||||
|
|
||||||
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
|
|
||||||
const LIOT_NAME = 'LIOT'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Workflow de l'écran « Ajouter un transporteur » (M4 Transport, ERP-165) —
|
|
||||||
* miroir conceptuel de `useSupplierForm` (M2) / `useProviderForm` (M3).
|
|
||||||
*
|
|
||||||
* Périmètre ERP-165 : le formulaire PRINCIPAL (pré-onglets) et l'orchestration de
|
|
||||||
* la barre d'onglets. La création est INCRÉMENTALE (spec-front § Écran Ajouter) :
|
|
||||||
* - on POST d'abord le formulaire principal (`POST /api/carriers`) ;
|
|
||||||
* - au succès le bloc principal passe en lecture seule, le 1er onglet (Qualimat)
|
|
||||||
* se déverrouille et devient actif ;
|
|
||||||
* - chaque onglet validé déverrouille le suivant (PATCH partiels par groupe de
|
|
||||||
* sérialisation) et passe en lecture seule.
|
|
||||||
*
|
|
||||||
* Les champs conditionnels du formulaire principal (indexation / benne / volume
|
|
||||||
* si affrété, décharge si AUTRE, cas LIOT) et la saisie assistée QUALIMAT arrivent
|
|
||||||
* à ERP-166 ; le contenu des onglets Adresses / Contacts / Prix aux tickets
|
|
||||||
* suivants. Ce composable pose le POST principal, le PATCH partiel et le gating
|
|
||||||
* des onglets.
|
|
||||||
*
|
|
||||||
* État 100 % local à l'instance (refs / reactive) — aucune persistance URL.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clés des onglets du flux de création, dans l'ordre de la barre (spec-front
|
|
||||||
* § Écran Ajouter). Toujours les 4 (pas de gating par permission au M4, ≠ l'onglet
|
|
||||||
* Comptabilité du M3).
|
|
||||||
*/
|
|
||||||
export const CARRIER_TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices'] as const
|
|
||||||
|
|
||||||
export function useCarrierForm() {
|
|
||||||
const api = useApi()
|
|
||||||
const { t } = useI18n()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
|
||||||
const mainErrors = useFormErrors()
|
|
||||||
|
|
||||||
// ── État du transporteur créé ─────────────────────────────────────────────
|
|
||||||
const carrierId = ref<number | null>(null)
|
|
||||||
const mainLocked = ref(false)
|
|
||||||
const mainSubmitting = ref(false)
|
|
||||||
const tabSubmitting = ref(false)
|
|
||||||
|
|
||||||
// ── Formulaire principal ──────────────────────────────────────────────────
|
|
||||||
const main = reactive<CarrierMainDraft>(emptyCarrierMain())
|
|
||||||
|
|
||||||
// Adresse copiée depuis QUALIMAT à la sélection (alimente l'onglet Adresses,
|
|
||||||
// ticket ultérieur). Vide tant qu'aucun transporteur QUALIMAT n'est intégré.
|
|
||||||
const qualimatAddress = ref<CarrierAddressCopy>(emptyCarrierAddressCopy())
|
|
||||||
|
|
||||||
// ── Affichage conditionnel du formulaire principal (RG-4.01 / 4.02 / 4.03) ──
|
|
||||||
// Cas LIOT : nom == « LIOT » → seul `liotPlates` est pertinent, le reste masqué.
|
|
||||||
const isLiot = computed(() => main.name.trim().toUpperCase() === LIOT_NAME)
|
|
||||||
// Transporteur QUALIMAT : la FK est posée → certification figée à « QUALIMAT ».
|
|
||||||
const isQualimat = computed(() => main.qualimatCarrierIri !== null)
|
|
||||||
// Certification masquée en cas LIOT ; lecture seule si QUALIMAT (ou bloc verrouillé).
|
|
||||||
const showCertification = computed(() => !isLiot.value)
|
|
||||||
const certificationReadonly = computed(() => isQualimat.value || mainLocked.value)
|
|
||||||
// RG-4.03 : champs d'affrètement (indexation / contenant / volume) visibles et
|
|
||||||
// obligatoires si « Affréter » coché — masqués en cas LIOT.
|
|
||||||
const showCharteredFields = computed(() => main.isChartered && !isLiot.value)
|
|
||||||
// RG-4.02 : décharge visible et obligatoire si certification == AUTRE (hors LIOT).
|
|
||||||
const showDischarge = computed(() => main.certificationType === 'AUTRE' && !isLiot.value)
|
|
||||||
|
|
||||||
// ── Onglets : ordre + gating progressif ───────────────────────────────────
|
|
||||||
const tabKeys = ref<string[]>([...CARRIER_TAB_KEYS])
|
|
||||||
// Index du dernier onglet déverrouillé. L'onglet Qualimat (index 0) est la saisie
|
|
||||||
// assistée du formulaire principal : accessible DÈS LE DÉPART (≠ Adresses /
|
|
||||||
// Contacts / Prix, déverrouillés seulement après le POST principal).
|
|
||||||
const unlockedIndex = ref(0)
|
|
||||||
const activeTab = ref<string>(CARRIER_TAB_KEYS[0])
|
|
||||||
// Onglets validés (passent en lecture seule).
|
|
||||||
const validated = reactive<Record<string, boolean>>({})
|
|
||||||
// Mode MODIFICATION (ticket ultérieur) : navigation libre, pas de verrouillage
|
|
||||||
// ni de bascule automatique d'onglet à la validation (cf. completeTab).
|
|
||||||
const editMode = ref(false)
|
|
||||||
|
|
||||||
function isValidated(key: string): boolean {
|
|
||||||
return validated[key] === true
|
|
||||||
}
|
|
||||||
|
|
||||||
function tabIndex(key: string): number {
|
|
||||||
return tabKeys.value.indexOf(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validation FRONT du formulaire principal : seul le nom est requis côté front
|
|
||||||
* (ERP-101) : feedback immédiat sur tous les champs obligatoires (y compris
|
|
||||||
* conditionnels), alignés sur les RG du back (qui reste autoritaire) :
|
|
||||||
* - RG-4.01 : nom requis ; certification requise hors cas LIOT (où tout est masqué) ;
|
|
||||||
* - RG-4.02 : décharge requise si certification AUTRE ;
|
|
||||||
* - RG-4.03 : indexation + contenant + volume requis si « Affréter ».
|
|
||||||
*/
|
|
||||||
function validateMainFront(): boolean {
|
|
||||||
let valid = true
|
|
||||||
if (!main.name?.trim()) {
|
|
||||||
mainErrors.setError('name', t('transport.carriers.form.errors.nameRequired'))
|
|
||||||
valid = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cas LIOT : seul le nom compte, les autres champs sont masqués (RG-4.01).
|
|
||||||
if (isLiot.value) {
|
|
||||||
return valid
|
|
||||||
}
|
|
||||||
|
|
||||||
// RG-4.01 : certification obligatoire hors LIOT.
|
|
||||||
if (!main.certificationType) {
|
|
||||||
mainErrors.setError('certificationType', t('transport.carriers.form.errors.certificationRequired'))
|
|
||||||
valid = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// RG-4.02 : décharge obligatoire si certification AUTRE.
|
|
||||||
if (main.certificationType === 'AUTRE' && !main.dischargeDocumentIri) {
|
|
||||||
mainErrors.setError('dischargeDocument', t('transport.carriers.form.errors.dischargeRequired'))
|
|
||||||
valid = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// RG-4.03 : indexation / contenant / volume obligatoires si affrété.
|
|
||||||
if (main.isChartered) {
|
|
||||||
if (!main.indexationRate.trim()) {
|
|
||||||
mainErrors.setError('indexationRate', t('transport.carriers.form.errors.indexationRequired'))
|
|
||||||
valid = false
|
|
||||||
}
|
|
||||||
if (!main.containerType) {
|
|
||||||
mainErrors.setError('containerType', t('transport.carriers.form.errors.containerTypeRequired'))
|
|
||||||
valid = false
|
|
||||||
}
|
|
||||||
if (!main.volumeM3.trim()) {
|
|
||||||
mainErrors.setError('volumeM3', t('transport.carriers.form.errors.volumeRequired'))
|
|
||||||
valid = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return valid
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Payload du POST principal (groupe `carrier:write:main`). `name` et
|
|
||||||
* `certificationType` sont omis s'ils sont vides afin que la 422 porte la
|
|
||||||
* violation métier (NotBlank sur le nom, « certification obligatoire » sur la
|
|
||||||
* certification) sur le champ plutôt qu'une erreur de type.
|
|
||||||
*/
|
|
||||||
function buildMainPayload(): Record<string, unknown> {
|
|
||||||
// Cas LIOT (RG-4.01) : seul `liotPlates` est pertinent ; les autres champs
|
|
||||||
// sont masqués côté front et non envoyés (le back stocke ce qu'il reçoit).
|
|
||||||
if (isLiot.value) {
|
|
||||||
const payload: Record<string, unknown> = { name: main.name, isChartered: false }
|
|
||||||
if (main.liotPlates.trim()) {
|
|
||||||
payload.liotPlates = main.liotPlates
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: Record<string, unknown> = { isChartered: main.isChartered }
|
|
||||||
if (main.name.trim()) {
|
|
||||||
payload.name = main.name
|
|
||||||
}
|
|
||||||
if (main.certificationType) {
|
|
||||||
payload.certificationType = main.certificationType
|
|
||||||
}
|
|
||||||
// FK QUALIMAT (saisie assistée, § 2.5) envoyée si une ligne a été intégrée.
|
|
||||||
if (main.qualimatCarrierIri) {
|
|
||||||
payload.qualimatCarrier = main.qualimatCarrierIri
|
|
||||||
}
|
|
||||||
// RG-4.02 : décharge envoyée seulement en certification AUTRE ; omise quand
|
|
||||||
// absente pour que la 422 « obligatoire » porte sur le champ.
|
|
||||||
if (main.certificationType === 'AUTRE' && main.dischargeDocumentIri) {
|
|
||||||
payload.dischargeDocument = main.dischargeDocumentIri
|
|
||||||
}
|
|
||||||
// RG-4.03 : indexation / contenant / volume envoyés seulement si affrété ;
|
|
||||||
// omis quand vides pour déclencher la 422 NotBlank inline sur le champ.
|
|
||||||
if (main.isChartered) {
|
|
||||||
if (main.indexationRate.trim()) {
|
|
||||||
payload.indexationRate = main.indexationRate
|
|
||||||
}
|
|
||||||
if (main.containerType) {
|
|
||||||
payload.containerType = main.containerType
|
|
||||||
}
|
|
||||||
if (main.volumeM3.trim()) {
|
|
||||||
payload.volumeM3 = main.volumeM3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /carriers (groupe `carrier:write:main`). Pré-check front, puis création.
|
|
||||||
* Au succès : verrouille le bloc principal, déverrouille l'onglet Adresses et
|
|
||||||
* bascule dessus (l'onglet Qualimat, saisie assistée, était déjà accessible).
|
|
||||||
* Retourne true si créé, false sinon.
|
|
||||||
*/
|
|
||||||
async function submitMain(): Promise<boolean> {
|
|
||||||
if (mainSubmitting.value) return false
|
|
||||||
mainErrors.clearErrors()
|
|
||||||
if (!validateMainFront()) return false
|
|
||||||
|
|
||||||
mainSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const created = await api.post<CarrierMainResponse>('/carriers', buildMainPayload(), {
|
|
||||||
headers: { Accept: 'application/ld+json' },
|
|
||||||
toast: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
carrierId.value = created.id
|
|
||||||
// Réaffiche les valeurs normalisées renvoyées par le serveur (nom en
|
|
||||||
// UPPERCASE — RG-4.13 ; certification éventuellement forcée).
|
|
||||||
main.name = created.name ?? main.name
|
|
||||||
main.certificationType = created.certificationType ?? main.certificationType
|
|
||||||
|
|
||||||
mainLocked.value = true
|
|
||||||
// Déverrouille l'onglet suivant (Adresses, index 1) et bascule dessus.
|
|
||||||
unlockedIndex.value = Math.max(unlockedIndex.value, 1)
|
|
||||||
activeTab.value = tabKeys.value[1] ?? CARRIER_TAB_KEYS[1]
|
|
||||||
toast.success({ title: t('transport.carriers.toast.createSuccess') })
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
// 409 = doublon de nom (RG-4.12) → erreur inline dédiée + toast ;
|
|
||||||
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
|
|
||||||
const status = (error as { response?: { status?: number } })?.response?.status
|
|
||||||
if (status === 409) {
|
|
||||||
const message = t('transport.carriers.form.duplicateName')
|
|
||||||
mainErrors.setError('name', message)
|
|
||||||
toast.error({ title: t('transport.carriers.toast.error'), message })
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
mainSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MODIFICATION du formulaire principal (ERP-170) : PATCH /api/carriers/{id} sur le
|
|
||||||
* groupe carrier:write:main (PAS de re-POST). Pré-check front + 409 doublon / 422
|
|
||||||
* inline comme `submitMain`. Ne verrouille rien et ne bascule pas d'onglet (édition
|
|
||||||
* = navigation libre). Retourne true si le PATCH a réussi.
|
|
||||||
*/
|
|
||||||
async function updateMain(): Promise<boolean> {
|
|
||||||
if (carrierId.value === null || mainSubmitting.value) return false
|
|
||||||
mainErrors.clearErrors()
|
|
||||||
if (!validateMainFront()) return false
|
|
||||||
|
|
||||||
mainSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const updated = await api.patch<CarrierMainResponse>(
|
|
||||||
`/carriers/${carrierId.value}`,
|
|
||||||
buildMainPayload(),
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
main.name = updated.name ?? main.name
|
|
||||||
main.certificationType = updated.certificationType ?? main.certificationType
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
const status = (error as { response?: { status?: number } })?.response?.status
|
|
||||||
if (status === 409) {
|
|
||||||
const message = t('transport.carriers.form.duplicateName')
|
|
||||||
mainErrors.setError('name', message)
|
|
||||||
toast.error({ title: t('transport.carriers.toast.error'), message })
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
mainSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pré-remplit le formulaire depuis le détail `GET /api/carriers/{id}` (écran
|
|
||||||
* Modification) : peuple carrierId + principal + adresses / contacts / prix via les
|
|
||||||
* mappers, passe en `editMode` (navigation libre, tous onglets accessibles, bloc
|
|
||||||
* principal éditable). Au moins un bloc Adresse / Contact affiché même sans donnée.
|
|
||||||
*/
|
|
||||||
function prefillFrom(detail: CarrierDetail): void {
|
|
||||||
carrierId.value = detail.id
|
|
||||||
editMode.value = true
|
|
||||||
mainLocked.value = false
|
|
||||||
unlockedIndex.value = tabKeys.value.length - 1
|
|
||||||
|
|
||||||
Object.assign(main, mapMainToDraft(detail))
|
|
||||||
|
|
||||||
const mappedAddresses = (detail.addresses ?? []).map(mapAddressToDraft)
|
|
||||||
addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyCarrierAddress()]
|
|
||||||
|
|
||||||
const mappedContacts = (detail.contacts ?? []).map(mapContactToDraft)
|
|
||||||
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyCarrierContact()]
|
|
||||||
|
|
||||||
prices.value = (detail.prices ?? []).map(mapPriceToDraft)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH partiel du transporteur (mode strict : un seul groupe de sérialisation
|
|
||||||
* par appel — spec-back § 2.9). Servira les onglets à champs scalaires des
|
|
||||||
* tickets suivants. No-op tant que le transporteur n'existe pas.
|
|
||||||
*/
|
|
||||||
async function patchCarrier(payload: Record<string, unknown>): Promise<void> {
|
|
||||||
if (carrierId.value === null) return
|
|
||||||
await api.patch(`/carriers/${carrierId.value}`, payload, { toast: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remontée d'erreur de suppression immédiate d'une sous-ressource (toast dédié). */
|
|
||||||
function notifyRemovalError(error: unknown): void {
|
|
||||||
toast.error({
|
|
||||||
title: t('transport.carriers.toast.error'),
|
|
||||||
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('transport.carriers.toast.error'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
|
|
||||||
* on n'arrête pas au premier bloc en échec (décision ERP-101). Réinitialise la
|
|
||||||
* cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou délègue le
|
|
||||||
* fallback à `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
|
|
||||||
* true si au moins un bloc a échoué. Miroir de `useProviderForm.submitRows`.
|
|
||||||
*/
|
|
||||||
async function submitRows<T>(
|
|
||||||
rows: T[],
|
|
||||||
target: Ref<Record<string, string>[]>,
|
|
||||||
saveRow: (row: T, index: number) => Promise<void>,
|
|
||||||
onUnmappedError: (error: unknown, index: number) => void,
|
|
||||||
shouldSkip?: (row: T, index: number) => boolean,
|
|
||||||
): Promise<boolean> {
|
|
||||||
target.value = []
|
|
||||||
let hasError = false
|
|
||||||
for (let index = 0; index < rows.length; index++) {
|
|
||||||
const row = rows[index] as T
|
|
||||||
if (shouldSkip?.(row, index)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await saveRow(row, index)
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
|
|
||||||
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
|
|
||||||
if (Object.keys(mapped).length > 0) {
|
|
||||||
target.value[index] = mapped
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
onUnmappedError(error, index)
|
|
||||||
}
|
|
||||||
hasError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasError
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Onglet Adresses (ERP-167) ─────────────────────────────────────────────
|
|
||||||
const addresses = ref<CarrierAddressFormDraft[]>([emptyCarrierAddress()])
|
|
||||||
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
|
||||||
const addressErrors = ref<Record<string, string>[]>([])
|
|
||||||
|
|
||||||
// « + Nouvelle adresse » désactivé tant que la dernière adresse n'est pas
|
|
||||||
// complète (CP + ville + rue — RG-4.05, gate d'ajout).
|
|
||||||
const canAddAddress = computed(() => {
|
|
||||||
const last = addresses.value[addresses.value.length - 1]
|
|
||||||
return last !== undefined && isCarrierAddressValid(last)
|
|
||||||
})
|
|
||||||
|
|
||||||
function addAddress(): void {
|
|
||||||
if (canAddAddress.value) {
|
|
||||||
addresses.value.push(emptyCarrierAddress())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Suppression immédiate d'une adresse existante (DELETE /carrier_addresses/{id}). */
|
|
||||||
async function removeAddress(index: number): Promise<void> {
|
|
||||||
await removeCollectionRow({
|
|
||||||
rows: addresses.value,
|
|
||||||
errors: addressErrors.value,
|
|
||||||
index,
|
|
||||||
endpoint: '/carrier_addresses',
|
|
||||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
|
||||||
makeEmpty: emptyCarrierAddress,
|
|
||||||
onError: notifyRemovalError,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valide l'onglet Adresses : POST des nouvelles adresses sur
|
|
||||||
* /carriers/{id}/addresses, PATCH des existantes sur /carrier_addresses/{id}
|
|
||||||
* (groupe carrier:write:addresses). Erreurs 422 collectées par ligne (RG-4.05
|
|
||||||
* « obligatoire si affrété » re-validée back). Retourne true si l'onglet a été
|
|
||||||
* validé (avancé/terminé).
|
|
||||||
*/
|
|
||||||
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
|
|
||||||
if (carrierId.value === null || tabSubmitting.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
tabSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const hasError = await submitRows(
|
|
||||||
addresses.value,
|
|
||||||
addressErrors,
|
|
||||||
async (address) => {
|
|
||||||
const body = buildCarrierAddressPayload(address)
|
|
||||||
if (address.id === null) {
|
|
||||||
const created = await api.post<{ id: number }>(
|
|
||||||
`/carriers/${carrierId.value}/addresses`,
|
|
||||||
body,
|
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
||||||
)
|
|
||||||
address.id = created.id
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await api.patch(`/carrier_addresses/${address.id}`, body, { toast: false })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError,
|
|
||||||
)
|
|
||||||
if (hasError) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
completeTab('addresses')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
tabSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Onglet Contacts (ERP-168) ─────────────────────────────────────────────
|
|
||||||
const contacts = ref<CarrierContactFormDraft[]>([emptyCarrierContact()])
|
|
||||||
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
|
||||||
const contactErrors = ref<Record<string, string>[]>([])
|
|
||||||
|
|
||||||
// « + Nouveau contact » désactivé tant que le DERNIER bloc n'est pas « nommé »
|
|
||||||
// (prénom OU nom) — aligné sur M1/M2/M3 (fonction / téléphone / email seuls ne
|
|
||||||
// suffisent pas à ajouter un nouveau bloc).
|
|
||||||
const canAddContact = computed(() => {
|
|
||||||
const last = contacts.value[contacts.value.length - 1]
|
|
||||||
return last !== undefined && isCarrierContactNamed(last)
|
|
||||||
})
|
|
||||||
|
|
||||||
function addContact(): void {
|
|
||||||
if (canAddContact.value) {
|
|
||||||
contacts.value.push(emptyCarrierContact())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Suppression immédiate d'un contact existant (DELETE /carrier_contacts/{id}). */
|
|
||||||
async function removeContact(index: number): Promise<void> {
|
|
||||||
await removeCollectionRow({
|
|
||||||
rows: contacts.value,
|
|
||||||
errors: contactErrors.value,
|
|
||||||
index,
|
|
||||||
endpoint: '/carrier_contacts',
|
|
||||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
|
||||||
makeEmpty: emptyCarrierContact,
|
|
||||||
onError: notifyRemovalError,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valide l'onglet Contacts : POST des nouveaux contacts sur
|
|
||||||
* /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id}
|
|
||||||
* (groupe carrier:write:contacts). RG-4.08 (≥ 1 champ rempli, max 2 téléphones)
|
|
||||||
* re-validée back → 422 par ligne. Si l'onglet ne contient QUE des amorces
|
|
||||||
* vides, on soumet la 1re pour déclencher la 422 RG-4.08 inline plutôt que de
|
|
||||||
* finaliser un onglet vide. Retourne true si l'onglet a été validé.
|
|
||||||
*/
|
|
||||||
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
|
|
||||||
if (carrierId.value === null || tabSubmitting.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
tabSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c))
|
|
||||||
const hasError = await submitRows(
|
|
||||||
contacts.value,
|
|
||||||
contactErrors,
|
|
||||||
async (contact) => {
|
|
||||||
const body = buildCarrierContactPayload(contact)
|
|
||||||
if (contact.id === null) {
|
|
||||||
const created = await api.post<{ id: number }>(
|
|
||||||
`/carriers/${carrierId.value}/contacts`,
|
|
||||||
body,
|
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
||||||
)
|
|
||||||
contact.id = created.id
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await api.patch(`/carrier_contacts/${contact.id}`, body, { toast: false })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError,
|
|
||||||
// Amorce vide neuve ignorée s'il reste un autre bloc soumettable ;
|
|
||||||
// sinon on la soumet pour déclencher la 422 RG-4.08 (sur firstName).
|
|
||||||
contact => hasSubmittable && contact.id === null && isCarrierContactBlank(contact),
|
|
||||||
)
|
|
||||||
if (hasError) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
completeTab('contacts')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
tabSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Onglet Prix (ERP-169) ─────────────────────────────────────────────────
|
|
||||||
// Un bloc présent par défaut (sens CLIENT pré-sélectionné). L'utilisateur ajoute
|
|
||||||
// les suivants via « + Nouveau prix ».
|
|
||||||
const prices = ref<CarrierPriceFormDraft[]>([emptyCarrierPrice()])
|
|
||||||
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
|
||||||
const priceErrors = ref<Record<string, string>[]>([])
|
|
||||||
|
|
||||||
// « + Nouveau prix » : autorisé si la liste est vide, sinon le dernier bloc doit
|
|
||||||
// être valide (branche complète + prix — RG-4.09→4.11, pré-check léger).
|
|
||||||
const canAddPrice = computed(() => {
|
|
||||||
const last = prices.value[prices.value.length - 1]
|
|
||||||
return last === undefined || isCarrierPriceValid(last)
|
|
||||||
})
|
|
||||||
|
|
||||||
function addPrice(): void {
|
|
||||||
if (canAddPrice.value) {
|
|
||||||
prices.value.push(emptyCarrierPrice())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pré-validation FRONT d'un bloc prix (ERP-101) : renvoie les erreurs inline par
|
|
||||||
* champ obligatoire (sens + branche active + communs). Nécessaire car côté back
|
|
||||||
* l'Assert\NotBlank sur les scalaires (price/priceState...) court-circuite la
|
|
||||||
* validation de branche du CarrierPriceProcessor : le 422 ne porterait jamais
|
|
||||||
* client/supplier/adresses en même temps. Messages alignés sur le back.
|
|
||||||
*/
|
|
||||||
function validatePriceRow(price: CarrierPriceFormDraft): Record<string, string> {
|
|
||||||
const errs: Record<string, string> = {}
|
|
||||||
const msg = (key: string): string => t(`transport.carriers.form.price.errors.${key}`)
|
|
||||||
|
|
||||||
if (!price.direction) {
|
|
||||||
errs.direction = msg('direction')
|
|
||||||
}
|
|
||||||
if (price.direction === 'CLIENT') {
|
|
||||||
if (!price.clientIri) errs.client = msg('client')
|
|
||||||
if (!price.clientDeliveryAddressIri) errs.clientDeliveryAddress = msg('clientDeliveryAddress')
|
|
||||||
if (!price.departureSiteIri) errs.departureSite = msg('departureSite')
|
|
||||||
}
|
|
||||||
if (price.direction === 'FOURNISSEUR') {
|
|
||||||
if (!price.supplierIri) errs.supplier = msg('supplier')
|
|
||||||
if (!price.supplierSupplyAddressIri) errs.supplierSupplyAddress = msg('supplierSupplyAddress')
|
|
||||||
if (!price.deliverySiteIri) errs.deliverySite = msg('deliverySite')
|
|
||||||
}
|
|
||||||
if (!price.containerType) errs.containerType = msg('containerType')
|
|
||||||
if (!price.pricingUnit) errs.pricingUnit = msg('pricingUnit')
|
|
||||||
if (!price.price || price.price.trim() === '') errs.price = msg('price')
|
|
||||||
if (!price.priceState) errs.priceState = msg('priceState')
|
|
||||||
|
|
||||||
return errs
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Suppression immédiate d'un prix existant (DELETE /carrier_prices/{id}). */
|
|
||||||
async function removePrice(index: number): Promise<void> {
|
|
||||||
await removeCollectionRow({
|
|
||||||
rows: prices.value,
|
|
||||||
errors: priceErrors.value,
|
|
||||||
index,
|
|
||||||
endpoint: '/carrier_prices',
|
|
||||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
|
||||||
makeEmpty: emptyCarrierPrice,
|
|
||||||
onError: notifyRemovalError,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valide l'onglet Prix : POST des nouveaux prix sur /carriers/{id}/prices, PATCH
|
|
||||||
* des existants sur /carrier_prices/{id} (groupe carrier:write:prices). La
|
|
||||||
* cohérence de branche CLIENT/FOURNISSEUR (RG-4.09→4.11) est re-validée back →
|
|
||||||
* 422 par ligne. Onglet Prix optionnel : une liste vide finalise sans appel.
|
|
||||||
* Retourne true si l'onglet a été validé (création terminée).
|
|
||||||
*/
|
|
||||||
async function submitPrices(onError: (error: unknown) => void): Promise<boolean> {
|
|
||||||
if (carrierId.value === null || tabSubmitting.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pré-check front : affiche toutes les obligations sous leur champ d'un coup
|
|
||||||
// (le back ne peut pas tout renvoyer en une passe — cf. validatePriceRow).
|
|
||||||
const frontErrors = prices.value.map(validatePriceRow)
|
|
||||||
if (frontErrors.some(errs => Object.keys(errs).length > 0)) {
|
|
||||||
priceErrors.value = frontErrors
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
tabSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const hasError = await submitRows(
|
|
||||||
prices.value,
|
|
||||||
priceErrors,
|
|
||||||
async (price) => {
|
|
||||||
const body = buildCarrierPricePayload(price)
|
|
||||||
if (price.id === null) {
|
|
||||||
const created = await api.post<{ id: number }>(
|
|
||||||
`/carriers/${carrierId.value}/prices`,
|
|
||||||
body,
|
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
||||||
)
|
|
||||||
price.id = created.id
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await api.patch(`/carrier_prices/${price.id}`, body, { toast: false })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError,
|
|
||||||
)
|
|
||||||
if (hasError) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
completeTab('prices')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
tabSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Intègre une ligne QUALIMAT sélectionnée dans l'onglet Qualimat (RG-4.01 /
|
|
||||||
* § 2.5) : copie le nom, force la certification à « QUALIMAT » (lecture seule),
|
|
||||||
* pose la FK `qualimatCarrier` (IRI) et copie l'adresse (pour l'onglet Adresses).
|
|
||||||
* Si le transporteur existe déjà (post-POST, cas nominal de l'onglet), persiste
|
|
||||||
* d'abord la copie via un PATCH partiel `carrier:write:main` : la copie locale
|
|
||||||
* (nom, certification figée « QUALIMAT », FK, adresse) n'est appliquée qu'en cas
|
|
||||||
* de succès, pour ne pas laisser l'UI dans un état QUALIMAT non sauvegardé si le
|
|
||||||
* PATCH échoue. Retourne true si l'intégration a abouti.
|
|
||||||
*/
|
|
||||||
async function applyQualimatSelection(row: QualimatCarrierRow): Promise<boolean> {
|
|
||||||
// Transporteur déjà créé : on persiste avant de refléter localement.
|
|
||||||
if (carrierId.value !== null) {
|
|
||||||
try {
|
|
||||||
await patchCarrier({
|
|
||||||
qualimatCarrier: row['@id'],
|
|
||||||
name: row.name,
|
|
||||||
certificationType: 'QUALIMAT',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main.name = row.name ?? ''
|
|
||||||
main.certificationType = 'QUALIMAT'
|
|
||||||
main.qualimatCarrierIri = row['@id']
|
|
||||||
qualimatAddress.value = {
|
|
||||||
country: 'France',
|
|
||||||
postalCode: row.postalCode ?? '',
|
|
||||||
city: row.city ?? '',
|
|
||||||
street: row.address ?? '',
|
|
||||||
}
|
|
||||||
// RG-4.05 : pré-remplit le 1er bloc de l'onglet Adresses par copie (la FK
|
|
||||||
// QUALIMAT survit, les champs restent éditables — § 2.5).
|
|
||||||
addresses.value = [{
|
|
||||||
id: null,
|
|
||||||
country: 'France',
|
|
||||||
postalCode: row.postalCode || null,
|
|
||||||
city: row.city || null,
|
|
||||||
street: row.address || null,
|
|
||||||
streetComplement: null,
|
|
||||||
}]
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marque un onglet validé (passe en lecture seule), déverrouille et avance à
|
|
||||||
* l'onglet suivant. Retourne true si c'était le dernier onglet du flux (création
|
|
||||||
* terminée), false sinon.
|
|
||||||
*/
|
|
||||||
function completeTab(key: string): boolean {
|
|
||||||
// En modification : navigation libre, l'onglet reste éditable après validation.
|
|
||||||
if (editMode.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
validated[key] = true
|
|
||||||
const index = tabIndex(key)
|
|
||||||
const next = tabKeys.value[index + 1]
|
|
||||||
if (next === undefined) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
|
|
||||||
activeTab.value = next
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// état
|
|
||||||
main,
|
|
||||||
qualimatAddress,
|
|
||||||
carrierId,
|
|
||||||
mainLocked,
|
|
||||||
mainSubmitting,
|
|
||||||
tabSubmitting,
|
|
||||||
mainErrors,
|
|
||||||
// affichage conditionnel
|
|
||||||
isLiot,
|
|
||||||
isQualimat,
|
|
||||||
showCertification,
|
|
||||||
certificationReadonly,
|
|
||||||
showCharteredFields,
|
|
||||||
showDischarge,
|
|
||||||
// onglets
|
|
||||||
tabKeys,
|
|
||||||
activeTab,
|
|
||||||
unlockedIndex,
|
|
||||||
validated,
|
|
||||||
editMode,
|
|
||||||
isValidated,
|
|
||||||
// adresses
|
|
||||||
addresses,
|
|
||||||
addressErrors,
|
|
||||||
canAddAddress,
|
|
||||||
addAddress,
|
|
||||||
removeAddress,
|
|
||||||
submitAddresses,
|
|
||||||
// contacts
|
|
||||||
contacts,
|
|
||||||
contactErrors,
|
|
||||||
canAddContact,
|
|
||||||
addContact,
|
|
||||||
removeContact,
|
|
||||||
submitContacts,
|
|
||||||
// prix
|
|
||||||
prices,
|
|
||||||
priceErrors,
|
|
||||||
canAddPrice,
|
|
||||||
addPrice,
|
|
||||||
removePrice,
|
|
||||||
submitPrices,
|
|
||||||
// actions
|
|
||||||
validateMainFront,
|
|
||||||
buildMainPayload,
|
|
||||||
submitMain,
|
|
||||||
updateMain,
|
|
||||||
prefillFrom,
|
|
||||||
patchCarrier,
|
|
||||||
applyQualimatSelection,
|
|
||||||
completeTab,
|
|
||||||
submitRows,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vue MINIMALE du referentiel QUALIMAT embarque (groupe `qualimat:read`) dans la
|
|
||||||
* LISTE des transporteurs. Seuls les champs consommes par le Repertoire sont
|
|
||||||
* types : `validityDate` alimente la colonne « Date de validité » (fond rouge si
|
|
||||||
* perimee — RG-4.04). L'id QUALIMAT est une chaine (colonne BIGINT cote back).
|
|
||||||
*/
|
|
||||||
export interface CarrierQualimat {
|
|
||||||
id: string
|
|
||||||
name: string | null
|
|
||||||
/** Date ISO de validite de l'agrement QUALIMAT (date_immutable) — RG-4.04. */
|
|
||||||
validityDate: string | null
|
|
||||||
status: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vue MINIMALE d'un transporteur pour le Repertoire (datatable). Volontairement
|
|
||||||
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
|
|
||||||
* Le detail complet (onglets Adresses / Contacts / Prix) est hors perimetre de
|
|
||||||
* cet ecran (ERP-164, ticket #9).
|
|
||||||
*
|
|
||||||
* `certificationType` : QUALIMAT | GMP_PLUS | OVOCOM | COMPTE_PROPRE | AUTRE, ou
|
|
||||||
* `null` dans le cas LIOT (compte-propre interne sans certification — RG-4.01).
|
|
||||||
* Le libelle affiche est resolu cote front (cle i18n `transport.carriers.certification.*`).
|
|
||||||
*/
|
|
||||||
export interface Carrier {
|
|
||||||
id: number
|
|
||||||
name: string | null
|
|
||||||
certificationType: string | null
|
|
||||||
/** Lien editable vers le referentiel QUALIMAT (null si transporteur non QUALIMAT). */
|
|
||||||
qualimatCarrier: CarrierQualimat | null
|
|
||||||
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
|
|
||||||
updatedAt: string | null
|
|
||||||
isArchived: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filtres du Repertoire transporteurs, branches sur les query params de
|
|
||||||
* `GET /api/carriers` (spec-back § 4.1). Pilotes par la page via `setFilters` :
|
|
||||||
* - `search` : recherche fuzzy sur le nom ;
|
|
||||||
* - `certificationType[]` : multi-valeurs (OR cote back) ;
|
|
||||||
* - `archivedOnly` : n'affiche QUE les archives (toggle « Voir les archivés »,
|
|
||||||
* aligne sur les autres repertoires M1/M2/M3).
|
|
||||||
*/
|
|
||||||
export interface CarrierFilters {
|
|
||||||
search?: string
|
|
||||||
'certificationType[]'?: string[]
|
|
||||||
archivedOnly?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repertoire transporteurs (M4, ERP-164) — simple enveloppe de
|
|
||||||
* `usePaginatedList<Carrier>` sur la ressource `/carriers` (regle ABSOLUE n°13 :
|
|
||||||
* pagination serveur obligatoire ; jamais de chargement integral en memoire).
|
|
||||||
* Miroir de `useSuppliersRepository` (M2) / `useProvidersRepository` (M3).
|
|
||||||
*
|
|
||||||
* Les filtres (recherche, certifications, archives) sont pilotes par la page via
|
|
||||||
* `setFilters` du composable partage — la remise en page 1 est garantie. Par
|
|
||||||
* defaut AUCUN `archivedOnly` n'est envoye : le back masque alors les archives
|
|
||||||
* (§ 2.4). Cocher « Voir les archivés » envoie `archivedOnly=true` (seules les
|
|
||||||
* archives sont listees, aligne sur Client / Fournisseur / Prestataire).
|
|
||||||
*
|
|
||||||
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
|
||||||
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
|
||||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
|
||||||
*/
|
|
||||||
export function useCarriersRepository() {
|
|
||||||
return usePaginatedList<Carrier, CarrierFilters>({ url: '/carriers' })
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ligne du référentiel QUALIMAT renvoyée par la saisie assistée (groupe
|
|
||||||
* `qualimat:read`). `@id` est l'IRI conservée comme FK `carrier.qualimatCarrier`
|
|
||||||
* (RG-4.01 / § 2.5) ; `validityDate` pilote le fond rouge de la colonne « Date de
|
|
||||||
* validité » (RG-4.04).
|
|
||||||
*/
|
|
||||||
export interface QualimatCarrierRow {
|
|
||||||
'@id': string
|
|
||||||
id: string
|
|
||||||
name: string | null
|
|
||||||
siret: string | null
|
|
||||||
address: string | null
|
|
||||||
postalCode: string | null
|
|
||||||
city: string | null
|
|
||||||
validityDate: string | null
|
|
||||||
status: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Filtre de la recherche QUALIMAT (branché sur le nom du transporteur). */
|
|
||||||
export interface QualimatSearchFilters {
|
|
||||||
search?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saisie assistée QUALIMAT (M4 Transport, ERP-166 — RG-4.01 / spec-back § 4.7).
|
|
||||||
*
|
|
||||||
* `GET /api/qualimat_carriers?search=` : référentiel en LECTURE SEULE, lignes
|
|
||||||
* actives uniquement (filtré côté serveur), recherche fuzzy nom + siret. Simple
|
|
||||||
* enveloppe de `usePaginatedList` (règle frontend : toute GetCollection passe par
|
|
||||||
* ce composable — pagination Hydra, état 100 % local) consommée par le
|
|
||||||
* `MalioDataTable` de l'onglet Qualimat. Le filtre `search` est piloté par le nom
|
|
||||||
* saisi dans le formulaire principal (pas de champ de recherche dédié).
|
|
||||||
*
|
|
||||||
* Volontairement PAR INSTANCE (état local à l'écran d'ajout).
|
|
||||||
*/
|
|
||||||
export function useQualimatSearch() {
|
|
||||||
return usePaginatedList<QualimatCarrierRow, QualimatSearchFilters>({ url: '/qualimat_carriers' })
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { mount, flushPromises } from '@vue/test-utils'
|
|
||||||
import { defineComponent, h, ref } from 'vue'
|
|
||||||
|
|
||||||
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
|
||||||
// La page ne les importe pas (auto-import) : on les expose en globals pour le
|
|
||||||
// runtime de test (happy-dom). Meme philosophie que les specs M1/M2/M3.
|
|
||||||
const mockPush = vi.hoisted(() => vi.fn())
|
|
||||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
|
||||||
const mockCan = vi.hoisted(() => vi.fn())
|
|
||||||
const mockSetFilters = vi.hoisted(() => vi.fn())
|
|
||||||
const mockFetch = vi.hoisted(() => vi.fn())
|
|
||||||
const mockToastError = vi.hoisted(() => vi.fn())
|
|
||||||
|
|
||||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
|
||||||
vi.stubGlobal('useHead', () => undefined)
|
|
||||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
|
||||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
|
||||||
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
|
|
||||||
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
|
||||||
|
|
||||||
// Le repository est lui aussi un auto-import : on controle items + setFilters.
|
|
||||||
vi.stubGlobal('useCarriersRepository', () => ({
|
|
||||||
items: ref([
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
name: 'TRANSPORTS ACME',
|
|
||||||
certificationType: 'QUALIMAT',
|
|
||||||
qualimatCarrier: { id: '42', name: 'TRANSPORTS ACME', validityDate: '2027-01-15', status: 'VALIDE' },
|
|
||||||
updatedAt: '2026-01-15T10:00:00+00:00',
|
|
||||||
isArchived: false,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
totalItems: ref(1),
|
|
||||||
currentPage: ref(1),
|
|
||||||
itemsPerPage: ref(10),
|
|
||||||
itemsPerPageOptions: ref([10, 25, 50]),
|
|
||||||
fetch: mockFetch,
|
|
||||||
goToPage: vi.fn(),
|
|
||||||
setItemsPerPage: vi.fn(),
|
|
||||||
setFilters: mockSetFilters,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
|
|
||||||
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
|
|
||||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
|
||||||
globalThis.URL.revokeObjectURL = vi.fn()
|
|
||||||
|
|
||||||
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
|
|
||||||
const CarriersIndex = (await import('../carriers/index.vue')).default
|
|
||||||
|
|
||||||
// ── Stubs de composants ──────────────────────────────────────────────────────
|
|
||||||
const ButtonStub = defineComponent({
|
|
||||||
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
|
||||||
emits: ['click'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const DataTableStub = defineComponent({
|
|
||||||
props: { items: { type: Array, default: () => [] } },
|
|
||||||
emits: ['row-click', 'update:page', 'update:per-page'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
return () => h('div', { 'data-testid': 'datatable' },
|
|
||||||
(props.items as Array<{ id: number }>).map(it =>
|
|
||||||
h('tr', { 'data-row-id': it.id, onClick: () => emit('row-click', it) }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const DrawerStub = defineComponent({
|
|
||||||
props: { modelValue: { type: Boolean, default: false } },
|
|
||||||
setup(_, { slots }) {
|
|
||||||
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
|
|
||||||
|
|
||||||
const PageHeaderStub = defineComponent({
|
|
||||||
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
|
||||||
})
|
|
||||||
|
|
||||||
const CheckboxStub = defineComponent({
|
|
||||||
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
|
||||||
emits: ['update:model-value'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
return () => h('input', {
|
|
||||||
'type': 'checkbox',
|
|
||||||
'data-id': props.id,
|
|
||||||
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
|
|
||||||
|
|
||||||
function mountPage() {
|
|
||||||
return mount(CarriersIndex, {
|
|
||||||
global: {
|
|
||||||
stubs: {
|
|
||||||
PageHeader: PageHeaderStub,
|
|
||||||
MalioButton: ButtonStub,
|
|
||||||
MalioDataTable: DataTableStub,
|
|
||||||
MalioDrawer: DrawerStub,
|
|
||||||
MalioAccordion: SlotStub,
|
|
||||||
MalioAccordionItem: SlotStub,
|
|
||||||
MalioInputText: InputTextStub,
|
|
||||||
MalioCheckbox: CheckboxStub,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Répertoire transporteurs (page /carriers)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPush.mockReset()
|
|
||||||
mockApiGet.mockReset().mockResolvedValue({ member: [] })
|
|
||||||
mockCan.mockReset().mockReturnValue(true)
|
|
||||||
mockSetFilters.mockReset()
|
|
||||||
mockFetch.mockReset()
|
|
||||||
mockToastError.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('charge la liste au montage', async () => {
|
|
||||||
mountPage()
|
|
||||||
await flushPromises()
|
|
||||||
expect(mockFetch).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
|
||||||
mockCan.mockImplementation((perm: string) => perm === 'transport.carriers.manage')
|
|
||||||
const wrapper = mountPage()
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.find('[data-label="transport.carriers.add"]').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
|
||||||
mockCan.mockImplementation((perm: string) => perm === 'transport.carriers.view')
|
|
||||||
const wrapper = mountPage()
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.find('[data-label="transport.carriers.add"]').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('navigue vers la consultation au clic sur une ligne', async () => {
|
|
||||||
const wrapper = mountPage()
|
|
||||||
await flushPromises()
|
|
||||||
await wrapper.find('tr[data-row-id="7"]').trigger('click')
|
|
||||||
expect(mockPush).toHaveBeenCalledWith('/carriers/7')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('appelle l\'export XLSX sur /carriers/export.xlsx en blob', async () => {
|
|
||||||
const wrapper = mountPage()
|
|
||||||
await flushPromises()
|
|
||||||
await wrapper.find('[data-label="transport.carriers.export"]').trigger('click')
|
|
||||||
await flushPromises()
|
|
||||||
expect(mockApiGet).toHaveBeenCalledWith(
|
|
||||||
'/carriers/export.xlsx',
|
|
||||||
expect.any(Object),
|
|
||||||
expect.objectContaining({ responseType: 'blob', toast: false }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('repercute le filtre « Voir les archivés » dans setFilters sans toucher l\'URL', async () => {
|
|
||||||
const wrapper = mountPage()
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
// Coche « Voir les archivés » puis applique les filtres.
|
|
||||||
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
|
|
||||||
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
|
|
||||||
|
|
||||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
|
||||||
{ archivedOnly: true },
|
|
||||||
{ replace: true },
|
|
||||||
)
|
|
||||||
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
|
||||||
expect(mockPush).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('repercute les certifications cochees dans setFilters (filtre multi)', async () => {
|
|
||||||
const wrapper = mountPage()
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
// Coche deux certifications via les cases a cocher (pattern repertoire clients).
|
|
||||||
await wrapper.find('input[data-id="filter-certification-QUALIMAT"]').setValue(true)
|
|
||||||
await wrapper.find('input[data-id="filter-certification-AUTRE"]').setValue(true)
|
|
||||||
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
|
|
||||||
|
|
||||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
|
||||||
{ 'certificationType[]': ['QUALIMAT', 'AUTRE'] },
|
|
||||||
{ replace: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => {
|
|
||||||
const wrapper = mountPage()
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
|
|
||||||
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
|
|
||||||
|
|
||||||
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
|
||||||
expect(wrapper.find('[data-label="transport.carriers.filters.title (1)"]').exists()).toBe(true)
|
|
||||||
|
|
||||||
// Réinitialiser → query propre (setFilters avec objet vide).
|
|
||||||
await wrapper.find('[data-label="transport.carriers.filters.reset"]').trigger('click')
|
|
||||||
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<!-- En-tête : retour consultation + titre. -->
|
|
||||||
<div class="flex items-center gap-3 pt-11">
|
|
||||||
<MalioButtonIcon
|
|
||||||
icon="mdi:arrow-left-bold"
|
|
||||||
icon-size="24"
|
|
||||||
variant="ghost"
|
|
||||||
v-bind="{ ariaLabel: t('transport.carriers.edit.back') }"
|
|
||||||
@click="goBack"
|
|
||||||
/>
|
|
||||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('transport.carriers.edit.title') }}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('transport.carriers.edit.loading') }}</p>
|
|
||||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('transport.carriers.edit.notFound') }}</p>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<!-- ── Formulaire principal (éditable, PATCH partiel) ─────────────── -->
|
|
||||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
|
||||||
<MalioInputText
|
|
||||||
v-model="main.name"
|
|
||||||
:label="t('transport.carriers.form.main.name')"
|
|
||||||
:required="true"
|
|
||||||
:error="mainErrors.errors.name"
|
|
||||||
/>
|
|
||||||
<MalioInputText
|
|
||||||
v-if="isLiot"
|
|
||||||
v-model="main.liotPlates"
|
|
||||||
:label="t('transport.carriers.form.main.liotPlates')"
|
|
||||||
:hint="t('transport.carriers.form.main.liotPlatesHint')"
|
|
||||||
:required="true"
|
|
||||||
:error="mainErrors.errors.liotPlates"
|
|
||||||
/>
|
|
||||||
<template v-if="!isLiot">
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="main.certificationType"
|
|
||||||
:options="certificationOptions"
|
|
||||||
:label="t('transport.carriers.form.main.certificationType')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="certificationReadonly"
|
|
||||||
:error="mainErrors.errors.certificationType"
|
|
||||||
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
|
|
||||||
/>
|
|
||||||
<MalioInputUpload
|
|
||||||
v-if="showDischarge"
|
|
||||||
:label="t('transport.carriers.form.main.discharge')"
|
|
||||||
accept="application/pdf,image/*"
|
|
||||||
:required="true"
|
|
||||||
:clearable="true"
|
|
||||||
:error="mainErrors.errors.dischargeDocument"
|
|
||||||
@clear="main.dischargeDocumentIri = null"
|
|
||||||
/>
|
|
||||||
<div v-else class="hidden xl:block"></div>
|
|
||||||
<div class="flex h-12 items-center">
|
|
||||||
<MalioCheckbox
|
|
||||||
id="carrier-edit-chartered"
|
|
||||||
:label="t('transport.carriers.form.main.isChartered')"
|
|
||||||
:model-value="main.isChartered"
|
|
||||||
:reserve-message-space="false"
|
|
||||||
@update:model-value="(val: boolean) => main.isChartered = val"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<template v-if="showCharteredFields">
|
|
||||||
<MalioInputAmount
|
|
||||||
:key="indexationKey"
|
|
||||||
:model-value="main.indexationRate"
|
|
||||||
:label="t('transport.carriers.form.main.indexationRate')"
|
|
||||||
icon-name="mdi:percent"
|
|
||||||
icon-position="right"
|
|
||||||
:required="true"
|
|
||||||
:error="mainErrors.errors.indexationRate"
|
|
||||||
@update:model-value="onIndexationInput"
|
|
||||||
/>
|
|
||||||
<!-- Contenant : Benne / Fond mouvant en radios, centrés (h-12) comme
|
|
||||||
à l'onglet Prix (Benne par défaut). -->
|
|
||||||
<div>
|
|
||||||
<div class="flex h-12 items-center gap-4">
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="main.containerType"
|
|
||||||
name="carrier-main-container"
|
|
||||||
value="BENNE"
|
|
||||||
:label="t('transport.carriers.containerType.BENNE')"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
|
|
||||||
/>
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="main.containerType"
|
|
||||||
name="carrier-main-container"
|
|
||||||
value="FOND_MOUVANT"
|
|
||||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="mainErrors.errors.containerType" class="ml-[2px] text-xs text-m-danger">{{ mainErrors.errors.containerType }}</p>
|
|
||||||
</div>
|
|
||||||
<MalioInputText
|
|
||||||
:model-value="main.volumeM3"
|
|
||||||
:label="t('transport.carriers.form.main.volumeM3')"
|
|
||||||
:required="true"
|
|
||||||
:error="mainErrors.errors.volumeM3"
|
|
||||||
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-12 flex justify-center">
|
|
||||||
<MalioButton
|
|
||||||
variant="primary"
|
|
||||||
:label="t('transport.carriers.edit.save')"
|
|
||||||
:disabled="mainSubmitting"
|
|
||||||
@click="onUpdateMain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Onglets éditables (navigation libre, PATCH partiel par onglet) ── -->
|
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
|
||||||
<template #addresses>
|
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
|
||||||
<CarrierAddressBlock
|
|
||||||
v-for="(address, index) in addresses"
|
|
||||||
:key="index"
|
|
||||||
:model-value="address"
|
|
||||||
:country-options="countryOptions"
|
|
||||||
:removable="isRowRemovable(addresses, index)"
|
|
||||||
:errors="addressErrors[index]"
|
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
|
||||||
@remove="askRemoveAddress(index)"
|
|
||||||
@degraded="onAddressDegraded"
|
|
||||||
/>
|
|
||||||
<div class="flex justify-center gap-6">
|
|
||||||
<MalioButton variant="secondary" icon-name="mdi:add-bold" icon-position="left" :label="t('transport.carriers.form.address.add')" :disabled="!canAddAddress" @click="addAddress" />
|
|
||||||
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitAddresses" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #contacts>
|
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
|
||||||
<CarrierContactBlock
|
|
||||||
v-for="(contact, index) in contacts"
|
|
||||||
:key="index"
|
|
||||||
:model-value="contact"
|
|
||||||
:removable="isRowRemovable(contacts, index)"
|
|
||||||
:errors="contactErrors[index]"
|
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
|
||||||
@remove="askRemoveContact(index)"
|
|
||||||
/>
|
|
||||||
<div class="flex justify-center gap-6">
|
|
||||||
<MalioButton variant="secondary" icon-name="mdi:add-bold" icon-position="left" :label="t('transport.carriers.form.contact.add')" :disabled="!canAddContact" @click="addContact" />
|
|
||||||
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitContacts" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #prices>
|
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
|
||||||
<CarrierPriceBlock
|
|
||||||
v-for="(price, index) in prices"
|
|
||||||
:key="index"
|
|
||||||
:model-value="price"
|
|
||||||
:client-options="clientOptions"
|
|
||||||
:supplier-options="supplierOptions"
|
|
||||||
:site-options="siteOptions"
|
|
||||||
removable
|
|
||||||
:errors="priceErrors[index]"
|
|
||||||
@update:model-value="(v) => prices[index] = v"
|
|
||||||
@remove="askRemovePrice(index)"
|
|
||||||
/>
|
|
||||||
<div class="flex justify-center gap-6">
|
|
||||||
<MalioButton variant="secondary" icon-name="mdi:add-bold" icon-position="left" :label="t('transport.carriers.form.price.add')" :disabled="!canAddPrice" @click="addPrice" />
|
|
||||||
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitPrices" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</MalioTabList>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Modal de confirmation de suppression de bloc. -->
|
|
||||||
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
|
|
||||||
<template #header>
|
|
||||||
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
|
|
||||||
</template>
|
|
||||||
<p>{{ t('transport.carriers.form.confirmDelete.message') }}</p>
|
|
||||||
<template #footer>
|
|
||||||
<MalioButton variant="secondary" button-class="flex-1" :label="t('transport.carriers.form.confirmDelete.cancel')" @click="deleteConfirm.open = false" />
|
|
||||||
<MalioButton variant="danger" button-class="flex-1" :label="t('transport.carriers.form.confirmDelete.confirm')" @click="runDeleteConfirm" />
|
|
||||||
</template>
|
|
||||||
</MalioModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
|
||||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
|
||||||
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
|
||||||
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
|
||||||
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
|
||||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
|
||||||
import { useCarrier } from '~/modules/transport/composables/useCarrier'
|
|
||||||
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
|
||||||
|
|
||||||
interface SelectOption {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const toast = useToast()
|
|
||||||
const api = useApi()
|
|
||||||
const { can } = usePermissions()
|
|
||||||
|
|
||||||
const carrierId = route.params.id as string
|
|
||||||
useHead({ title: t('transport.carriers.edit.title') })
|
|
||||||
|
|
||||||
// Gating route : l'édition est réservée à `manage` ; sinon retour consultation.
|
|
||||||
if (!can('transport.carriers.manage')) {
|
|
||||||
await navigateTo(`/carriers/${carrierId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { carrier, loading, error, load } = useCarrier(carrierId)
|
|
||||||
|
|
||||||
const {
|
|
||||||
main,
|
|
||||||
mainSubmitting,
|
|
||||||
tabSubmitting,
|
|
||||||
mainErrors,
|
|
||||||
isLiot,
|
|
||||||
certificationReadonly,
|
|
||||||
showCharteredFields,
|
|
||||||
showDischarge,
|
|
||||||
addresses,
|
|
||||||
addressErrors,
|
|
||||||
canAddAddress,
|
|
||||||
addAddress,
|
|
||||||
removeAddress,
|
|
||||||
submitAddresses,
|
|
||||||
contacts,
|
|
||||||
contactErrors,
|
|
||||||
canAddContact,
|
|
||||||
addContact,
|
|
||||||
removeContact,
|
|
||||||
submitContacts,
|
|
||||||
prices,
|
|
||||||
priceErrors,
|
|
||||||
canAddPrice,
|
|
||||||
addPrice,
|
|
||||||
removePrice,
|
|
||||||
submitPrices,
|
|
||||||
updateMain,
|
|
||||||
prefillFrom,
|
|
||||||
} = useCarrierForm()
|
|
||||||
|
|
||||||
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
|
|
||||||
const certificationOptions = computed<SelectOption[]>(() => {
|
|
||||||
const codes: string[] = [...SELECTABLE_CERTIFICATIONS]
|
|
||||||
if (main.certificationType === 'QUALIMAT') codes.unshift('QUALIMAT')
|
|
||||||
return codes.map(code => ({ value: code, label: t(`transport.carriers.certification.${code}`) }))
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const TAB_ICONS: Record<string, string> = {
|
|
||||||
addresses: 'mdi:map-marker-outline',
|
|
||||||
contacts: 'mdi:account-box-plus-outline',
|
|
||||||
prices: 'mdi:payment',
|
|
||||||
}
|
|
||||||
const activeTab = ref('addresses')
|
|
||||||
const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({
|
|
||||||
key,
|
|
||||||
label: t(`transport.carriers.tab.${key}`),
|
|
||||||
icon: TAB_ICONS[key],
|
|
||||||
})))
|
|
||||||
|
|
||||||
// ── Référentiels (pays + clients / fournisseurs / sites pour l'onglet Prix) ───
|
|
||||||
const countryOptions = ref<SelectOption[]>([{ value: 'France', label: 'France' }])
|
|
||||||
const clientOptions = ref<SelectOption[]>([])
|
|
||||||
const supplierOptions = ref<SelectOption[]>([])
|
|
||||||
const siteOptions = ref<SelectOption[]>([])
|
|
||||||
|
|
||||||
async function loadOptions(url: string, target: typeof clientOptions, labelOf: (m: Record<string, unknown>) => string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const data = await api.get<{ member?: Record<string, unknown>[] }>(url, { pagination: 'false' }, { headers: { Accept: 'application/ld+json' }, toast: false })
|
|
||||||
target.value = (data.member ?? []).map(m => ({ value: String(m['@id']), label: labelOf(m) }))
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
target.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCountries(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const data = await api.get<{ member?: { name: string }[] }>('/countries', { pagination: 'false' }, { headers: { Accept: 'application/ld+json' }, toast: false })
|
|
||||||
const list = (data.member ?? []).map(c => ({ value: c.name, label: c.name }))
|
|
||||||
countryOptions.value = list.some(c => c.value === 'France') ? list : [{ value: 'France', label: 'France' }, ...list]
|
|
||||||
}
|
|
||||||
catch { /* fallback France */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chargement + préremplissage ──────────────────────────────────────────────
|
|
||||||
onMounted(async () => {
|
|
||||||
await load()
|
|
||||||
if (carrier.value) {
|
|
||||||
prefillFrom(carrier.value)
|
|
||||||
}
|
|
||||||
loadCountries().catch(() => {})
|
|
||||||
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
|
|
||||||
void loadOptions('/suppliers', supplierOptions, m => String(m.companyName ?? m['@id']))
|
|
||||||
void loadOptions('/sites', siteOptions, m => String(m.name ?? m['@id']))
|
|
||||||
})
|
|
||||||
|
|
||||||
function apiErrorMessage(err: unknown): string {
|
|
||||||
const data = (err as { response?: { _data?: unknown } })?.response?._data
|
|
||||||
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
|
|
||||||
// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
|
|
||||||
const indexationKey = ref(0)
|
|
||||||
|
|
||||||
/** Saisie de l'indexation : plafonne à 100 et re-synchronise le champ si plafonné. */
|
|
||||||
function onIndexationInput(value: string): void {
|
|
||||||
const clamped = clampPercent(value)
|
|
||||||
main.indexationRate = clamped
|
|
||||||
if (clamped !== value) {
|
|
||||||
indexationKey.value += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack(): void {
|
|
||||||
router.push(`/carriers/${carrierId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PATCH du formulaire principal (pas de re-POST). */
|
|
||||||
async function onUpdateMain(): Promise<void> {
|
|
||||||
const ok = await updateMain()
|
|
||||||
if (ok) {
|
|
||||||
toast.success({ title: t('transport.carriers.toast.updateSuccess') })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmitAddresses(): Promise<void> {
|
|
||||||
const ok = await submitAddresses(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
|
|
||||||
if (ok) toast.success({ title: t('transport.carriers.toast.addressSaved') })
|
|
||||||
}
|
|
||||||
async function onSubmitContacts(): Promise<void> {
|
|
||||||
const ok = await submitContacts(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
|
|
||||||
if (ok) toast.success({ title: t('transport.carriers.toast.contactSaved') })
|
|
||||||
}
|
|
||||||
async function onSubmitPrices(): Promise<void> {
|
|
||||||
const ok = await submitPrices(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
|
|
||||||
if (ok) toast.success({ title: t('transport.carriers.toast.priceSaved') })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Suppression de bloc (modal de confirmation générique) ────────────────────
|
|
||||||
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
|
|
||||||
|
|
||||||
function askRemoveAddress(index: number): void {
|
|
||||||
deleteConfirm.action = () => { void removeAddress(index) }
|
|
||||||
deleteConfirm.open = true
|
|
||||||
}
|
|
||||||
function askRemoveContact(index: number): void {
|
|
||||||
deleteConfirm.action = () => { void removeContact(index) }
|
|
||||||
deleteConfirm.open = true
|
|
||||||
}
|
|
||||||
function askRemovePrice(index: number): void {
|
|
||||||
deleteConfirm.action = () => { void removePrice(index) }
|
|
||||||
deleteConfirm.open = true
|
|
||||||
}
|
|
||||||
function runDeleteConfirm(): void {
|
|
||||||
deleteConfirm.action?.()
|
|
||||||
deleteConfirm.action = null
|
|
||||||
deleteConfirm.open = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAddressDegraded(): void {
|
|
||||||
toast.warning({ title: t('transport.carriers.toast.error'), message: t('transport.carriers.form.address.degraded') })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,497 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<!-- En-tête : retour répertoire + nom + actions. -->
|
|
||||||
<div class="flex items-center gap-3 pt-11">
|
|
||||||
<MalioButtonIcon
|
|
||||||
icon="mdi:arrow-left-bold"
|
|
||||||
icon-size="24"
|
|
||||||
variant="ghost"
|
|
||||||
v-bind="{ ariaLabel: t('transport.carriers.consultation.back') }"
|
|
||||||
@click="goBack"
|
|
||||||
/>
|
|
||||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-12">
|
|
||||||
<MalioButton
|
|
||||||
v-if="canEdit"
|
|
||||||
variant="secondary"
|
|
||||||
icon-name="mdi:pencil-outline"
|
|
||||||
icon-position="left"
|
|
||||||
:label="t('transport.carriers.action.edit')"
|
|
||||||
@click="goEdit"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
v-if="showArchive"
|
|
||||||
variant="secondary"
|
|
||||||
icon-name="mdi:archive-arrow-down-outline"
|
|
||||||
icon-position="left"
|
|
||||||
:label="t('transport.carriers.action.archive')"
|
|
||||||
@click="askToggleArchive"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
v-if="showRestore"
|
|
||||||
variant="secondary"
|
|
||||||
icon-name="mdi:archive-arrow-up-outline"
|
|
||||||
icon-position="left"
|
|
||||||
:label="t('transport.carriers.action.restore')"
|
|
||||||
@click="askToggleArchive"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('transport.carriers.consultation.loading') }}</p>
|
|
||||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('transport.carriers.consultation.notFound') }}</p>
|
|
||||||
|
|
||||||
<template v-else-if="carrier">
|
|
||||||
<!-- ── Bloc principal (lecture seule) — même disposition que l'ajout ── -->
|
|
||||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
|
||||||
<MalioInputText :model-value="main.name" :label="t('transport.carriers.form.main.name')" readonly />
|
|
||||||
|
|
||||||
<!-- Cas LIOT : seul le champ immatriculations. -->
|
|
||||||
<MalioInputText
|
|
||||||
v-if="isLiot"
|
|
||||||
:model-value="main.liotPlates"
|
|
||||||
:label="t('transport.carriers.form.main.liotPlates')"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Cas standard : certification + décharge (col 3 réservée) + affrètement (col 4). -->
|
|
||||||
<template v-if="!isLiot">
|
|
||||||
<MalioInputText
|
|
||||||
:model-value="certificationLabel"
|
|
||||||
:label="t('transport.carriers.form.main.certificationType')"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Colonne 3 réservée à la décharge (si AUTRE), sinon vide (xl). -->
|
|
||||||
<MalioInputText
|
|
||||||
v-if="main.certificationType === 'AUTRE'"
|
|
||||||
:model-value="dischargeLabel"
|
|
||||||
:label="t('transport.carriers.form.main.discharge')"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
<div v-else class="hidden xl:block"></div>
|
|
||||||
|
|
||||||
<!-- Affréter : colonne 4, centré (h-12) comme à l'ajout. -->
|
|
||||||
<div class="flex h-12 items-center">
|
|
||||||
<MalioCheckbox
|
|
||||||
id="carrier-view-chartered"
|
|
||||||
:label="t('transport.carriers.form.main.isChartered')"
|
|
||||||
:model-value="main.isChartered"
|
|
||||||
readonly
|
|
||||||
:reserve-message-space="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Champs d'affrètement (ligne 2) si affrété. -->
|
|
||||||
<template v-if="main.isChartered">
|
|
||||||
<MalioInputText :model-value="indexationDisplay" :label="t('transport.carriers.form.main.indexationRate')" readonly />
|
|
||||||
<!-- Contenant : radios désactivés (lecture seule), aligné sur l'ajout / la modif. -->
|
|
||||||
<div>
|
|
||||||
<div class="flex h-12 items-center gap-4">
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="main.containerType"
|
|
||||||
name="carrier-view-container"
|
|
||||||
value="BENNE"
|
|
||||||
:label="t('transport.carriers.containerType.BENNE')"
|
|
||||||
readonly
|
|
||||||
group-class="mt-0"
|
|
||||||
/>
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="main.containerType"
|
|
||||||
name="carrier-view-container"
|
|
||||||
value="FOND_MOUVANT"
|
|
||||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
|
||||||
readonly
|
|
||||||
group-class="mt-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MalioInputText :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" readonly />
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Onglets (Adresses · Contacts · Prix) — ouvre sur Adresses ──── -->
|
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
|
||||||
<template #addresses>
|
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
|
||||||
<CarrierAddressBlock
|
|
||||||
v-for="(address, index) in addresses"
|
|
||||||
:key="index"
|
|
||||||
:model-value="address"
|
|
||||||
:country-options="countryOptionsFor(address.country)"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #contacts>
|
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
|
||||||
<CarrierContactBlock
|
|
||||||
v-for="(contact, index) in contacts"
|
|
||||||
:key="index"
|
|
||||||
:model-value="contact"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Prix : tableau présentationnel regroupé par contenant + export. -->
|
|
||||||
<template #prices>
|
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
|
||||||
<!-- Police / bordures / radius alignés sur MalioDataTable (header
|
|
||||||
16px, corps 14px). 1re colonne « Contenant » : libellé du
|
|
||||||
groupe (Fond Mouvant / Benne) fusionné en rowspan ; séparateur
|
|
||||||
épais entre les deux groupes. -->
|
|
||||||
<table class="w-full table-fixed border-separate border-spacing-0 overflow-hidden rounded-malio border border-black text-left text-black">
|
|
||||||
<!-- Répartition (table-fixed) : « Contenant » étroite ; Transporteurs
|
|
||||||
et Adresse livraisons larges ; Forfait / Tonne / Indexation / État
|
|
||||||
réduits. -->
|
|
||||||
<colgroup>
|
|
||||||
<col class="w-[110px]" />
|
|
||||||
<col class="w-[20%]" />
|
|
||||||
<col class="w-[11%]" />
|
|
||||||
<col class="w-[24%]" />
|
|
||||||
<col class="w-[9%]" />
|
|
||||||
<col class="w-[9%]" />
|
|
||||||
<col class="w-[9%]" />
|
|
||||||
<col class="w-[9%]" />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="border-b border-r border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.group') }}</th>
|
|
||||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.carrier') }}</th>
|
|
||||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.aproOrSite') }}</th>
|
|
||||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.delivery') }}</th>
|
|
||||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.forfait') }}</th>
|
|
||||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.tonne') }}</th>
|
|
||||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.indexation') }}</th>
|
|
||||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.state') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template v-for="(group, gi) in priceGroups" :key="group.label">
|
|
||||||
<tr
|
|
||||||
v-for="(row, i) in group.rows"
|
|
||||||
:key="`${gi}-${i}`"
|
|
||||||
>
|
|
||||||
<!-- Cellule de groupe fusionnée (rowspan), centrée verticalement ;
|
|
||||||
séparateur épais en bas entre les groupes (sauf dernier). -->
|
|
||||||
<td
|
|
||||||
v-if="i === 0"
|
|
||||||
:rowspan="group.rows.length"
|
|
||||||
class="border-r border-black px-3 text-center align-middle text-[14px] font-medium"
|
|
||||||
:class="groupBorder(gi)"
|
|
||||||
>
|
|
||||||
{{ group.label }}
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ headerTitle }}</td>
|
|
||||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.apro }}</td>
|
|
||||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.delivery }}</td>
|
|
||||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.forfait }}</td>
|
|
||||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.tonne }}</td>
|
|
||||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.indexation }}</td>
|
|
||||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.state }}</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
<tr v-if="!hasPrices">
|
|
||||||
<td colspan="8" class="px-3 py-4 text-center text-[14px] text-m-muted">
|
|
||||||
{{ t('transport.carriers.consultation.price.empty') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div v-if="hasPrices" class="flex justify-center">
|
|
||||||
<MalioButton
|
|
||||||
variant="primary"
|
|
||||||
:label="t('transport.carriers.consultation.price.export')"
|
|
||||||
:disabled="exporting"
|
|
||||||
@click="exportPrices"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</MalioTabList>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Modal de confirmation archivage / restauration. -->
|
|
||||||
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
|
|
||||||
<template #header>
|
|
||||||
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
|
|
||||||
</template>
|
|
||||||
<p>{{ confirmArchive.message }}</p>
|
|
||||||
<template #footer>
|
|
||||||
<MalioButton
|
|
||||||
variant="secondary"
|
|
||||||
button-class="flex-1"
|
|
||||||
:label="t('transport.carriers.form.confirmDelete.cancel')"
|
|
||||||
@click="confirmArchive.open = false"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="danger"
|
|
||||||
button-class="flex-1"
|
|
||||||
:label="confirmArchive.confirmLabel"
|
|
||||||
@click="runToggleArchive"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</MalioModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
|
||||||
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
|
||||||
import { useCarrier } from '~/modules/transport/composables/useCarrier'
|
|
||||||
import {
|
|
||||||
canEditCarrier,
|
|
||||||
labelOfRelation,
|
|
||||||
mapAddressToDraft,
|
|
||||||
mapContactToDraft,
|
|
||||||
mapMainToDraft,
|
|
||||||
showArchiveAction,
|
|
||||||
showRestoreAction,
|
|
||||||
type CarrierPriceRead,
|
|
||||||
} from '~/modules/transport/utils/forms/carrierMappers'
|
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
|
||||||
|
|
||||||
interface SelectOption {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const toast = useToast()
|
|
||||||
const api = useApi()
|
|
||||||
const { can } = usePermissions()
|
|
||||||
|
|
||||||
const carrierId = route.params.id as string
|
|
||||||
const { carrier, loading, error, load, archive, restore } = useCarrier(carrierId)
|
|
||||||
|
|
||||||
const isArchived = computed(() => carrier.value?.isArchived ?? false)
|
|
||||||
const canEdit = computed(() => canEditCarrier(can))
|
|
||||||
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
|
|
||||||
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
|
|
||||||
|
|
||||||
const headerTitle = computed(() => carrier.value?.name || t('transport.carriers.consultation.title'))
|
|
||||||
useHead({ title: t('transport.carriers.consultation.title') })
|
|
||||||
|
|
||||||
// ── Bloc principal mappé (lecture seule) ─────────────────────────────────────
|
|
||||||
const main = computed(() => mapMainToDraft(carrier.value ?? { id: 0, '@id': '' }))
|
|
||||||
const isLiot = computed(() => main.value.name.trim().toUpperCase() === 'LIOT')
|
|
||||||
const certificationLabel = computed(() => main.value.certificationType
|
|
||||||
? t(`transport.carriers.certification.${main.value.certificationType}`)
|
|
||||||
: '')
|
|
||||||
// Indexation affichée avec le « % » (comme l'icône du champ amount de l'ajout).
|
|
||||||
const indexationDisplay = computed(() => main.value.indexationRate ? `${main.value.indexationRate} %` : '')
|
|
||||||
// Décharge : nom du fichier embarqué si présent (sinon vide ; la colonne reste réservée).
|
|
||||||
const dischargeLabel = computed(() => {
|
|
||||||
const doc = carrier.value?.dischargeDocument
|
|
||||||
if (doc && typeof doc !== 'string') {
|
|
||||||
const meta = doc as Record<string, unknown>
|
|
||||||
return String(meta.originalFilename ?? meta.name ?? '')
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Onglets : Adresses · Contacts · Prix (ouvre sur Adresses, pas de Qualimat) ──
|
|
||||||
const activeTab = ref('addresses')
|
|
||||||
const TAB_ICONS: Record<string, string> = {
|
|
||||||
addresses: 'mdi:map-marker-outline',
|
|
||||||
contacts: 'mdi:account-box-plus-outline',
|
|
||||||
prices: 'mdi:payment',
|
|
||||||
}
|
|
||||||
const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({
|
|
||||||
key,
|
|
||||||
label: t(`transport.carriers.tab.${key}`),
|
|
||||||
icon: TAB_ICONS[key],
|
|
||||||
})))
|
|
||||||
|
|
||||||
// Au moins un bloc affiché même sans donnée (bloc vide en lecture seule).
|
|
||||||
const addresses = computed(() => {
|
|
||||||
const list = (carrier.value?.addresses ?? []).map(mapAddressToDraft)
|
|
||||||
return list.length > 0 ? list : [mapAddressToDraft({ id: 0, '@id': '' })]
|
|
||||||
})
|
|
||||||
const contacts = computed(() => {
|
|
||||||
const list = (carrier.value?.contacts ?? []).map(mapContactToDraft)
|
|
||||||
return list.length > 0 ? list : [mapContactToDraft({ id: 0, '@id': '' })]
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Pays : une seule option (valeur courante), suffisant pour l'affichage readonly. */
|
|
||||||
function countryOptionsFor(country: string): SelectOption[] {
|
|
||||||
return country ? [{ value: country, label: country }] : []
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tableau Prix consultation (regroupé par contenant Fond Mouvant / Benne) ───
|
|
||||||
const PRICE_GROUP_ORDER = ['FOND_MOUVANT', 'BENNE'] as const
|
|
||||||
|
|
||||||
interface PriceRowView {
|
|
||||||
apro: string
|
|
||||||
delivery: string
|
|
||||||
forfait: string
|
|
||||||
tonne: string
|
|
||||||
indexation: string
|
|
||||||
state: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Groupe de prix d'un même contenant (Fond Mouvant / Benne) — cellule fusionnée. */
|
|
||||||
interface PriceGroupView {
|
|
||||||
label: string
|
|
||||||
rows: PriceRowView[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Formate un montant décimal en « 1 000,00 € » (chaîne vide si absent). */
|
|
||||||
function formatAmount(value: string | null | undefined): string {
|
|
||||||
if (!value) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const n = Number(value)
|
|
||||||
if (Number.isNaN(n)) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return `${n.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) :
|
|
||||||
* - « Adresse sites » = le site (Châtellerault / Saint-Jean / Pommevic…) ;
|
|
||||||
* - « Adresse livraisons » = l'adresse (voie) du client/fournisseur ;
|
|
||||||
* - le prix tombe dans Forfait € OU Tonne € selon `pricingUnit`.
|
|
||||||
*/
|
|
||||||
function toPriceRow(price: CarrierPriceRead): PriceRowView {
|
|
||||||
const isClient = price.direction === 'CLIENT'
|
|
||||||
return {
|
|
||||||
apro: isClient ? labelOfRelation(price.departureSite) : labelOfRelation(price.deliverySite),
|
|
||||||
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
|
|
||||||
forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '',
|
|
||||||
tonne: price.pricingUnit === 'TONNE' ? formatAmount(price.price) : '',
|
|
||||||
// CarrierPrice n'a pas de taux d'indexation propre → on affiche celui du
|
|
||||||
// transporteur (formulaire principal). À faire évoluer si un taux par prix
|
|
||||||
// est requis (gap back).
|
|
||||||
indexation: main.value.indexationRate ? `${main.value.indexationRate} %` : '',
|
|
||||||
state: price.priceState ? t(`transport.carriers.form.price.state${stateSuffix(price.priceState)}`) : '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** EN_COURS → EnCours, VALIDE → Valide, NON_VALIDE → NonValide (clés i18n existantes). */
|
|
||||||
function stateSuffix(state: string): string {
|
|
||||||
const map: Record<string, string> = { EN_COURS: 'EnCours', VALIDE: 'Valide', NON_VALIDE: 'NonValide' }
|
|
||||||
return map[state] ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prix regroupés par contenant (Fond Mouvant puis Benne) — une cellule fusionnée
|
|
||||||
// par groupe (rowspan) à gauche, conformément à la maquette.
|
|
||||||
const priceGroups = computed<PriceGroupView[]>(() => {
|
|
||||||
const list = carrier.value?.prices ?? []
|
|
||||||
return PRICE_GROUP_ORDER
|
|
||||||
.map(container => ({
|
|
||||||
label: t(`transport.carriers.containerType.${container}`),
|
|
||||||
rows: list.filter(p => p.containerType === container).map(toPriceRow),
|
|
||||||
}))
|
|
||||||
.filter(group => group.rows.length > 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasPrices = computed(() => priceGroups.value.length > 0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bordure basse d'une cellule de données :
|
|
||||||
* - ligne interne d'un groupe → fine grise ;
|
|
||||||
* - dernière ligne d'un groupe NON final → épaisse noire (séparateur de groupe) ;
|
|
||||||
* - dernière ligne du DERNIER groupe → aucune (le cadre du tableau s'en charge,
|
|
||||||
* évite la double bordure tout en bas).
|
|
||||||
*/
|
|
||||||
function dataBorder(group: PriceGroupView, i: number, gi: number): string {
|
|
||||||
const isLastRow = i === group.rows.length - 1
|
|
||||||
const isLastGroup = gi === priceGroups.value.length - 1
|
|
||||||
if (!isLastRow) {
|
|
||||||
return 'border-b border-m-muted/30'
|
|
||||||
}
|
|
||||||
return isLastGroup ? '' : 'border-b-2 border-black'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bordure basse de la cellule de groupe fusionnée (séparateur épais sauf dernier groupe). */
|
|
||||||
function groupBorder(gi: number): string {
|
|
||||||
return gi === priceGroups.value.length - 1 ? '' : 'border-b-2 border-black'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Export XLSX des prix ─────────────────────────────────────────────────────
|
|
||||||
const exporting = ref(false)
|
|
||||||
|
|
||||||
async function exportPrices(): Promise<void> {
|
|
||||||
if (exporting.value) return
|
|
||||||
exporting.value = true
|
|
||||||
try {
|
|
||||||
const blob = await api.get<Blob>(`/carriers/${carrierId}/prices/export.xlsx`, {}, {
|
|
||||||
responseType: 'blob',
|
|
||||||
toast: false,
|
|
||||||
} as unknown as Parameters<typeof api.get>[2])
|
|
||||||
triggerDownload(blob, `transporteur-${carrierId}-prix.xlsx`)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
toast.error({ title: t('transport.carriers.toast.error'), message: t('transport.carriers.toast.exportError') })
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
exporting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerDownload(blob: Blob, filename: string): void {
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = filename
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
link.remove()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Navigation / archivage ───────────────────────────────────────────────────
|
|
||||||
function goBack(): void {
|
|
||||||
router.push('/carriers')
|
|
||||||
}
|
|
||||||
|
|
||||||
function goEdit(): void {
|
|
||||||
router.push(`/carriers/${carrierId}/edit`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' })
|
|
||||||
|
|
||||||
function askToggleArchive(): void {
|
|
||||||
const archiving = !isArchived.value
|
|
||||||
confirmArchive.title = archiving ? t('transport.carriers.action.archive') : t('transport.carriers.action.restore')
|
|
||||||
confirmArchive.message = archiving
|
|
||||||
? t('transport.carriers.consultation.confirmArchive.message')
|
|
||||||
: t('transport.carriers.consultation.confirmRestore.message')
|
|
||||||
confirmArchive.confirmLabel = archiving ? t('transport.carriers.action.archive') : t('transport.carriers.action.restore')
|
|
||||||
confirmArchive.open = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runToggleArchive(): Promise<void> {
|
|
||||||
const archiving = !isArchived.value
|
|
||||||
confirmArchive.open = false
|
|
||||||
try {
|
|
||||||
await (archiving ? archive() : restore())
|
|
||||||
toast.success({
|
|
||||||
title: archiving
|
|
||||||
? t('transport.carriers.toast.archiveSuccess')
|
|
||||||
: t('transport.carriers.toast.restoreSuccess'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
// Surface le message back (ex. 409 « homonyme actif » à la restauration),
|
|
||||||
// propagé exprès par useCarrier ; fallback générique sinon.
|
|
||||||
const data = (err as { response?: { _data?: unknown } })?.response?._data
|
|
||||||
toast.error({
|
|
||||||
title: t('transport.carriers.toast.error'),
|
|
||||||
message: extractApiErrorMessage(data) || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<PageHeader>
|
|
||||||
{{ t('transport.carriers.title') }}
|
|
||||||
<template #actions>
|
|
||||||
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter. -->
|
|
||||||
<div class="flex items-center gap-8">
|
|
||||||
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
|
|
||||||
<MalioButton
|
|
||||||
v-if="canView"
|
|
||||||
variant="tertiary"
|
|
||||||
:label="filterButtonLabel"
|
|
||||||
icon-name="mdi:tune"
|
|
||||||
icon-position="left"
|
|
||||||
icon-size="24"
|
|
||||||
@click="openFilters"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
v-if="canManage"
|
|
||||||
variant="secondary"
|
|
||||||
:label="t('transport.carriers.add')"
|
|
||||||
icon-name="mdi:add-bold"
|
|
||||||
icon-position="left"
|
|
||||||
@click="goToCreate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<!-- Datatable branchee sur usePaginatedList via useCarriersRepository :
|
|
||||||
pagination serveur, tri name ASC par defaut (cote back). -->
|
|
||||||
<MalioDataTable
|
|
||||||
:columns="columns"
|
|
||||||
:items="rows"
|
|
||||||
:total-items="totalItems"
|
|
||||||
:page="currentPage"
|
|
||||||
:per-page="itemsPerPage"
|
|
||||||
:per-page-options="itemsPerPageOptions"
|
|
||||||
row-clickable
|
|
||||||
:empty-message="t('transport.carriers.empty')"
|
|
||||||
@row-click="onRowClick"
|
|
||||||
@update:page="goToPage"
|
|
||||||
@update:per-page="setItemsPerPage"
|
|
||||||
>
|
|
||||||
<!-- Certification : libelle i18n (le back renvoie le code enum). -->
|
|
||||||
<template #cell-certificationType="{ item }">
|
|
||||||
{{ formatCertification(item) }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Date de validite QUALIMAT : fond rouge si perimee (< aujourd'hui — RG-4.04). -->
|
|
||||||
<template #cell-validityDate="{ item }">
|
|
||||||
<span
|
|
||||||
v-if="getValidityDate(item)"
|
|
||||||
:class="isValidityExpired(item) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
|
|
||||||
>
|
|
||||||
{{ formatDateFr(getValidityDate(item)) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Derniere activite : date de derniere modification (updatedAt). -->
|
|
||||||
<template #cell-lastActivity="{ item }">
|
|
||||||
{{ formatDateFr(item.updatedAt as string | null) }}
|
|
||||||
</template>
|
|
||||||
</MalioDataTable>
|
|
||||||
|
|
||||||
<div class="flex justify-center mt-4">
|
|
||||||
<MalioButton
|
|
||||||
v-if="canView"
|
|
||||||
variant="primary"
|
|
||||||
:label="t('transport.carriers.export')"
|
|
||||||
:disabled="exporting"
|
|
||||||
@click="exportXlsx"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
|
||||||
« Voir les résultats ». Meme pattern que les repertoires M1/M2/M3.
|
|
||||||
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
|
|
||||||
<MalioDrawer
|
|
||||||
v-model="filterDrawerOpen"
|
|
||||||
drawer-class="max-w-[450px]"
|
|
||||||
body-class="p-0"
|
|
||||||
footer-class="justify-between border-t border-black p-6"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<h2 class="text-[24px] font-bold uppercase">{{ t('transport.carriers.filters.title') }}</h2>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<MalioAccordion>
|
|
||||||
<!-- Recherche : nom du transporteur (param `search`). -->
|
|
||||||
<MalioAccordionItem :title="t('transport.carriers.filters.search')" value="search">
|
|
||||||
<MalioInputText
|
|
||||||
v-model="draftSearch"
|
|
||||||
icon-name="mdi:magnify"
|
|
||||||
/>
|
|
||||||
</MalioAccordionItem>
|
|
||||||
|
|
||||||
<!-- Certification : cases a cocher (multi). Valeur = code enum.
|
|
||||||
Meme pattern que le filtre Categories du repertoire clients. -->
|
|
||||||
<MalioAccordionItem :title="t('transport.carriers.filters.certification')" value="certification">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<MalioCheckbox
|
|
||||||
v-for="opt in certificationOptions"
|
|
||||||
:id="`filter-certification-${opt.value}`"
|
|
||||||
:key="opt.value"
|
|
||||||
:label="opt.label"
|
|
||||||
:model-value="draftCertificationTypes.includes(opt.value)"
|
|
||||||
@update:model-value="(val: boolean) => toggleCertification(opt.value, val)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</MalioAccordionItem>
|
|
||||||
|
|
||||||
<!-- Statut : voir uniquement les archives (sinon actifs uniquement). -->
|
|
||||||
<MalioAccordionItem :title="t('transport.carriers.filters.status')" value="status">
|
|
||||||
<MalioCheckbox
|
|
||||||
id="filter-archived-only"
|
|
||||||
:label="t('transport.carriers.filters.archivedOnly')"
|
|
||||||
:model-value="draftArchivedOnly"
|
|
||||||
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
|
||||||
/>
|
|
||||||
</MalioAccordionItem>
|
|
||||||
</MalioAccordion>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<MalioButton
|
|
||||||
variant="tertiary"
|
|
||||||
:label="t('transport.carriers.filters.reset')"
|
|
||||||
button-class="w-m-btn-action"
|
|
||||||
@click="resetFilters"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="primary"
|
|
||||||
:label="t('transport.carriers.filters.apply')"
|
|
||||||
button-class="w-[170px]"
|
|
||||||
@click="applyFilters"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</MalioDrawer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
|
|
||||||
interface FilterOption {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const api = useApi()
|
|
||||||
const router = useRouter()
|
|
||||||
const toast = useToast()
|
|
||||||
const { can } = usePermissions()
|
|
||||||
|
|
||||||
useHead({ title: t('transport.carriers.title') })
|
|
||||||
|
|
||||||
// Bouton « Ajouter » reserve a `manage` (Admin + Bureau). « Exporter » et
|
|
||||||
// « Filtrer » suivent `view` (Admin / Bureau / Commerciale). Compta et Usine
|
|
||||||
// n'ont aucun acces (item sidebar masque cote back).
|
|
||||||
const canManage = computed(() => can('transport.carriers.manage'))
|
|
||||||
const canView = computed(() => can('transport.carriers.view'))
|
|
||||||
|
|
||||||
const {
|
|
||||||
items: carriers,
|
|
||||||
totalItems,
|
|
||||||
currentPage,
|
|
||||||
itemsPerPage,
|
|
||||||
itemsPerPageOptions,
|
|
||||||
fetch: loadCarriers,
|
|
||||||
goToPage,
|
|
||||||
setItemsPerPage,
|
|
||||||
setFilters,
|
|
||||||
} = useCarriersRepository()
|
|
||||||
|
|
||||||
// Mappe les transporteurs en objets « plats » pour MalioDataTable (items typees
|
|
||||||
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
|
||||||
// implicite, contrairement a l'interface Carrier. Meme pattern que M1/M2/M3.
|
|
||||||
const rows = computed(() => carriers.value.map(carrier => ({
|
|
||||||
id: carrier.id,
|
|
||||||
name: carrier.name,
|
|
||||||
certificationType: carrier.certificationType,
|
|
||||||
validityDate: carrier.qualimatCarrier?.validityDate ?? null,
|
|
||||||
updatedAt: carrier.updatedAt,
|
|
||||||
})))
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ key: 'name', label: t('transport.carriers.column.name') },
|
|
||||||
{ key: 'certificationType', label: t('transport.carriers.column.certification') },
|
|
||||||
{ key: 'validityDate', label: t('transport.carriers.column.validityDate') },
|
|
||||||
{ key: 'lastActivity', label: t('transport.carriers.column.lastActivity') },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Codes de certification (miroir de l'enum back) + cas LIOT (null). Le libelle
|
|
||||||
// est resolu par i18n ; un code inconnu retombe sur le code brut.
|
|
||||||
const CERTIFICATION_CODES = ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
|
|
||||||
|
|
||||||
const certificationOptions = computed<FilterOption[]>(() =>
|
|
||||||
CERTIFICATION_CODES.map(code => ({
|
|
||||||
value: code,
|
|
||||||
label: t(`transport.carriers.certification.${code}`),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Libelle i18n de la certification (vide en cas LIOT — certificationType null). */
|
|
||||||
function formatCertification(item: Record<string, unknown>): string {
|
|
||||||
const code = item.certificationType as string | null | undefined
|
|
||||||
if (!code) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return t(`transport.carriers.certification.${code}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Date de validite QUALIMAT de la ligne (null si transporteur non QUALIMAT). */
|
|
||||||
function getValidityDate(item: Record<string, unknown>): string | null {
|
|
||||||
return (item.validityDate as string | null | undefined) ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-4.04 : un agrement QUALIMAT est perime si sa date de validite est anterieure
|
|
||||||
* a la date du jour (comparaison jour a jour, sans l'heure).
|
|
||||||
*/
|
|
||||||
function isValidityExpired(item: Record<string, unknown>): boolean {
|
|
||||||
const value = getValidityDate(item)
|
|
||||||
if (!value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const today = new Date()
|
|
||||||
today.setHours(0, 0, 0, 0)
|
|
||||||
date.setHours(0, 0, 0, 0)
|
|
||||||
return date.getTime() < today.getTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format court francais JJ-MM-AAAA (spec M4). Chaine vide si date absente / invalide. */
|
|
||||||
function formatDateFr(value: string | null | undefined): string {
|
|
||||||
if (!value) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
return `${day}-${month}-${date.getFullYear()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clic sur une ligne → ecran Consultation (route a plat /carriers/{id}). */
|
|
||||||
function onRowClick(item: Record<string, unknown>): void {
|
|
||||||
router.push(`/carriers/${item.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToCreate(): void {
|
|
||||||
router.push('/carriers/new')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
|
||||||
// Deux niveaux d'etat (pattern repertoires M1/M2/M3) :
|
|
||||||
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
|
||||||
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
|
|
||||||
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
|
||||||
const filterDrawerOpen = ref(false)
|
|
||||||
|
|
||||||
const draftSearch = ref('')
|
|
||||||
const draftCertificationTypes = ref<string[]>([])
|
|
||||||
const draftArchivedOnly = ref(false)
|
|
||||||
|
|
||||||
const appliedSearch = ref('')
|
|
||||||
const appliedCertificationTypes = ref<string[]>([])
|
|
||||||
const appliedArchivedOnly = ref(false)
|
|
||||||
|
|
||||||
const activeFilterCount = computed(() => {
|
|
||||||
let count = 0
|
|
||||||
if (appliedSearch.value.trim() !== '') count++
|
|
||||||
if (appliedCertificationTypes.value.length > 0) count++
|
|
||||||
if (appliedArchivedOnly.value) count++
|
|
||||||
return count
|
|
||||||
})
|
|
||||||
|
|
||||||
const filterButtonLabel = computed(() => {
|
|
||||||
const base = t('transport.carriers.filters.title')
|
|
||||||
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
|
||||||
})
|
|
||||||
|
|
||||||
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la reouverture
|
|
||||||
// reflete les filtres actifs.
|
|
||||||
function openFilters(): void {
|
|
||||||
draftSearch.value = appliedSearch.value
|
|
||||||
draftCertificationTypes.value = [...appliedCertificationTypes.value]
|
|
||||||
draftArchivedOnly.value = appliedArchivedOnly.value
|
|
||||||
filterDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Coche / decoche une certification dans le brouillon (filtre multi). */
|
|
||||||
function toggleCertification(code: string, selected: boolean): void {
|
|
||||||
draftCertificationTypes.value = selected
|
|
||||||
? [...draftCertificationTypes.value, code]
|
|
||||||
: draftCertificationTypes.value.filter(c => c !== code)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
|
|
||||||
* `certificationType[]` pour que PHP la parse en tableau (OR cote back). Les
|
|
||||||
* filtres vides sont omis pour une query propre.
|
|
||||||
*/
|
|
||||||
function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
|
||||||
const payload: Record<string, string | string[] | boolean> = {}
|
|
||||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
|
||||||
if (appliedCertificationTypes.value.length > 0) payload['certificationType[]'] = [...appliedCertificationTypes.value]
|
|
||||||
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
|
|
||||||
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
|
|
||||||
function applyFilters(): void {
|
|
||||||
appliedSearch.value = draftSearch.value.trim()
|
|
||||||
appliedCertificationTypes.value = [...draftCertificationTypes.value]
|
|
||||||
appliedArchivedOnly.value = draftArchivedOnly.value
|
|
||||||
|
|
||||||
setFilters(buildFilterPayload(), { replace: true })
|
|
||||||
filterDrawerOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
|
||||||
// Le drawer reste ouvert pour montrer le formulaire vide.
|
|
||||||
function resetFilters(): void {
|
|
||||||
draftSearch.value = ''
|
|
||||||
draftCertificationTypes.value = []
|
|
||||||
draftArchivedOnly.value = false
|
|
||||||
|
|
||||||
appliedSearch.value = ''
|
|
||||||
appliedCertificationTypes.value = []
|
|
||||||
appliedArchivedOnly.value = false
|
|
||||||
|
|
||||||
setFilters({}, { replace: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
|
||||||
// Memes filtres que la vue : l'export reflete exactement ce que l'utilisateur voit.
|
|
||||||
const exporting = ref(false)
|
|
||||||
|
|
||||||
async function exportXlsx(): Promise<void> {
|
|
||||||
if (exporting.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
exporting.value = true
|
|
||||||
try {
|
|
||||||
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
|
||||||
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
|
||||||
// contenu faute d'overload blob sur le client partage (meme pattern M2/M3).
|
|
||||||
const blob = await api.get<Blob>('/carriers/export.xlsx', buildFilterPayload(), {
|
|
||||||
responseType: 'blob',
|
|
||||||
toast: false,
|
|
||||||
} as unknown as Parameters<typeof api.get>[2])
|
|
||||||
|
|
||||||
triggerDownload(blob, 'repertoire-transporteurs.xlsx')
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
toast.error({
|
|
||||||
title: t('transport.carriers.toast.error'),
|
|
||||||
message: t('transport.carriers.toast.exportError'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
exporting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
|
||||||
function triggerDownload(blob: Blob, filename: string): void {
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = filename
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
link.remove()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadCarriers()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,768 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<!-- En-tete : retour vers le repertoire + titre. -->
|
|
||||||
<div class="flex items-center gap-3 pt-11">
|
|
||||||
<MalioButtonIcon
|
|
||||||
icon="mdi:arrow-left-bold"
|
|
||||||
icon-size="24"
|
|
||||||
variant="ghost"
|
|
||||||
v-bind="{ ariaLabel: t('transport.carriers.form.back') }"
|
|
||||||
@click="goBack"
|
|
||||||
/>
|
|
||||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('transport.carriers.form.title') }}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Formulaire principal (pre-onglets) ─────────────────────────────
|
|
||||||
Champs conditionnels (ERP-166) : cas LIOT (nom == LIOT) → seul
|
|
||||||
« immatriculations » ; certification AUTRE → champ Decharge ; Affreter
|
|
||||||
coche → indexation / contenant / volume. La certification est en lecture
|
|
||||||
seule pour un transporteur QUALIMAT (saisie assistee, onglet Qualimat). -->
|
|
||||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
|
||||||
<MalioInputText
|
|
||||||
v-model="main.name"
|
|
||||||
:label="t('transport.carriers.form.main.name')"
|
|
||||||
:required="true"
|
|
||||||
:readonly="mainLocked"
|
|
||||||
:error="mainErrors.errors.name"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Cas LIOT : seul le champ immatriculations est pertinent. -->
|
|
||||||
<MalioInputText
|
|
||||||
v-if="isLiot"
|
|
||||||
v-model="main.liotPlates"
|
|
||||||
:label="t('transport.carriers.form.main.liotPlates')"
|
|
||||||
:hint="t('transport.carriers.form.main.liotPlatesHint')"
|
|
||||||
:required="true"
|
|
||||||
:readonly="mainLocked"
|
|
||||||
:error="mainErrors.errors.liotPlates"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Cas standard : certification + affretement + champs conditionnels. -->
|
|
||||||
<template v-if="!isLiot">
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="main.certificationType"
|
|
||||||
:options="certificationOptions"
|
|
||||||
:label="t('transport.carriers.form.main.certificationType')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="certificationReadonly"
|
|
||||||
:error="mainErrors.errors.certificationType"
|
|
||||||
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Colonne 3 RÉSERVÉE à la Décharge (RG-4.02 : visible et obligatoire
|
|
||||||
si certification AUTRE). Si elle n'apparaît pas, on garde la colonne
|
|
||||||
vide (xl) pour qu'« Affréter » reste en colonne 4 de la ligne 1.
|
|
||||||
L'upload reel (File → IRI via useUpload) arrive a ERP-171. -->
|
|
||||||
<!-- TODO ERP-171 : brancher useUpload pour resoudre le File en IRI
|
|
||||||
(main.dischargeDocumentIri). Le champ est deja visible/obligatoire. -->
|
|
||||||
<MalioInputUpload
|
|
||||||
v-if="showDischarge"
|
|
||||||
:label="t('transport.carriers.form.main.discharge')"
|
|
||||||
accept="application/pdf,image/*"
|
|
||||||
:required="true"
|
|
||||||
:readonly="mainLocked"
|
|
||||||
:clearable="true"
|
|
||||||
:error="mainErrors.errors.dischargeDocument"
|
|
||||||
@clear="main.dischargeDocumentIri = null"
|
|
||||||
/>
|
|
||||||
<div v-else class="hidden xl:block"></div>
|
|
||||||
|
|
||||||
<!-- « Affréter » : toujours en colonne 4 de la ligne 1 (colonne 3
|
|
||||||
réservée à la décharge ci-dessus). Wrapper h-12 + centrage vertical
|
|
||||||
pour aligner la case sur la ligne de champ des inputs/selects. -->
|
|
||||||
<div class="flex h-12 items-center">
|
|
||||||
<MalioCheckbox
|
|
||||||
id="carrier-is-chartered"
|
|
||||||
:label="t('transport.carriers.form.main.isChartered')"
|
|
||||||
:model-value="main.isChartered"
|
|
||||||
:readonly="mainLocked"
|
|
||||||
:reserve-message-space="false"
|
|
||||||
@update:model-value="(val: boolean) => main.isChartered = val"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RG-4.03 : champs d'affretement (ligne 2) visibles + obligatoires si
|
|
||||||
« Affreter ». La ligne 1 étant pleine (4 colonnes), ils démarrent
|
|
||||||
naturellement en colonne 1 de la ligne 2. -->
|
|
||||||
<template v-if="showCharteredFields">
|
|
||||||
<!-- Indexation : montant en % (icône à droite), plafonné à 100. La
|
|
||||||
:key force le ré-affichage du champ contrôlé quand on plafonne
|
|
||||||
(sinon le modelValue inchangé n'est pas re-synchronisé par Vue). -->
|
|
||||||
<MalioInputAmount
|
|
||||||
:key="indexationKey"
|
|
||||||
:model-value="main.indexationRate"
|
|
||||||
:label="t('transport.carriers.form.main.indexationRate')"
|
|
||||||
icon-name="mdi:percent"
|
|
||||||
icon-position="right"
|
|
||||||
:required="true"
|
|
||||||
:readonly="mainLocked"
|
|
||||||
:error="mainErrors.errors.indexationRate"
|
|
||||||
@update:model-value="onIndexationInput"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Contenant : Benne / Fond mouvant en radios, centrés (h-12) comme
|
|
||||||
à l'onglet Prix (Benne par défaut). -->
|
|
||||||
<div>
|
|
||||||
<div class="flex h-12 items-center gap-4">
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="main.containerType"
|
|
||||||
name="carrier-main-container"
|
|
||||||
value="BENNE"
|
|
||||||
:label="t('transport.carriers.containerType.BENNE')"
|
|
||||||
:disabled="mainLocked"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
|
|
||||||
/>
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="main.containerType"
|
|
||||||
name="carrier-main-container"
|
|
||||||
value="FOND_MOUVANT"
|
|
||||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
|
||||||
:disabled="mainLocked"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="mainErrors.errors.containerType" class="ml-[2px] text-xs text-m-danger">{{ mainErrors.errors.containerType }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Volume m³ : champ texte restreint aux nombres à décimales (point). -->
|
|
||||||
<MalioInputText
|
|
||||||
:model-value="main.volumeM3"
|
|
||||||
:label="t('transport.carriers.form.main.volumeM3')"
|
|
||||||
:required="true"
|
|
||||||
:readonly="mainLocked"
|
|
||||||
:error="mainErrors.errors.volumeM3"
|
|
||||||
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!mainLocked" class="mt-12 flex justify-center">
|
|
||||||
<MalioButton
|
|
||||||
variant="primary"
|
|
||||||
:label="t('transport.carriers.form.submit')"
|
|
||||||
:disabled="mainSubmitting"
|
|
||||||
@click="onSubmitMain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Onglets a validation incrementale ─────────────────────────────
|
|
||||||
Barre Qualimat · Adresses · Contacts · Prix. Onglet Qualimat = saisie
|
|
||||||
assistee (table de selection) ; Adresses / Contacts / Prix arrivent aux
|
|
||||||
tickets suivants (placeholders « A venir »). -->
|
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
|
||||||
<!-- Onglet Qualimat : datatable paginé filtré par le NOM du transporteur
|
|
||||||
(pas de champ de recherche dédié — RG-4.01 / 4.04). -->
|
|
||||||
<template #qualimat>
|
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
|
||||||
<!-- table-fixed : 1re colonne (radio) étroite, les 3 autres à parts égales. -->
|
|
||||||
<MalioDataTable
|
|
||||||
class="qualimat-table"
|
|
||||||
table-class="table-fixed"
|
|
||||||
:columns="qualimatColumns"
|
|
||||||
:items="qualimatRows"
|
|
||||||
:total-items="qualimatTotalDisplay"
|
|
||||||
:page="qualimatPage"
|
|
||||||
:per-page="qualimatPerPage"
|
|
||||||
:per-page-options="qualimatPerPageOptions"
|
|
||||||
row-clickable
|
|
||||||
:empty-message="qualimatEmptyMessage"
|
|
||||||
@row-click="onQualimatRowClick"
|
|
||||||
@update:page="qualimatGoToPage"
|
|
||||||
@update:per-page="qualimatSetPerPage"
|
|
||||||
>
|
|
||||||
<!-- Radio reflétant la ligne QUALIMAT intégrée (lecture). -->
|
|
||||||
<template #cell-select="{ item }">
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="main.qualimatCarrierIri"
|
|
||||||
name="qualimat-row"
|
|
||||||
:value="item.iri"
|
|
||||||
group-class="mt-0"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<!-- Date de validité : fond rouge si périmée (RG-4.04). -->
|
|
||||||
<template #cell-validityDate="{ item }">
|
|
||||||
<span
|
|
||||||
v-if="item.validityDate"
|
|
||||||
:class="isExpired(item.validityDate as string) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
|
|
||||||
>
|
|
||||||
{{ formatDateFr(item.validityDate as string) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</MalioDataTable>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Onglet Adresses (ERP-167) : un bloc par adresse + BAN. RG-4.05
|
|
||||||
préremplissage si QUALIMAT ; RG-4.07 pas de Valider si QUALIMAT. -->
|
|
||||||
<template #addresses>
|
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
|
||||||
<CarrierAddressBlock
|
|
||||||
v-for="(address, index) in addresses"
|
|
||||||
:key="index"
|
|
||||||
:model-value="address"
|
|
||||||
:country-options="countryOptions"
|
|
||||||
:removable="isRowRemovable(addresses, index)"
|
|
||||||
:readonly="isQualimat || isValidated('addresses')"
|
|
||||||
:errors="addressErrors[index]"
|
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
|
||||||
@remove="askRemoveAddress(index)"
|
|
||||||
@degraded="onAddressDegraded"
|
|
||||||
/>
|
|
||||||
<!-- RG-4.07 : pas de bouton Valider pour un transporteur QUALIMAT
|
|
||||||
(adresse copiée et persistée automatiquement). -->
|
|
||||||
<div v-if="!isQualimat && !isValidated('addresses')" class="flex justify-center gap-6">
|
|
||||||
<MalioButton
|
|
||||||
variant="secondary"
|
|
||||||
icon-name="mdi:add-bold"
|
|
||||||
icon-position="left"
|
|
||||||
:label="t('transport.carriers.form.address.add')"
|
|
||||||
:disabled="!canAddAddress"
|
|
||||||
@click="addAddress"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="primary"
|
|
||||||
:label="t('transport.carriers.form.submit')"
|
|
||||||
:disabled="tabSubmitting || carrierId === null"
|
|
||||||
@click="onSubmitAddresses"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Onglet Contacts (ERP-168) : un bloc par contact (RG-4.08 ≥ 1 champ,
|
|
||||||
max 2 téléphones). Erreurs 422 par ligne. -->
|
|
||||||
<template #contacts>
|
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
|
||||||
<CarrierContactBlock
|
|
||||||
v-for="(contact, index) in contacts"
|
|
||||||
:key="index"
|
|
||||||
:model-value="contact"
|
|
||||||
:removable="isRowRemovable(contacts, index)"
|
|
||||||
:readonly="isValidated('contacts')"
|
|
||||||
:errors="contactErrors[index]"
|
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
|
||||||
@remove="askRemoveContact(index)"
|
|
||||||
/>
|
|
||||||
<div v-if="!isValidated('contacts')" class="flex justify-center gap-6">
|
|
||||||
<MalioButton
|
|
||||||
variant="secondary"
|
|
||||||
icon-name="mdi:add-bold"
|
|
||||||
icon-position="left"
|
|
||||||
:label="t('transport.carriers.form.contact.add')"
|
|
||||||
:disabled="!canAddContact"
|
|
||||||
@click="addContact"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="primary"
|
|
||||||
:label="t('transport.carriers.form.submit')"
|
|
||||||
:disabled="tabSubmitting || carrierId === null"
|
|
||||||
@click="onSubmitContacts"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Onglet Prix (ERP-169) : blocs multiples, branche CLIENT/FOURNISSEUR
|
|
||||||
(RG-4.09→4.11). Démarre vide ; l'utilisateur ajoute via « + Nouveau prix ». -->
|
|
||||||
<template #prices>
|
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
|
||||||
<CarrierPriceBlock
|
|
||||||
v-for="(price, index) in prices"
|
|
||||||
:key="index"
|
|
||||||
:model-value="price"
|
|
||||||
:client-options="clientOptions"
|
|
||||||
:supplier-options="supplierOptions"
|
|
||||||
:site-options="siteOptions"
|
|
||||||
:removable="!isValidated('prices')"
|
|
||||||
:readonly="isValidated('prices')"
|
|
||||||
:errors="priceErrors[index]"
|
|
||||||
@update:model-value="(v) => prices[index] = v"
|
|
||||||
@remove="askRemovePrice(index)"
|
|
||||||
/>
|
|
||||||
<div v-if="!isValidated('prices')" class="flex justify-center gap-6">
|
|
||||||
<MalioButton
|
|
||||||
variant="secondary"
|
|
||||||
icon-name="mdi:add-bold"
|
|
||||||
icon-position="left"
|
|
||||||
:label="t('transport.carriers.form.price.add')"
|
|
||||||
:disabled="!canAddPrice"
|
|
||||||
@click="addPrice"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="primary"
|
|
||||||
:label="t('transport.carriers.form.submit')"
|
|
||||||
:disabled="tabSubmitting || carrierId === null"
|
|
||||||
@click="onSubmitPrices"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Plus d'onglet placeholder : tous les onglets ont leur contenu. -->
|
|
||||||
<template
|
|
||||||
v-for="key in placeholderTabs"
|
|
||||||
:key="key"
|
|
||||||
#[key]
|
|
||||||
>
|
|
||||||
<div class="mt-12 flex justify-center text-m-muted">
|
|
||||||
{{ t('transport.carriers.form.comingSoon') }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</MalioTabList>
|
|
||||||
|
|
||||||
<!-- Modal de confirmation d'integration QUALIMAT (RG-4.01). -->
|
|
||||||
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
|
|
||||||
<template #header>
|
|
||||||
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.qualimat.confirm.title') }}</h2>
|
|
||||||
</template>
|
|
||||||
<p>{{ t('transport.carriers.form.qualimat.confirm.message') }}</p>
|
|
||||||
<template #footer>
|
|
||||||
<MalioButton
|
|
||||||
variant="secondary"
|
|
||||||
button-class="flex-1"
|
|
||||||
:label="t('transport.carriers.form.qualimat.confirm.cancel')"
|
|
||||||
@click="confirmOpen = false"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="primary"
|
|
||||||
button-class="flex-1"
|
|
||||||
:label="t('transport.carriers.form.qualimat.confirm.confirm')"
|
|
||||||
@click="confirmIntegrate"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</MalioModal>
|
|
||||||
|
|
||||||
<!-- Modal de confirmation de suppression (bloc adresse). -->
|
|
||||||
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
|
|
||||||
<template #header>
|
|
||||||
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
|
|
||||||
</template>
|
|
||||||
<p>{{ t('transport.carriers.form.confirmDelete.message') }}</p>
|
|
||||||
<template #footer>
|
|
||||||
<MalioButton
|
|
||||||
variant="secondary"
|
|
||||||
button-class="flex-1"
|
|
||||||
:label="t('transport.carriers.form.confirmDelete.cancel')"
|
|
||||||
@click="deleteConfirm.open = false"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="danger"
|
|
||||||
button-class="flex-1"
|
|
||||||
:label="t('transport.carriers.form.confirmDelete.confirm')"
|
|
||||||
@click="runDeleteConfirm"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</MalioModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
||||||
import { debounce } from '~/shared/utils/debounce'
|
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
|
||||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
|
||||||
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
|
||||||
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
|
||||||
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
|
||||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
|
||||||
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
|
||||||
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
|
||||||
|
|
||||||
interface SelectOption {
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const api = useApi()
|
|
||||||
const router = useRouter()
|
|
||||||
const toast = useToast()
|
|
||||||
const { can } = usePermissions()
|
|
||||||
|
|
||||||
useHead({ title: t('transport.carriers.form.title') })
|
|
||||||
|
|
||||||
// Gating de la route : la creation est reservee a `manage` (Admin / Bureau).
|
|
||||||
// Commerciale (consultation seule), Compta et Usine sont rediriges vers le repertoire.
|
|
||||||
if (!can('transport.carriers.manage')) {
|
|
||||||
await navigateTo('/carriers')
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
main,
|
|
||||||
carrierId,
|
|
||||||
mainLocked,
|
|
||||||
mainSubmitting,
|
|
||||||
tabSubmitting,
|
|
||||||
mainErrors,
|
|
||||||
isLiot,
|
|
||||||
isQualimat,
|
|
||||||
certificationReadonly,
|
|
||||||
showCharteredFields,
|
|
||||||
showDischarge,
|
|
||||||
tabKeys,
|
|
||||||
activeTab,
|
|
||||||
unlockedIndex,
|
|
||||||
isValidated,
|
|
||||||
addresses,
|
|
||||||
addressErrors,
|
|
||||||
canAddAddress,
|
|
||||||
addAddress,
|
|
||||||
removeAddress,
|
|
||||||
submitAddresses,
|
|
||||||
contacts,
|
|
||||||
contactErrors,
|
|
||||||
canAddContact,
|
|
||||||
addContact,
|
|
||||||
removeContact,
|
|
||||||
submitContacts,
|
|
||||||
prices,
|
|
||||||
priceErrors,
|
|
||||||
canAddPrice,
|
|
||||||
addPrice,
|
|
||||||
removePrice,
|
|
||||||
submitPrices,
|
|
||||||
submitMain,
|
|
||||||
applyQualimatSelection,
|
|
||||||
} = useCarrierForm()
|
|
||||||
|
|
||||||
const {
|
|
||||||
items: qualimatItems,
|
|
||||||
totalItems: qualimatTotal,
|
|
||||||
currentPage: qualimatPage,
|
|
||||||
itemsPerPage: qualimatPerPage,
|
|
||||||
itemsPerPageOptions: qualimatPerPageOptions,
|
|
||||||
goToPage: qualimatGoToPage,
|
|
||||||
setItemsPerPage: qualimatSetPerPage,
|
|
||||||
setFilters: qualimatSetFilters,
|
|
||||||
} = useQualimatSearch()
|
|
||||||
|
|
||||||
// Certifications selectionnables manuellement (spec § Formulaire principal) :
|
|
||||||
// GMP+ / OVOCOM / Compte-propre / Autre. QUALIMAT n'y figure PAS — il est posé par
|
|
||||||
// la saisie assistee (onglet Qualimat). On l'ajoute cependant aux options QUAND il
|
|
||||||
// est deja selectionne (transporteur QUALIMAT integre), uniquement pour AFFICHER
|
|
||||||
// son libelle dans le select en lecture seule.
|
|
||||||
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
|
|
||||||
|
|
||||||
const certificationOptions = computed<SelectOption[]>(() => {
|
|
||||||
const codes: string[] = [...SELECTABLE_CERTIFICATIONS]
|
|
||||||
if (main.certificationType === 'QUALIMAT') {
|
|
||||||
codes.unshift('QUALIMAT')
|
|
||||||
}
|
|
||||||
return codes.map(code => ({
|
|
||||||
value: code,
|
|
||||||
label: t(`transport.carriers.certification.${code}`),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Colonnes du datatable de selection QUALIMAT (radio / Nom / Adresse / Validite).
|
|
||||||
const qualimatColumns = [
|
|
||||||
{ key: 'select', label: '' },
|
|
||||||
{ key: 'name', label: t('transport.carriers.form.qualimat.columns.name') },
|
|
||||||
{ key: 'address', label: t('transport.carriers.form.qualimat.columns.address') },
|
|
||||||
{ key: 'validityDate', label: t('transport.carriers.form.qualimat.columns.validityDate') },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Le datatable n'affiche QUE des résultats de recherche : vide tant que le Nom n'est
|
|
||||||
// pas saisi (pas de liste complète par défaut). `main.name` pilote l'affichage.
|
|
||||||
const hasQualimatSearch = computed(() => main.name.trim() !== '')
|
|
||||||
|
|
||||||
// Lignes « plates » pour MalioDataTable (l'IRI sert au radio + à retrouver la ligne
|
|
||||||
// source au clic). Le détail QUALIMAT complet reste dans `qualimatItems`.
|
|
||||||
const qualimatRows = computed(() => {
|
|
||||||
if (!hasQualimatSearch.value) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return qualimatItems.value.map(row => ({
|
|
||||||
id: row.id,
|
|
||||||
iri: row['@id'],
|
|
||||||
name: row.name,
|
|
||||||
address: formatQualimatAddress(row),
|
|
||||||
validityDate: row.validityDate,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Total / message vide alignés sur « tableau vide tant qu'on n'a pas recherché ».
|
|
||||||
const qualimatTotalDisplay = computed(() => (hasQualimatSearch.value ? qualimatTotal.value : 0))
|
|
||||||
const qualimatEmptyMessage = computed(() => hasQualimatSearch.value
|
|
||||||
? t('transport.carriers.form.qualimat.empty')
|
|
||||||
: t('transport.carriers.form.qualimat.searchHint'))
|
|
||||||
|
|
||||||
|
|
||||||
// Icone (Iconify) affichee dans chaque onglet, par cle.
|
|
||||||
const TAB_ICONS: Record<string, string> = {
|
|
||||||
qualimat: 'mdi:truck-fast-outline',
|
|
||||||
addresses: 'mdi:map-marker-outline',
|
|
||||||
contacts: 'mdi:account-box-plus-outline',
|
|
||||||
prices: 'mdi:payment',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Onglets desactives tant que le formulaire principal n'est pas valide
|
|
||||||
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
|
|
||||||
const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
|
||||||
key,
|
|
||||||
label: t(`transport.carriers.tab.${key}`),
|
|
||||||
icon: TAB_ICONS[key],
|
|
||||||
disabled: index > unlockedIndex.value,
|
|
||||||
})))
|
|
||||||
|
|
||||||
// Tous les onglets ont désormais leur contenu (qualimat / addresses / contacts / prices).
|
|
||||||
const placeholderTabs = computed(() => tabKeys.value.filter(
|
|
||||||
key => key !== 'qualimat' && key !== 'addresses' && key !== 'contacts' && key !== 'prices',
|
|
||||||
))
|
|
||||||
|
|
||||||
// ── Référentiels de l'onglet Prix (clients / fournisseurs / sites) ───────────
|
|
||||||
const clientOptions = ref<SelectOption[]>([])
|
|
||||||
const supplierOptions = ref<SelectOption[]>([])
|
|
||||||
const siteOptions = ref<SelectOption[]>([])
|
|
||||||
|
|
||||||
/** Charge un référentiel paginé (?pagination=false) et mappe en options { IRI, libellé }. */
|
|
||||||
async function loadOptions(
|
|
||||||
url: string,
|
|
||||||
target: typeof clientOptions,
|
|
||||||
labelOf: (m: Record<string, unknown>) => string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const data = await api.get<{ member?: Record<string, unknown>[] }>(
|
|
||||||
url,
|
|
||||||
{ pagination: 'false' },
|
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
||||||
)
|
|
||||||
target.value = (data.member ?? []).map(m => ({ value: String(m['@id']), label: labelOf(m) }))
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
target.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Charge les référentiels de l'onglet Prix (non bloquant : selects vides si échec). */
|
|
||||||
function loadPriceReferentials(): void {
|
|
||||||
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
|
|
||||||
void loadOptions('/suppliers', supplierOptions, m => String(m.companyName ?? m['@id']))
|
|
||||||
void loadOptions('/sites', siteOptions, m => String(m.name ?? m['@id']))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Onglet Adresses (ERP-167) ────────────────────────────────────────────────
|
|
||||||
// Pays : France garantie en tete meme si /countries echoue (resilience), pour
|
|
||||||
// rester preselectionnable par defaut sur chaque adresse (RG-4.05).
|
|
||||||
const countryOptions = ref<SelectOption[]>([{ value: 'France', label: 'France' }])
|
|
||||||
|
|
||||||
/** Charge le referentiel pays (/api/countries) ; conserve France par defaut si echec. */
|
|
||||||
async function loadCountries(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const data = await api.get<{ member?: { name: string }[] }>(
|
|
||||||
'/countries',
|
|
||||||
{ pagination: 'false' },
|
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
||||||
)
|
|
||||||
const list = (data.member ?? []).map(c => ({ value: c.name, label: c.name }))
|
|
||||||
countryOptions.value = list.some(c => c.value === 'France')
|
|
||||||
? list
|
|
||||||
: [{ value: 'France', label: 'France' }, ...list]
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// Reste sur le fallback France (non bloquant).
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadCountries().catch(() => {})
|
|
||||||
loadPriceReferentials()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Avertissement unique quand l'autocompletion d'adresse bascule en degrade.
|
|
||||||
const addressDegradedNotified = ref(false)
|
|
||||||
|
|
||||||
function onAddressDegraded(): void {
|
|
||||||
if (addressDegradedNotified.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
addressDegradedNotified.value = true
|
|
||||||
toast.warning({
|
|
||||||
title: t('transport.carriers.toast.error'),
|
|
||||||
message: t('transport.carriers.form.address.degraded'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Message d'erreur affichable (toast) extrait d'une erreur API — jamais undefined. */
|
|
||||||
function apiErrorMessage(error: unknown): string {
|
|
||||||
const data = (error as { response?: { _data?: unknown } })?.response?._data
|
|
||||||
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Valide l'onglet Adresses (POST/PATCH par ligne ; avance gere par le composable). */
|
|
||||||
async function onSubmitAddresses(): Promise<void> {
|
|
||||||
const ok = await submitAddresses(error => toast.error({
|
|
||||||
title: t('transport.carriers.toast.error'),
|
|
||||||
message: apiErrorMessage(error),
|
|
||||||
}))
|
|
||||||
if (ok) {
|
|
||||||
toast.success({ title: t('transport.carriers.toast.addressSaved') })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal de confirmation de suppression (générique : bloc adresse OU contact).
|
|
||||||
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
|
|
||||||
|
|
||||||
function askRemoveAddress(index: number): void {
|
|
||||||
deleteConfirm.action = () => { void removeAddress(index) }
|
|
||||||
deleteConfirm.open = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Valide l'onglet Contacts (POST/PATCH par ligne ; avance gérée par le composable). */
|
|
||||||
async function onSubmitContacts(): Promise<void> {
|
|
||||||
const ok = await submitContacts(error => toast.error({
|
|
||||||
title: t('transport.carriers.toast.error'),
|
|
||||||
message: apiErrorMessage(error),
|
|
||||||
}))
|
|
||||||
if (ok) {
|
|
||||||
toast.success({ title: t('transport.carriers.toast.contactSaved') })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function askRemoveContact(index: number): void {
|
|
||||||
deleteConfirm.action = () => { void removeContact(index) }
|
|
||||||
deleteConfirm.open = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Valide l'onglet Prix (POST/PATCH par ligne ; avance gérée par le composable). */
|
|
||||||
async function onSubmitPrices(): Promise<void> {
|
|
||||||
const ok = await submitPrices(error => toast.error({
|
|
||||||
title: t('transport.carriers.toast.error'),
|
|
||||||
message: apiErrorMessage(error),
|
|
||||||
}))
|
|
||||||
if (ok) {
|
|
||||||
toast.success({ title: t('transport.carriers.toast.priceSaved') })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function askRemovePrice(index: number): void {
|
|
||||||
deleteConfirm.action = () => { void removePrice(index) }
|
|
||||||
deleteConfirm.open = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function runDeleteConfirm(): void {
|
|
||||||
deleteConfirm.action?.()
|
|
||||||
deleteConfirm.action = null
|
|
||||||
deleteConfirm.open = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Saisie assistee QUALIMAT (onglet Qualimat) ───────────────────────────────
|
|
||||||
const confirmOpen = ref(false)
|
|
||||||
const pendingRow = ref<QualimatCarrierRow | null>(null)
|
|
||||||
|
|
||||||
// Le datatable QUALIMAT est filtré par le NOM saisi dans le formulaire principal
|
|
||||||
// (RG-4.01) — pas de champ de recherche dédié. Aucune recherche tant que le Nom est
|
|
||||||
// vide (tableau vide par défaut) ; sinon re-filtrage debouncé à chaque frappe.
|
|
||||||
const filterQualimatByName = debounce((term: string) => {
|
|
||||||
if (term.trim() === '') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void qualimatSetFilters({ search: term })
|
|
||||||
}, 300)
|
|
||||||
|
|
||||||
watch(() => main.name, term => filterQualimatByName(term))
|
|
||||||
|
|
||||||
/** Adresse QUALIMAT condensee pour la colonne « Adresse » (voie · CP · ville). */
|
|
||||||
function formatQualimatAddress(row: QualimatCarrierRow): string {
|
|
||||||
return [row.address, row.postalCode, row.city].filter(Boolean).join(' · ')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** RG-4.04 : un agrement est perime si sa date de validite est < aujourd'hui. */
|
|
||||||
function isExpired(value: string): boolean {
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const today = new Date()
|
|
||||||
today.setHours(0, 0, 0, 0)
|
|
||||||
date.setHours(0, 0, 0, 0)
|
|
||||||
return date.getTime() < today.getTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format court francais JJ-MM-AAAA (chaine vide si date absente / invalide). */
|
|
||||||
function formatDateFr(value: string | null | undefined): string {
|
|
||||||
if (!value) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
return `${day}-${month}-${date.getFullYear()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clic sur une ligne du datatable → retrouve la ligne QUALIMAT source + modal. */
|
|
||||||
function onQualimatRowClick(item: Record<string, unknown>): void {
|
|
||||||
const row = qualimatItems.value.find(r => r.id === item.id)
|
|
||||||
if (row) {
|
|
||||||
askIntegrate(row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ouvre la modal de confirmation d'integration pour une ligne QUALIMAT. */
|
|
||||||
function askIntegrate(row: QualimatCarrierRow): void {
|
|
||||||
pendingRow.value = row
|
|
||||||
confirmOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Confirme l'integration : copie + PATCH (cf. useCarrierForm.applyQualimatSelection). */
|
|
||||||
async function confirmIntegrate(): Promise<void> {
|
|
||||||
const row = pendingRow.value
|
|
||||||
confirmOpen.value = false
|
|
||||||
if (row === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ok = await applyQualimatSelection(row)
|
|
||||||
if (ok) {
|
|
||||||
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
|
|
||||||
// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
|
|
||||||
const indexationKey = ref(0)
|
|
||||||
|
|
||||||
/** Saisie de l'indexation : plafonne à 100 et re-synchronise le champ si plafonné. */
|
|
||||||
function onIndexationInput(value: string): void {
|
|
||||||
const clamped = clampPercent(value)
|
|
||||||
main.indexationRate = clamped
|
|
||||||
if (clamped !== value) {
|
|
||||||
indexationKey.value += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retour vers le repertoire transporteurs (fleche d'en-tete). */
|
|
||||||
function goBack(): void {
|
|
||||||
router.push('/carriers')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valide le formulaire principal (POST /carriers ; bascule geree par le composable).
|
|
||||||
* RG-4.07 : pour un transporteur QUALIMAT, l'adresse copiee est persistee
|
|
||||||
* automatiquement (pas de bouton Valider dans l'onglet Adresses).
|
|
||||||
*/
|
|
||||||
async function onSubmitMain(): Promise<void> {
|
|
||||||
const ok = await submitMain()
|
|
||||||
if (ok && isQualimat.value) {
|
|
||||||
await submitAddresses(error => toast.error({
|
|
||||||
title: t('transport.carriers.toast.error'),
|
|
||||||
message: apiErrorMessage(error),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Datatable QUALIMAT en table-fixed : la colonne radio (1re) reste étroite,
|
|
||||||
les 3 autres (nom / adresse / validité) se partagent l'espace à parts égales. */
|
|
||||||
.qualimat-table :deep(th:first-child),
|
|
||||||
.qualimat-table :deep(td:first-child) {
|
|
||||||
width: 56px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
/**
|
|
||||||
* Types du workflow « Ajouter un transporteur » (M4 Transport, ERP-165 / ERP-166).
|
|
||||||
*
|
|
||||||
* Périmètre :
|
|
||||||
* - ERP-165 : formulaire PRINCIPAL minimal (Nom + Certification + Affréter).
|
|
||||||
* - ERP-166 : champs CONDITIONNELS du formulaire principal (indexation / benne /
|
|
||||||
* volume si affrété — RG-4.03 ; décharge si AUTRE — RG-4.02 ; immatriculations
|
|
||||||
* LIOT — RG-4.01) + saisie assistée QUALIMAT (copie name / certification /
|
|
||||||
* adresse + FK qualimatCarrier — RG-4.01 / § 2.5).
|
|
||||||
*
|
|
||||||
* L'upload réel de la décharge (file → IRI via useUpload) arrive à ERP-171 ; ici
|
|
||||||
* on porte seulement l'IRI résolu (`dischargeDocumentIri`).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Brouillon du formulaire principal. Les décimales (indexation / volume) sont
|
|
||||||
* portées en `string` car `MalioInputNumber` émet une chaîne ; le serveur parse.
|
|
||||||
* `certificationType` est un code enum back (GMP_PLUS | OVOCOM | COMPTE_PROPRE |
|
|
||||||
* AUTRE | QUALIMAT — ce dernier posé par la saisie assistée) ou `null`.
|
|
||||||
* `containerType` vaut `BENNE` | `FOND_MOUVANT` (radio) ou `null`.
|
|
||||||
*/
|
|
||||||
export interface CarrierMainDraft {
|
|
||||||
name: string
|
|
||||||
certificationType: string | null
|
|
||||||
isChartered: boolean
|
|
||||||
indexationRate: string
|
|
||||||
containerType: string | null
|
|
||||||
volumeM3: string
|
|
||||||
liotPlates: string
|
|
||||||
/** IRI du document de décharge (résolu par useUpload — ERP-171). */
|
|
||||||
dischargeDocumentIri: string | null
|
|
||||||
/** IRI de la ligne QUALIMAT liée (saisie assistée — null si non QUALIMAT). */
|
|
||||||
qualimatCarrierIri: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Brouillon principal vide (état initial du formulaire de création). */
|
|
||||||
export function emptyCarrierMain(): CarrierMainDraft {
|
|
||||||
return {
|
|
||||||
name: '',
|
|
||||||
certificationType: null,
|
|
||||||
isChartered: false,
|
|
||||||
indexationRate: '',
|
|
||||||
// Défaut métier : Benne pré-sélectionné (radio du formulaire principal).
|
|
||||||
containerType: 'BENNE',
|
|
||||||
volumeM3: '',
|
|
||||||
liotPlates: '',
|
|
||||||
dischargeDocumentIri: null,
|
|
||||||
qualimatCarrierIri: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adresse copiée depuis le référentiel QUALIMAT à la sélection (RG-4.01 / § 2.5).
|
|
||||||
* Stockée dans l'état du formulaire pour alimenter l'onglet Adresses (ticket
|
|
||||||
* ultérieur) ; pré-remplie « France » côté pays par défaut.
|
|
||||||
*/
|
|
||||||
export interface CarrierAddressCopy {
|
|
||||||
country: string
|
|
||||||
postalCode: string
|
|
||||||
city: string
|
|
||||||
street: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adresse copiée vide. */
|
|
||||||
export function emptyCarrierAddressCopy(): CarrierAddressCopy {
|
|
||||||
return { country: 'France', postalCode: '', city: '', street: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Brouillon d'un bloc Adresse (onglet Adresses, ERP-167) — sous-ressource
|
|
||||||
* `CarrierAddress` (groupe `carrier:write:addresses`). Version SIMPLIFIÉE de
|
|
||||||
* l'adresse fournisseur (M2) / prestataire (M3) : pas de sites / catégories /
|
|
||||||
* contacts ni type d'adresse (les sites du M4 vivent dans l'onglet Prix).
|
|
||||||
*/
|
|
||||||
export interface CarrierAddressFormDraft {
|
|
||||||
/** Id serveur une fois l'adresse créée (null tant que non persistée). */
|
|
||||||
id: number | null
|
|
||||||
/** Pays (chaîne libre, défaut « France »). */
|
|
||||||
country: string
|
|
||||||
postalCode: string | null
|
|
||||||
city: string | null
|
|
||||||
street: string | null
|
|
||||||
streetComplement: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Brouillon d'adresse vide (pays France par défaut, RG-4.05). */
|
|
||||||
export function emptyCarrierAddress(): CarrierAddressFormDraft {
|
|
||||||
return {
|
|
||||||
id: null,
|
|
||||||
country: 'France',
|
|
||||||
postalCode: null,
|
|
||||||
city: null,
|
|
||||||
street: null,
|
|
||||||
streetComplement: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Brouillon d'un bloc Contact (onglet Contacts, ERP-168) — sous-ressource
|
|
||||||
* `CarrierContact` (groupe `carrier:write:contacts`). Les téléphones sont saisis
|
|
||||||
* en `phonePrimary` / `phoneSecondary` côté UI, puis envoyés au back sous forme du
|
|
||||||
* tableau `phones` (max 2 — RG-4.08). `hasSecondaryPhone` pilote l'affichage du 2e
|
|
||||||
* numéro (révélé via le bouton « + »). Pas d'`iri` : aucune relation M2M depuis
|
|
||||||
* l'adresse au M4 (≠ M3).
|
|
||||||
*/
|
|
||||||
export interface CarrierContactFormDraft {
|
|
||||||
/** Id serveur une fois le contact créé (null tant que non persisté). */
|
|
||||||
id: number | null
|
|
||||||
firstName: string | null
|
|
||||||
lastName: string | null
|
|
||||||
jobTitle: string | null
|
|
||||||
phonePrimary: string | null
|
|
||||||
phoneSecondary: string | null
|
|
||||||
email: string | null
|
|
||||||
/** UI : le 2e numéro a été révélé via le bouton « + » (max 2 téléphones). */
|
|
||||||
hasSecondaryPhone: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Brouillon de contact vide (état initial d'un bloc Contact). */
|
|
||||||
export function emptyCarrierContact(): CarrierContactFormDraft {
|
|
||||||
return {
|
|
||||||
id: null,
|
|
||||||
firstName: null,
|
|
||||||
lastName: null,
|
|
||||||
jobTitle: null,
|
|
||||||
phonePrimary: null,
|
|
||||||
phoneSecondary: null,
|
|
||||||
email: null,
|
|
||||||
hasSecondaryPhone: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Brouillon d'un bloc Prix (onglet Prix, ERP-169 — RG-4.09→4.11). `direction`
|
|
||||||
* pilote la branche active : CLIENT (client + adresse de livraison + site de
|
|
||||||
* départ) ou FOURNISSEUR (fournisseur + adresse d'appro + site de livraison). Les
|
|
||||||
* relations partent au back en IRI (string). `price` est une chaîne (MalioInputAmount).
|
|
||||||
*/
|
|
||||||
export interface CarrierPriceFormDraft {
|
|
||||||
id: number | null
|
|
||||||
direction: 'CLIENT' | 'FOURNISSEUR' | null
|
|
||||||
// Branche CLIENT (RG-4.10).
|
|
||||||
clientIri: string | null
|
|
||||||
clientDeliveryAddressIri: string | null
|
|
||||||
departureSiteIri: string | null
|
|
||||||
// Branche FOURNISSEUR (RG-4.11).
|
|
||||||
supplierIri: string | null
|
|
||||||
supplierSupplyAddressIri: string | null
|
|
||||||
deliverySiteIri: string | null
|
|
||||||
// Communs (toujours requis).
|
|
||||||
containerType: string | null
|
|
||||||
pricingUnit: string | null
|
|
||||||
price: string | null
|
|
||||||
priceState: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Brouillon de prix vide (état initial d'un bloc Prix : tout masqué tant que direction null). */
|
|
||||||
export function emptyCarrierPrice(): CarrierPriceFormDraft {
|
|
||||||
return {
|
|
||||||
id: null,
|
|
||||||
// Défaut métier : sens CLIENT pré-sélectionné (un bloc prix CLIENT est présent
|
|
||||||
// d'office à l'ouverture de l'onglet).
|
|
||||||
direction: 'CLIENT',
|
|
||||||
clientIri: null,
|
|
||||||
clientDeliveryAddressIri: null,
|
|
||||||
departureSiteIri: null,
|
|
||||||
supplierIri: null,
|
|
||||||
supplierSupplyAddressIri: null,
|
|
||||||
deliverySiteIri: null,
|
|
||||||
// Défauts métier : Benne + Forfait pré-sélectionnés à l'ajout d'un bloc prix.
|
|
||||||
containerType: 'BENNE',
|
|
||||||
pricingUnit: 'FORFAIT',
|
|
||||||
price: null,
|
|
||||||
priceState: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Réponse du POST / PATCH principal (groupe `carrier:read`). Le serveur renvoie
|
|
||||||
* le nom normalisé (UPPERCASE, RG-4.13) que l'UI réaffiche tel quel.
|
|
||||||
*/
|
|
||||||
export interface CarrierMainResponse {
|
|
||||||
id: number
|
|
||||||
name: string | null
|
|
||||||
certificationType: string | null
|
|
||||||
'@id'?: string
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import {
|
|
||||||
canEditCarrier,
|
|
||||||
iriOf,
|
|
||||||
labelOfRelation,
|
|
||||||
mapAddressToDraft,
|
|
||||||
mapContactToDraft,
|
|
||||||
mapMainToDraft,
|
|
||||||
mapPriceToDraft,
|
|
||||||
showArchiveAction,
|
|
||||||
showRestoreAction,
|
|
||||||
type CarrierDetail,
|
|
||||||
} from '../carrierMappers'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests des mappers détail → brouillons (M4 Transport, ERP-170) : peuplent les écrans
|
|
||||||
* Consultation / Modification depuis la SEULE réponse `GET /api/carriers/{id}`, et
|
|
||||||
* helpers de visibilité des boutons (Modifier / Archiver / Restaurer) selon la permission.
|
|
||||||
*/
|
|
||||||
describe('carrierMappers', () => {
|
|
||||||
it('iriOf : objet embarqué, IRI nu, ou null', () => {
|
|
||||||
expect(iriOf({ '@id': '/api/clients/3' })).toBe('/api/clients/3')
|
|
||||||
expect(iriOf('/api/sites/1')).toBe('/api/sites/1')
|
|
||||||
expect(iriOf(null)).toBeNull()
|
|
||||||
expect(iriOf(undefined)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('labelOfRelation : name (site) à défaut adresse condensée', () => {
|
|
||||||
expect(labelOfRelation({ '@id': '/api/sites/1', name: 'Châtellerault' })).toBe('Châtellerault')
|
|
||||||
expect(labelOfRelation({ '@id': '/api/client_addresses/8', street: '1 rue X', postalCode: '86000', city: 'Poitiers' })).toBe('1 rue X · 86000 · Poitiers')
|
|
||||||
expect(labelOfRelation('/api/sites/1')).toBe('')
|
|
||||||
expect(labelOfRelation(null)).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('mapMainToDraft : scalaires + IRI décharge / qualimat', () => {
|
|
||||||
const detail: CarrierDetail = {
|
|
||||||
'@id': '/api/carriers/7',
|
|
||||||
id: 7,
|
|
||||||
name: 'TRANSPORTS ACME',
|
|
||||||
certificationType: 'QUALIMAT',
|
|
||||||
isChartered: true,
|
|
||||||
indexationRate: '5.00',
|
|
||||||
containerType: 'BENNE',
|
|
||||||
volumeM3: '30.00',
|
|
||||||
dischargeDocument: { '@id': '/api/uploaded_documents/4' },
|
|
||||||
qualimatCarrier: { '@id': '/api/qualimat_carriers/42' },
|
|
||||||
}
|
|
||||||
expect(mapMainToDraft(detail)).toEqual({
|
|
||||||
name: 'TRANSPORTS ACME',
|
|
||||||
certificationType: 'QUALIMAT',
|
|
||||||
isChartered: true,
|
|
||||||
indexationRate: '5.00',
|
|
||||||
containerType: 'BENNE',
|
|
||||||
volumeM3: '30.00',
|
|
||||||
liotPlates: '',
|
|
||||||
dischargeDocumentIri: '/api/uploaded_documents/4',
|
|
||||||
qualimatCarrierIri: '/api/qualimat_carriers/42',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('mapAddressToDraft : pays par défaut France si absent', () => {
|
|
||||||
expect(mapAddressToDraft({ '@id': '/api/carrier_addresses/3', id: 3, postalCode: '86000', city: 'Poitiers' }))
|
|
||||||
.toEqual({ id: 3, country: 'France', postalCode: '86000', city: 'Poitiers', street: null, streetComplement: null })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('mapContactToDraft : hasSecondaryPhone vrai seulement si 2e numéro présent', () => {
|
|
||||||
const one = mapContactToDraft({ '@id': '/api/carrier_contacts/1', id: 1, firstName: 'Jean', phonePrimary: '0102030405' })
|
|
||||||
expect(one.hasSecondaryPhone).toBe(false)
|
|
||||||
expect(one.firstName).toBe('Jean')
|
|
||||||
|
|
||||||
const two = mapContactToDraft({ '@id': '/api/carrier_contacts/2', id: 2, phonePrimary: '0102030405', phoneSecondary: '0605040302' })
|
|
||||||
expect(two.hasSecondaryPhone).toBe(true)
|
|
||||||
expect(two.phoneSecondary).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('mapPriceToDraft : direction + IRIs des relations de branche', () => {
|
|
||||||
const draft = mapPriceToDraft({
|
|
||||||
'@id': '/api/carrier_prices/5',
|
|
||||||
id: 5,
|
|
||||||
direction: 'CLIENT',
|
|
||||||
client: { '@id': '/api/clients/3' },
|
|
||||||
clientDeliveryAddress: { '@id': '/api/client_addresses/8' },
|
|
||||||
departureSite: '/api/sites/1',
|
|
||||||
containerType: 'BENNE',
|
|
||||||
pricingUnit: 'FORFAIT',
|
|
||||||
price: '120.00',
|
|
||||||
priceState: 'EN_COURS',
|
|
||||||
})
|
|
||||||
expect(draft).toMatchObject({
|
|
||||||
id: 5,
|
|
||||||
direction: 'CLIENT',
|
|
||||||
clientIri: '/api/clients/3',
|
|
||||||
clientDeliveryAddressIri: '/api/client_addresses/8',
|
|
||||||
departureSiteIri: '/api/sites/1',
|
|
||||||
supplierIri: null,
|
|
||||||
containerType: 'BENNE',
|
|
||||||
pricingUnit: 'FORFAIT',
|
|
||||||
price: '120.00',
|
|
||||||
priceState: 'EN_COURS',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('visibilité des boutons selon la permission', () => {
|
|
||||||
const can = (granted: string[]) => (code: string) => granted.includes(code)
|
|
||||||
|
|
||||||
// Modifier : seulement avec manage.
|
|
||||||
expect(canEditCarrier(can(['transport.carriers.manage']))).toBe(true)
|
|
||||||
expect(canEditCarrier(can(['transport.carriers.view']))).toBe(false)
|
|
||||||
|
|
||||||
// Archiver : permission archive ET actif ; Restaurer : archive ET archivé.
|
|
||||||
const withArchive = can(['transport.carriers.archive'])
|
|
||||||
const noArchive = can(['transport.carriers.manage'])
|
|
||||||
expect(showArchiveAction(withArchive, false)).toBe(true)
|
|
||||||
expect(showArchiveAction(withArchive, true)).toBe(false)
|
|
||||||
expect(showRestoreAction(withArchive, true)).toBe(true)
|
|
||||||
expect(showRestoreAction(withArchive, false)).toBe(false)
|
|
||||||
expect(showArchiveAction(noArchive, false)).toBe(false)
|
|
||||||
expect(showRestoreAction(noArchive, true)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { clampPercent, sanitizeDecimal } from '../numberInput'
|
|
||||||
|
|
||||||
describe('numberInput — saisie volume / indexation (ERP-170)', () => {
|
|
||||||
it('sanitizeDecimal : ne garde que chiffres + un seul point', () => {
|
|
||||||
expect(sanitizeDecimal('30')).toBe('30')
|
|
||||||
expect(sanitizeDecimal('30.5')).toBe('30.5')
|
|
||||||
expect(sanitizeDecimal('30,5 kg')).toBe('30.5') // virgule FR → point ; espace + lettres retirés
|
|
||||||
expect(sanitizeDecimal('1.2.3')).toBe('1.23') // un seul point conservé
|
|
||||||
expect(sanitizeDecimal('abc12.3x')).toBe('12.3')
|
|
||||||
expect(sanitizeDecimal('')).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clampPercent : plafonne à 100, laisse le reste tel quel', () => {
|
|
||||||
expect(clampPercent('50')).toBe('50')
|
|
||||||
expect(clampPercent('100')).toBe('100')
|
|
||||||
expect(clampPercent('150')).toBe('100')
|
|
||||||
expect(clampPercent('100.01')).toBe('100')
|
|
||||||
expect(clampPercent('12,5')).toBe('12,5') // ≤ 100 → inchangé
|
|
||||||
expect(clampPercent('')).toBe('')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Helpers purs de l'onglet Adresse transporteur (M4 Transport, ERP-167) — miroir
|
|
||||||
* SIMPLIFIÉ de `providerAddress` (M3) / `SupplierAddressBlock` (M2), sans sites /
|
|
||||||
* catégories / contacts (les sites du M4 vivent dans l'onglet Prix). Testables
|
|
||||||
* sans Vue.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-4.05 : gate du bouton « + Nouvelle adresse ». Une adresse est « complète »
|
|
||||||
* (donc on autorise l'ajout d'un nouveau bloc) dès qu'elle porte un code postal,
|
|
||||||
* une ville ET une rue. Les RG conditionnelles (obligatoire si affrété) restent
|
|
||||||
* validées par le back (422 inline) — ce gate empêche seulement d'empiler des
|
|
||||||
* blocs vides.
|
|
||||||
*/
|
|
||||||
export function isCarrierAddressValid(address: CarrierAddressFormDraft): boolean {
|
|
||||||
return Boolean(address.postalCode?.trim() && address.city?.trim() && address.street?.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Payload de la sous-ressource addresses (groupe `carrier:write:addresses`). Les
|
|
||||||
* scalaires sont nullable côté entité : on envoie `null` quand le champ est vide
|
|
||||||
* (le `CarrierAddressProcessor` re-valide la présence si affrété — RG-4.05 — et
|
|
||||||
* renvoie une 422 par champ).
|
|
||||||
*/
|
|
||||||
export function buildCarrierAddressPayload(address: CarrierAddressFormDraft): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
country: address.country,
|
|
||||||
postalCode: address.postalCode || null,
|
|
||||||
city: address.city || null,
|
|
||||||
street: address.street || null,
|
|
||||||
streetComplement: address.streetComplement || null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168) — ALIGNÉ
|
|
||||||
* sur `providerContact.ts` (M3) / les autres modules : mêmes règles de validité et
|
|
||||||
* de gating « + Nouveau contact » (un contact est « nommé » dès qu'il porte un
|
|
||||||
* prénom OU un nom). Seule spécificité M4 conservée : les téléphones partent au back
|
|
||||||
* dans le tableau virtuel `phones` (max 2), mappés par le CarrierContactProcessor.
|
|
||||||
* Testables sans Vue ni API.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
|
|
||||||
|
|
||||||
/** Vrai si une chaîne porte au moins un caractère non-espace. */
|
|
||||||
function isFilled(value: string | null | undefined): boolean {
|
|
||||||
return value !== null && value !== undefined && value.trim() !== ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Un bloc Contact est VIDE tant qu'aucun champ comptant pour la validité n'est
|
|
||||||
* rempli — prénom / nom / fonction / téléphone principal / email. `phoneSecondary`
|
|
||||||
* est EXCLU (aligné M1/M2/M3 : un bloc ne portant qu'un 2e numéro reste vide). Sert
|
|
||||||
* le filtrage des amorces vides à la soumission.
|
|
||||||
*/
|
|
||||||
export function isCarrierContactBlank(contact: CarrierContactFormDraft): boolean {
|
|
||||||
return ![
|
|
||||||
contact.firstName,
|
|
||||||
contact.lastName,
|
|
||||||
contact.jobTitle,
|
|
||||||
contact.phonePrimary,
|
|
||||||
contact.email,
|
|
||||||
].some(isFilled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Un contact est « nommé » (valide) dès qu'il porte un prénom OU un nom — aligné
|
|
||||||
* sur M1/M2/M3. Pilote le gating « + Nouveau contact » : la fonction / le téléphone
|
|
||||||
* / l'email seuls ne suffisent pas pour ajouter un nouveau bloc.
|
|
||||||
*/
|
|
||||||
export function isCarrierContactNamed(contact: CarrierContactFormDraft): boolean {
|
|
||||||
return isFilled(contact.firstName) || isFilled(contact.lastName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Payload de la sous-ressource contacts (groupe `carrier:write:contacts`). Les
|
|
||||||
* chaînes vides partent à null (le serveur normalise/trim). Les téléphones sont
|
|
||||||
* regroupés dans le tableau `phones` (numéros non vides, max 2 — RG-4.08) ; le 2e
|
|
||||||
* numéro n'est inclus que s'il a été révélé (`hasSecondaryPhone`).
|
|
||||||
*/
|
|
||||||
export function buildCarrierContactPayload(contact: CarrierContactFormDraft): Record<string, unknown> {
|
|
||||||
const phones = [
|
|
||||||
contact.phonePrimary,
|
|
||||||
contact.hasSecondaryPhone ? contact.phoneSecondary : null,
|
|
||||||
].filter((phone): phone is string => isFilled(phone))
|
|
||||||
|
|
||||||
return {
|
|
||||||
firstName: contact.firstName || null,
|
|
||||||
lastName: contact.lastName || null,
|
|
||||||
jobTitle: contact.jobTitle || null,
|
|
||||||
email: contact.email || null,
|
|
||||||
phones,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/**
|
|
||||||
* Helpers purs des écrans Consultation / Modification transporteur (M4, ERP-170) —
|
|
||||||
* miroir de `providerDetail.ts` (M3). Mappent le payload `GET /api/carriers/{id}`
|
|
||||||
* (relations embarquées via les groupes `carrier:item:read` + `qualimat:read` +
|
|
||||||
* read-groups cross-module client/supplier/site/adresses) vers les brouillons
|
|
||||||
* « plats » partagés avec les blocs Adresse / Contact / Prix.
|
|
||||||
*
|
|
||||||
* Ne touchent ni à l'API ni à l'état réactif (testables unitairement). Les champs
|
|
||||||
* nuls peuvent être OMIS (skip_null_values) → toujours lire avec `?? null`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { formatPhoneFR } from '~/shared/utils/phone'
|
|
||||||
import type {
|
|
||||||
CarrierAddressFormDraft,
|
|
||||||
CarrierContactFormDraft,
|
|
||||||
CarrierMainDraft,
|
|
||||||
CarrierPriceFormDraft,
|
|
||||||
} from '~/modules/transport/types/carrierForm'
|
|
||||||
|
|
||||||
/** Référence Hydra embarquée minimale (@id toujours présent). */
|
|
||||||
export interface HydraRef {
|
|
||||||
'@id': string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Une relation peut être embarquée (objet), un IRI nu (chaîne) ou absente. */
|
|
||||||
export type Relation = HydraRef | string | null | undefined
|
|
||||||
|
|
||||||
/** Adresse embarquée (groupe carrier:item:read). */
|
|
||||||
export interface CarrierAddressRead extends HydraRef {
|
|
||||||
id: number
|
|
||||||
country?: string | null
|
|
||||||
postalCode?: string | null
|
|
||||||
city?: string | null
|
|
||||||
street?: string | null
|
|
||||||
streetComplement?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Contact embarqué (groupe carrier:item:read). */
|
|
||||||
export interface CarrierContactRead extends HydraRef {
|
|
||||||
id: number
|
|
||||||
firstName?: string | null
|
|
||||||
lastName?: string | null
|
|
||||||
jobTitle?: string | null
|
|
||||||
phonePrimary?: string | null
|
|
||||||
phoneSecondary?: string | null
|
|
||||||
email?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prix embarqué (groupe carrier:item:read + relations cross-module). */
|
|
||||||
export interface CarrierPriceRead extends HydraRef {
|
|
||||||
id: number
|
|
||||||
direction?: string | null
|
|
||||||
client?: Relation
|
|
||||||
clientDeliveryAddress?: Relation
|
|
||||||
departureSite?: Relation
|
|
||||||
supplier?: Relation
|
|
||||||
supplierSupplyAddress?: Relation
|
|
||||||
deliverySite?: Relation
|
|
||||||
containerType?: string | null
|
|
||||||
pricingUnit?: string | null
|
|
||||||
price?: string | null
|
|
||||||
priceState?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Détail d'un transporteur (`GET /api/carriers/{id}`). Tous les champs optionnels :
|
|
||||||
* skip_null_values peut omettre n'importe quelle clé.
|
|
||||||
*/
|
|
||||||
export interface CarrierDetail extends HydraRef {
|
|
||||||
id: number
|
|
||||||
name?: string | null
|
|
||||||
certificationType?: string | null
|
|
||||||
isChartered?: boolean
|
|
||||||
indexationRate?: string | null
|
|
||||||
containerType?: string | null
|
|
||||||
volumeM3?: string | null
|
|
||||||
liotPlates?: string | null
|
|
||||||
dischargeDocument?: Relation
|
|
||||||
qualimatCarrier?: Relation
|
|
||||||
isArchived?: boolean
|
|
||||||
addresses?: CarrierAddressRead[]
|
|
||||||
contacts?: CarrierContactRead[]
|
|
||||||
prices?: CarrierPriceRead[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extrait l'IRI d'une relation (objet embarqué, IRI nu, ou null si absente). */
|
|
||||||
export function iriOf(relation: Relation): string | null {
|
|
||||||
if (relation === null || relation === undefined) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (typeof relation === 'string') {
|
|
||||||
return relation
|
|
||||||
}
|
|
||||||
return relation['@id'] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Libellé d'affichage d'une relation embarquée : `name` (site) à défaut une adresse
|
|
||||||
* condensée (voie · CP · ville). Chaîne vide si la relation est un IRI nu / absente.
|
|
||||||
*/
|
|
||||||
export function labelOfRelation(relation: Relation): string {
|
|
||||||
if (!relation || typeof relation === 'string') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const name = relation.name as string | undefined
|
|
||||||
if (name) {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
const parts = [relation.street, relation.postalCode, relation.city].filter(Boolean)
|
|
||||||
return parts.join(' · ')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mappe le détail vers le brouillon du formulaire principal. */
|
|
||||||
export function mapMainToDraft(detail: CarrierDetail): CarrierMainDraft {
|
|
||||||
return {
|
|
||||||
name: detail.name ?? '',
|
|
||||||
certificationType: detail.certificationType ?? null,
|
|
||||||
isChartered: detail.isChartered ?? false,
|
|
||||||
indexationRate: detail.indexationRate ?? '',
|
|
||||||
containerType: detail.containerType ?? null,
|
|
||||||
volumeM3: detail.volumeM3 ?? '',
|
|
||||||
liotPlates: detail.liotPlates ?? '',
|
|
||||||
dischargeDocumentIri: iriOf(detail.dischargeDocument),
|
|
||||||
qualimatCarrierIri: iriOf(detail.qualimatCarrier),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mappe une adresse embarquée vers un brouillon. */
|
|
||||||
export function mapAddressToDraft(address: CarrierAddressRead): CarrierAddressFormDraft {
|
|
||||||
return {
|
|
||||||
id: address.id,
|
|
||||||
country: address.country ?? 'France',
|
|
||||||
postalCode: address.postalCode ?? null,
|
|
||||||
city: address.city ?? null,
|
|
||||||
street: address.street ?? null,
|
|
||||||
streetComplement: address.streetComplement ?? null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mappe un contact embarqué vers un brouillon (téléphones formatés XX XX XX XX XX). */
|
|
||||||
export function mapContactToDraft(contact: CarrierContactRead): CarrierContactFormDraft {
|
|
||||||
const secondary = contact.phoneSecondary ?? null
|
|
||||||
return {
|
|
||||||
id: contact.id,
|
|
||||||
firstName: contact.firstName ?? null,
|
|
||||||
lastName: contact.lastName ?? null,
|
|
||||||
jobTitle: contact.jobTitle ?? null,
|
|
||||||
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
|
|
||||||
phoneSecondary: secondary ? formatPhoneFR(secondary) : null,
|
|
||||||
email: contact.email ?? null,
|
|
||||||
hasSecondaryPhone: secondary !== null && secondary !== '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mappe un prix embarqué vers un brouillon (relations en IRI). */
|
|
||||||
export function mapPriceToDraft(price: CarrierPriceRead): CarrierPriceFormDraft {
|
|
||||||
const direction = price.direction === 'CLIENT' || price.direction === 'FOURNISSEUR'
|
|
||||||
? price.direction
|
|
||||||
: null
|
|
||||||
return {
|
|
||||||
id: price.id,
|
|
||||||
direction,
|
|
||||||
clientIri: iriOf(price.client),
|
|
||||||
clientDeliveryAddressIri: iriOf(price.clientDeliveryAddress),
|
|
||||||
departureSiteIri: iriOf(price.departureSite),
|
|
||||||
supplierIri: iriOf(price.supplier),
|
|
||||||
supplierSupplyAddressIri: iriOf(price.supplierSupplyAddress),
|
|
||||||
deliverySiteIri: iriOf(price.deliverySite),
|
|
||||||
containerType: price.containerType ?? null,
|
|
||||||
pricingUnit: price.pricingUnit ?? null,
|
|
||||||
price: price.price ?? null,
|
|
||||||
priceState: price.priceState ?? null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bouton « Modifier » : visible avec la permission `manage` (Admin / Bureau). */
|
|
||||||
export function canEditCarrier(can: (code: string) => boolean): boolean {
|
|
||||||
return can('transport.carriers.manage')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bouton « Archiver » : permission archive ET transporteur encore actif (Admin seul). */
|
|
||||||
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
|
||||||
return can('transport.carriers.archive') && !isArchived
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bouton « Restaurer » : permission archive ET transporteur déjà archivé (Admin seul). */
|
|
||||||
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
|
||||||
return can('transport.carriers.archive') && isArchived
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* Helpers purs de l'onglet Prix transporteur (M4 Transport, ERP-169 — RG-4.09→4.11).
|
|
||||||
* Une ligne porte une branche CLIENT ou FOURNISSEUR selon `direction` ; les champs
|
|
||||||
* de la branche INACTIVE doivent toujours partir à null (CHECK BDD
|
|
||||||
* chk_carrier_price_client_branch / supplier_branch). Testables sans Vue ni API.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CarrierPriceFormDraft } from '~/modules/transport/types/carrierForm'
|
|
||||||
|
|
||||||
/** Vrai si une chaîne porte au moins un caractère non-espace. */
|
|
||||||
function isFilled(value: string | null | undefined): boolean {
|
|
||||||
return value !== null && value !== undefined && value.trim() !== ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Payload de la sous-ressource prix (groupe `carrier:write:prices`). Envoie les
|
|
||||||
* communs + UNIQUEMENT la branche active (l'autre branche à null, exigée par les
|
|
||||||
* CHECK BDD). Les relations partent en IRI (string|null).
|
|
||||||
*
|
|
||||||
* IMPORTANT : les scalaires obligatoires (direction / containerType / pricingUnit /
|
|
||||||
* price / priceState) sont OMIS s'ils sont vides — on n'envoie JAMAIS `null` sur un
|
|
||||||
* champ string. Sinon API Platform lève un 400 « The type of the "price" attribute
|
|
||||||
* must be "string", "NULL" given. » AVANT la validation (non mappable inline). Omis,
|
|
||||||
* le champ reste null côté entité → l'Assert\NotBlank renvoie un 422 propre rattaché
|
|
||||||
* au champ, affiché sous l'input comme les autres blocs (ERP-101). Le back re-valide
|
|
||||||
* aussi l'obligation conditionnelle de branche + l'appartenance de l'adresse.
|
|
||||||
*/
|
|
||||||
export function buildCarrierPricePayload(price: CarrierPriceFormDraft): Record<string, unknown> {
|
|
||||||
const payload: Record<string, unknown> = {}
|
|
||||||
|
|
||||||
// Scalaires : présents seulement si remplis (jamais `null` → évite le 400 de type).
|
|
||||||
if (isFilled(price.direction)) payload.direction = price.direction
|
|
||||||
if (isFilled(price.containerType)) payload.containerType = price.containerType
|
|
||||||
if (isFilled(price.pricingUnit)) payload.pricingUnit = price.pricingUnit
|
|
||||||
if (isFilled(price.price)) payload.price = price.price
|
|
||||||
if (isFilled(price.priceState)) payload.priceState = price.priceState
|
|
||||||
|
|
||||||
// Branche active en IRI (null toléré sur une relation, ne déclenche pas le 400 de
|
|
||||||
// type) ; branche inactive forcée à null (CHECK BDD chk_carrier_price_*_branch).
|
|
||||||
if (price.direction === 'CLIENT') {
|
|
||||||
payload.client = price.clientIri || null
|
|
||||||
payload.clientDeliveryAddress = price.clientDeliveryAddressIri || null
|
|
||||||
payload.departureSite = price.departureSiteIri || null
|
|
||||||
payload.supplier = null
|
|
||||||
payload.supplierSupplyAddress = null
|
|
||||||
payload.deliverySite = null
|
|
||||||
}
|
|
||||||
else if (price.direction === 'FOURNISSEUR') {
|
|
||||||
payload.supplier = price.supplierIri || null
|
|
||||||
payload.supplierSupplyAddress = price.supplierSupplyAddressIri || null
|
|
||||||
payload.deliverySite = price.deliverySiteIri || null
|
|
||||||
payload.client = null
|
|
||||||
payload.clientDeliveryAddress = null
|
|
||||||
payload.departureSite = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pré-check léger du gating « + Nouveau prix » : direction choisie, prix rempli, et
|
|
||||||
* branche active complète (client/adresse/site OU fournisseur/adresse/site). Le back
|
|
||||||
* reste la couche autoritaire (RG-4.09→4.11) ; ce pré-check évite d'empiler des
|
|
||||||
* blocs vides.
|
|
||||||
*/
|
|
||||||
export function isCarrierPriceValid(price: CarrierPriceFormDraft): boolean {
|
|
||||||
if (!isFilled(price.price) || !isFilled(price.containerType) || !isFilled(price.pricingUnit) || !isFilled(price.priceState)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (price.direction === 'CLIENT') {
|
|
||||||
return isFilled(price.clientIri) && isFilled(price.clientDeliveryAddressIri) && isFilled(price.departureSiteIri)
|
|
||||||
}
|
|
||||||
if (price.direction === 'FOURNISSEUR') {
|
|
||||||
return isFilled(price.supplierIri) && isFilled(price.supplierSupplyAddressIri) && isFilled(price.deliverySiteIri)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Helpers de saisie numérique du formulaire principal transporteur (ERP-170).
|
|
||||||
* Champs texte restreints (volume m³ décimal, indexation plafonnée). Purs / testables.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restreint une saisie à un nombre décimal : chiffres + UN seul point (RG volume m³,
|
|
||||||
* « nombres avec des points » comme les autres modules). La virgule décimale FR est
|
|
||||||
* convertie en point (« 30,5 » → « 30.5 ») ; tout autre caractère est supprimé.
|
|
||||||
*/
|
|
||||||
export function sanitizeDecimal(value: string): string {
|
|
||||||
let cleaned = (value ?? '').replace(/,/g, '.').replace(/[^0-9.]/g, '')
|
|
||||||
const dot = cleaned.indexOf('.')
|
|
||||||
if (dot !== -1) {
|
|
||||||
// Conserve le 1er point, retire les suivants.
|
|
||||||
cleaned = cleaned.slice(0, dot + 1) + cleaned.slice(dot + 1).replace(/\./g, '')
|
|
||||||
}
|
|
||||||
return cleaned
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plafonne un pourcentage à 100 (contrainte FRONT : l'indexation n'a pas de max back).
|
|
||||||
* Renvoie « 100 » si la valeur saisie dépasse 100, sinon la valeur telle quelle.
|
|
||||||
*/
|
|
||||||
export function clampPercent(value: string): string {
|
|
||||||
const n = Number(String(value ?? '').replace(',', '.').replace(/\s/g, ''))
|
|
||||||
return (!Number.isNaN(n) && n > 100) ? '100' : value
|
|
||||||
}
|
|
||||||
Generated
+10
-10
@@ -7,7 +7,7 @@
|
|||||||
"name": "starseed-frontend",
|
"name": "starseed-frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.12",
|
"@malio/layer-ui": "^1.7.10",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -583,9 +583,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
|
||||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -594,9 +594,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
|
||||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1866,9 +1866,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.7.12",
|
"version": "1.7.10",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.12/layer-ui-1.7.12.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
|
||||||
"integrity": "sha512-ezQLqi19K2ogI3XwSMsUyluU9x5C4W0tu1muxFbL3foKjibRYRg/FdvySivEhEsalAAt1E88V6Sv/06xPqyYTw==",
|
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.12",
|
"@malio/layer-ui": "^1.7.10",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -95,11 +95,10 @@ export const personas: Record<PersonaKey, Persona> = {
|
|||||||
'technique.providers.accounting.view',
|
'technique.providers.accounting.view',
|
||||||
'technique.providers.accounting.manage',
|
'technique.providers.accounting.manage',
|
||||||
'technique.providers.archive',
|
'technique.providers.archive',
|
||||||
// Transport — Repertoire transporteurs (M4, ERP-164). Meme logique :
|
// Transport — Repertoire transporteurs (M4, ERP-153). Meme logique :
|
||||||
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
|
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
|
||||||
// n°7). L'item transporteurs vit desormais dans la section Administration
|
// n°7). transport.carriers.view n'ajoute pas de lien dans la section
|
||||||
// (1er item, ERP-164) mais sur la route `/carriers` (hors `/admin/<slug>`),
|
// Administration, donc expectedAdminLinks reste inchange.
|
||||||
// donc il n'entre pas dans ALL_ADMIN_LINKS : expectedAdminLinks reste inchange.
|
|
||||||
'transport.carriers.view',
|
'transport.carriers.view',
|
||||||
'transport.carriers.manage',
|
'transport.carriers.manage',
|
||||||
'transport.carriers.archive',
|
'transport.carriers.archive',
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-4.08 (correctif) — aligne la regle de validite d'un contact transporteur sur
|
|
||||||
* le M1/M2/M3 : au moins le PRENOM OU le NOM (et non plus « un champ quelconque
|
|
||||||
* parmi prenom/nom/fonction/telephone/email »). Remplace le CHECK
|
|
||||||
* chk_carrier_contact_filled par chk_carrier_contact_name et met a jour les
|
|
||||||
* commentaires de colonnes. La garde applicative (CarrierContactProcessor::validateName)
|
|
||||||
* est alignee dans le meme commit ; le catalogue ColumnCommentsCatalog aussi.
|
|
||||||
*
|
|
||||||
* Placee au namespace racine DoctrineMigrations (et non en modulaire Transport) :
|
|
||||||
* elle ALTERE une table creee par une migration racine (Version20260615150000) ;
|
|
||||||
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
|
|
||||||
* (cf. CLAUDE.md regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
|
|
||||||
*/
|
|
||||||
final class Version20260617120000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'RG-4.08 : contact transporteur valide si prenom OU nom (alignement M1/M2/M3) — CHECK chk_carrier_contact_name.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE carrier_contact DROP CONSTRAINT chk_carrier_contact_filled');
|
|
||||||
$this->addSql('ALTER TABLE carrier_contact ADD CONSTRAINT chk_carrier_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
|
|
||||||
|
|
||||||
$this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), max 2 telephones.$_$');
|
|
||||||
$this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).$_$');
|
|
||||||
$this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).$_$');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE carrier_contact DROP CONSTRAINT chk_carrier_contact_name');
|
|
||||||
$this->addSql('ALTER TABLE carrier_contact ADD CONSTRAINT chk_carrier_contact_filled CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)');
|
|
||||||
|
|
||||||
$this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.$_$');
|
|
||||||
$this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).$_$');
|
|
||||||
$this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).$_$');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,8 +21,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de
|
* Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de
|
||||||
* SupplierContact (M2) : au moins le prenom OU le nom (RG-4.08, garanti par le
|
* SupplierContact (M2) : au moins un champ rempli (RG-4.08, garanti par le
|
||||||
* CHECK chk_carrier_contact_name + le Processor), max 2 telephones.
|
* CHECK chk_carrier_contact_filled + le Processor), max 2 telephones.
|
||||||
*
|
*
|
||||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||||
* transporteur). Ecriture : groupe `carrier:write:contacts`.
|
* transporteur). Ecriture : groupe `carrier:write:contacts`.
|
||||||
|
|||||||
+26
-19
@@ -23,21 +23,21 @@ use function is_string;
|
|||||||
/**
|
/**
|
||||||
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
|
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
|
||||||
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
|
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
|
||||||
* perimetre ERP-160. RG-4.08 (correctif, alignement M1/M2/M3) : un contact exige
|
* perimetre ERP-160, AVEC deux specificites M4 : RG-4.08 (≥ 1 champ rempli, max
|
||||||
* au moins le PRENOM OU le NOM (la fonction / le telephone / l'email seuls ne
|
* 2 telephones) portee a la fois par le CHECK BDD chk_carrier_contact_filled et
|
||||||
* suffisent pas), porte a la fois par le CHECK BDD chk_carrier_contact_name et par
|
* par ce Processor.
|
||||||
* ce Processor ; le « max 2 telephones » reste une specificite M4.
|
|
||||||
*
|
*
|
||||||
* Sequence :
|
* Sequence :
|
||||||
* - POST / PATCH : rattachement au transporteur parent (linkParent),
|
* - POST / PATCH : rattachement au transporteur parent (linkParent),
|
||||||
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
|
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
|
||||||
* mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary
|
* mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary
|
||||||
* (max 2, chiffres uniquement), puis garde « prenom OU nom » avant persistance.
|
* (max 2, chiffres uniquement), puis garde RG-4.08 (≥ 1 champ) avant
|
||||||
|
* persistance.
|
||||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||||
*
|
*
|
||||||
* La garde « prenom OU nom » vit ICI (double du CHECK BDD) pour transformer une
|
* RG-4.08 vit ICI (double du CHECK BDD) pour transformer une violation SQL (500
|
||||||
* violation SQL (500 generique) en 422 propre rattachee au champ `firstName`
|
* generique) en 422 propre rattachee au champ `firstName` (mapping inline
|
||||||
* (mapping inline ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
|
* ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
|
||||||
* point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en
|
* point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en
|
||||||
* lecture seule).
|
* lecture seule).
|
||||||
*
|
*
|
||||||
@@ -77,7 +77,7 @@ final class CarrierContactProcessor implements ProcessorInterface
|
|||||||
$this->linkParent($data, $uriVariables);
|
$this->linkParent($data, $uriVariables);
|
||||||
$this->normalize($data);
|
$this->normalize($data);
|
||||||
$this->applyPhones($data);
|
$this->applyPhones($data);
|
||||||
$this->validateName($data);
|
$this->validateAtLeastOneField($data);
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
}
|
}
|
||||||
@@ -187,18 +187,25 @@ final class CarrierContactProcessor implements ProcessorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-4.08 (alignement M1/M2/M3) : un bloc Contact exige au moins le PRENOM OU le
|
* RG-4.08 : un bloc Contact est valide des qu'au moins 1 champ est rempli
|
||||||
* NOM — un contact se materialise par son nom ; fonction / telephone / email
|
* (firstName, lastName, jobTitle, phonePrimary ou email — meme perimetre que
|
||||||
* seuls ne suffisent pas. Double garde avec le CHECK BDD chk_carrier_contact_name
|
* le CHECK BDD chk_carrier_contact_filled, qui exclut phoneSecondary). Double
|
||||||
* — leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL. Joue apres
|
* garde : leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL.
|
||||||
* normalisation + mapping telephones, donc les chaines vides sont deja null.
|
* Joue apres normalisation + mapping telephones, donc les chaines vides sont
|
||||||
|
* deja ramenees a null.
|
||||||
*/
|
*/
|
||||||
private function validateName(CarrierContact $contact): void
|
private function validateAtLeastOneField(CarrierContact $contact): void
|
||||||
{
|
{
|
||||||
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
|
if (
|
||||||
|
null === $contact->getFirstName()
|
||||||
|
&& null === $contact->getLastName()
|
||||||
|
&& null === $contact->getJobTitle()
|
||||||
|
&& null === $contact->getPhonePrimary()
|
||||||
|
&& null === $contact->getEmail()
|
||||||
|
) {
|
||||||
$violations = new ConstraintViolationList();
|
$violations = new ConstraintViolationList();
|
||||||
$violations->add(new ConstraintViolation(
|
$violations->add(new ConstraintViolation(
|
||||||
'Le prénom ou le nom du contact est obligatoire.',
|
'Renseignez au moins un champ pour le contact.',
|
||||||
null,
|
null,
|
||||||
[],
|
[],
|
||||||
$contact,
|
$contact,
|
||||||
@@ -212,8 +219,8 @@ final class CarrierContactProcessor implements ProcessorInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
|
* Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
|
||||||
* contrairement aux noms de personne). Evite de persister une chaine vide
|
* contrairement aux noms de personne). Garantit que RG-4.08 detecte un champ
|
||||||
* (« » devient null) cote fonction du contact.
|
* « non rempli » meme si le client envoie une chaine vide.
|
||||||
*/
|
*/
|
||||||
private function blankToNull(?string $value): ?string
|
private function blankToNull(?string $value): ?string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ class CarrierFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Ajoute un contact normalise au transporteur (cascade persist via
|
* Ajoute un contact normalise au transporteur (cascade persist via
|
||||||
* Carrier.contacts). Prenom OU nom toujours fourni (RG-4.08, chk_carrier_contact_name).
|
* Carrier.contacts). Au moins un champ est toujours fourni (RG-4.08).
|
||||||
*/
|
*/
|
||||||
private function addContact(
|
private function addContact(
|
||||||
Carrier $carrier,
|
Carrier $carrier,
|
||||||
|
|||||||
@@ -509,11 +509,11 @@ final class ColumnCommentsCatalog
|
|||||||
] + self::timestampableBlamableComments(),
|
] + self::timestampableBlamableComments(),
|
||||||
|
|
||||||
'carrier_contact' => [
|
'carrier_contact' => [
|
||||||
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), max 2 telephones.',
|
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.',
|
||||||
'id' => 'Identifiant interne auto-incremente.',
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.',
|
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.',
|
||||||
'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).',
|
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
|
||||||
'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).',
|
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
|
||||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
||||||
'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).',
|
'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).',
|
||||||
'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).',
|
'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).',
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ use Symfony\Component\Console\Output\NullOutput;
|
|||||||
* POST /api/carriers/{id}/contacts, PATCH/DELETE /api/carrier_contacts/{id}.
|
* POST /api/carriers/{id}/contacts, PATCH/DELETE /api/carrier_contacts/{id}.
|
||||||
*
|
*
|
||||||
* Contrat verifie :
|
* Contrat verifie :
|
||||||
* - RG-4.08 : contact sans prenom ni nom -> 422 (alignement M1/M2/M3) ;
|
* - RG-4.08 : contact totalement vide -> 422 (au moins 1 champ requis) ;
|
||||||
* - RG-4.08 : un nom (ou prenom) suffit -> 201 ;
|
* - RG-4.08 : 1 seul champ rempli -> 201 ;
|
||||||
* - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ;
|
* - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ;
|
||||||
* - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ;
|
* - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ;
|
||||||
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
|
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
|
||||||
@@ -51,8 +51,7 @@ final class CarrierContactApiTest extends AbstractCarrierApiTestCase
|
|||||||
|
|
||||||
public function testEmptyContactReturns422(): void
|
public function testEmptyContactReturns422(): void
|
||||||
{
|
{
|
||||||
// RG-4.08 (alignement M1/M2/M3) : sans prenom ni nom -> 422 (garde Processor,
|
// RG-4.08 : aucun champ rempli -> 422 (garde Processor, double du CHECK BDD).
|
||||||
// double du CHECK BDD chk_carrier_contact_name).
|
|
||||||
$carrier = $this->seedCarrier('Contact Vide');
|
$carrier = $this->seedCarrier('Contact Vide');
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
@@ -61,13 +60,13 @@ final class CarrierContactApiTest extends AbstractCarrierApiTestCase
|
|||||||
'json' => [],
|
'json' => [],
|
||||||
]);
|
]);
|
||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
// La violation est rattachee a `firstName` (mapping inline ERP-101).
|
// RG-4.08 : la violation est rattachee a `firstName` (mapping inline ERP-101).
|
||||||
self::assertViolationOnPath($response, 'firstName');
|
self::assertViolationOnPath($response, 'firstName');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSingleFieldContactIsCreated(): void
|
public function testSingleFieldContactIsCreated(): void
|
||||||
{
|
{
|
||||||
// RG-4.08 : un nom (ou prenom) suffit a valider le bloc.
|
// RG-4.08 : un seul champ suffit a valider le bloc.
|
||||||
$carrier = $this->seedCarrier('Contact Mono');
|
$carrier = $this->seedCarrier('Contact Mono');
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user