466 lines
17 KiB
Vue
466 lines
17 KiB
Vue
<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 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>
|
||
|
||
<div class="space-y-2">
|
||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Карточка контрагента</h1>
|
||
<p class="text-sm leading-6 text-[#466653]">
|
||
Заполните реквизиты компании, банка, подписанта и основания.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<section class="surface-card rounded-3xl p-5 md:p-6">
|
||
<div class="mb-5 space-y-1">
|
||
<h2 class="text-xl font-black tracking-[-0.02em] text-[#123824]">Данные компании</h2>
|
||
<p class="text-sm leading-6 text-[#5c7b69]">
|
||
Найдите компанию через DaData или заполните реквизиты вручную.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="grid gap-4">
|
||
<div ref="partyDropdownRef" class="relative">
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">Поиск компании</span>
|
||
<div class="relative">
|
||
<input
|
||
v-model="companySearch"
|
||
type="text"
|
||
class="input manager-field w-full pr-11"
|
||
placeholder="Введите название компании или ИНН"
|
||
@input="schedulePartySuggest"
|
||
@focus="partyOpen = partySuggestions.length > 0"
|
||
>
|
||
<span v-if="partyLoading" class="loading loading-spinner loading-sm absolute right-4 top-1/2 -translate-y-1/2 text-[#5c7b69]" />
|
||
</div>
|
||
</label>
|
||
|
||
<div
|
||
v-if="partyOpen && partySuggestions.length > 0"
|
||
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-[1.4rem] border border-[#e1ebe4] bg-white p-2 shadow-[0_24px_48px_rgba(18,56,36,0.12)]"
|
||
>
|
||
<button
|
||
v-for="item in partySuggestions"
|
||
:key="`${item.value}-${item.data?.inn || ''}`"
|
||
type="button"
|
||
class="mb-1 w-full rounded-2xl px-4 py-3 text-left transition hover:bg-[#f5faf7]"
|
||
@click="applyPartySuggestion(item)"
|
||
>
|
||
<span class="block text-sm font-semibold text-[#123824]">{{ item.value }}</span>
|
||
<span class="mt-1 block text-xs text-[#5c7b69]">
|
||
ИНН: {{ item.data?.inn || '—' }}<span v-if="item.data?.kpp"> • КПП: {{ item.data.kpp }}</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid gap-4 md:grid-cols-2">
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">Краткое наименование</span>
|
||
<input v-model="counterpartyForm.companyName" type="text" class="input manager-field w-full">
|
||
</label>
|
||
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">Полное наименование</span>
|
||
<input v-model="counterpartyForm.companyFullName" type="text" class="input manager-field w-full">
|
||
</label>
|
||
</div>
|
||
|
||
<div class="grid gap-4 md:grid-cols-3">
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">ИНН</span>
|
||
<input v-model="counterpartyForm.inn" type="text" class="input manager-field w-full">
|
||
</label>
|
||
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">КПП</span>
|
||
<input v-model="counterpartyForm.kpp" type="text" class="input manager-field w-full">
|
||
</label>
|
||
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">ОГРН</span>
|
||
<input v-model="counterpartyForm.ogrn" type="text" class="input manager-field w-full">
|
||
</label>
|
||
</div>
|
||
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">Юридический адрес</span>
|
||
<textarea v-model="counterpartyForm.legalAddress" class="textarea manager-field min-h-28 w-full" />
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="surface-card rounded-3xl p-5 md:p-6">
|
||
<div class="mb-5">
|
||
<h2 class="text-xl font-black tracking-[-0.02em] text-[#123824]">Банковские реквизиты</h2>
|
||
</div>
|
||
|
||
<div class="grid gap-4">
|
||
<div ref="bankDropdownRef" class="relative">
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">Поиск банка</span>
|
||
<div class="relative">
|
||
<input
|
||
v-model="bankSearch"
|
||
type="text"
|
||
class="input manager-field w-full pr-11"
|
||
placeholder="Введите название банка"
|
||
@input="scheduleBankSuggest"
|
||
@focus="bankOpen = bankSuggestions.length > 0"
|
||
>
|
||
<span v-if="bankLoading" class="loading loading-spinner loading-sm absolute right-4 top-1/2 -translate-y-1/2 text-[#5c7b69]" />
|
||
</div>
|
||
</label>
|
||
|
||
<div
|
||
v-if="bankOpen && bankSuggestions.length > 0"
|
||
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-[1.4rem] border border-[#e1ebe4] bg-white p-2 shadow-[0_24px_48px_rgba(18,56,36,0.12)]"
|
||
>
|
||
<button
|
||
v-for="item in bankSuggestions"
|
||
:key="`${item.value}-${item.data?.bic || ''}`"
|
||
type="button"
|
||
class="mb-1 w-full rounded-2xl px-4 py-3 text-left transition hover:bg-[#f5faf7]"
|
||
@click="applyBankSuggestion(item)"
|
||
>
|
||
<span class="block text-sm font-semibold text-[#123824]">{{ item.value }}</span>
|
||
<span class="mt-1 block text-xs text-[#5c7b69]">БИК: {{ item.data?.bic || '—' }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid gap-4 md:grid-cols-2">
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">Банк</span>
|
||
<input v-model="counterpartyForm.bankName" type="text" class="input manager-field w-full">
|
||
</label>
|
||
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">БИК</span>
|
||
<input v-model="counterpartyForm.bik" type="text" class="input manager-field w-full">
|
||
</label>
|
||
</div>
|
||
|
||
<div class="grid gap-4 md:grid-cols-2">
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">Корреспондентский счет</span>
|
||
<input v-model="counterpartyForm.correspondentAccount" type="text" class="input manager-field w-full">
|
||
</label>
|
||
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">Расчетный счет</span>
|
||
<input v-model="counterpartyForm.checkingAccount" type="text" class="input manager-field w-full">
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="surface-card rounded-3xl p-5 md:p-6">
|
||
<div class="mb-5">
|
||
<h2 class="text-xl font-black tracking-[-0.02em] text-[#123824]">Подписанты и основания</h2>
|
||
</div>
|
||
|
||
<div class="grid gap-4">
|
||
<div class="grid gap-4 md:grid-cols-2">
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">ФИО подписанта</span>
|
||
<input
|
||
v-model="counterpartyForm.signerFullName"
|
||
type="text"
|
||
class="input manager-field w-full"
|
||
placeholder="Иванов Иван Иванович"
|
||
>
|
||
</label>
|
||
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">Должность</span>
|
||
<input
|
||
v-model="counterpartyForm.signerPosition"
|
||
type="text"
|
||
class="input manager-field w-full"
|
||
placeholder="Генеральный директор"
|
||
>
|
||
</label>
|
||
</div>
|
||
|
||
<label class="block space-y-2">
|
||
<span class="text-sm font-semibold text-[#355947]">Основание полномочий</span>
|
||
<textarea
|
||
v-model="counterpartyForm.signerBasis"
|
||
class="textarea manager-field min-h-28 w-full"
|
||
placeholder="Действует на основании Устава"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="mt-6 border-t border-[#edf2ee] pt-5">
|
||
<button
|
||
class="btn h-12 w-full rounded-full border-0 bg-[#123824] px-7 text-white shadow-[0_18px_34px_rgba(18,56,36,0.18)] hover:bg-[#0f2f20] disabled:border-0 disabled:bg-[#cfd8d2] disabled:text-[#6f8579] md:w-auto"
|
||
:disabled="saveCounterpartyMutation.loading.value || !profileIsComplete"
|
||
@click="saveCounterpartyProfile"
|
||
>
|
||
{{ saveCounterpartyMutation.loading.value ? 'Сохраняем...' : 'Сохранить' }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<div v-if="profileFeedback" class="alert" :class="profileFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
|
||
{{ profileFeedback }}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|