feat(desktop): выход из кооператива с email-подтверждением — UI
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled

- 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:
coopops
2026-06-15 15:19:30 +00:00
parent 6bba3c7b76
commit 59585dc4ea
8 changed files with 221 additions and 27 deletions
@@ -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';
@@ -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';