Files
web-frontend/app/pages/profile.vue
2026-04-02 17:17:16 +07:00

519 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 {
MeDocument,
MyCounterpartyProfileDocument,
MyMessengerConnectionsDocument,
UpsertMyCounterpartyProfileDocument,
} from '~/composables/graphql/generated';
import { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink';
import { isCounterpartyProfileComplete } from '~/composables/useCounterpartyProfile';
type MessengerItem = {
type: 'TELEGRAM' | 'MAX';
isActive: boolean;
channelId: string;
};
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 config = useRuntimeConfig();
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 meQuery = useQuery(MeDocument);
const profileQuery = useQuery(MyCounterpartyProfileDocument);
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
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));
const telegramConnection = computed(() =>
connectionsQuery.result.value?.myMessengerConnections?.find(
(item: MessengerItem) => item.type === 'TELEGRAM' && item.isActive,
),
);
const maxConnection = computed(() =>
connectionsQuery.result.value?.myMessengerConnections?.find(
(item: MessengerItem) => item.type === 'MAX' && item.isActive,
),
);
function buildBotConnectUrl(baseUrl: string) {
const accountEmail = meQuery.result.value?.me?.email?.trim().toLowerCase();
if (!accountEmail || !baseUrl) {
return '';
}
return buildMessengerBotStartUrl(baseUrl, accountEmail);
}
const telegramConnectUrl = computed(() => buildBotConnectUrl(config.public.telegramBotUrl || ''));
const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl || ''));
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">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Профиль</h1>
<div class="grid gap-4 lg:grid-cols-[1.8fr_1fr]">
<div class="surface-card rounded-3xl p-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-xl font-bold text-[#123824]">Карточка контрагента</h2>
<span class="badge" :class="profileIsComplete ? 'badge-success' : 'badge-warning'">
{{ profileIsComplete ? 'Заполнена' : 'Не заполнена' }}
</span>
</div>
<div class="mt-4 grid gap-4 xl:grid-cols-3">
<div class="rounded-2xl border border-base-300 bg-base-100 p-4">
<h3 class="mb-3 text-base font-bold">1. Контрагент (Dadata)</h3>
<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>
</div>
<div class="rounded-2xl border border-base-300 bg-base-100 p-4">
<h3 class="mb-3 text-base font-bold">2. Банк (Dadata)</h3>
<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>
</div>
<div class="rounded-2xl border border-base-300 bg-base-100 p-4">
<h3 class="mb-3 text-base font-bold">3. Подписант и основание</h3>
<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>
</div>
</div>
<div v-if="profileFeedback" class="alert mt-4" :class="profileFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
{{ profileFeedback }}
</div>
<div class="alert mt-4" :class="profileIsComplete ? 'alert-success' : 'alert-warning'">
{{
profileIsComplete
? 'Карточка контрагента заполнена. Оформление заявки в корзине доступно.'
: 'Пока карточка не заполнена полностью, оформление заявки в корзине будет заблокировано.'
}}
</div>
</div>
<aside class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Уведомления</h2>
<div class="mt-4 space-y-3">
<div class="rounded-2xl border border-[#d6ebde] bg-white/75 p-4">
<p class="font-semibold">Telegram</p>
<p class="text-sm opacity-80">
{{ telegramConnection ? `Подключен: ${telegramConnection.channelId}` : 'Не подключен' }}
</p>
<a
:href="telegramConnectUrl || undefined"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary mt-3 w-full"
:class="{ 'btn-disabled pointer-events-none': !telegramConnectUrl }"
>
{{ telegramConnection ? 'Переподключить Telegram' : 'Подключить Telegram' }}
</a>
</div>
<div class="rounded-2xl border border-[#d6ebde] bg-white/75 p-4">
<p class="font-semibold">Max</p>
<p class="text-sm opacity-80">
{{ maxConnection ? `Подключен: ${maxConnection.channelId}` : 'Не подключен' }}
</p>
<a
:href="maxConnectUrl || undefined"
target="_blank"
rel="noopener noreferrer"
class="btn btn-accent mt-3 w-full"
:class="{ 'btn-disabled pointer-events-none': !maxConnectUrl }"
>
{{ maxConnection ? 'Переподключить Max' : 'Подключить Max' }}
</a>
</div>
</div>
</aside>
</div>
</section>
</template>