Files
web-frontend/app/pages/profile/counterparty.vue
2026-04-06 21:38:14 +07:00

491 lines
19 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>
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Карточка контрагента</h1>
<span
class="inline-flex items-center rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.16em]"
:class="profileIsComplete ? 'bg-[#e8f6ee] text-[#0d854a]' : 'bg-[#fff3dc] text-[#b06b00]'"
>
{{ profileIsComplete ? 'Заполнено' : 'Нужно заполнить' }}
</span>
</div>
<p class="max-w-3xl text-sm leading-6 text-[#355947]">
Заполните реквизиты компании, банка и подписанта. После этого карточку можно будет использовать в заказах без ручного добивания данных.
</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 space-y-1">
<h2 class="text-xl font-black tracking-[-0.02em] text-[#123824]">Банковские реквизиты</h2>
<p class="text-sm leading-6 text-[#5c7b69]">
Подтяните банк по справочнику или внесите реквизиты вручную.
</p>
</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 space-y-1">
<h2 class="text-xl font-black tracking-[-0.02em] text-[#123824]">Подписанты и основания</h2>
<p class="text-sm leading-6 text-[#5c7b69]">
Укажите, кто подписывает документы и на каком основании действует.
</p>
</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 flex flex-col gap-3 border-t border-[#edf2ee] pt-5 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<p class="text-sm font-semibold text-[#123824]">
{{ profileIsComplete ? 'Карточка готова к использованию в заказах.' : 'Заполните обязательные поля, чтобы сохранить карточку.' }}
</p>
<p v-if="profileUpdatedAt" class="text-xs text-[#6f8579]">
Обновлено: {{ new Date(profileUpdatedAt).toLocaleString() }}
</p>
</div>
<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>