Split profile into dedicated pages for counterparty, notifications, and addresses
This commit is contained in:
@@ -140,7 +140,7 @@ async function submitCart() {
|
|||||||
</div>
|
</div>
|
||||||
<div v-else-if="!isCounterpartyComplete" class="alert alert-warning">
|
<div v-else-if="!isCounterpartyComplete" class="alert alert-warning">
|
||||||
Для оформления заявки заполните карточку контрагента в
|
Для оформления заявки заполните карточку контрагента в
|
||||||
<NuxtLink to="/profile" class="link link-hover font-semibold">профиле</NuxtLink>.
|
<NuxtLink to="/profile/counterparty" class="link link-hover font-semibold">профиле</NuxtLink>.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="surface-card rounded-3xl p-4 md:p-5">
|
<div class="surface-card rounded-3xl p-4 md:p-5">
|
||||||
@@ -151,7 +151,7 @@ async function submitCart() {
|
|||||||
</div>
|
</div>
|
||||||
<div v-else-if="!hasDeliveryAddresses" class="alert alert-warning mt-3">
|
<div v-else-if="!hasDeliveryAddresses" class="alert alert-warning mt-3">
|
||||||
Адреса не добавлены.
|
Адреса не добавлены.
|
||||||
<NuxtLink to="/profile" class="link link-hover font-semibold">Добавить адрес в профиле</NuxtLink>
|
<NuxtLink to="/profile/addresses" class="link link-hover font-semibold">Добавить адрес в профиле</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="mt-3 space-y-2">
|
<div v-else class="mt-3 space-y-2">
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -1,493 +1,56 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
import { useQuery } from '@vue/apollo-composable';
|
||||||
import {
|
import {
|
||||||
CreateMyDeliveryAddressDocument,
|
|
||||||
DeleteMyDeliveryAddressDocument,
|
|
||||||
MeDocument,
|
|
||||||
MyCounterpartyProfileDocument,
|
MyCounterpartyProfileDocument,
|
||||||
MyDeliveryAddressesDocument,
|
MyDeliveryAddressesDocument,
|
||||||
MyMessengerConnectionsDocument,
|
MyMessengerConnectionsDocument,
|
||||||
SetMyDefaultDeliveryAddressDocument,
|
|
||||||
UpsertMyCounterpartyProfileDocument,
|
|
||||||
type MyDeliveryAddressesQuery,
|
type MyDeliveryAddressesQuery,
|
||||||
} from '~/composables/graphql/generated';
|
} from '~/composables/graphql/generated';
|
||||||
import { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink';
|
|
||||||
import { isCounterpartyProfileComplete } from '~/composables/useCounterpartyProfile';
|
import { isCounterpartyProfileComplete } from '~/composables/useCounterpartyProfile';
|
||||||
|
|
||||||
type MessengerItem = {
|
type MessengerItem = {
|
||||||
type: 'TELEGRAM' | 'MAX';
|
type: 'TELEGRAM' | 'MAX';
|
||||||
isActive: boolean;
|
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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type AddressSuggestion = {
|
|
||||||
value: string;
|
|
||||||
unrestricted_value?: string;
|
|
||||||
data?: {
|
|
||||||
fias_id?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeliveryAddressItem = MyDeliveryAddressesQuery['myDeliveryAddresses'][number];
|
type DeliveryAddressItem = MyDeliveryAddressesQuery['myDeliveryAddresses'][number];
|
||||||
|
|
||||||
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 addressFeedback = ref('');
|
|
||||||
const addressFeedbackTone = ref<'success' | 'error'>('success');
|
|
||||||
|
|
||||||
const meQuery = useQuery(MeDocument);
|
|
||||||
const profileQuery = useQuery(MyCounterpartyProfileDocument);
|
const profileQuery = useQuery(MyCounterpartyProfileDocument);
|
||||||
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
|
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
|
||||||
const deliveryAddressesQuery = useQuery(MyDeliveryAddressesDocument);
|
const deliveryAddressesQuery = useQuery(MyDeliveryAddressesDocument);
|
||||||
|
|
||||||
const saveCounterpartyMutation = useMutation(UpsertMyCounterpartyProfileDocument, { throws: 'never' });
|
const profileIsComplete = computed(() => isCounterpartyProfileComplete(profileQuery.result.value?.myCounterpartyProfile));
|
||||||
const createAddressMutation = useMutation(CreateMyDeliveryAddressDocument, { throws: 'never' });
|
|
||||||
const setDefaultAddressMutation = useMutation(SetMyDefaultDeliveryAddressDocument, { throws: 'never' });
|
|
||||||
const deleteAddressMutation = useMutation(DeleteMyDeliveryAddressDocument, { throws: 'never' });
|
|
||||||
|
|
||||||
const companySearch = ref('');
|
const telegramConnected = computed(() =>
|
||||||
const partySuggestions = ref<PartySuggestion[]>([]);
|
Boolean(
|
||||||
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 addressForm = reactive({
|
|
||||||
label: '',
|
|
||||||
address: '',
|
|
||||||
unrestrictedValue: '',
|
|
||||||
fiasId: '',
|
|
||||||
});
|
|
||||||
const addressSearch = ref('');
|
|
||||||
const addressSuggestions = ref<AddressSuggestion[]>([]);
|
|
||||||
const addressLoading = ref(false);
|
|
||||||
const addressOpen = ref(false);
|
|
||||||
const addressSearchTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const addressBusyId = ref<string | null>(null);
|
|
||||||
|
|
||||||
const partyDropdownRef = ref<HTMLElement | null>(null);
|
|
||||||
const bankDropdownRef = ref<HTMLElement | null>(null);
|
|
||||||
const addressDropdownRef = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const counterpartySectionRef = ref<HTMLElement | null>(null);
|
|
||||||
const notificationsSectionRef = ref<HTMLElement | null>(null);
|
|
||||||
const addressesSectionRef = 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(
|
connectionsQuery.result.value?.myMessengerConnections?.find(
|
||||||
(item: MessengerItem) => item.type === 'TELEGRAM' && item.isActive,
|
(item: MessengerItem) => item.type === 'TELEGRAM' && item.isActive,
|
||||||
),
|
),
|
||||||
);
|
|
||||||
|
|
||||||
const maxConnection = computed(() =>
|
|
||||||
connectionsQuery.result.value?.myMessengerConnections?.find(
|
|
||||||
(item: MessengerItem) => item.type === 'MAX' && item.isActive,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectedMessengerCount = computed(() => {
|
const maxConnected = computed(() =>
|
||||||
let count = 0;
|
Boolean(
|
||||||
if (telegramConnection.value) {
|
connectionsQuery.result.value?.myMessengerConnections?.find(
|
||||||
count += 1;
|
(item: MessengerItem) => item.type === 'MAX' && item.isActive,
|
||||||
}
|
),
|
||||||
if (maxConnection.value) {
|
),
|
||||||
count += 1;
|
);
|
||||||
}
|
|
||||||
return count;
|
const connectedMessengerCount = computed(() => Number(telegramConnected.value) + Number(maxConnected.value));
|
||||||
});
|
|
||||||
|
|
||||||
const notificationsSummary = computed(() => {
|
const notificationsSummary = computed(() => {
|
||||||
if (connectedMessengerCount.value === 0) {
|
if (connectedMessengerCount.value === 0) {
|
||||||
return 'Уведомления не подключены. Настройте Telegram или Max, чтобы не пропускать обновления заказа.';
|
return 'Уведомления не подключены. Подключите Telegram и Max для удобной работы с заказами.';
|
||||||
}
|
}
|
||||||
if (connectedMessengerCount.value === 1) {
|
if (connectedMessengerCount.value === 1) {
|
||||||
return 'Подключён 1 канал уведомлений. Рекомендуем подключить второй как резерв.';
|
return 'Подключен 1 канал. Рекомендуем подключить второй канал как резервный.';
|
||||||
}
|
}
|
||||||
return 'Подключены оба канала уведомлений. Вы будете получать обновления по заказам в мессенджерах.';
|
return 'Оба канала подключены. Вы будете получать уведомления в Telegram и Max.';
|
||||||
});
|
});
|
||||||
|
|
||||||
const deliveryAddresses = computed<DeliveryAddressItem[]>(() => deliveryAddressesQuery.result.value?.myDeliveryAddresses ?? []);
|
const deliveryAddresses = computed<DeliveryAddressItem[]>(() => deliveryAddressesQuery.result.value?.myDeliveryAddresses ?? []);
|
||||||
const defaultDeliveryAddress = computed(() => deliveryAddresses.value.find((item) => item.isDefault) ?? null);
|
const defaultDeliveryAddress = computed(() => deliveryAddresses.value.find((item) => item.isDefault) ?? null);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAddressTimer() {
|
|
||||||
if (!addressSearchTimer.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearTimeout(addressSearchTimer.value);
|
|
||||||
addressSearchTimer.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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAddressSuggestions() {
|
|
||||||
const query = addressSearch.value.trim();
|
|
||||||
if (query.length < 2) {
|
|
||||||
addressSuggestions.value = [];
|
|
||||||
addressOpen.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addressLoading.value = true;
|
|
||||||
await $fetch<{ suggestions: AddressSuggestion[] }>('/api/dadata/address', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { query },
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
addressSuggestions.value = response.suggestions || [];
|
|
||||||
addressOpen.value = addressSuggestions.value.length > 0;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
addressLoading.value = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedulePartySuggest() {
|
|
||||||
clearPartyTimer();
|
|
||||||
partySearchTimer.value = setTimeout(() => {
|
|
||||||
void fetchPartySuggestions();
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleBankSuggest() {
|
|
||||||
clearBankTimer();
|
|
||||||
bankSearchTimer.value = setTimeout(() => {
|
|
||||||
void fetchBankSuggestions();
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAddressSuggest() {
|
|
||||||
clearAddressTimer();
|
|
||||||
addressSearchTimer.value = setTimeout(() => {
|
|
||||||
void fetchAddressSuggestions();
|
|
||||||
}, 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 applyAddressSuggestion(item: AddressSuggestion) {
|
|
||||||
addressOpen.value = false;
|
|
||||||
addressSearch.value = item.value;
|
|
||||||
addressForm.address = item.value;
|
|
||||||
addressForm.unrestrictedValue = item.unrestricted_value || item.value;
|
|
||||||
addressForm.fiasId = item.data?.fias_id || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToSection(section: 'counterparty' | 'notifications' | 'addresses') {
|
|
||||||
const element =
|
|
||||||
section === 'counterparty'
|
|
||||||
? counterpartySectionRef.value
|
|
||||||
: section === 'notifications'
|
|
||||||
? notificationsSectionRef.value
|
|
||||||
: addressesSectionRef.value;
|
|
||||||
|
|
||||||
element?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (addressDropdownRef.value && target && !addressDropdownRef.value.contains(target)) {
|
|
||||||
addressOpen.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();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addDeliveryAddress() {
|
|
||||||
addressFeedback.value = '';
|
|
||||||
|
|
||||||
const normalizedAddress = addressForm.address.trim() || addressSearch.value.trim();
|
|
||||||
if (normalizedAddress.length < 5) {
|
|
||||||
addressFeedbackTone.value = 'error';
|
|
||||||
addressFeedback.value = 'Введите адрес через подсказки DaData.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await createAddressMutation.mutate({
|
|
||||||
input: {
|
|
||||||
label: addressForm.label.trim() ? addressForm.label.trim() : null,
|
|
||||||
address: normalizedAddress,
|
|
||||||
unrestrictedValue: addressForm.unrestrictedValue.trim() ? addressForm.unrestrictedValue.trim() : null,
|
|
||||||
fiasId: addressForm.fiasId.trim() ? addressForm.fiasId.trim() : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = result?.data?.createMyDeliveryAddress;
|
|
||||||
if (!payload) {
|
|
||||||
addressFeedbackTone.value = 'error';
|
|
||||||
addressFeedback.value = createAddressMutation.error.value?.message || 'Не удалось добавить адрес.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addressForm.label = '';
|
|
||||||
addressForm.address = '';
|
|
||||||
addressForm.unrestrictedValue = '';
|
|
||||||
addressForm.fiasId = '';
|
|
||||||
addressSearch.value = '';
|
|
||||||
addressSuggestions.value = [];
|
|
||||||
addressOpen.value = false;
|
|
||||||
|
|
||||||
addressFeedbackTone.value = 'success';
|
|
||||||
addressFeedback.value = 'Адрес сохранён.';
|
|
||||||
await deliveryAddressesQuery.refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setDefaultAddress(addressId: string) {
|
|
||||||
addressFeedback.value = '';
|
|
||||||
addressBusyId.value = addressId;
|
|
||||||
|
|
||||||
const result = await setDefaultAddressMutation.mutate({ addressId });
|
|
||||||
const payload = result?.data?.setMyDefaultDeliveryAddress;
|
|
||||||
addressBusyId.value = null;
|
|
||||||
|
|
||||||
if (!payload) {
|
|
||||||
addressFeedbackTone.value = 'error';
|
|
||||||
addressFeedback.value = setDefaultAddressMutation.error.value?.message || 'Не удалось сделать адрес основным.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addressFeedbackTone.value = 'success';
|
|
||||||
addressFeedback.value = 'Основной адрес обновлён.';
|
|
||||||
await deliveryAddressesQuery.refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAddress(addressId: string) {
|
|
||||||
addressFeedback.value = '';
|
|
||||||
addressBusyId.value = addressId;
|
|
||||||
|
|
||||||
const result = await deleteAddressMutation.mutate({ addressId });
|
|
||||||
const payload = result?.data?.deleteMyDeliveryAddress;
|
|
||||||
addressBusyId.value = null;
|
|
||||||
|
|
||||||
if (!payload) {
|
|
||||||
addressFeedbackTone.value = 'error';
|
|
||||||
addressFeedback.value = deleteAddressMutation.error.value?.message || 'Не удалось удалить адрес.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addressFeedbackTone.value = 'success';
|
|
||||||
addressFeedback.value = 'Адрес удалён.';
|
|
||||||
await deliveryAddressesQuery.refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', closeDropdownsFromOutside);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('click', closeDropdownsFromOutside);
|
|
||||||
clearPartyTimer();
|
|
||||||
clearBankTimer();
|
|
||||||
clearAddressTimer();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -495,354 +58,35 @@ onBeforeUnmount(() => {
|
|||||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Профиль</h1>
|
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Профиль</h1>
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-3">
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
<article
|
<NuxtLink to="/profile/counterparty" class="block rounded-3xl border border-[#d6ebde] bg-[#f7fffb] p-5 transition hover:shadow-md">
|
||||||
class="rounded-3xl border p-4"
|
|
||||||
:class="profileIsComplete ? 'border-[#76c893] bg-[#ecfdf3]' : 'border-[#f5d49a] bg-[#fff9eb]'"
|
|
||||||
>
|
|
||||||
<p class="text-sm opacity-70">Карточка контрагента</p>
|
<p class="text-sm opacity-70">Карточка контрагента</p>
|
||||||
<p class="mt-2 text-lg font-bold text-[#123824]">
|
<p class="mt-2 text-lg font-bold text-[#123824]">{{ profileIsComplete ? 'Заполнена' : 'Требует внимания' }}</p>
|
||||||
{{ profileIsComplete ? 'Заполнена' : 'Требует внимания' }}
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 text-sm text-[#355947]">
|
<p class="mt-2 text-sm text-[#355947]">
|
||||||
{{
|
{{
|
||||||
profileIsComplete
|
profileIsComplete
|
||||||
? 'Можно оформлять заказы и отправлять заявки менеджеру.'
|
? 'Данные контрагента заполнены.'
|
||||||
: 'Заполните реквизиты, чтобы открыть оформление заявок.'
|
: 'Пожалуйста, заполните карточку контрагента, чтобы получить максимум возможностей личного кабинета.'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<button class="btn btn-sm mt-4 w-full" @click="scrollToSection('counterparty')">Открыть карточку</button>
|
</NuxtLink>
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="rounded-3xl border border-[#d6ebde] bg-[#f7fffb] p-4">
|
<NuxtLink to="/profile/notifications" class="block rounded-3xl border border-[#d6ebde] bg-[#f7fffb] p-5 transition hover:shadow-md">
|
||||||
<p class="text-sm opacity-70">Уведомления</p>
|
<p class="text-sm opacity-70">Уведомления</p>
|
||||||
<p class="mt-2 text-lg font-bold text-[#123824]">{{ connectedMessengerCount }}/2 каналов подключено</p>
|
<p class="mt-2 text-lg font-bold text-[#123824]">{{ connectedMessengerCount }}/2 каналов подключено</p>
|
||||||
<p class="mt-2 text-sm text-[#355947]">{{ notificationsSummary }}</p>
|
<p class="mt-2 text-sm text-[#355947]">{{ notificationsSummary }}</p>
|
||||||
<button class="btn btn-sm mt-4 w-full" @click="scrollToSection('notifications')">Открыть уведомления</button>
|
</NuxtLink>
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="rounded-3xl border border-[#d6ebde] bg-[#f7fffb] p-4">
|
<NuxtLink to="/profile/addresses" class="block rounded-3xl border border-[#d6ebde] bg-[#f7fffb] p-5 transition hover:shadow-md">
|
||||||
<p class="text-sm opacity-70">Адреса доставки</p>
|
<p class="text-sm opacity-70">Адреса доставки</p>
|
||||||
<p class="mt-2 text-lg font-bold text-[#123824]">{{ deliveryAddresses.length }}</p>
|
<p class="mt-2 text-lg font-bold text-[#123824]">{{ deliveryAddresses.length }}</p>
|
||||||
<p class="mt-2 text-sm text-[#355947]">
|
<p class="mt-2 text-sm text-[#355947]">
|
||||||
{{
|
{{
|
||||||
defaultDeliveryAddress
|
defaultDeliveryAddress
|
||||||
? `Основной: ${defaultDeliveryAddress.label || defaultDeliveryAddress.address}`
|
? `Основной: ${defaultDeliveryAddress.label || defaultDeliveryAddress.address}`
|
||||||
: 'Добавьте хотя бы один адрес для оформления заявок.'
|
: 'Адреса пока не добавлены. Добавьте минимум один адрес доставки.'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<button class="btn btn-sm mt-4 w-full" @click="scrollToSection('addresses')">Открыть адреса</button>
|
</NuxtLink>
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-[1.7fr_1fr]">
|
|
||||||
<div ref="counterpartySectionRef" class="surface-card rounded-3xl p-5">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h2 class="text-xl font-bold text-[#123824]">Карточка контрагента</h2>
|
|
||||||
<p class="text-sm text-[#355947]">
|
|
||||||
Пожалуйста, заполните реквизиты, чтобы получить максимум возможностей личного кабинета.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 space-y-4">
|
|
||||||
<section>
|
|
||||||
<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>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="divider my-0" />
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<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>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="divider my-0" />
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<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>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="profileFeedback" class="alert mt-4" :class="profileFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
|
|
||||||
{{ profileFeedback }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<aside ref="notificationsSectionRef" class="surface-card rounded-3xl p-5">
|
|
||||||
<h2 class="text-xl font-bold text-[#123824]">Уведомления</h2>
|
|
||||||
<p class="mt-2 text-sm text-[#355947]">{{ notificationsSummary }}</p>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<aside ref="addressesSectionRef" class="surface-card rounded-3xl p-5">
|
|
||||||
<h2 class="text-xl font-bold text-[#123824]">Адреса доставки</h2>
|
|
||||||
<p class="mt-2 text-sm text-[#355947]">
|
|
||||||
Добавьте адрес через DaData и выберите основной. Этот адрес будет подставляться при оформлении заявки из корзины.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<fieldset class="fieldset mt-4">
|
|
||||||
<legend class="fieldset-legend">Название адреса (необязательно)</legend>
|
|
||||||
<input v-model="addressForm.label" type="text" class="input input-bordered w-full" placeholder="Склад МСК" >
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div ref="addressDropdownRef" class="relative mt-2">
|
|
||||||
<fieldset class="fieldset">
|
|
||||||
<legend class="fieldset-legend">Поиск адреса (DaData)</legend>
|
|
||||||
<input
|
|
||||||
v-model="addressSearch"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered w-full"
|
|
||||||
placeholder="Начните вводить адрес"
|
|
||||||
@input="scheduleAddressSuggest"
|
|
||||||
@focus="addressOpen = addressSuggestions.length > 0"
|
|
||||||
>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<span v-if="addressLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="addressOpen && addressSuggestions.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 addressSuggestions"
|
|
||||||
:key="`${item.value}-${item.data?.fias_id || ''}`"
|
|
||||||
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="applyAddressSuggestion(item)"
|
|
||||||
>
|
|
||||||
<span class="block text-sm font-semibold">{{ item.value }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn btn-primary mt-4 w-full" :disabled="createAddressMutation.loading.value" @click="addDeliveryAddress">
|
|
||||||
{{ createAddressMutation.loading.value ? 'Добавляем…' : 'Добавить адрес' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="addressFeedback" class="alert mt-3" :class="addressFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
|
|
||||||
{{ addressFeedback }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 space-y-2">
|
|
||||||
<div v-if="deliveryAddressesQuery.loading.value" class="alert surface-card border-0">Загрузка адресов...</div>
|
|
||||||
<div v-else-if="deliveryAddresses.length === 0" class="alert surface-card border-0">
|
|
||||||
Пока нет адресов доставки. Добавьте первый адрес через DaData.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article
|
|
||||||
v-for="address in deliveryAddresses"
|
|
||||||
:key="address.id"
|
|
||||||
class="rounded-2xl border border-[#d6ebde] bg-white/75 p-3"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold text-[#123824]">{{ address.label || 'Адрес доставки' }}</p>
|
|
||||||
<p class="text-sm text-[#355947]">{{ address.unrestrictedValue || address.address }}</p>
|
|
||||||
</div>
|
|
||||||
<span v-if="address.isDefault" class="badge badge-success">Основной</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
v-if="!address.isDefault"
|
|
||||||
class="btn btn-sm"
|
|
||||||
:disabled="addressBusyId === address.id"
|
|
||||||
@click="setDefaultAddress(address.id)"
|
|
||||||
>
|
|
||||||
{{ addressBusyId === address.id ? 'Сохраняем…' : 'Сделать основным' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-ghost text-error"
|
|
||||||
:disabled="addressBusyId === address.id"
|
|
||||||
@click="deleteAddress(address.id)"
|
|
||||||
>
|
|
||||||
{{ addressBusyId === address.id ? 'Удаляем…' : 'Удалить' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
282
app/pages/profile/addresses.vue
Normal file
282
app/pages/profile/addresses.vue
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||||
|
import {
|
||||||
|
CreateMyDeliveryAddressDocument,
|
||||||
|
DeleteMyDeliveryAddressDocument,
|
||||||
|
MyDeliveryAddressesDocument,
|
||||||
|
SetMyDefaultDeliveryAddressDocument,
|
||||||
|
type MyDeliveryAddressesQuery,
|
||||||
|
} from '~/composables/graphql/generated';
|
||||||
|
|
||||||
|
type AddressSuggestion = {
|
||||||
|
value: string;
|
||||||
|
unrestricted_value?: string;
|
||||||
|
data?: {
|
||||||
|
fias_id?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeliveryAddressItem = MyDeliveryAddressesQuery['myDeliveryAddresses'][number];
|
||||||
|
|
||||||
|
const addressFeedback = ref('');
|
||||||
|
const addressFeedbackTone = ref<'success' | 'error'>('success');
|
||||||
|
|
||||||
|
const deliveryAddressesQuery = useQuery(MyDeliveryAddressesDocument);
|
||||||
|
const createAddressMutation = useMutation(CreateMyDeliveryAddressDocument, { throws: 'never' });
|
||||||
|
const setDefaultAddressMutation = useMutation(SetMyDefaultDeliveryAddressDocument, { throws: 'never' });
|
||||||
|
const deleteAddressMutation = useMutation(DeleteMyDeliveryAddressDocument, { throws: 'never' });
|
||||||
|
|
||||||
|
const addressForm = reactive({
|
||||||
|
label: '',
|
||||||
|
address: '',
|
||||||
|
unrestrictedValue: '',
|
||||||
|
fiasId: '',
|
||||||
|
});
|
||||||
|
const addressSearch = ref('');
|
||||||
|
const addressSuggestions = ref<AddressSuggestion[]>([]);
|
||||||
|
const addressLoading = ref(false);
|
||||||
|
const addressOpen = ref(false);
|
||||||
|
const addressSearchTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const addressBusyId = ref<string | null>(null);
|
||||||
|
const addressDropdownRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const deliveryAddresses = computed<DeliveryAddressItem[]>(() => deliveryAddressesQuery.result.value?.myDeliveryAddresses ?? []);
|
||||||
|
|
||||||
|
function clearAddressTimer() {
|
||||||
|
if (!addressSearchTimer.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(addressSearchTimer.value);
|
||||||
|
addressSearchTimer.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAddressSuggestions() {
|
||||||
|
const query = addressSearch.value.trim();
|
||||||
|
if (query.length < 2) {
|
||||||
|
addressSuggestions.value = [];
|
||||||
|
addressOpen.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addressLoading.value = true;
|
||||||
|
await $fetch<{ suggestions: AddressSuggestion[] }>('/api/dadata/address', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { query },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
addressSuggestions.value = response.suggestions || [];
|
||||||
|
addressOpen.value = addressSuggestions.value.length > 0;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
addressLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAddressSuggest() {
|
||||||
|
clearAddressTimer();
|
||||||
|
addressSearchTimer.value = setTimeout(() => {
|
||||||
|
void fetchAddressSuggestions();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAddressSuggestion(item: AddressSuggestion) {
|
||||||
|
addressOpen.value = false;
|
||||||
|
addressSearch.value = item.value;
|
||||||
|
addressForm.address = item.value;
|
||||||
|
addressForm.unrestrictedValue = item.unrestricted_value || item.value;
|
||||||
|
addressForm.fiasId = item.data?.fias_id || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdownsFromOutside(event: MouseEvent) {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (addressDropdownRef.value && target && !addressDropdownRef.value.contains(target)) {
|
||||||
|
addressOpen.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addDeliveryAddress() {
|
||||||
|
addressFeedback.value = '';
|
||||||
|
|
||||||
|
const normalizedAddress = addressForm.address.trim() || addressSearch.value.trim();
|
||||||
|
if (normalizedAddress.length < 5) {
|
||||||
|
addressFeedbackTone.value = 'error';
|
||||||
|
addressFeedback.value = 'Введите адрес через подсказки DaData.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createAddressMutation.mutate({
|
||||||
|
input: {
|
||||||
|
label: addressForm.label.trim() ? addressForm.label.trim() : null,
|
||||||
|
address: normalizedAddress,
|
||||||
|
unrestrictedValue: addressForm.unrestrictedValue.trim() ? addressForm.unrestrictedValue.trim() : null,
|
||||||
|
fiasId: addressForm.fiasId.trim() ? addressForm.fiasId.trim() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = result?.data?.createMyDeliveryAddress;
|
||||||
|
if (!payload) {
|
||||||
|
addressFeedbackTone.value = 'error';
|
||||||
|
addressFeedback.value = createAddressMutation.error.value?.message || 'Не удалось добавить адрес.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addressForm.label = '';
|
||||||
|
addressForm.address = '';
|
||||||
|
addressForm.unrestrictedValue = '';
|
||||||
|
addressForm.fiasId = '';
|
||||||
|
addressSearch.value = '';
|
||||||
|
addressSuggestions.value = [];
|
||||||
|
addressOpen.value = false;
|
||||||
|
|
||||||
|
addressFeedbackTone.value = 'success';
|
||||||
|
addressFeedback.value = 'Адрес сохранён.';
|
||||||
|
await deliveryAddressesQuery.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDefaultAddress(addressId: string) {
|
||||||
|
addressFeedback.value = '';
|
||||||
|
addressBusyId.value = addressId;
|
||||||
|
|
||||||
|
const result = await setDefaultAddressMutation.mutate({ addressId });
|
||||||
|
const payload = result?.data?.setMyDefaultDeliveryAddress;
|
||||||
|
addressBusyId.value = null;
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
addressFeedbackTone.value = 'error';
|
||||||
|
addressFeedback.value = setDefaultAddressMutation.error.value?.message || 'Не удалось сделать адрес основным.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addressFeedbackTone.value = 'success';
|
||||||
|
addressFeedback.value = 'Основной адрес обновлён.';
|
||||||
|
await deliveryAddressesQuery.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAddress(addressId: string) {
|
||||||
|
addressFeedback.value = '';
|
||||||
|
addressBusyId.value = addressId;
|
||||||
|
|
||||||
|
const result = await deleteAddressMutation.mutate({ addressId });
|
||||||
|
const payload = result?.data?.deleteMyDeliveryAddress;
|
||||||
|
addressBusyId.value = null;
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
addressFeedbackTone.value = 'error';
|
||||||
|
addressFeedback.value = deleteAddressMutation.error.value?.message || 'Не удалось удалить адрес.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addressFeedbackTone.value = 'success';
|
||||||
|
addressFeedback.value = 'Адрес удалён.';
|
||||||
|
await deliveryAddressesQuery.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', closeDropdownsFromOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', closeDropdownsFromOutside);
|
||||||
|
clearAddressTimer();
|
||||||
|
});
|
||||||
|
</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]">
|
||||||
|
Добавьте адрес через DaData и выберите основной. Этот адрес будет использоваться по умолчанию в корзине.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<fieldset class="fieldset mt-4">
|
||||||
|
<legend class="fieldset-legend">Название адреса (необязательно)</legend>
|
||||||
|
<input v-model="addressForm.label" type="text" class="input input-bordered w-full" placeholder="Склад МСК" >
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div ref="addressDropdownRef" class="relative mt-2">
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend class="fieldset-legend">Поиск адреса (DaData)</legend>
|
||||||
|
<input
|
||||||
|
v-model="addressSearch"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="Начните вводить адрес"
|
||||||
|
@input="scheduleAddressSuggest"
|
||||||
|
@focus="addressOpen = addressSuggestions.length > 0"
|
||||||
|
>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<span v-if="addressLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="addressOpen && addressSuggestions.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 addressSuggestions"
|
||||||
|
:key="`${item.value}-${item.data?.fias_id || ''}`"
|
||||||
|
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="applyAddressSuggestion(item)"
|
||||||
|
>
|
||||||
|
<span class="block text-sm font-semibold">{{ item.value }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary mt-4 w-full" :disabled="createAddressMutation.loading.value" @click="addDeliveryAddress">
|
||||||
|
{{ createAddressMutation.loading.value ? 'Добавляем…' : 'Добавить адрес' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="addressFeedback" class="alert mt-3" :class="addressFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
|
||||||
|
{{ addressFeedback }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="surface-card rounded-3xl p-5">
|
||||||
|
<h2 class="text-xl font-bold text-[#123824]">Список адресов</h2>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
<div v-if="deliveryAddressesQuery.loading.value" class="alert surface-card border-0">Загрузка адресов...</div>
|
||||||
|
<div v-else-if="deliveryAddresses.length === 0" class="alert surface-card border-0">
|
||||||
|
Пока нет адресов доставки.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-for="address in deliveryAddresses"
|
||||||
|
:key="address.id"
|
||||||
|
class="rounded-2xl border border-[#d6ebde] bg-white/75 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-[#123824]">{{ address.label || 'Адрес доставки' }}</p>
|
||||||
|
<p class="text-sm text-[#355947]">{{ address.unrestrictedValue || address.address }}</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="address.isDefault" class="badge badge-success">Основной</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-if="!address.isDefault"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:disabled="addressBusyId === address.id"
|
||||||
|
@click="setDefaultAddress(address.id)"
|
||||||
|
>
|
||||||
|
{{ addressBusyId === address.id ? 'Сохраняем…' : 'Сделать основным' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost text-error"
|
||||||
|
:disabled="addressBusyId === address.id"
|
||||||
|
@click="deleteAddress(address.id)"
|
||||||
|
>
|
||||||
|
{{ addressBusyId === address.id ? 'Удаляем…' : 'Удалить' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
435
app/pages/profile/counterparty.vue
Normal file
435
app/pages/profile/counterparty.vue
Normal 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>
|
||||||
89
app/pages/profile/notifications.vue
Normal file
89
app/pages/profile/notifications.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuery } from '@vue/apollo-composable';
|
||||||
|
import {
|
||||||
|
MeDocument,
|
||||||
|
MyMessengerConnectionsDocument,
|
||||||
|
} from '~/composables/graphql/generated';
|
||||||
|
import { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink';
|
||||||
|
|
||||||
|
type MessengerItem = {
|
||||||
|
type: 'TELEGRAM' | 'MAX';
|
||||||
|
isActive: boolean;
|
||||||
|
channelId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const meQuery = useQuery(MeDocument);
|
||||||
|
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
|
||||||
|
|
||||||
|
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 || ''));
|
||||||
|
</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]">
|
||||||
|
Подключите Telegram и Max, чтобы получать статусы заказов и важные уведомления в удобном канале.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user