Split profile into dedicated pages for counterparty, notifications, and addresses

This commit is contained in:
Ruslan Bakiev
2026-04-03 10:34:38 +07:00
parent 4c82c2437a
commit 9222d71cd2
5 changed files with 834 additions and 784 deletions

View File

@@ -0,0 +1,435 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
MyCounterpartyProfileDocument,
UpsertMyCounterpartyProfileDocument,
} from '~/composables/graphql/generated';
import { isCounterpartyProfileComplete } from '~/composables/useCounterpartyProfile';
type PartySuggestion = {
value: string;
unrestricted_value?: string;
data?: {
inn?: string;
kpp?: string;
ogrn?: string;
address?: { value?: string };
management?: {
name?: string;
post?: string;
};
};
};
type BankSuggestion = {
value: string;
unrestricted_value?: string;
data?: {
bic?: string;
correspondent_account?: string;
};
};
const counterpartyForm = reactive({
companyName: '',
companyFullName: '',
inn: '',
kpp: '',
ogrn: '',
legalAddress: '',
bankName: '',
bik: '',
correspondentAccount: '',
checkingAccount: '',
signerFullName: '',
signerPosition: '',
signerBasis: '',
});
const profileFeedback = ref('');
const profileFeedbackTone = ref<'success' | 'error'>('success');
const profileQuery = useQuery(MyCounterpartyProfileDocument);
const saveCounterpartyMutation = useMutation(UpsertMyCounterpartyProfileDocument, { throws: 'never' });
const companySearch = ref('');
const partySuggestions = ref<PartySuggestion[]>([]);
const partyLoading = ref(false);
const partyOpen = ref(false);
const partySearchTimer = ref<ReturnType<typeof setTimeout> | null>(null);
const bankSearch = ref('');
const bankSuggestions = ref<BankSuggestion[]>([]);
const bankLoading = ref(false);
const bankOpen = ref(false);
const bankSearchTimer = ref<ReturnType<typeof setTimeout> | null>(null);
const partyDropdownRef = ref<HTMLElement | null>(null);
const bankDropdownRef = ref<HTMLElement | null>(null);
watch(
() => profileQuery.result.value?.myCounterpartyProfile,
(profile) => {
if (!profile) {
return;
}
counterpartyForm.companyName = profile.companyName;
counterpartyForm.companyFullName = profile.companyFullName;
counterpartyForm.inn = profile.inn;
counterpartyForm.kpp = profile.kpp ?? '';
counterpartyForm.ogrn = profile.ogrn ?? '';
counterpartyForm.legalAddress = profile.legalAddress;
counterpartyForm.bankName = profile.bankName;
counterpartyForm.bik = profile.bik;
counterpartyForm.correspondentAccount = profile.correspondentAccount;
counterpartyForm.checkingAccount = profile.checkingAccount;
counterpartyForm.signerFullName = profile.signerFullName;
counterpartyForm.signerPosition = profile.signerPosition;
counterpartyForm.signerBasis = profile.signerBasis;
companySearch.value = profile.companyName;
bankSearch.value = profile.bankName;
},
{ immediate: true },
);
const profileUpdatedAt = computed(() => profileQuery.result.value?.myCounterpartyProfile?.updatedAt ?? null);
const profileIsComplete = computed(() => isCounterpartyProfileComplete(counterpartyForm));
function clearPartyTimer() {
if (!partySearchTimer.value) {
return;
}
clearTimeout(partySearchTimer.value);
partySearchTimer.value = null;
}
function clearBankTimer() {
if (!bankSearchTimer.value) {
return;
}
clearTimeout(bankSearchTimer.value);
bankSearchTimer.value = null;
}
async function fetchPartySuggestions() {
const query = companySearch.value.trim();
if (query.length < 2) {
partySuggestions.value = [];
partyOpen.value = false;
return;
}
partyLoading.value = true;
await $fetch<{ suggestions: PartySuggestion[] }>('/api/dadata/party', {
method: 'POST',
body: { query },
})
.then((response) => {
partySuggestions.value = response.suggestions || [];
partyOpen.value = partySuggestions.value.length > 0;
})
.finally(() => {
partyLoading.value = false;
});
}
async function fetchBankSuggestions() {
const query = bankSearch.value.trim();
if (query.length < 2) {
bankSuggestions.value = [];
bankOpen.value = false;
return;
}
bankLoading.value = true;
await $fetch<{ suggestions: BankSuggestion[] }>('/api/dadata/bank', {
method: 'POST',
body: { query },
})
.then((response) => {
bankSuggestions.value = response.suggestions || [];
bankOpen.value = bankSuggestions.value.length > 0;
})
.finally(() => {
bankLoading.value = false;
});
}
function schedulePartySuggest() {
clearPartyTimer();
partySearchTimer.value = setTimeout(() => {
void fetchPartySuggestions();
}, 250);
}
function scheduleBankSuggest() {
clearBankTimer();
bankSearchTimer.value = setTimeout(() => {
void fetchBankSuggestions();
}, 250);
}
function applyPartySuggestion(item: PartySuggestion) {
partyOpen.value = false;
companySearch.value = item.value;
counterpartyForm.companyName = item.value;
counterpartyForm.companyFullName = item.unrestricted_value || item.value;
counterpartyForm.inn = item.data?.inn || '';
counterpartyForm.kpp = item.data?.kpp || '';
counterpartyForm.ogrn = item.data?.ogrn || '';
counterpartyForm.legalAddress = item.data?.address?.value || '';
if (!counterpartyForm.signerFullName && item.data?.management?.name) {
counterpartyForm.signerFullName = item.data.management.name;
}
if (!counterpartyForm.signerPosition && item.data?.management?.post) {
counterpartyForm.signerPosition = item.data.management.post;
}
}
function applyBankSuggestion(item: BankSuggestion) {
bankOpen.value = false;
bankSearch.value = item.value;
counterpartyForm.bankName = item.value;
counterpartyForm.bik = item.data?.bic || '';
counterpartyForm.correspondentAccount = item.data?.correspondent_account || '';
}
function closeDropdownsFromOutside(event: MouseEvent) {
const target = event.target as Node | null;
if (partyDropdownRef.value && target && !partyDropdownRef.value.contains(target)) {
partyOpen.value = false;
}
if (bankDropdownRef.value && target && !bankDropdownRef.value.contains(target)) {
bankOpen.value = false;
}
}
async function saveCounterpartyProfile() {
profileFeedback.value = '';
const result = await saveCounterpartyMutation.mutate({
input: {
companyName: counterpartyForm.companyName,
companyFullName: counterpartyForm.companyFullName,
inn: counterpartyForm.inn,
kpp: counterpartyForm.kpp.trim() ? counterpartyForm.kpp.trim() : null,
ogrn: counterpartyForm.ogrn.trim() ? counterpartyForm.ogrn.trim() : null,
legalAddress: counterpartyForm.legalAddress,
bankName: counterpartyForm.bankName,
bik: counterpartyForm.bik,
correspondentAccount: counterpartyForm.correspondentAccount,
checkingAccount: counterpartyForm.checkingAccount,
signerFullName: counterpartyForm.signerFullName,
signerPosition: counterpartyForm.signerPosition,
signerBasis: counterpartyForm.signerBasis,
},
});
const payload = result?.data?.upsertMyCounterpartyProfile;
if (!payload) {
profileFeedbackTone.value = 'error';
profileFeedback.value = saveCounterpartyMutation.error.value?.message || 'Не удалось сохранить карточку контрагента.';
return;
}
profileFeedbackTone.value = 'success';
profileFeedback.value = 'Карточка контрагента сохранена.';
await profileQuery.refetch();
}
onMounted(() => {
document.addEventListener('click', closeDropdownsFromOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeDropdownsFromOutside);
clearPartyTimer();
clearBankTimer();
});
</script>
<template>
<section class="space-y-6">
<NuxtLink to="/profile" class="link link-hover text-sm"> Назад в профиль</NuxtLink>
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Карточка контрагента</h1>
<div class="surface-card rounded-3xl p-5">
<p class="text-sm text-[#355947]">
Заполните реквизиты, чтобы оформить заявки и получить полный функционал личного кабинета.
</p>
<div class="mt-4 space-y-4">
<section>
<h2 class="mb-3 text-base font-bold">1. Контрагент (DaData)</h2>
<div ref="partyDropdownRef" class="relative">
<fieldset class="fieldset">
<legend class="fieldset-legend">Поиск компании</legend>
<input
v-model="companySearch"
type="text"
class="input input-bordered w-full"
placeholder="Введите название или ИНН"
@input="schedulePartySuggest"
@focus="partyOpen = partySuggestions.length > 0"
>
</fieldset>
<span v-if="partyLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
<div
v-if="partyOpen && partySuggestions.length > 0"
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-box border border-base-300 bg-base-100 p-2 shadow-xl"
>
<button
v-for="item in partySuggestions"
:key="`${item.value}-${item.data?.inn || ''}`"
type="button"
class="btn btn-ghost mb-1 h-auto min-h-0 w-full justify-start whitespace-normal px-3 py-2 text-left"
@click="applyPartySuggestion(item)"
>
<span>
<span class="block text-sm font-semibold">{{ item.value }}</span>
<span class="block text-xs opacity-70">ИНН: {{ item.data?.inn || '—' }} <span v-if="item.data?.kpp"> КПП: {{ item.data.kpp }}</span></span>
</span>
</button>
</div>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Краткое наименование</legend>
<input v-model="counterpartyForm.companyName" type="text" class="input input-bordered w-full" >
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Полное наименование</legend>
<input v-model="counterpartyForm.companyFullName" type="text" class="input input-bordered w-full" >
</fieldset>
<div class="grid gap-3 sm:grid-cols-3">
<fieldset class="fieldset">
<legend class="fieldset-legend">ИНН</legend>
<input v-model="counterpartyForm.inn" type="text" class="input input-bordered w-full" >
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">КПП</legend>
<input v-model="counterpartyForm.kpp" type="text" class="input input-bordered w-full" >
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">ОГРН</legend>
<input v-model="counterpartyForm.ogrn" type="text" class="input input-bordered w-full" >
</fieldset>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Юридический адрес</legend>
<textarea v-model="counterpartyForm.legalAddress" class="textarea textarea-bordered min-h-24 w-full" />
</fieldset>
</section>
<div class="divider my-0" />
<section>
<h2 class="mb-3 text-base font-bold">2. Банк (DaData)</h2>
<div ref="bankDropdownRef" class="relative">
<fieldset class="fieldset">
<legend class="fieldset-legend">Поиск банка</legend>
<input
v-model="bankSearch"
type="text"
class="input input-bordered w-full"
placeholder="Введите название банка"
@input="scheduleBankSuggest"
@focus="bankOpen = bankSuggestions.length > 0"
>
</fieldset>
<span v-if="bankLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
<div
v-if="bankOpen && bankSuggestions.length > 0"
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-box border border-base-300 bg-base-100 p-2 shadow-xl"
>
<button
v-for="item in bankSuggestions"
:key="`${item.value}-${item.data?.bic || ''}`"
type="button"
class="btn btn-ghost mb-1 h-auto min-h-0 w-full justify-start whitespace-normal px-3 py-2 text-left"
@click="applyBankSuggestion(item)"
>
<span>
<span class="block text-sm font-semibold">{{ item.value }}</span>
<span class="block text-xs opacity-70">БИК: {{ item.data?.bic || '—' }}</span>
</span>
</button>
</div>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Банк</legend>
<input v-model="counterpartyForm.bankName" type="text" class="input input-bordered w-full" >
</fieldset>
<div class="grid gap-3 sm:grid-cols-2">
<fieldset class="fieldset">
<legend class="fieldset-legend">БИК</legend>
<input v-model="counterpartyForm.bik" type="text" class="input input-bordered w-full" >
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Корр. счет</legend>
<input v-model="counterpartyForm.correspondentAccount" type="text" class="input input-bordered w-full" >
</fieldset>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Расчетный счет</legend>
<input v-model="counterpartyForm.checkingAccount" type="text" class="input input-bordered w-full" >
</fieldset>
</section>
<div class="divider my-0" />
<section>
<h2 class="mb-3 text-base font-bold">3. Подписант и основание</h2>
<fieldset class="fieldset">
<legend class="fieldset-legend">ФИО подписанта</legend>
<input v-model="counterpartyForm.signerFullName" type="text" class="input input-bordered w-full" placeholder="Иванов Иван Иванович" >
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Должность</legend>
<input v-model="counterpartyForm.signerPosition" type="text" class="input input-bordered w-full" placeholder="Генеральный директор" >
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Основание полномочий</legend>
<textarea
v-model="counterpartyForm.signerBasis"
class="textarea textarea-bordered min-h-24 w-full"
placeholder="Действует на основании Устава"
/>
</fieldset>
<button class="btn btn-primary mt-4 w-full" :disabled="saveCounterpartyMutation.loading.value || !profileIsComplete" @click="saveCounterpartyProfile">
{{ saveCounterpartyMutation.loading.value ? 'Сохраняем' : 'Сохранить' }}
</button>
<p v-if="profileUpdatedAt" class="mt-2 text-xs opacity-70">Обновлено: {{ new Date(profileUpdatedAt).toLocaleString() }}</p>
</section>
</div>
<div v-if="profileFeedback" class="alert mt-4" :class="profileFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
{{ profileFeedback }}
</div>
</div>
</section>
</template>