Files
web-frontend/app/pages/profile/counterparty.vue

436 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>