fix(marketplace): онбординг ЦПП — единый список 1-2-3-4 + лоадер внутри карточки

Шаги 3–4 (участки, ПВЗ) больше не вынесены во вторую карточку «Дальнейшая
настройка» — они идут тем же сквозным списком после шагов совета (extraSteps
в CouncilOnboardingCard), нумерация продолжается. Доп.шаги видны всегда, в том
числе после утверждения документов.

Лоадер вынесен из оверлея на всё окно (WindowLoader, .absolute-full) внутрь
карточки (q-inner-loading с резервом высоты) — больше не «болтается» поверх уже
отрисованной страницы.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-06-01 06:33:23 +00:00
parent a97dc61258
commit 7afb20af73
3 changed files with 145 additions and 180 deletions
@@ -1,8 +1,11 @@
<script lang="ts" setup>
import { onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { CouncilOnboardingCard } from 'src/shared/ui/CouncilOnboarding';
import { BaseBadge, BaseButton } from 'src/shared/ui/base';
import {
CouncilOnboardingCard,
type ICouncilOnboardingExtraStep,
} from 'src/shared/ui/CouncilOnboarding';
import { BaseBadge } from 'src/shared/ui/base';
import type { BaseBadgeVariant } from 'src/shared/ui/base';
import { useSystemStore } from 'src/entities/System/model';
import { useMarketplaceOnboarding } from '../model/composable';
@@ -16,6 +19,10 @@ import { useMarketplaceOnboarding } from '../model/composable';
* решения; статус шага меняется на «завершён» по РЕАЛЬНОМУ ончейн-решению
* совета (без stub-кнопки). Когда оба документа утверждены — расширение
* подключается автоматически и пайщики получают доступ к Столу заказов.
*
* Шаги 3–4 (добавить кооперативные участки, назначить ПВЗ) выполняются на
* других столах — здесь они идут тем же сквозным списком (extraSteps) со
* ссылками, без дублирования функционала.
*/
const { config, loading, submitting, isCompleted, loadState, handleStepSubmit } =
@@ -28,35 +35,33 @@ const chipLabel = computed(() =>
isCompleted.value ? 'Подключено' : 'Не подключено',
);
// Шаги 3–4 после утверждения двух документов Советом: они выполняются на других
// столах, поэтому здесь — только подсказка со ссылками (без дублирования
// функционала добавления участков/ПВЗ).
const router = useRouter();
const systemStore = useSystemStore();
const coopname = computed(() => systemStore.info?.coopname || '');
const NEXT_STEPS = [
// id доп.шага = имя маршрута стола, куда ведём.
const EXTRA_STEPS = computed<ICouncilOnboardingExtraStep[]>(() => [
{
n: 3,
id: 'branches',
title: 'Добавьте кооперативные участки',
description:
'Кооперативные участки создаются юридически вне системы, а здесь добавляются уже оформленные участки с их председателями. Без хотя бы одного участка пунктам выдачи не на чем работать.',
cta: 'Перейти к участкам',
route: 'branches',
actionLabel: 'Перейти к участкам',
disabled: !coopname.value,
},
{
n: 4,
id: 'marketplace-issuance-points',
title: 'Назначьте пункты выдачи (ПВЗ)',
description:
'Отметьте нужные кооперативные участки как пункты выдачи заказов и задайте режим их работы — тогда пайщики смогут выбирать ПВЗ при заказе.',
cta: 'Перейти к ПВЗ',
route: 'marketplace-issuance-points',
actionLabel: 'Перейти к ПВЗ',
disabled: !coopname.value,
},
] as const;
]);
function goTo(name: string): void {
function onExtraAction(step: ICouncilOnboardingExtraStep): void {
if (!coopname.value) return;
void router.push({ name, params: { coopname: coopname.value } });
void router.push({ name: step.id, params: { coopname: coopname.value } });
}
onMounted(async () => {
@@ -66,110 +71,30 @@ onMounted(async () => {
<template lang="pug">
q-page.onboarding-l1(role="region", aria-label="Подключение ЦПП Стол заказов")
.onboarding-l1__card
CouncilOnboardingCard(
:config="config",
:loading="loading",
:submitting="submitting",
title="Подключение ЦПП «Стол заказов»",
subtitle="Целевая Потребительская Программа должна быть принята Советом кооператива, прежде чем пайщики смогут пользоваться Столом заказов.",
:completion-title="config.completionTitle",
:completion-message="config.completionMessage",
@step-submit="handleStepSubmit"
)
template(#status)
BaseBadge(:variant="chipVariant", dot) {{ chipLabel }}
.onboarding-l1__next
.onboarding-l1__next-head Дальнейшая настройка
.onboarding-l1__next-sub Эти шаги выполняются на других столах после них Стол заказов готов к работе.
.onboarding-l1__step(v-for="step in NEXT_STEPS", :key="step.n")
.onboarding-l1__step-num {{ step.n }}
.onboarding-l1__step-body
.onboarding-l1__step-title {{ step.title }}
.onboarding-l1__step-desc {{ step.description }}
BaseButton.onboarding-l1__step-cta(
variant="ghost",
size="sm",
:disabled="!coopname",
@click="goTo(step.route)"
)
| {{ step.cta }}
template(#icon-right)
q-icon(name="arrow_forward", size="16px")
CouncilOnboardingCard(
:config="config",
:loading="loading",
:submitting="submitting",
:extra-steps="EXTRA_STEPS",
title="Подключение ЦПП «Стол заказов»",
subtitle="Целевая Потребительская Программа должна быть принята Советом кооператива, прежде чем пайщики смогут пользоваться Столом заказов.",
:completion-title="config.completionTitle",
:completion-message="config.completionMessage",
@step-submit="handleStepSubmit",
@extra-action="onExtraAction"
)
template(#status)
BaseBadge(:variant="chipVariant", dot) {{ chipLabel }}
</template>
<style scoped lang="scss">
.onboarding-l1 {
padding: var(--p-6, 24px);
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
}
&__card {
width: 100%;
}
&__next {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
padding: var(--p-5, 20px);
border: 1px solid var(--p-line);
border-radius: var(--p-r-lg, 12px);
}
&__next-head {
font-size: var(--p-fs-h3);
font-weight: 600;
color: var(--p-ink-1);
}
&__next-sub {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
}
&__step {
display: flex;
gap: var(--p-3, 12px);
align-items: flex-start;
padding-top: var(--p-3, 12px);
border-top: 1px solid var(--p-line);
}
&__step-num {
flex: 0 0 28px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--p-surface-2);
color: var(--p-ink-2);
font-weight: 600;
}
&__step-body {
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
}
&__step-title {
font-weight: 600;
color: var(--p-ink-1);
}
&__step-desc {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-2);
}
&__step-cta {
align-self: flex-start;
@media (max-width: 768px) {
.onboarding-l1 {
padding: var(--p-4, 16px);
}
}
</style>
@@ -1,71 +1,81 @@
<template lang="pug">
// Показываем лоадер пока данные загружаются
WindowLoader(
v-if="loading"
:text="loadingText"
)
//- Единая карточка онбординга. Лоадер — внутри карточки (q-inner-loading),
//- а не оверлеем на всё окно: иначе он «болтается» поверх уже отрисованной
//- страницы. Шаги совета и доп.шаги (навигация на другие столы) идут одним
//- сквозным списком 1-2-3-4 — без второй карточки и заголовков-разделов.
q-card.council-onboarding(flat, :class="{ 'council-onboarding--loading': loading }")
q-inner-loading(:showing="loading")
q-spinner(color="primary", size="2.5em")
div.text-caption.text-grey-7.q-mt-sm(v-if="loadingText") {{ loadingText }}
// Показываем поздравление если онбординг завершен — канон-состояние
// EmptyState (центрированная плитка-иконка + заголовок + тело), с
// success-акцентом на иконке вместо «голой» крупной иконки в карточке.
slot(name="completion" v-else-if="isCompleted")
q-card(flat)
EmptyState.council-onboarding__done(
:title="completionTitle"
:body="completionMessage"
)
template(#icon)
q-icon(name="celebration" size="26px")
template(v-if="!loading")
//- Поздравление, если шаги совета завершены — канон-состояние EmptyState.
slot(name="completion", v-if="isCompleted")
EmptyState.council-onboarding__done(
:title="completionTitle",
:body="completionMessage"
)
template(#icon)
q-icon(name="celebration", size="26px")
// Показываем шаги если онбординг не завершен и данные загружены
q-card(v-else flat)
q-card-section
div.text-h5 {{ title }}
div.text-caption.text-grey-7.q-mt-xs(v-if="subtitle") {{ subtitle }}
// Таймер и статус подключения в одном ряду — статус (напр. чип
// «Не подключено») переехал сюда из отдельной шапки страницы, чтобы
// не дублировать заголовок. Свой flex с gap, а не Quasar .row: у .row
// включён flex-wrap и при justify-between одиночный чип сваливался бы
// в начало новой строки.
div.council-onboarding__status-row(v-if="countdownLabel || hasStatusSlot")
q-chip(
v-if="countdownLabel"
color="primary"
text-color="white"
icon="schedule"
) {{ countdownLabel }}
slot(name="status")
q-separator
q-card-section
q-list(separator)
q-item.council-onboarding__step(v-for="(step, index) in steps" :key="step.id")
q-item-section
div.row.items-center.q-gutter-sm
q-icon(
:name="getIcon(step)"
:color="getIconColor(step)"
size="22px"
)
div.text-subtitle1 {{ index + 1 }}. {{ step.title }}
div.text-caption.text-grey-7.q-mt-xs {{ step.description }}
// Чип-статус в блочной обёртке: q-item-section — flex-column со
// stretch, прямой q-chip растянулся бы на всю ширину. Обёртка
// тянется, а inline-flex чип внутри хагает контент слева.
div.q-mt-sm(v-if="step.status === 'in_progress'")
q-chip.q-ma-none(
dense
color="amber"
text-color="black"
icon="hourglass_top"
) Ожидаем решение совета
q-item-section(side top)
q-btn(
v-if="showAction(index)"
:disable="submitting"
color="primary"
label="Объявить собрание совета"
@click="() => handleStepClick(step)"
)
//- Иначе — шапка и шаги совета.
template(v-else)
q-card-section
div.text-h5 {{ title }}
div.text-caption.text-grey-7.q-mt-xs(v-if="subtitle") {{ subtitle }}
div.council-onboarding__status-row(v-if="countdownLabel || hasStatusSlot")
q-chip(
v-if="countdownLabel",
color="primary",
text-color="white",
icon="schedule"
) {{ countdownLabel }}
slot(name="status")
q-separator
q-card-section
q-list(separator)
q-item.council-onboarding__step(v-for="(step, index) in steps", :key="step.id")
q-item-section
div.row.items-center.q-gutter-sm
q-icon(:name="getIcon(step)", :color="getIconColor(step)", size="22px")
div.text-subtitle1 {{ index + 1 }}. {{ step.title }}
div.text-caption.text-grey-7.q-mt-xs {{ step.description }}
div.q-mt-sm(v-if="step.status === 'in_progress'")
q-chip.q-ma-none(
dense,
color="amber",
text-color="black",
icon="hourglass_top"
) Ожидаем решение совета
q-item-section(side, top)
q-btn(
v-if="showAction(index)",
:disable="submitting",
color="primary",
label="Объявить собрание совета",
@click="() => handleStepClick(step)"
)
//- Доп.шаги — показываем ВСЕГДА (в т.ч. после завершения шагов совета),
//- нумерация продолжает шаги совета. Единый список, без отдельной карточки.
template(v-if="extraStepsList.length")
q-separator
q-card-section
q-list(separator)
q-item.council-onboarding__step(v-for="(step, i) in extraStepsList", :key="step.id")
q-item-section
div.row.items-center.q-gutter-sm
q-icon(name="radio_button_unchecked", color="grey-6", size="22px")
div.text-subtitle1 {{ steps.length + i + 1 }}. {{ step.title }}
div.text-caption.text-grey-7.q-mt-xs {{ step.description }}
q-item-section(side, top)
q-btn(
outline,
color="primary",
:label="step.actionLabel",
:disable="step.disabled",
@click="() => emit('extra-action', step)"
)
BaseDialog(
v-model='dialogOpen',
@@ -100,8 +110,11 @@ import { DocumentHtmlReader } from 'src/shared/ui/DocumentHtmlReader';
import { EmptyState } from 'src/shared/ui/base/EmptyState';
import { BaseDialog } from 'src/shared/ui/base/BaseDialog';
import { BaseButton } from 'src/shared/ui/base/BaseButton';
import { WindowLoader } from 'src/shared/ui/Loader';
import type { ICouncilOnboardingStep, ICouncilOnboardingConfig } from './types';
import type {
ICouncilOnboardingStep,
ICouncilOnboardingConfig,
ICouncilOnboardingExtraStep,
} from './types';
interface Props {
config: ICouncilOnboardingConfig;
@@ -114,6 +127,10 @@ interface Props {
subtitle?: string;
completionTitle?: string;
completionMessage?: string;
// Доп.шаги (навигация на другие столы) — рисуются в общем списке после
// шагов совета и видны всегда. По умолчанию пусто (другие онбординги не
// передают — поведение не меняется).
extraSteps?: ICouncilOnboardingExtraStep[];
}
const props = withDefaults(defineProps<Props>(), {
@@ -123,10 +140,12 @@ const props = withDefaults(defineProps<Props>(), {
title: 'Адаптация к работе на платформе',
completionTitle: 'Онбординг завершен!',
completionMessage: 'Все необходимые документы утверждены.',
extraSteps: () => [],
});
const emit = defineEmits<{
(e: 'step-submit', step: ICouncilOnboardingStep): void;
(e: 'extra-action', step: ICouncilOnboardingExtraStep): void;
}>();
const slots = useSlots();
@@ -142,6 +161,8 @@ const dialogDecision = ref('');
const dialogDecisionPrefix = ref('');
const steps = computed(() => props.config.steps);
// Null-safe доступ к доп.шагам: prop опционален, дефолт — пустой список.
const extraStepsList = computed(() => props.extraSteps ?? []);
const countdownLabel = computed(() => {
if (!props.config.expireAt) return null;
@@ -228,6 +249,12 @@ watch(
</script>
<style scoped lang="scss">
// Пока идёт загрузка — резервируем высоту, чтобы q-inner-loading центрировал
// спиннер в осмысленной области, а не в схлопнутой пустой карточке.
.council-onboarding--loading {
min-height: 240px;
}
// Таймер + статус подключения в один ряд с предсказуемым зазором. Не Quasar
// .row + .q-gutter: у чипов свои дефолтные margin'ы, из-за которых зазор
// «прыгает»; здесь gap + сброс margin дают ровную линию и аккуратный перенос.
@@ -16,6 +16,19 @@ export interface ICouncilOnboardingStep {
depends_on?: string[]; // ID шагов, которые должны быть завершены перед этим шагом
}
/**
* Доп. шаг онбординга, не связанный с собранием совета: навигация на другой
* стол (например «добавить кооперативные участки», «назначить ПВЗ»). Рисуется в
* том же списке после шагов совета, продолжая сквозную нумерацию.
*/
export interface ICouncilOnboardingExtraStep {
id: string;
title: string;
description: string;
actionLabel: string;
disabled?: boolean;
}
export interface ICouncilOnboardingConfig {
steps: ICouncilOnboardingStep[];
expireAt?: Date | null;