feat(front) : consultation + modification prestataire (ERP-145) (#107)
Auto Tag Develop / tag (push) Successful in 8s

Empilée sur ERP-144 (#106).

## Périmètre ERP-145
Écrans **Consultation** (lecture seule) et **Modification** (édition par onglet), peuplés depuis la **seule** réponse `GET /api/providers/{id}` (embed contacts/adresses/ribs + refs comptables — pas de N+1).

### Consultation — `pages/providers/[id]/index.vue` (`/providers/{id}`)
- Ouverture par défaut sur **Contacts** ; tous champs readonly ; onglets **Contacts · Adresse · Rapports · Échanges · Comptabilité** (navigation libre). Rapports/Échanges = placeholders « À venir ».
- Flèche retour → répertoire. Bouton **Modifier** (si `manage` OU `accounting.manage`). Bouton **Archiver** (Admin seul, `archive`) → modal → PATCH `{isArchived:true}` ; **Restaurer** si archivé.
- Comptabilité visible seulement si `accounting.view` ; banque/RIB affichés selon le type de règlement (VIREMENT/LCR).

### Modification — `pages/providers/[id]/edit.vue` (`/providers/{id}/edit`)
- Pré-rempli ; **bloc principal éditable** (Nom/Catégories/Sites, PATCH `provider:write:main` via `updateMain`) ; onglets Contact/Adresse/Comptabilité en **navigation libre**, PATCH partiel par onglet (réutilise `useProviderForm` en `editMode`).
- Onglets sans permission `manage` / `accounting.manage` restent **readonly** (pas de bouton Valider / suppression). Accès réservé à `manage` OU `accounting.manage`.

### Composables / helpers
- **`useProvider(id)`** : charge le détail (ld+json) + archive/restore (PATCH isArchived seul, puis rechargement).
- **`useProviderForm`** étendu : `updateMain()` (PATCH principal en édition) + `editMode` (completeTab ne verrouille/avance plus).
- **`providerDetail.ts`** : mapping embed → brouillons + options role-indépendantes (libellés depuis l'embed) + règles d'actions (Modifier/Archiver/Restaurer).

## Conformité
- `useApi()` only ; `Malio*` only ; `usePermissions()` pour boutons/onglets ; aucun texte FR en dur ; pas d'import inter-module (règle ABSOLUE n°1).

## Vérifications
- Vitest : 470/470 (16 nouveaux : mapping détail, actions par permission, updateMain + editMode).
- ESLint : OK · `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : **Consultation** (ACME) — bloc principal readonly + libellés catégories/sites résolus depuis l'embed, 5 onglets, Modifier+Archiver visibles (admin), Comptabilité readonly. **Modification** — bloc principal éditable pré-rempli (Site « 86 17 »), 3 onglets navigation libre, onglet Contact pré-rempli.

Reviewed-on: #107
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #107.
This commit is contained in:
2026-06-15 09:29:44 +00:00
committed by Autin
parent 19ac8833eb
commit c76c447aa2
18 changed files with 1812 additions and 71 deletions
@@ -0,0 +1,538 @@
<template>
<div>
<!-- En-tete : retour consultation + nom du prestataire. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('technique.providers.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.edit.notFound') }}</p>
<template v-else-if="provider">
<!-- Bloc principal (pre-rempli, editable si `manage`) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:label="t('technique.providers.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelectCheckbox
:model-value="main.siteIris"
:options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
/>
</div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="mainSubmitting"
@click="onUpdateMain"
/>
</div>
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Contact -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<ProviderContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="index > 0"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="tabSubmitting"
@click="onSubmitContacts"
/>
</div>
</div>
</template>
<!-- Onglet Adresse -->
<template #address>
<div class="mt-12 flex flex-col gap-6">
<ProviderAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:category-options="referentials.categories.value"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="tabSubmitting"
@click="onSubmitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<MalioSelect
v-if="isBankRequired"
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton
v-if="isRibRequired"
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="tabSubmitting"
@click="onSubmitAccounting"
/>
</div>
</div>
</template>
</MalioTabList>
</template>
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ confirmModal.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.cancel')"
@click="confirmModal.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.confirm')"
@click="runConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useProvider } from '~/modules/technique/composables/useProvider'
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
import {
canEditProvider,
irisOf,
mapAccountingDraft,
mapAddressToDraft,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
} from '~/modules/technique/utils/forms/providerDetail'
import {
isBankRequiredForPaymentType,
isRibRequiredForPaymentType,
} from '~/modules/technique/utils/forms/providerAccounting'
import {
emptyProviderAddress,
emptyProviderContact,
emptyProviderRib,
} from '~/modules/technique/types/providerForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const providerId = route.params.id as string
// Acces : l'edition exige `manage` OU `accounting.manage` (le role Compta edite
// son onglet). Sinon retour consultation.
if (!canEditProvider(canAny)) {
await navigateTo(`/providers/${providerId}`)
}
const businessReadonly = computed(() => !can('technique.providers.manage'))
const referentials = useProviderReferentials()
const { provider, loading, error, load } = useProvider(providerId)
const {
main,
providerId: formProviderId,
mainErrors,
mainSubmitting,
tabSubmitting,
editMode,
canAccountingView,
tabKeys,
activeTab,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
accounting,
ribs,
accountingErrors,
ribErrors,
accountingReadonly,
setPaymentType,
canAddRib,
addRib,
removeRib,
submitAccounting,
updateMain,
} = useProviderForm()
// Modification : navigation libre + pas de verrouillage a la validation.
editMode.value = true
activeTab.value = 'contact'
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.edit.title'))
useHead({ title: t('technique.providers.edit.title') })
// ── Onglets (navigation libre ; Comptabilite si accounting.view) ───────────────
const TAB_ICONS: Record<string, string> = {
contact: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
accounting: 'mdi:bank-circle-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
key,
label: t(`technique.providers.tab.${key}`),
icon: TAB_ICONS[key],
})))
/** Pre-remplit les brouillons depuis la SEULE reponse detail. */
function prefill(): void {
const d = provider.value
if (!d) return
// Indispensable : pilote les URLs des PATCH/POST par onglet (sinon les submits no-op).
formProviderId.value = d.id
main.companyName = d.companyName ?? null
main.categoryIris = irisOf(d.categories)
main.siteIris = irisOf(d.sites)
const mappedContacts = (d.contacts ?? []).map(mapContactToDraft)
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyProviderContact()]
const mappedAddresses = (d.addresses ?? []).map(mapAddressToDraft)
addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyProviderAddress()]
if (canAccountingView.value) {
Object.assign(accounting, mapAccountingDraft(d))
ribs.value = (d.ribs ?? []).map(mapRibToDraft)
// Garantit un bloc RIB visible si le type de reglement est LCR.
if (isRibRequiredForPaymentType(paymentTypeCodeOf(d.paymentType)) && ribs.value.length === 0) {
ribs.value.push(emptyProviderRib())
}
}
}
// ── Comptabilite : RG-3.07 / RG-3.08 pilotees par le code du type de reglement ──
const selectedPaymentTypeCode = computed(() =>
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
)
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void {
const iri = value === null ? null : String(value)
const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null
setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code))
}
// ── Options adresses ──────────────────────────────────────────────────────────
const contactOptions = computed<RefOption[]>(() =>
contacts.value
.filter(c => c.iri !== null)
.map(c => ({
value: c.iri as string,
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
})),
)
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
const addressDegradedNotified = ref(false)
function onAddressDegraded(): void {
if (addressDegradedNotified.value) return
addressDegradedNotified.value = true
toast.warning({
title: t('technique.providers.toast.error'),
message: t('technique.providers.form.address.degraded'),
})
}
// ── Navigation + helpers ──────────────────────────────────────────────────────
function goBack(): void {
router.push(`/providers/${providerId}`)
}
function apiErrorMessage(err: unknown): string {
const data = (err as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
}
/** PATCH du bloc principal (groupe provider:write:main). */
async function onUpdateMain(): Promise<void> {
if (await updateMain()) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
}
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(err => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(err),
}))
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(err => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(err),
}))
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
async function onSubmitAccounting(): Promise<void> {
const ok = await submitAccounting(
isBankRequired.value,
isRibRequired.value,
err => toast.error({ title: t('technique.providers.toast.error'), message: apiErrorMessage(err) }),
)
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
// ── Modal de confirmation generique ───────────────────────────────────────────
const confirmModal = reactive({
open: false,
message: '',
action: null as null | (() => void),
})
function askConfirm(message: string, action: () => void): void {
confirmModal.message = message
confirmModal.action = action
confirmModal.open = true
}
function runConfirm(): void {
confirmModal.action?.()
confirmModal.action = null
confirmModal.open = false
}
function askRemoveContact(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
}
function askRemoveAddress(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
}
function askRemoveRib(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
}
onMounted(async () => {
referentials.loadMain().catch(() => {})
if (canAccountingView.value) {
referentials.loadAccounting().catch(() => {})
}
await load()
prefill()
})
</script>
@@ -0,0 +1,308 @@
<template>
<div>
<!-- En-tete : retour repertoire + nom du prestataire + 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('technique.providers.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('technique.providers.action.edit')"
@click="goEdit"
/>
<MalioButton
v-if="showArchive"
variant="secondary"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('technique.providers.action.archive')"
@click="askToggleArchive"
/>
<MalioButton
v-if="showRestore"
variant="secondary"
icon-name="mdi:archive-arrow-up-outline"
icon-position="left"
:label="t('technique.providers.action.restore')"
@click="askToggleArchive"
/>
</div>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.consultation.notFound') }}</p>
<template v-else-if="provider">
<!-- Bloc principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="provider.companyName"
:label="t('technique.providers.form.main.companyName')"
readonly
/>
<MalioSelectCheckbox
:model-value="mainCategoryIris"
:options="mainCategoryOptions"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
readonly
/>
<MalioSelectCheckbox
:model-value="mainSiteIris"
:options="mainSiteOptions"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
readonly
/>
</div>
<!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<ProviderContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
readonly
/>
</div>
</template>
<!-- Onglet Adresse -->
<template #address>
<div class="mt-12 flex flex-col gap-6">
<ProviderAddressBlock
v-for="(view, index) in addressViews"
:key="index"
:model-value="view.draft"
:category-options="view.categoryOptions"
:site-options="view.siteOptions"
:contact-options="contactOptions"
:country-options="countryOptionsFor(view.draft.country)"
readonly
/>
</div>
</template>
<!-- Onglets placeholder « A venir » (comme les autres modules). -->
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" readonly />
<MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" readonly />
<MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" readonly empty-option-label="" />
<MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" readonly />
<MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" readonly empty-option-label="" />
<MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" readonly empty-option-label="" />
<MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" readonly empty-option-label="" />
</div>
</div>
<!-- Blocs RIB (uniquement si type de reglement = LCR). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" readonly />
<MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" readonly />
<MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" readonly />
</div>
</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('technique.providers.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 { useProvider } from '~/modules/technique/composables/useProvider'
import {
canEditProvider,
categoryOptionsOf,
contactOptionsOf,
irisOf,
mapAccountingDraft,
mapAddressToDraft,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
} from '~/modules/technique/utils/forms/providerDetail'
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const providerId = route.params.id as string
const { provider, loading, error, load, archive, restore } = useProvider(providerId)
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
const canEdit = computed(() => canEditProvider(canAny))
const isArchived = computed(() => provider.value?.isArchived ?? false)
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.consultation.title'))
useHead({ title: t('technique.providers.consultation.title') })
// ── Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité) ──
const activeTab = ref('contacts')
const TAB_ICONS: Record<string, string> = {
contacts: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
reports: 'mdi:file-chart-outline',
exchanges: 'mdi:swap-horizontal',
accounting: 'mdi:bank-circle-outline',
}
const tabs = computed(() => {
const keys = ['contacts', 'address', 'reports', 'exchanges']
if (canAccountingView.value) keys.push('accounting')
return keys.map(key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }))
})
// ── Donnees mappees depuis la SEULE reponse detail ─────────────────────────────
const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
const mainSiteIris = computed(() => irisOf(provider.value?.sites))
const mainCategoryOptions = computed(() => categoryOptionsOf(provider.value?.categories))
const mainSiteOptions = computed(() => siteOptionsOf(provider.value?.sites))
// Au moins un bloc affiche meme sans donnee (bloc vide en lecture seule, comme
// l'onglet Comptabilite et les autres modules — pas de message « Aucun … »).
const contacts = computed(() => {
const list = (provider.value?.contacts ?? []).map(mapContactToDraft)
return list.length > 0 ? list : [emptyProviderContact()]
})
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
// Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
const addressViews = computed(() => {
const views = (provider.value?.addresses ?? []).map(address => ({
draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
}))
return views.length > 0
? views
: [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
})
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
function countryOptionsFor(country: string): { value: string, label: string }[] {
return country ? [{ value: country, label: country }] : []
}
// ── Comptabilite (presente uniquement si accounting.view) ──────────────────────
const accounting = computed(() => mapAccountingDraft(provider.value ?? { id: 0, '@id': '' }))
const paymentTypeCode = computed(() => paymentTypeCodeOf(provider.value?.paymentType))
const isBankRequired = computed(() => isBankRequiredForPaymentType(paymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(paymentTypeCode.value))
const visibleRibs = computed(() => isRibRequired.value ? (provider.value?.ribs ?? []).map(mapRibToDraft) : [])
// Options « une entree » construites depuis l'embed (libelles role-independants).
const tvaModeOptions = computed(() => referentialOptionOf(provider.value?.tvaMode))
const paymentDelayOptions = computed(() => referentialOptionOf(provider.value?.paymentDelay))
const paymentTypeOptions = computed(() => referentialOptionOf(provider.value?.paymentType))
const bankOptions = computed(() => referentialOptionOf(provider.value?.bank))
// ── Navigation / actions ───────────────────────────────────────────────────────
function goBack(): void {
router.push('/providers')
}
function goEdit(): void {
router.push(`/providers/${providerId}/edit`)
}
// ── Archivage / restauration ───────────────────────────────────────────────────
const confirmArchive = reactive({
open: false,
title: '',
message: '',
confirmLabel: '',
})
function askToggleArchive(): void {
const archiving = !isArchived.value
confirmArchive.title = archiving
? t('technique.providers.action.archive')
: t('technique.providers.action.restore')
confirmArchive.message = archiving
? t('technique.providers.consultation.confirmArchive')
: t('technique.providers.consultation.confirmRestore')
confirmArchive.confirmLabel = archiving
? t('technique.providers.action.archive')
: t('technique.providers.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('technique.providers.toast.archiveSuccess')
: t('technique.providers.toast.restoreSuccess'),
})
}
catch {
// 409 a la restauration (homonyme actif) ou autre : toast generique.
toast.error({ title: t('technique.providers.toast.error') })
}
}
onMounted(load)
</script>
@@ -85,7 +85,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitContacts"
/>
</div>
@@ -121,7 +121,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitAddresses"
/>
</div>
@@ -251,7 +251,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitAccounting"
/>
</div>
@@ -314,6 +314,7 @@ const referentials = useProviderReferentials()
const {
main,
providerId,
mainLocked,
mainSubmitting,
mainErrors,
@@ -362,15 +363,33 @@ function apiErrorMessage(error: unknown): string {
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
}
// Dernier onglet REMPLISSABLE par le role : tabKeys exclut deja la Comptabilite
// si l'user n'a pas accounting.view. Sa validation cloture l'ajout (redirection).
const lastFillableTab = computed(() => tabKeys.value[tabKeys.value.length - 1])
/**
* Apres validation d'un onglet (creation) : si c'est le dernier onglet du role,
* l'ajout est termine -> toast final + retour au repertoire (miroir M1/M2) ; sinon
* toast de mise a jour (l'onglet suivant a deja ete deverrouille par completeTab).
*/
function onTabSaved(key: string): void {
if (key === lastFillableTab.value) {
toast.success({ title: t('technique.providers.toast.addComplete') })
router.push('/providers')
return
}
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
// ── Onglet Contact ──────────────────────────────────────────────────────────
/** Valide l'onglet Contact ; toast de succes si l'onglet a ete finalise. */
/** Valide l'onglet Contact ; redirige si c'est le dernier onglet du role. */
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
onTabSaved('contact')
}
}
@@ -413,14 +432,14 @@ function onAddressDegraded(): void {
})
}
/** Valide l'onglet Adresse ; toast de succes si l'onglet a ete finalise. */
/** Valide l'onglet Adresse ; redirige si c'est le dernier onglet du role. */
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
onTabSaved('address')
}
}
@@ -450,7 +469,7 @@ function askRemoveRib(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
}
/** Valide l'onglet Comptabilite ; toast de succes si l'onglet a ete finalise. */
/** Valide l'onglet Comptabilite ; redirige si c'est le dernier onglet du role. */
async function onSubmitAccounting(): Promise<void> {
const ok = await submitAccounting(
isBankRequired.value,
@@ -461,7 +480,7 @@ async function onSubmitAccounting(): Promise<void> {
}),
)
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
onTabSaved('accounting')
}
}