diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 85d206e..4db541f 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -390,9 +390,34 @@ }, "tab": { "contact": "Contact", + "contacts": "Contacts", "address": "Adresse", + "reports": "Rapports", + "exchanges": "Échanges", "accounting": "Comptabilité" }, + "action": { + "edit": "Modifier", + "archive": "Archiver", + "restore": "Restaurer" + }, + "consultation": { + "title": "Fiche prestataire", + "back": "Retour au répertoire", + "loading": "Chargement…", + "notFound": "Prestataire introuvable.", + "emptyContacts": "Aucun contact.", + "emptyAddresses": "Aucune adresse.", + "confirmArchive": "Archiver ce prestataire ? Il n'apparaîtra plus dans le répertoire actif.", + "confirmRestore": "Restaurer ce prestataire ? Il réapparaîtra dans le répertoire actif." + }, + "edit": { + "title": "Modifier le prestataire", + "back": "Retour à la fiche", + "loading": "Chargement…", + "notFound": "Prestataire introuvable.", + "save": "Enregistrer" + }, "form": { "title": "Ajouter un prestataire", "back": "Précédent", @@ -459,7 +484,9 @@ "error": "Une erreur est survenue. Réessayez.", "exportError": "L'export du répertoire prestataires a échoué. Réessayez.", "createSuccess": "Prestataire créé avec succès", - "updateSuccess": "Prestataire mis à jour avec succès" + "updateSuccess": "Prestataire mis à jour avec succès", + "archiveSuccess": "Prestataire archivé avec succès", + "restoreSuccess": "Prestataire restauré avec succès" } } }, diff --git a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts index a4a7c4f..97c3514 100644 --- a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts +++ b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts @@ -585,3 +585,70 @@ describe('useProviderForm — onglet Comptabilite (ERP-144)', () => { expect(mockPatch).not.toHaveBeenCalled() }) }) + +describe('useProviderForm — modification (ERP-145)', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + permState.accountingView = false + permState.accountingManage = false + }) + + it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => { + const form = useProviderForm() + form.editMode.value = true + form.activeTab.value = 'contact' + + expect(form.completeTab('contact')).toBe(false) + expect(form.isValidated('contact')).toBe(false) + expect(form.activeTab.value).toBe('contact') + }) + + it('updateMain : PATCH /providers/{id} sur le groupe principal (pas de POST)', async () => { + mockPatch.mockResolvedValueOnce({ id: 7, companyName: 'MAINTENANCE PRO' }) + const form = useProviderForm() + form.providerId.value = 7 + form.main.companyName = 'Maintenance Pro' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const ok = await form.updateMain() + + expect(ok).toBe(true) + expect(mockPost).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith( + '/providers/7', + { companyName: 'Maintenance Pro', categories: [CAT_MAINT], sites: [SITE_86] }, + { toast: false }, + ) + // Reaffiche le nom normalise renvoye par le serveur. + expect(form.main.companyName).toBe('MAINTENANCE PRO') + }) + + it('updateMain : RG-3.03 front -> bloque le PATCH sans site', async () => { + const form = useProviderForm() + form.providerId.value = 7 + form.main.companyName = 'X' + form.main.categoryIris = [CAT_MAINT] + + const ok = await form.updateMain() + + expect(ok).toBe(false) + expect(mockPatch).not.toHaveBeenCalled() + expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired') + }) + + it('updateMain : 409 doublon -> erreur inline sur companyName', async () => { + mockPatch.mockRejectedValueOnce({ response: { status: 409 } }) + const form = useProviderForm() + form.providerId.value = 7 + form.main.companyName = 'Doublon' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const ok = await form.updateMain() + + expect(ok).toBe(false) + expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany') + }) +}) diff --git a/frontend/modules/technique/composables/useProvider.ts b/frontend/modules/technique/composables/useProvider.ts new file mode 100644 index 0000000..db795e2 --- /dev/null +++ b/frontend/modules/technique/composables/useProvider.ts @@ -0,0 +1,70 @@ +import { ref } from 'vue' +import type { ProviderDetail } from '~/modules/technique/utils/forms/providerDetail' + +/** + * Chargement et actions d'archivage d'un prestataire unique (ecrans Consultation / + * Modification, ERP-145). Miroir de `useSupplier` (M2). Lit le detail embarque via + * `GET /api/providers/{id}` (contacts / adresses + leurs sous-collections / ribs + * sous `provider:item:read` / `provider:read:accounting`) — une SEULE requete + * peuple les deux ecrans (embed borne, pas de N+1). + * + * L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload Hydra + * complet (avec les `@id` des relations embarquees, indispensables au pre-remplissage). + * + * Etat 100 % local a l'instance (refs). Les erreurs d'archivage / restauration + * (notamment le 409 d'homonyme actif a la restauration) sont PROPAGEES a l'appelant, + * qui decide du toast a afficher. + */ +export function useProvider(id: number | string) { + const api = useApi() + + const provider = ref(null) + const loading = ref(false) + const error = ref(false) + + /** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */ + function fetchDetail(): Promise { + return api.get( + `/providers/${id}`, + {}, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + } + + /** Charge le detail du prestataire. En cas d'echec : `error = true`, `provider = null`. */ + async function load(): Promise { + loading.value = true + error.value = false + try { + provider.value = await fetchDetail() + } + catch { + error.value = true + provider.value = null + } + finally { + loading.value = false + } + } + + /** + * Bascule l'archivage (PATCH `isArchived` SEUL — groupe provider:write:archive ; + * tout autre champ => 422), puis RECHARGE le detail complet : la reponse du PATCH + * ne porte que `provider:read` (ni l'embed des sous-collections ni les libelles + * comptables), un simple merge laisserait l'affichage incoherent. Toute erreur est + * propagee a l'appelant AVANT le rechargement. + */ + async function setArchived(isArchived: boolean): Promise { + await api.patch(`/providers/${id}`, { isArchived }, { toast: false }) + provider.value = await fetchDetail() + } + + return { + provider, + loading, + error, + load, + archive: () => setArchived(true), + restore: () => setArchived(false), + } +} diff --git a/frontend/modules/technique/composables/useProviderForm.ts b/frontend/modules/technique/composables/useProviderForm.ts index af1e090..8b4d821 100644 --- a/frontend/modules/technique/composables/useProviderForm.ts +++ b/frontend/modules/technique/composables/useProviderForm.ts @@ -91,6 +91,9 @@ export function useProviderForm() { const activeTab = ref('contact') // Onglets valides (passent en lecture seule). const validated = reactive>({}) + // Mode MODIFICATION (ERP-145) : navigation libre, pas de verrouillage ni de + // bascule automatique d'onglet a la validation (cf. completeTab). + const editMode = ref(false) function isValidated(key: string): boolean { return validated[key] === true @@ -192,12 +195,55 @@ export function useProviderForm() { await api.patch(`/providers/${providerId.value}`, payload, { toast: false }) } + /** + * MODIFICATION du bloc principal (ERP-145) : PATCH /providers/{id} sur le groupe + * provider:write:main (nom + categories + sites). Pre-check front RG-3.03/3.09, + * 409 doublon de nom (RG-3.10) et 422 mappes inline comme a la creation. A la + * difference de `submitMain`, ne verrouille rien et ne bascule pas d'onglet (la + * navigation est libre en modification). Retourne true si le PATCH a reussi. + */ + async function updateMain(): Promise { + if (providerId.value === null || mainSubmitting.value) return false + mainErrors.clearErrors() + if (!validateMainFront()) return false + + mainSubmitting.value = true + try { + const updated = await api.patch( + `/providers/${providerId.value}`, + buildMainPayload(), + { toast: false }, + ) + main.companyName = updated.companyName ?? main.companyName + return true + } + catch (error) { + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 409) { + const message = t('technique.providers.form.duplicateCompany') + mainErrors.setError('companyName', message) + toast.error({ title: t('technique.providers.toast.error'), message }) + } + else { + mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') }) + } + return false + } + finally { + mainSubmitting.value = false + } + } + /** * Marque un onglet valide (passe en lecture seule), deverrouille et avance a * l'onglet suivant. Retourne true si c'etait le dernier onglet du flux * (creation terminee), false sinon. */ function completeTab(key: string): boolean { + // En modification : navigation libre, l'onglet reste editable apres validation. + if (editMode.value) { + return false + } validated[key] = true const index = tabIndex(key) const next = tabKeys.value[index + 1] @@ -521,6 +567,7 @@ export function useProviderForm() { activeTab, unlockedIndex, validated, + editMode, isValidated, // contacts contacts, @@ -551,6 +598,7 @@ export function useProviderForm() { validateMainFront, buildMainPayload, submitMain, + updateMain, patchProvider, completeTab, submitRows, diff --git a/frontend/modules/technique/pages/providers/[id]/edit.vue b/frontend/modules/technique/pages/providers/[id]/edit.vue new file mode 100644 index 0000000..a60fec5 --- /dev/null +++ b/frontend/modules/technique/pages/providers/[id]/edit.vue @@ -0,0 +1,534 @@ + + + diff --git a/frontend/modules/technique/pages/providers/[id]/index.vue b/frontend/modules/technique/pages/providers/[id]/index.vue new file mode 100644 index 0000000..f10e1b3 --- /dev/null +++ b/frontend/modules/technique/pages/providers/[id]/index.vue @@ -0,0 +1,303 @@ + + + diff --git a/frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts new file mode 100644 index 0000000..e939f96 --- /dev/null +++ b/frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi } from 'vitest' + +// formatPhoneFR est auto-importe dans le helper via le chemin partage ; on le mocke +// pour un rendu deterministe (la mise en forme exacte est testee ailleurs). +vi.mock('~/shared/utils/phone', () => ({ + formatPhoneFR: (v: string) => `fmt(${v})`, +})) + +const { + canEditProvider, + categoryOptionsOf, + contactOptionsOf, + iriOf, + irisOf, + mapAccountingDraft, + mapAddressToDraft, + mapContactToDraft, + mapRibToDraft, + paymentTypeCodeOf, + referentialOptionOf, + showArchiveAction, + showRestoreAction, + siteOptionsOf, +} = await import('../providerDetail') + +/** + * Helpers purs des ecrans Consultation / Modification (ERP-145) : mapping du + * detail embarque vers les brouillons + regles d'affichage des actions (Modifier / + * Archiver / Restaurer). + */ +describe('providerDetail helpers', () => { + describe('iriOf / irisOf', () => { + it('extrait l\'IRI d\'un objet embarque, d\'un IRI nu, ou null', () => { + expect(iriOf({ '@id': '/api/banks/2' })).toBe('/api/banks/2') + expect(iriOf('/api/banks/2')).toBe('/api/banks/2') + expect(iriOf(null)).toBeNull() + expect(iriOf(undefined)).toBeNull() + }) + + it('extrait les IRI d\'une collection embarquee', () => { + expect(irisOf([{ '@id': '/api/sites/1' }, { '@id': '/api/sites/2' }])).toEqual(['/api/sites/1', '/api/sites/2']) + expect(irisOf(undefined)).toEqual([]) + }) + }) + + describe('mapContactToDraft', () => { + it('mappe les champs, formate les telephones et derive hasSecondaryPhone', () => { + const draft = mapContactToDraft({ + '@id': '/api/provider_contacts/5', + id: 5, + firstName: 'Jean', + lastName: 'Dupont', + phonePrimary: '0102030405', + phoneSecondary: '0607080910', + email: 'jean@x.fr', + }) + expect(draft).toMatchObject({ + id: 5, + iri: '/api/provider_contacts/5', + firstName: 'Jean', + lastName: 'Dupont', + phonePrimary: 'fmt(0102030405)', + phoneSecondary: 'fmt(0607080910)', + email: 'jean@x.fr', + hasSecondaryPhone: true, + }) + }) + + it('hasSecondaryPhone faux sans 2e numero', () => { + const draft = mapContactToDraft({ '@id': '/api/provider_contacts/6', id: 6, lastName: 'Doe' }) + expect(draft.hasSecondaryPhone).toBe(false) + expect(draft.phoneSecondary).toBeNull() + }) + }) + + describe('mapAddressToDraft', () => { + it('extrait les IRI des sites / categories / contacts embarques', () => { + const draft = mapAddressToDraft({ + '@id': '/api/provider_addresses/3', + id: 3, + country: 'France', + postalCode: '86100', + city: 'Châtellerault', + street: '1 rue du Test', + sites: [{ '@id': '/api/sites/1' }], + categories: [{ '@id': '/api/categories/7' }], + contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'], + }) + expect(draft.siteIris).toEqual(['/api/sites/1']) + expect(draft.categoryIris).toEqual(['/api/categories/7']) + expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6']) + expect(draft.id).toBe(3) + }) + }) + + describe('mapAccountingDraft / mapRibToDraft', () => { + it('mappe les scalaires et les IRI des referentiels embarques', () => { + const draft = mapAccountingDraft({ + '@id': '/api/providers/9', + id: 9, + siren: '123456789', + accountNumber: '4010', + nTva: 'FR123', + tvaMode: { '@id': '/api/tva_modes/1', label: 'TVA' }, + paymentType: { '@id': '/api/payment_types/3', code: 'VIREMENT' }, + bank: { '@id': '/api/banks/2' }, + }) + expect(draft.tvaModeIri).toBe('/api/tva_modes/1') + expect(draft.paymentTypeIri).toBe('/api/payment_types/3') + expect(draft.bankIri).toBe('/api/banks/2') + expect(draft.paymentDelayIri).toBeNull() + expect(draft.siren).toBe('123456789') + }) + + it('mappe un RIB embarque', () => { + expect(mapRibToDraft({ '@id': '/api/provider_ribs/1', id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' })) + .toEqual({ id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' }) + }) + }) + + describe('options builders (libelles role-independants depuis l\'embed)', () => { + it('categoryOptionsOf / siteOptionsOf / contactOptionsOf', () => { + expect(categoryOptionsOf([{ '@id': '/api/categories/7', name: 'Maintenance', code: 'MAINT' }])) + .toEqual([{ value: '/api/categories/7', label: 'Maintenance' }]) + expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault' }])) + .toEqual([{ value: '/api/sites/1', label: 'Châtellerault' }]) + expect(contactOptionsOf([{ '@id': '/api/provider_contacts/5', id: 5, firstName: 'Jean', lastName: 'Dupont' }])) + .toEqual([{ value: '/api/provider_contacts/5', label: 'Jean Dupont' }]) + }) + + it('referentialOptionOf / paymentTypeCodeOf', () => { + expect(referentialOptionOf({ '@id': '/api/banks/2', label: 'SG' })) + .toEqual([{ value: '/api/banks/2', label: 'SG' }]) + expect(referentialOptionOf(null)).toEqual([]) + expect(referentialOptionOf('/api/banks/2')).toEqual([]) + expect(paymentTypeCodeOf({ '@id': '/api/payment_types/3', code: 'LCR' })).toBe('LCR') + expect(paymentTypeCodeOf(null)).toBeNull() + }) + }) + + describe('actions selon permissions', () => { + /** Fabrique un `can` qui n'autorise que les codes fournis. */ + const canFor = (granted: string[]) => (code: string) => granted.includes(code) + const canAnyFor = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c)) + + it('« Modifier » visible avec manage OU accounting.manage (Compta inclus)', () => { + expect(canEditProvider(canAnyFor(['technique.providers.manage']))).toBe(true) + expect(canEditProvider(canAnyFor(['technique.providers.accounting.manage']))).toBe(true) + expect(canEditProvider(canAnyFor(['technique.providers.view']))).toBe(false) + }) + + it('« Archiver » visible seulement avec archive ET prestataire actif (Admin seul)', () => { + const admin = canFor(['technique.providers.archive']) + const bureau = canFor(['technique.providers.manage']) + expect(showArchiveAction(admin, false)).toBe(true) + expect(showArchiveAction(admin, true)).toBe(false) // deja archive -> Restaurer + expect(showArchiveAction(bureau, false)).toBe(false) // pas la permission archive + }) + + it('« Restaurer » visible seulement avec archive ET prestataire archive', () => { + const admin = canFor(['technique.providers.archive']) + expect(showRestoreAction(admin, true)).toBe(true) + expect(showRestoreAction(admin, false)).toBe(false) + expect(showRestoreAction(canFor([]), true)).toBe(false) + }) + }) +}) diff --git a/frontend/modules/technique/utils/forms/providerDetail.ts b/frontend/modules/technique/utils/forms/providerDetail.ts new file mode 100644 index 0000000..36bbd04 --- /dev/null +++ b/frontend/modules/technique/utils/forms/providerDetail.ts @@ -0,0 +1,245 @@ +/** + * Helpers purs des ecrans Consultation / Modification prestataire (M3 Technique, + * ERP-145) — miroir SIMPLIFIE de `supplierConsultation.ts` (M2). Mappent le payload + * `GET /api/providers/{id}` (relations embarquees, cf. groupes `provider:item:read` + * + `provider:read:accounting`) vers les brouillons « plats » partages avec + * `ProviderContactBlock` / `ProviderAddressBlock` et l'onglet Comptabilite. + * + * Ne touchent ni a l'API ni a l'etat reactif (testables unitairement). + * + * Rappels de contrat back (JSON reel fige — ERP-139, spec-back § 4.0.bis) : + * - categories / sites du prestataire et des adresses : OBJETS embarques (avec @id) ; + * - refs comptables (tvaMode/paymentDelay/paymentType/bank) : OBJETS embarques + * `{@id, id, label, (code pour paymentType)}` ; + * - champs nuls OMIS (skip_null_values) → toujours lire avec `?? null` ; + * - champs comptables + `ribs` TOTALEMENT ABSENTS sans permission accounting.view. + * + * Differences M2 : pas de type d'adresse / bennes / triage, pas d'onglet Information. + */ + +import { formatPhoneFR } from '~/shared/utils/phone' +import type { + ProviderAccountingDraft, + ProviderAddressFormDraft, + ProviderContactFormDraft, + ProviderRibFormDraft, +} from '~/modules/technique/types/providerForm' +import type { RefOption } from '~/modules/technique/composables/useProviderReferentials' + +/** Reference Hydra embarquee minimale (@id toujours present). */ +export interface HydraRef { + '@id': string + [key: string]: unknown +} + +/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */ +export type Relation = HydraRef | string | null | undefined + +/** Site embarque (groupe site:read). */ +export interface SiteRead extends HydraRef { + name?: string + postalCode?: string + color?: string +} + +/** Categorie embarquee (groupe category:read). */ +export interface CategoryRead extends HydraRef { + code?: string + name?: string +} + +/** Contact embarque (groupe provider:item:read). */ +export interface ContactRead extends HydraRef { + id: number + firstName?: string | null + lastName?: string | null + jobTitle?: string | null + phonePrimary?: string | null + phoneSecondary?: string | null + email?: string | null +} + +/** Adresse embarquee (groupe provider:item:read) — version simplifiee M3. */ +export interface AddressRead extends HydraRef { + id: number + country?: string | null + postalCode?: string | null + city?: string | null + street?: string | null + streetComplement?: string | null + sites?: SiteRead[] + categories?: CategoryRead[] + // L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu. + contacts?: Array +} + +/** RIB embarque (groupe provider:read:accounting, present ssi accounting.view). */ +export interface RibRead extends HydraRef { + id: number + label?: string | null + bic?: string | null + iban?: string | null +} + +/** + * Detail d'un prestataire (`GET /api/providers/{id}`). Tous les champs sont + * optionnels : skip_null_values + gating accounting peuvent omettre n'importe + * quelle cle. + */ +export interface ProviderDetail extends HydraRef { + id: number + companyName?: string | null + isArchived?: boolean + categories?: CategoryRead[] + sites?: SiteRead[] + contacts?: ContactRead[] + addresses?: AddressRead[] + ribs?: RibRead[] + // Onglet Comptabilite (present ssi accounting.view) + siren?: string | null + accountNumber?: string | null + nTva?: string | null + tvaMode?: Relation + paymentDelay?: Relation + paymentType?: Relation + bank?: Relation +} + +/** Extrait l'IRI d'une relation (objet embarque, 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 +} + +/** IRI des elements d'une collection embarquee (categories / sites du prestataire). */ +export function irisOf(items: HydraRef[] | undefined): string[] { + return (items ?? []).map(i => i['@id']) +} + +/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */ +export function mapContactToDraft(contact: ContactRead): ProviderContactFormDraft { + const phoneSecondary = contact.phoneSecondary ?? null + return { + id: contact.id, + iri: contact['@id'] ?? null, + firstName: contact.firstName ?? null, + lastName: contact.lastName ?? null, + jobTitle: contact.jobTitle ?? null, + phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null, + phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null, + email: contact.email ?? null, + hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '', + } +} + +/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */ +export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraft { + return { + id: address.id, + country: address.country ?? 'France', + postalCode: address.postalCode ?? null, + city: address.city ?? null, + street: address.street ?? null, + streetComplement: address.streetComplement ?? null, + categoryIris: (address.categories ?? []).map(c => c['@id']), + siteIris: (address.sites ?? []).map(s => s['@id']), + contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])), + } +} + +/** Mappe un RIB embarque vers un brouillon. */ +export function mapRibToDraft(rib: RibRead): ProviderRibFormDraft { + return { + id: rib.id, + label: rib.label ?? null, + bic: rib.bic ?? null, + iban: rib.iban ?? null, + } +} + +/** Mappe les champs comptables (scalaires + IRI des referentiels embarques). */ +export function mapAccountingDraft(provider: ProviderDetail): ProviderAccountingDraft { + return { + siren: provider.siren ?? null, + accountNumber: provider.accountNumber ?? null, + nTva: provider.nTva ?? null, + tvaModeIri: iriOf(provider.tvaMode), + paymentDelayIri: iriOf(provider.paymentDelay), + paymentTypeIri: iriOf(provider.paymentType), + bankIri: iriOf(provider.bank), + } +} + +/** + * Options de categories (value=IRI, label=nom) construites depuis l'embed. + * Source role-independante : evite de dependre de `GET /categories` (403 possible + * pour un role metier), qui laisserait les libelles vides en consultation. + */ +export function categoryOptionsOf(categories: CategoryRead[] | undefined): RefOption[] { + return (categories ?? []).map(c => ({ + value: c['@id'], + label: c.name ?? c.code ?? c['@id'], + })) +} + +/** Options de sites (value=IRI, label=nom) construites depuis un embed. */ +export function siteOptionsOf(sites: SiteRead[] | undefined): RefOption[] { + return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] })) +} + +/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed prestataire. */ +export function contactOptionsOf(contacts: ContactRead[] | undefined): RefOption[] { + return (contacts ?? []).map(c => ({ + value: c['@id'], + label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']), + })) +} + +/** + * Liste a une seule option (ou vide) construite depuis un referentiel embarque + * (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en + * lecture seule. Le libelle vient de l'embed, jamais d'un GET de referentiel — + * l'affichage reste correct quel que soit le role. + */ +export function referentialOptionOf(relation: Relation): RefOption[] { + if (!relation || typeof relation === 'string') { + return [] + } + const label = (relation.label as string | undefined) + ?? (relation.name as string | undefined) + ?? relation['@id'] + return [{ value: relation['@id'], label }] +} + +/** Code metier d'un referentiel embarque (PaymentType.code = 'LCR' / 'VIREMENT'), ou null. */ +export function paymentTypeCodeOf(relation: Relation): string | null { + if (!relation || typeof relation === 'string') { + return null + } + return (relation.code as string | undefined) ?? null +} + +/** + * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet — + * `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir + * ouvrir l'edition pour son onglet Comptabilite). Le readonly fin par onglet est + * gere sur l'ecran d'edition. + */ +export function canEditProvider(canAny: (codes: string[]) => boolean): boolean { + return canAny(['technique.providers.manage', 'technique.providers.accounting.manage']) +} + +/** Bouton « Archiver » : permission archive ET prestataire encore actif (Admin seul). */ +export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean { + return can('technique.providers.archive') && !isArchived +} + +/** Bouton « Restaurer » : permission archive ET prestataire deja archive (Admin seul). */ +export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean { + return can('technique.providers.archive') && isArchived +}