feat(desktop): выход из кооператива с email-подтверждением — UI
- ExitButton: диалог теперь показывает сгенерированное заявление (DocumentHtmlReader) с предупреждением «читайте внимательно, необратимо» + сумма к возврату, затем «Подписать и подать» (generateApplication → показ → submitSignedApplication). - ExitOverlay: новое состояние AWAITING_CONFIRMATION — «пройдите по ссылке из письма» + кнопка «Отменить выход»; после перехода/отмены overlay переключается на ончейн-статус (рассмотрение Совета → одобрено → выплаты). - useExitGate: isAwaitingConfirmation + cancelExit (Mutations.MembershipExit.CancelMembershipExit). - model: generateApplication / submitSignedApplication / confirmExit вместо единого processMembershipExit. - MembershipExitConfirmPage + маршрут membership-exit/confirm (цель ссылки из письма): onMounted confirmExit(token) → loadExitStatus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { ProfilePage } from 'src/pages/User/ProfilePage';
|
||||
import { WalletPage } from 'src/pages/User/WalletPage';
|
||||
import { MembershipExitPage } from 'src/pages/User/MembershipExitPage';
|
||||
import { MembershipExitConfirmPage } from 'src/pages/User/MembershipExitConfirmPage';
|
||||
import { ConnectionAgreementPage, InstallationCompletedPage } from 'src/pages/Union/ConnectionAgreement';
|
||||
import { UserPaymentMethodsPage } from 'src/pages/User/PaymentMethodsPage';
|
||||
import { ContactsPage } from 'src/pages/Contacts';
|
||||
@@ -56,6 +57,19 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
component: markRaw(MembershipExitPage),
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Подтверждение выхода',
|
||||
icon: 'logout',
|
||||
roles: [],
|
||||
requiresAuth: true,
|
||||
hidden: true,
|
||||
},
|
||||
path: 'membership-exit/confirm',
|
||||
name: 'membership-exit-confirm',
|
||||
component: markRaw(MembershipExitConfirmPage),
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Удостоверение',
|
||||
|
||||
@@ -94,19 +94,25 @@ export function useMembershipExit() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Полный процесс подачи заявления на выход:
|
||||
* 1. Генерирует уникальный exit_hash.
|
||||
* 2. Генерирует документ заявления о выходе (200).
|
||||
* 3. Подписывает его приватным ключом пайщика.
|
||||
* 4. Подаёт заявление на выход с тем же exit_hash.
|
||||
* Шаг 1: генерирует документ заявления о выходе (200) — показываем пайщику
|
||||
* перед подписанием («читайте внимательно»).
|
||||
*/
|
||||
async function processMembershipExit(): Promise<ICreateMembershipExitResult> {
|
||||
const exit_hash = await generateUniqueHash();
|
||||
|
||||
const document = await generateMembershipExitApplication({
|
||||
async function generateApplication(): Promise<IGenerateMembershipExitApplicationResult> {
|
||||
return generateMembershipExitApplication({
|
||||
username: session.username,
|
||||
skip_save: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Шаг 2: подписывает показанный документ приватным ключом пайщика и подаёт
|
||||
* заявление. На бэкенде заявление принимается и уходит письмо с подтверждением —
|
||||
* в блокчейн отправится только после перехода по ссылке (confirmExit).
|
||||
*/
|
||||
async function submitSignedApplication(
|
||||
document: IGenerateMembershipExitApplicationResult,
|
||||
): Promise<ICreateMembershipExitResult> {
|
||||
const exit_hash = await generateUniqueHash();
|
||||
|
||||
const digitalDocument = new DigitalDocument(document);
|
||||
const signedDocument =
|
||||
@@ -114,18 +120,30 @@ export function useMembershipExit() {
|
||||
session.username,
|
||||
);
|
||||
|
||||
const result = await createMembershipExit({
|
||||
return createMembershipExit({
|
||||
exit_hash,
|
||||
statement: signedDocument,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждение выхода по токену из письма — отправляет ранее подписанное
|
||||
* заявление в блокчейн.
|
||||
*/
|
||||
async function confirmExit(token: string): Promise<ICreateMembershipExitResult> {
|
||||
const { [Mutations.MembershipExit.ConfirmMembershipExit.name]: result } =
|
||||
await client.Mutation(Mutations.MembershipExit.ConfirmMembershipExit.mutation, {
|
||||
variables: { token },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
generateMembershipExitApplication,
|
||||
generateApplication,
|
||||
submitSignedApplication,
|
||||
createMembershipExit,
|
||||
confirmExit,
|
||||
getReturnPreview,
|
||||
processMembershipExit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { client } from 'src/shared/api/client';
|
||||
import { Queries } from '@coopenomics/sdk';
|
||||
import { Mutations, Queries, Zeus } from '@coopenomics/sdk';
|
||||
import { useSystemStore } from 'src/entities/System/model';
|
||||
import { useSessionStore } from 'src/entities/Session';
|
||||
|
||||
@@ -23,6 +23,26 @@ export function useExitGate() {
|
||||
// Активный выход → блокируем кабинет.
|
||||
const isExitActive = computed(() => !!exitStatus.value);
|
||||
|
||||
// Off-chain фаза: заявление подписано, ждём перехода по ссылке из письма.
|
||||
const isAwaitingConfirmation = computed(
|
||||
() => exitStatus.value?.status === Zeus.MembershipExitStatus.AWAITING_CONFIRMATION,
|
||||
);
|
||||
|
||||
/**
|
||||
* Отмена заявления на выход до подтверждения по email (кнопка на экране ожидания).
|
||||
* После отмены кабинет разблокируется.
|
||||
*/
|
||||
async function cancelExit(): Promise<void> {
|
||||
if (!session.username) return;
|
||||
await client.Mutation(Mutations.MembershipExit.CancelMembershipExit.mutation, {
|
||||
variables: {
|
||||
coopname: info.coopname,
|
||||
username: session.username,
|
||||
},
|
||||
});
|
||||
await loadExitStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает текущий статус выхода пайщика (и предрасчёт суммы — для отображения
|
||||
* планируемого платежа, пока совет не зафиксировал итог).
|
||||
@@ -97,8 +117,10 @@ export function useExitGate() {
|
||||
return {
|
||||
exitStatus,
|
||||
isExitActive,
|
||||
isAwaitingConfirmation,
|
||||
plannedAmount,
|
||||
loaded,
|
||||
loadExitStatus,
|
||||
cancelExit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,25 +15,32 @@ div
|
||||
BaseDialog(
|
||||
v-model='showDialog',
|
||||
title='Выход из кооператива',
|
||||
size='md',
|
||||
size='lg',
|
||||
@update:model-value='(v) => !v && clear()'
|
||||
)
|
||||
Form(
|
||||
:handler-submit='handlerSubmit',
|
||||
:is-submitting='isSubmitting',
|
||||
:disabled='!document || loadingDoc',
|
||||
:button-cancel-txt='"Отменить"',
|
||||
:button-submit-txt='"Подать заявление на выход"',
|
||||
:button-submit-txt='"Подписать и подать заявление"',
|
||||
@cancel='clear'
|
||||
)
|
||||
BaseBanner(variant='warn')
|
||||
template(#icon)
|
||||
q-icon(name='warning')
|
||||
div
|
||||
p.q-mb-sm Подтвердите, что хотите добровольно выйти из кооператива. Действие необратимо.
|
||||
p.q-mb-none После подтверждения кабинет блокируется и запускается процесс выхода: получение решения Совета и возврат паевого взноса в срок, установленный Уставом кооператива.
|
||||
p.q-mb-sm Внимательно прочитайте заявление. Выход из кооператива — необратимое действие.
|
||||
p.q-mb-none После подписания и подтверждения по ссылке из письма кабинет блокируется и запускается процесс выхода: получение решения Совета и возврат паевого взноса в срок, установленный Уставом кооператива.
|
||||
|
||||
div.q-mt-md(v-if='loadingDoc')
|
||||
q-spinner(size='24px', color='primary')
|
||||
span.q-ml-sm.text-grey-7 Формирование заявления…
|
||||
div.exit-doc.q-mt-md(v-else-if='document')
|
||||
DocumentHtmlReader(:html='document.html')
|
||||
|
||||
div.q-mt-md(v-if='loadingPreview')
|
||||
q-spinner(size='24px', color='primary')
|
||||
q-spinner(size='20px', color='primary')
|
||||
span.q-ml-sm.text-grey-7 Расчёт суммы к возврату…
|
||||
div.q-mt-md(v-else-if='preview')
|
||||
div.text-body2.text-grey-7 Сумма к возврату
|
||||
@@ -46,6 +53,7 @@ import { ref, watch } from 'vue';
|
||||
import { BaseButton } from 'src/shared/ui/base/BaseButton';
|
||||
import { BaseDialog } from 'src/shared/ui/base/BaseDialog';
|
||||
import { BaseBanner } from 'src/shared/ui/base/BaseBanner';
|
||||
import { DocumentHtmlReader } from 'src/shared/ui/DocumentHtmlReader';
|
||||
import { Form } from 'src/shared/ui/Form';
|
||||
import { useWalletStore } from 'src/entities/Wallet';
|
||||
import { FailAlert, SuccessAlert } from 'src/shared/api';
|
||||
@@ -54,6 +62,7 @@ import {
|
||||
useExitDialog,
|
||||
useExitGate,
|
||||
type IMembershipExitReturnPreview,
|
||||
type IGenerateMembershipExitApplicationResult,
|
||||
} from '../model';
|
||||
|
||||
interface Props {
|
||||
@@ -66,21 +75,31 @@ withDefaults(defineProps<Props>(), {
|
||||
|
||||
const walletStore = useWalletStore();
|
||||
const { showDialog, open } = useExitDialog();
|
||||
const { processMembershipExit, getReturnPreview } = useMembershipExit();
|
||||
const { generateApplication, submitSignedApplication, getReturnPreview } = useMembershipExit();
|
||||
const { loadExitStatus } = useExitGate();
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
const loadingDoc = ref(false);
|
||||
const loadingPreview = ref(false);
|
||||
const document = ref<IGenerateMembershipExitApplicationResult | null>(null);
|
||||
const preview = ref<IMembershipExitReturnPreview | null>(null);
|
||||
|
||||
watch(showDialog, async (opened) => {
|
||||
if (!opened) return;
|
||||
loadingDoc.value = true;
|
||||
loadingPreview.value = true;
|
||||
try {
|
||||
document.value = await generateApplication();
|
||||
} catch (error) {
|
||||
console.error('Ошибка формирования заявления о выходе:', error);
|
||||
FailAlert('Не удалось сформировать заявление о выходе');
|
||||
} finally {
|
||||
loadingDoc.value = false;
|
||||
}
|
||||
try {
|
||||
preview.value = await getReturnPreview();
|
||||
} catch (error) {
|
||||
console.error('Ошибка расчёта суммы возврата:', error);
|
||||
FailAlert('Не удалось рассчитать сумму к возврату');
|
||||
} finally {
|
||||
loadingPreview.value = false;
|
||||
}
|
||||
@@ -89,16 +108,18 @@ watch(showDialog, async (opened) => {
|
||||
const clear = (): void => {
|
||||
showDialog.value = false;
|
||||
isSubmitting.value = false;
|
||||
document.value = null;
|
||||
preview.value = null;
|
||||
};
|
||||
|
||||
const handlerSubmit = async (): Promise<void> => {
|
||||
if (!document.value) return;
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await processMembershipExit();
|
||||
// Сразу подтягиваем статус — overlay заблокирует кабинет и покажет процесс выхода.
|
||||
await submitSignedApplication(document.value);
|
||||
// Подтягиваем статус — overlay заблокирует кабинет и покажет экран ожидания письма.
|
||||
await loadExitStatus();
|
||||
SuccessAlert('Заявление на выход из кооператива подано. Ожидайте решения Совета.');
|
||||
SuccessAlert('Заявление подписано. Перейдите по ссылке из письма, чтобы подтвердить выход.');
|
||||
clear();
|
||||
} catch (e: any) {
|
||||
FailAlert(e);
|
||||
@@ -108,4 +129,12 @@ const handlerSubmit = async (): Promise<void> => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped lang="scss">
|
||||
.exit-doc {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--p-line);
|
||||
border-radius: var(--p-r-md);
|
||||
padding: var(--p-4);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,8 +7,36 @@ BaseDialog(
|
||||
:close-on-backdrop='false',
|
||||
:close-on-escape='false'
|
||||
)
|
||||
div.row.justify-center
|
||||
div.col-12.col-md-7.text-center.q-pt-xl(v-if='exitStatus')
|
||||
//- Фаза 1: заявление подписано, ждём подтверждения по ссылке из письма.
|
||||
div.row.justify-center(v-if='isAwaitingConfirmation')
|
||||
div.col-12.col-md-7.text-center.q-pt-xl
|
||||
q-icon(name='mark_email_unread', size='64px', color='primary')
|
||||
|
||||
div.text-h5.q-mt-md Подтвердите выход по ссылке из письма
|
||||
|
||||
p.text-body1.text-grey-7.q-mt-sm
|
||||
| Процесс выхода запущен. Мы отправили письмо на вашу электронную почту —
|
||||
| перейдите по ссылке в нём, чтобы подтвердить выход и запустить процедуру
|
||||
| возврата паевого взноса. Письмо могло попасть в папку «Спам».
|
||||
|
||||
p.text-body2.text-grey-6.q-mt-sm
|
||||
| Пока вы не перешли по ссылке, заявление не отправлено и его можно отменить.
|
||||
|
||||
div.exit-overlay__amount.q-mt-lg(v-if='plannedAmount')
|
||||
div.text-overline.text-grey-6 Планируемая сумма к возврату
|
||||
div.t-mono.text-h4 {{ plannedAmount }}
|
||||
|
||||
div.q-mt-xl
|
||||
BaseButton(
|
||||
variant='secondary',
|
||||
label='Отменить выход',
|
||||
:loading='cancelling',
|
||||
@click='onCancel'
|
||||
)
|
||||
|
||||
//- Фаза 2-3: заявление отправлено в блокчейн — рассмотрение советом / одобрено.
|
||||
div.row.justify-center(v-else-if='exitStatus')
|
||||
div.col-12.col-md-7.text-center.q-pt-xl
|
||||
q-icon(
|
||||
:name='isAuthorized ? "payments" : "hourglass_top"',
|
||||
size='64px',
|
||||
@@ -29,16 +57,33 @@ BaseDialog(
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { Zeus } from '@coopenomics/sdk';
|
||||
import { BaseDialog } from 'src/shared/ui/base/BaseDialog';
|
||||
import { BaseButton } from 'src/shared/ui/base/BaseButton';
|
||||
import { FailAlert, SuccessAlert } from 'src/shared/api';
|
||||
import { useExitGate } from '../model';
|
||||
|
||||
const { exitStatus, isExitActive, plannedAmount } = useExitGate();
|
||||
const { exitStatus, isExitActive, isAwaitingConfirmation, plannedAmount, cancelExit } =
|
||||
useExitGate();
|
||||
|
||||
const isAuthorized = computed(
|
||||
() => exitStatus.value?.status === Zeus.MembershipExitStatus.AUTHORIZED,
|
||||
);
|
||||
|
||||
const cancelling = ref(false);
|
||||
|
||||
const onCancel = async (): Promise<void> => {
|
||||
cancelling.value = true;
|
||||
try {
|
||||
await cancelExit();
|
||||
SuccessAlert('Заявление на выход отменено.');
|
||||
} catch (e: any) {
|
||||
FailAlert(e);
|
||||
} finally {
|
||||
cancelling.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ui';
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
<template lang="pug">
|
||||
.membership-exit-confirm.row.justify-center
|
||||
.col-12.col-md-6.text-center.q-pt-xl
|
||||
template(v-if='loading')
|
||||
q-spinner(size='56px', color='primary')
|
||||
div.text-h6.q-mt-md Подтверждаем выход из кооператива…
|
||||
|
||||
template(v-else-if='error')
|
||||
q-icon(name='error_outline', size='56px', color='negative')
|
||||
div.text-h6.q-mt-md Не удалось подтвердить выход
|
||||
p.text-body2.text-grey-7.q-mt-sm {{ error }}
|
||||
BaseButton.q-mt-lg(variant='secondary', label='Вернуться в кабинет', @click='goToCabinet')
|
||||
|
||||
template(v-else)
|
||||
q-icon(name='check_circle', size='56px', color='positive')
|
||||
div.text-h6.q-mt-md Выход подтверждён
|
||||
p.text-body2.text-grey-7.q-mt-sm Заявление отправлено. Процесс выхода запущен — ожидайте решения Совета.
|
||||
BaseButton.q-mt-lg(variant='primary', label='Продолжить', @click='goToCabinet')
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { BaseButton } from 'src/shared/ui/base/BaseButton';
|
||||
import { useSystemStore } from 'src/entities/System/model';
|
||||
import { useMembershipExit, useExitGate } from 'src/features/Membership/ExitFromCoop';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { info } = useSystemStore();
|
||||
const { confirmExit } = useMembershipExit();
|
||||
const { loadExitStatus } = useExitGate();
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const goToCabinet = (): void => {
|
||||
router.replace({ name: 'wallet', params: { coopname: info.coopname } });
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const token = String(route.query.token || '');
|
||||
if (!token) {
|
||||
error.value = 'Ссылка некорректна: отсутствует токен подтверждения.';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await confirmExit(token);
|
||||
await loadExitStatus();
|
||||
} catch (e: any) {
|
||||
error.value =
|
||||
e?.message || 'Ссылка недействительна или срок её действия истёк. Подайте заявление заново.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.membership-exit-confirm {
|
||||
padding: var(--p-6, 24px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as MembershipExitConfirmPage } from './MembershipExitConfirmPage.vue';
|
||||
Reference in New Issue
Block a user