45cb5c834c
Auto Tag Develop / tag (push) Successful in 8s
## Contexte (ERP-172) Sur les ecrans de **modification**, supprimer un bloc Contact / Adresse / RIB ne supprimait pas la sous-ressource cote serveur : - **M1 / M2** : DELETE differe au clic « Enregistrer » de l'onglet -> ne partait jamais si l'utilisateur ne re-validait pas. - **M3** : aucun DELETE (`splice` local uniquement). ## Correctifs ### 1. DELETE immediat des sous-ressources - Nouveau helper partage `frontend/shared/utils/collectionRow.ts` (`removeCollectionRow`) + tests Vitest. - A la confirmation de la modale : bloc existant (`id` en base) -> `DELETE` immediat ; bloc jamais persiste -> retrait local ; echec serveur (ex. 409 dernier RIB d'une LCR) -> bloc conserve + message back. - Branche sur M1 / M2 / M3 (contacts / adresses / RIB). Suppression du mecanisme differe (`removed*Ids` + boucles dans `submit*`) devenu mort. ### 2. Affichage de la poubelle unifie (`isRowRemovable`) Regle identique sur les 3 modules : poubelle visible sur un bloc **seulement s'il reste un autre bloc deja enregistre** (`id` en base). - Tant que rien n'est enregistre -> aucune poubelle (plus de suppression d'un simple brouillon non valide). - On peut jeter un brouillon non enregistre s'il reste un bloc enregistre. - On ne peut jamais supprimer son dernier bloc enregistre. - Applique aux ecrans **new + edit** des 3 modules (contacts / adresses / RIB). ## Tests - Helper couvert par Vitest (`removeCollectionRow` + `isRowRemovable`). - `make nuxt-test` : 480 tests OK. `make nuxt-lint` : OK. ## A verifier (golden path) Sur les 3 modules : supprimer un bloc existant -> `DELETE` part immediatement -> reload -> le bloc a disparu ; la poubelle n'apparait qu'avec un 2e bloc deja enregistre. Reviewed-on: #109 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
544 lines
24 KiB
Vue
544 lines
24 KiB
Vue
<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">
|
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
|
bloc enregistre. -->
|
|
<ProviderContactBlock
|
|
v-for="(contact, index) in contacts"
|
|
:key="index"
|
|
:model-value="contact"
|
|
:removable="isRowRemovable(contacts, index)"
|
|
: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="isRowRemovable(addresses, index)"
|
|
: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 && isRowRemovable(visibleRibs, index)"
|
|
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'
|
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
|
|
|
// 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>
|