From e612eae391ca23f19dc9215f51e3423cef9fa560 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 17 Jun 2026 11:35:14 +0200 Subject: [PATCH] feat(transport) : consultation + modification transporteur (ERP-170) --- frontend/i18n/locales/fr.json | 43 +- .../__tests__/useCarrierForm.test.ts | 46 +++ .../transport/composables/useCarrier.ts | 68 ++++ .../transport/composables/useCarrierForm.ts | 71 ++++ .../transport/pages/carriers/[id]/edit.vue | 345 ++++++++++++++++ .../transport/pages/carriers/[id]/index.vue | 368 ++++++++++++++++++ .../forms/__tests__/carrierMappers.test.ts | 120 ++++++ .../transport/utils/forms/carrierMappers.ts | 190 +++++++++ 8 files changed, 1250 insertions(+), 1 deletion(-) create mode 100644 frontend/modules/transport/composables/useCarrier.ts create mode 100644 frontend/modules/transport/pages/carriers/[id]/edit.vue create mode 100644 frontend/modules/transport/pages/carriers/[id]/index.vue create mode 100644 frontend/modules/transport/utils/forms/__tests__/carrierMappers.test.ts create mode 100644 frontend/modules/transport/utils/forms/carrierMappers.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index c5fff11..7c71117 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -530,7 +530,48 @@ "integrateSuccess": "Transporteur QUALIMAT intégré", "addressSaved": "Adresse enregistrée", "contactSaved": "Contact enregistré", - "priceSaved": "Prix 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": "Transporteur", + "aproOrSite": "Adresse appro / site", + "delivery": "Adresse de livraison", + "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", diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts index cfc4e90..4050ab4 100644 --- a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -912,3 +912,49 @@ describe('useCarrierForm — onglet Prix (ERP-169)', () => { 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') + }) +}) diff --git a/frontend/modules/transport/composables/useCarrier.ts b/frontend/modules/transport/composables/useCarrier.ts new file mode 100644 index 0000000..df889f6 --- /dev/null +++ b/frontend/modules/transport/composables/useCarrier.ts @@ -0,0 +1,68 @@ +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(null) + const loading = ref(false) + const error = ref(false) + + /** Récupère le détail complet (embed qualimatCarrier + addresses / contacts / prices). */ + function fetchDetail(): Promise { + return api.get( + `/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 { + 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 { + await api.patch(`/carriers/${id}`, { isArchived }, { toast: false }) + carrier.value = await fetchDetail() + } + + return { + carrier, + loading, + error, + load, + archive: () => setArchived(true), + restore: () => setArchived(false), + } +} diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index 0433ab7..6db00a7 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -18,6 +18,13 @@ import { 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). */ @@ -257,6 +264,68 @@ export function useCarrierForm() { } } + /** + * 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 { + if (carrierId.value === null || mainSubmitting.value) return false + mainErrors.clearErrors() + if (!validateMainFront()) return false + + mainSubmitting.value = true + try { + const updated = await api.patch( + `/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 @@ -702,6 +771,8 @@ export function useCarrierForm() { validateMainFront, buildMainPayload, submitMain, + updateMain, + prefillFrom, patchCarrier, applyQualimatSelection, completeTab, diff --git a/frontend/modules/transport/pages/carriers/[id]/edit.vue b/frontend/modules/transport/pages/carriers/[id]/edit.vue new file mode 100644 index 0000000..2857975 --- /dev/null +++ b/frontend/modules/transport/pages/carriers/[id]/edit.vue @@ -0,0 +1,345 @@ + + + diff --git a/frontend/modules/transport/pages/carriers/[id]/index.vue b/frontend/modules/transport/pages/carriers/[id]/index.vue new file mode 100644 index 0000000..beb1306 --- /dev/null +++ b/frontend/modules/transport/pages/carriers/[id]/index.vue @@ -0,0 +1,368 @@ + + + diff --git a/frontend/modules/transport/utils/forms/__tests__/carrierMappers.test.ts b/frontend/modules/transport/utils/forms/__tests__/carrierMappers.test.ts new file mode 100644 index 0000000..ecafb91 --- /dev/null +++ b/frontend/modules/transport/utils/forms/__tests__/carrierMappers.test.ts @@ -0,0 +1,120 @@ +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) + }) +}) diff --git a/frontend/modules/transport/utils/forms/carrierMappers.ts b/frontend/modules/transport/utils/forms/carrierMappers.ts new file mode 100644 index 0000000..7ddd01e --- /dev/null +++ b/frontend/modules/transport/utils/forms/carrierMappers.ts @@ -0,0 +1,190 @@ +/** + * 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 +}