c76c447aa2
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>
309 lines
15 KiB
Vue
309 lines
15 KiB
Vue
<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>
|