update
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Successful in 14m12s

This commit is contained in:
Alex Ant
2026-07-14 22:31:19 +03:00
parent 33f1ac1a15
commit 345aadbd6a
32 changed files with 1243 additions and 217 deletions
@@ -0,0 +1,83 @@
export const DOC_WIZARD_STEP_GENERATOR = 'doc_generator';
export const DOC_WIZARD_STEP_BLAGOROST = 'doc_blagorost';
export const DOC_WIZARD_REGISTRY: Record<string, number> = {
[DOC_WIZARD_STEP_GENERATOR]: 994,
[DOC_WIZARD_STEP_BLAGOROST]: 998,
};
export const DOC_WIZARD_STEP_ORDER = [DOC_WIZARD_STEP_GENERATOR, DOC_WIZARD_STEP_BLAGOROST] as const;
export type DocWizardStepKey = (typeof DOC_WIZARD_STEP_ORDER)[number];
export function isDocWizardStep(key: string): key is DocWizardStepKey {
return (DOC_WIZARD_STEP_ORDER as readonly string[]).includes(key);
}
/** Группа 1: положение ГЕНЕРАТОР, шаблон договора, положение БЛАГОРОСТ — объявляются параллельно. */
export const COUNCIL_GROUP_FOUNDATION = 'council_foundation';
/** Группа 2: оферты ГЕНЕРАТОР и БЛАГОРОСТ — объявляются параллельно, каждая после своего положения. */
export const COUNCIL_GROUP_OFFERS = 'council_offers';
/** @deprecated Миграция черновиков stepper → COUNCIL_GROUP_OFFERS */
export const COUNCIL_GROUP_GENERATOR_OFFER = 'council_generator_offer';
/** @deprecated Миграция черновиков stepper → COUNCIL_GROUP_OFFERS */
export const COUNCIL_GROUP_BLAGOROST_OFFER = 'council_blagorost_offer';
export const COUNCIL_STEP_GROUP_ORDER = [
COUNCIL_GROUP_FOUNDATION,
COUNCIL_GROUP_OFFERS,
] as const;
export type CouncilGroupStepKey = (typeof COUNCIL_STEP_GROUP_ORDER)[number];
export const COUNCIL_STEP_IDS_BY_GROUP: Record<CouncilGroupStepKey, readonly string[]> = {
[COUNCIL_GROUP_FOUNDATION]: [
'generator_program_template',
'generation_contract_template',
'blagorost_program',
],
[COUNCIL_GROUP_OFFERS]: ['generator_offer_template', 'blagorost_offer_template'],
};
export const COUNCIL_WIZARD_STEP_META: Record<CouncilGroupStepKey, { label: string; description: string }> = {
[COUNCIL_GROUP_FOUNDATION]: {
label: 'Утверждение положений и шаблонов ЦПП',
description:
'Положение «ГЕНЕРАТОР», шаблон договора участия в хозяйственной деятельности и положение «БЛАГОРОСТ»',
},
[COUNCIL_GROUP_OFFERS]: {
label: 'Утверждение оферт ЦПП',
description: 'Оферты «ГЕНЕРАТОР» и «БЛАГОРОСТ» для пайщиков',
},
};
export function isCouncilGroupStep(key: string): key is CouncilGroupStepKey {
return (COUNCIL_STEP_GROUP_ORDER as readonly string[]).includes(key);
}
export function resolveCouncilGroupForStepId(stepId: string): CouncilGroupStepKey | null {
for (const groupKey of COUNCIL_STEP_GROUP_ORDER) {
if (COUNCIL_STEP_IDS_BY_GROUP[groupKey].includes(stepId)) {
return groupKey;
}
}
return null;
}
export function normalizeWizardStepKey(key: string): string {
if (isDocWizardStep(key)) {
return key;
}
if (key === COUNCIL_GROUP_GENERATOR_OFFER || key === COUNCIL_GROUP_BLAGOROST_OFFER) {
return COUNCIL_GROUP_OFFERS;
}
if (isCouncilGroupStep(key)) {
return key;
}
return resolveCouncilGroupForStepId(key) ?? key;
}
export function getCouncilStepIdsForGroup(groupKey: CouncilGroupStepKey): readonly string[] {
return COUNCIL_STEP_IDS_BY_GROUP[groupKey];
}
@@ -13,16 +13,24 @@ export type EditableFieldKey =
export const PREVIEW_PLACEHOLDER = '______';
export const FIELD_LABELS: Record<EditableFieldKey, string> = {
generator_program_purpose: 'Направление самореализации после слов «по …»',
eoap_definition: 'Определение ЕОАП после слов «а именно:»',
generator_task_goal: 'Характеристика ЕОАП после слов «развитие ЕОАП как …»',
idea_unit_cost: 'Стоимость единицы идеи (цифрами)',
idea_unit_cost_words: 'Стоимость единицы идеи (прописью)',
blagorost_goal_expansion: 'Потребность участников в развитии ЕОАП',
blagorost_task_expansion: 'Задача расширения участников ЕОАП',
blagorost_task_development: 'Задача развития ЕОАП',
return_source_description: 'Описание экономического эффекта ЕОАП',
return_additional_source: 'Дополнительный источник возврата паевых взносов',
generator_program_purpose:
'Направление ЦПП: какую экосистему или сферу деятельности развивают пайщики (название, правовая основа, цель интеграции в экономику)',
eoap_definition:
'Определение Платформы: что это, из чего состоит, кто участники, на какой технологии и принципах работает',
generator_task_goal:
'Главная задача ЦПП: чем Платформа должна стать для участников (центр привлечения чего и для чего)',
idea_unit_cost: 'Стоимость одной Идеи в рублях — только число',
idea_unit_cost_words: 'Та же стоимость Идеи прописью',
blagorost_goal_expansion:
'Зачем расширять участие в Платформе: какая потребность участников закрывается при росте сообщества',
blagorost_task_expansion:
'Задача расширения: как описывается рост числа участников Платформы и их взаимодействия',
blagorost_task_development:
'Задача развития: как описывается привлечение инноваций, разработок и решений в Платформу',
return_source_description:
'Основной источник возврата взносов: что создаёт экономический эффект (инфраструктура, продукт, механизм)',
return_additional_source:
'Дополнительные источники возврата: взносы пользователей, продукты и приложения, передаваемые или создаваемые в Обществе',
};
export const GENERATOR_DOC_FIELDS: EditableFieldKey[] = [
@@ -51,11 +59,9 @@ export const DOCUMENT_SECTIONS = [
{
registryId: 994,
tabLabel: 'Положение ГЕНЕРАТОР',
hint: 'Параметры подставляются в положение о программе «ГЕНЕРАТОР». Пустые поля отображаются прочерками — заполните их прямо в тексте документа.',
},
{
registryId: 998,
tabLabel: 'Положение БЛАГОРОСТ',
hint: 'Параметры подставляются в положение о программе «БЛАГОРОСТ», включая источники возврата. Заполните поля inline в тексте документа.',
},
] as const;
@@ -15,16 +15,16 @@ interface GeneratedDocument {
type CapitalOnboardingStepId = Mutations.Capital.CompleteOnboardingStep.IInput['data']['step'];
const onboardingState = ref<CapitalOnboardingState | null>(null);
const loading = ref(false);
const submitting = ref(false);
const generatingDocument = ref(false);
const currentGeneratedDoc = ref<GeneratedDocument | null>(null);
export const useCapitalOnboarding = () => {
const systemStore = useSystemStore();
const sessionStore = useSessionStore();
const onboardingState = ref<CapitalOnboardingState | null>(null);
const loading = ref(false);
const submitting = ref(false);
const generatingDocument = ref(false);
const currentGeneratedDoc = ref<GeneratedDocument | null>(null);
// Маппинг шагов на registry_id
const stepToRegistryId: Record<CapitalOnboardingStepId, number> = {
'generator_program_template': 994,
@@ -161,6 +161,10 @@ export const useCapitalOnboarding = () => {
return stepsConfig.value.every(step => step.status === 'completed');
});
const hasPendingCouncilDecisions = computed(() =>
stepsConfig.value.some((step) => step.status === 'in_progress'),
);
const docDataHash = computed(() => onboardingState.value?.capital_program_doc_data_hash || null);
const isDocParamsReady = computed(() => Boolean(docDataHash.value));
@@ -171,18 +175,27 @@ export const useCapitalOnboarding = () => {
completionMessage: 'Все необходимые документы для работы с программой утверждены.',
}));
const loadState = async () => {
const loadState = async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
try {
loading.value = true;
if (!silent) {
loading.value = true;
}
await systemStore.loadSystemInfo();
onboardingState.value = await api.loadOnboardingState();
} catch (error) {
FailAlert(error);
if (!silent) {
FailAlert(error);
}
} finally {
loading.value = false;
if (!silent) {
loading.value = false;
}
}
};
const refreshState = () => loadState({ silent: true });
const handleStepClick = async (step: ICouncilOnboardingStep) => {
try {
const doc = await generateDocument(step);
@@ -238,9 +251,11 @@ export const useCapitalOnboarding = () => {
generatingDocument,
currentGeneratedDoc,
isOnboardingCompleted,
hasPendingCouncilDecisions,
docDataHash,
isDocParamsReady,
loadState,
refreshState,
handleStepClick,
handleStepSubmit,
handleDocParamsSaved,
@@ -1 +1,8 @@
export { useCapitalOnboarding } from './composable';
export { useCapitalProgramDocParams, getRegistryIdForDocWizardStep } from './useCapitalProgramDocParams';
export {
DOC_WIZARD_STEP_BLAGOROST,
DOC_WIZARD_STEP_GENERATOR,
DOC_WIZARD_STEP_ORDER,
isDocWizardStep,
} from './capitalOnboardingWizard';
@@ -0,0 +1,226 @@
import { computed, reactive, ref } from 'vue';
import { useQuasar } from 'quasar';
import type { CapitalProgramPrivateData } from 'cooptypes';
import { useSystemStore } from 'src/entities/System/model';
import { useSessionStore } from 'src/entities/Session';
import { api as documentApi } from 'src/shared/lib/document/api';
import { api } from '../api';
import {
ALL_DOC_FIELDS,
BLAGOROST_DOC_FIELDS,
FIELD_LABELS,
GENERATOR_DOC_FIELDS,
PREVIEW_PLACEHOLDER,
type EditableFieldKey,
} from './capitalProgramDocFields';
import {
DOC_WIZARD_STEP_BLAGOROST,
DOC_WIZARD_STEP_GENERATOR,
} from './capitalOnboardingWizard';
import {
getCapitalProgramDocParamsDraftKey,
readCapitalProgramDocParamsDraft,
writeCapitalProgramDocParamsDraft,
} from './useCapitalProgramDocParamsDraft';
type PreviewState = {
html: string;
docDataHash: string;
};
const DOC_FIELDS_BY_REGISTRY: Record<number, EditableFieldKey[]> = {
994: GENERATOR_DOC_FIELDS,
998: BLAGOROST_DOC_FIELDS,
};
function createEmptyForm(): Record<EditableFieldKey, string> {
return Object.fromEntries(ALL_DOC_FIELDS.map((key) => [key, ''])) as Record<EditableFieldKey, string>;
}
export function useCapitalProgramDocParams(options?: { onSaved?: (hash: string) => void }) {
const $q = useQuasar();
const systemStore = useSystemStore();
const sessionStore = useSessionStore();
const saving = ref(false);
const savedHash = ref('');
const loadingRegistryId = ref<number | null>(null);
const wizardStepKey = ref<string>(DOC_WIZARD_STEP_GENERATOR);
const previews = reactive<Partial<Record<number, PreviewState>>>({});
const form = reactive<Record<EditableFieldKey, string>>(createEmptyForm());
const draftStorageKey = computed(() =>
getCapitalProgramDocParamsDraftKey(systemStore.info?.coopname ?? '', sessionStore.username ?? ''),
);
const editableFields = computed(() =>
ALL_DOC_FIELDS.map((key) => ({
key,
label: FIELD_LABELS[key],
})),
);
function persistDraftToStorage() {
writeCapitalProgramDocParamsDraft(draftStorageKey.value, {
form: { ...form },
wizardStepKey: wizardStepKey.value,
savedHash: savedHash.value,
previews: { ...previews },
});
}
function restoreDraftFromStorage() {
const draft = readCapitalProgramDocParamsDraft(draftStorageKey.value);
if (!draft) return;
Object.assign(form, draft.form);
savedHash.value = draft.savedHash;
Object.assign(previews, draft.previews);
wizardStepKey.value = draft.wizardStepKey ?? DOC_WIZARD_STEP_GENERATOR;
}
function getPreviewPayload(): CapitalProgramPrivateData {
return Object.fromEntries(
ALL_DOC_FIELDS.map((key) => [key, String(form[key] ?? '').trim() || PREVIEW_PLACEHOLDER]),
) as CapitalProgramPrivateData;
}
function getMissingCount(registryId: number): number {
const fields = DOC_FIELDS_BY_REGISTRY[registryId] ?? [];
return fields.filter((key) => !String(form[key] ?? '').trim()).length;
}
function validateSection(registryId: number): string | null {
const fields = DOC_FIELDS_BY_REGISTRY[registryId] ?? [];
const missing = fields
.filter((key) => !String(form[key] ?? '').trim())
.map((key) => FIELD_LABELS[key]);
if (missing.length) {
return `Заполните все поля документа: ${missing.join(', ')}`;
}
return null;
}
function validateAll(): string | null {
const missing = editableFields.value
.filter(({ key }) => !String(form[key] ?? '').trim())
.map(({ label }) => label);
if (missing.length) {
return `Заполните все параметры в документах: ${missing.join(', ')}`;
}
return null;
}
function getDocDataHashFromResult(result: Awaited<ReturnType<typeof documentApi.generateDocument>>): string {
const meta = result.meta as { doc_data_hash?: string } | undefined;
return meta?.doc_data_hash ?? '';
}
async function loadPreview(registryId: number) {
loadingRegistryId.value = registryId;
try {
const result = await documentApi.generateDocument({
coopname: systemStore.info?.coopname ?? '',
username: sessionStore.username ?? '',
registry_id: registryId,
doc_data: getPreviewPayload(),
});
previews[registryId] = {
html: result.html,
docDataHash: getDocDataHashFromResult(result),
};
persistDraftToStorage();
return result;
} catch (error) {
$q.notify({
type: 'negative',
message: error instanceof Error ? error.message : 'Не удалось сформировать предпросмотр',
});
throw error;
} finally {
loadingRegistryId.value = null;
}
}
async function saveParams() {
const validationError = validateAll();
if (validationError) {
$q.notify({ type: 'warning', message: validationError });
return false;
}
saving.value = true;
try {
await loadPreview(994);
await loadPreview(998);
const hash = previews[994]?.docDataHash ?? previews[998]?.docDataHash;
if (!hash) {
throw new Error('Не удалось получить хеш параметров документов');
}
await api.saveProgramDocDataHash(hash);
savedHash.value = hash;
persistDraftToStorage();
options?.onSaved?.(hash);
$q.notify({
type: 'positive',
message: 'Параметры документов ЦПП сохранены',
});
return true;
} catch (error) {
$q.notify({
type: 'negative',
message: error instanceof Error ? error.message : 'Не удалось сохранить параметры',
});
return false;
} finally {
saving.value = false;
}
}
async function ensurePreview(registryId: number) {
if (previews[registryId]?.html) return;
await loadPreview(registryId);
}
function setWizardStepKey(key: string) {
wizardStepKey.value = key;
persistDraftToStorage();
}
return {
form,
previews,
savedHash,
saving,
loadingRegistryId,
wizardStepKey,
restoreDraftFromStorage,
persistDraftToStorage,
getMissingCount,
validateSection,
loadPreview,
ensurePreview,
saveParams,
setWizardStepKey,
};
}
export function getRegistryIdForDocWizardStep(stepKey: string): number | null {
if (stepKey === DOC_WIZARD_STEP_GENERATOR) return 994;
if (stepKey === DOC_WIZARD_STEP_BLAGOROST) return 998;
return null;
}
@@ -0,0 +1,105 @@
import { ALL_DOC_FIELDS, type EditableFieldKey } from './capitalProgramDocFields';
const STORAGE_PREFIX = 'capital-cpp-doc-params-draft';
export type CapitalProgramDocParamsPreviewState = {
html: string;
docDataHash: string;
};
export type CapitalProgramDocParamsDraft = {
form: Record<EditableFieldKey, string>;
/** @deprecated используйте wizardStepKey */
activeTab?: number;
wizardStepKey?: string;
savedHash: string;
previews: Partial<Record<number, CapitalProgramDocParamsPreviewState>>;
};
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
export function getCapitalProgramDocParamsDraftKey(coopname: string, username: string): string {
return `${STORAGE_PREFIX}:${coopname || 'unknown'}:${username || 'unknown'}`;
}
function isEditableFieldKey(key: string): key is EditableFieldKey {
return (ALL_DOC_FIELDS as readonly string[]).includes(key);
}
function createEmptyForm(): Record<EditableFieldKey, string> {
return Object.fromEntries(ALL_DOC_FIELDS.map((key) => [key, ''])) as Record<EditableFieldKey, string>;
}
function normalizeForm(raw: unknown): Record<EditableFieldKey, string> | null {
if (!raw || typeof raw !== 'object') return null;
const form = createEmptyForm();
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (isEditableFieldKey(key) && typeof value === 'string') {
form[key] = value;
}
}
return form;
}
function normalizePreviews(raw: unknown): Partial<Record<number, CapitalProgramDocParamsPreviewState>> {
const previews: Partial<Record<number, CapitalProgramDocParamsPreviewState>> = {};
if (!raw || typeof raw !== 'object') return previews;
for (const [id, preview] of Object.entries(raw as Record<string, unknown>)) {
const registryId = Number(id);
if (!Number.isFinite(registryId) || !preview || typeof preview !== 'object') continue;
const html = (preview as CapitalProgramDocParamsPreviewState).html;
const docDataHash = (preview as CapitalProgramDocParamsPreviewState).docDataHash;
if (typeof html === 'string' && typeof docDataHash === 'string') {
previews[registryId] = { html, docDataHash };
}
}
return previews;
}
export function readCapitalProgramDocParamsDraft(storageKey: string): CapitalProgramDocParamsDraft | null {
if (!isBrowser()) return null;
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<CapitalProgramDocParamsDraft>;
const form = normalizeForm(parsed.form);
if (!form) return null;
const legacyTab = typeof parsed.activeTab === 'number' ? parsed.activeTab : 994;
const wizardStepKey =
typeof parsed.wizardStepKey === 'string'
? parsed.wizardStepKey
: legacyTab === 998
? 'doc_blagorost'
: 'doc_generator';
return {
form,
wizardStepKey,
savedHash: typeof parsed.savedHash === 'string' ? parsed.savedHash : '',
previews: normalizePreviews(parsed.previews),
};
} catch {
localStorage.removeItem(storageKey);
return null;
}
}
export function writeCapitalProgramDocParamsDraft(storageKey: string, draft: CapitalProgramDocParamsDraft): void {
if (!isBrowser()) return;
try {
localStorage.setItem(storageKey, JSON.stringify(draft));
} catch (error) {
console.warn('Failed to persist capital CPP doc params draft:', error);
}
}
@@ -0,0 +1,91 @@
<template lang="pug">
.capital-council-group
.capital-council-group__item(
v-for='(step, index) in steps'
:key='step.id'
)
.capital-council-group__head
.capital-council-group__title
span.capital-council-group__index {{ index + 1 }}.
span {{ step.title }}
.capital-council-group__status
BaseChip(
v-if='step.status === "completed"'
variant='pos'
size='sm'
) Утверждено советом
BaseChip(
v-else-if='step.status === "in_progress"'
variant='warn'
size='sm'
) Ожидаем решение совета
p.capital-council-group__desc.t-body2 {{ step.description }}
BaseButton.capital-council-group__action(
v-if='canDeclare(step)'
variant='primary'
size='sm'
:disable='busy'
@click='emit("declare", step.id)'
) Объявить собрание совета
q-separator.capital-council-group__sep(v-if='index < steps.length - 1')
</template>
<script setup lang="ts">
import { BaseButton, BaseChip } from 'src/shared/ui/base';
import type { ICouncilOnboardingStep } from 'src/shared/ui/CouncilOnboarding';
defineProps<{
steps: ICouncilOnboardingStep[];
busy?: boolean;
canDeclare: (step: ICouncilOnboardingStep) => boolean;
}>();
const emit = defineEmits<{
declare: [stepId: string];
}>();
</script>
<style scoped lang="scss">
.capital-council-group__item {
padding: var(--p-2) 0;
}
.capital-council-group__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--p-3);
}
.capital-council-group__title {
display: flex;
gap: var(--p-2);
font-weight: 600;
color: var(--p-ink);
}
.capital-council-group__index {
flex: 0 0 auto;
color: var(--p-ink-2);
}
.capital-council-group__status {
flex: 0 0 auto;
}
.capital-council-group__desc {
margin: var(--p-2) 0 0;
color: var(--p-ink-2);
}
.capital-council-group__action {
margin-top: var(--p-3);
}
.capital-council-group__sep {
margin-top: var(--p-4);
}
</style>
@@ -1,98 +1,124 @@
<template lang="pug">
div
div.capital-onboarding.q-pa-md
WindowLoader(
v-if="loading"
text="Загрузка данных онбординга..."
v-if='loading'
text='Загрузка данных онбординга...'
)
template(v-else-if="!isOnboardingCompleted")
CapitalProgramDocumentParametersWidget(
:doc-data-hash='docDataHash'
@saved='handleDocParamsSaved'
)
template(v-else-if='!isOnboardingCompleted')
.capital-onboarding__shell
BaseBanner.capital-onboarding__banner(variant='info')
template(#icon)
q-icon(name='info' size='20px')
| Сначала заполните параметры положений «ГЕНЕРАТОР» и «БЛАГОРОСТ», затем объявите собрания
| совета для утверждения документов ЦПП.
q-card(v-if='isDocParamsReady' flat class='q-mt-md')
q-card-section.row.items-center.justify-between
div
div.text-h5 Адаптация к работе с программой "Благорост"
q-separator
q-card-section
q-list(separator)
q-item(v-for="(step, index) in config?.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 {{ step.description }}
q-chip(
v-if="step.status === 'in_progress'"
dense
color="amber"
text-color="black"
icon="hourglass_top"
class="q-mt-xs"
) Ожидаем решение совета
q-item-section(side)
q-btn(
v-if="showAction(index)"
:disable="submitting || generatingDocument"
:loading="generatingDocument && currentStepId === step.id"
color="primary"
label="Объявить собрание совета"
@click="() => openDialog(step)"
)
BaseDialog(
v-model="dialogOpen",
title="Предложение повестки",
size="lg",
:close-on-backdrop="false",
:close-on-escape="false",
@update:model-value="(v) => !v && closeDialog()"
)
Loader(
v-if="generatingDocument"
text="Генерация документа..."
VerticalStepper.capital-onboarding__stepper(
:steps='wizardSteps'
:active-key='activeWizardStep'
:completed='completedWizardSteps'
:clickable-completed='true'
@change='onStepperChange'
)
q-card-section(v-else-if="currentStep && currentGeneratedDoc && !generatingDocument")
template(#active='{ step }')
CapitalProgramDocumentStepPanel(
v-if='isDocWizardStep(step.key)'
v-model='form'
:preview-html='getDocPreviewHtml(step.key)'
:loading='isDocStepLoading(step.key)'
@refresh='refreshDocStep(step.key)'
)
CapitalCouncilStepGroup(
v-else-if='isCouncilGroupStep(step.key)'
:steps='getCouncilStepsForGroup(step.key)'
:busy='submitting || generatingDocument'
:can-declare='canDeclareCouncilStep'
@declare='openCouncilDialog'
)
.capital-onboarding__footer(v-if='showFooter')
BaseButton(
v-if='showBackButton'
variant='ghost'
:disable='footerBusy'
@click='goBack'
) Назад
q-space(v-if='showBackButton && showContinueButton')
BaseButton(
v-if='showContinueButton'
variant='primary'
:class='{ "capital-onboarding__continue--solo": !showBackButton }'
:loading='footerBusy'
@click='goNext'
) {{ continueLabel }}
BaseDialog(
v-model='dialogOpen'
title='Предложение повестки'
size='lg'
:close-on-backdrop='false'
:close-on-escape='false'
@update:model-value='(v) => !v && closeDialog()'
)
Loader(
v-if='generatingDocument'
text='Генерация документа...'
)
template(v-else-if='currentStep && currentGeneratedDoc && !generatingDocument')
.capital-onboarding__dialog-section
div.row.items-center.q-gutter-xs.text-subtitle1.text-weight-medium
q-icon(name="help_outline" size="18px" class="text-primary")
q-icon(name='help_outline' size='18px' color='primary')
span Вопрос на повестке
div.q-mt-sm.q-pa-sm.text-body1.rounded-borders {{ currentStep.question }}
div.q-mt-sm.t-body {{ currentStep.question }}
q-separator.q-my-md
div.row.items-center.q-gutter-xs.text-subtitle1.text-weight-medium
q-icon(name="gavel" size="18px" class="text-primary")
q-icon(name='gavel' size='18px' color='primary')
span Проект решения
div.q-mt-sm.q-pa-sm.rounded-borders
div(v-if="currentStep.decisionPrefix") {{ currentStep.decisionPrefix }}
DocumentHtmlReader(:html="currentGeneratedDoc.html" :sanitize="false")
div.q-mt-sm.text-caption.text-grey-6
div.q-mt-sm
div(v-if='currentStep.decisionPrefix') {{ currentStep.decisionPrefix }}
DocumentHtmlReader(:html='currentGeneratedDoc.html' :sanitize='false')
div.t-caption.text-grey-6.q-mt-sm
strong {{ currentGeneratedDoc.full_title }}
div.q-pb-lg(v-if="currentGeneratedDoc && !generatingDocument")
q-btn(flat label="Отмена" @click="closeDialog" :disable="submitting")
q-btn(
color="primary"
label="Объявить"
:loading="submitting"
@click="submitStep"
)
template(#footer v-if='currentGeneratedDoc && !generatingDocument')
BaseButton(variant='ghost' :disable='submitting' @click='closeDialog') Отмена
BaseButton(variant='primary' :loading='submitting' @click='submitStep') Объявить
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
import { useQuasar } from 'quasar';
import { DocumentHtmlReader } from 'src/shared/ui/DocumentHtmlReader';
import { BaseBanner, BaseButton } from 'src/shared/ui/base';
import { BaseDialog } from 'src/shared/ui/base/BaseDialog';
import { WindowLoader, Loader } from 'src/shared/ui/Loader';
import { VerticalStepper } from 'src/shared/ui/domain/VerticalStepper';
import type { StepperStep } from 'src/shared/ui/domain/VerticalStepper';
import type { ICouncilOnboardingStep } from 'src/shared/ui/CouncilOnboarding';
import CapitalProgramDocumentParametersWidget from './CapitalProgramDocumentParametersWidget.vue';
import { useDataPoller } from 'src/shared/lib/composables';
import { POLL_INTERVALS } from 'src/shared/lib/consts';
import CapitalProgramDocumentStepPanel from './CapitalProgramDocumentStepPanel.vue';
import CapitalCouncilStepGroup from './CapitalCouncilStepGroup.vue';
import { useCapitalOnboarding } from '../model';
import { useCapitalProgramDocParams, getRegistryIdForDocWizardStep } from '../model/useCapitalProgramDocParams';
import {
COUNCIL_GROUP_FOUNDATION,
COUNCIL_GROUP_OFFERS,
COUNCIL_STEP_GROUP_ORDER,
COUNCIL_WIZARD_STEP_META,
DOC_WIZARD_STEP_BLAGOROST,
DOC_WIZARD_STEP_GENERATOR,
DOC_WIZARD_STEP_ORDER,
getCouncilStepIdsForGroup,
isCouncilGroupStep,
isDocWizardStep,
normalizeWizardStepKey,
type CouncilGroupStepKey,
} from '../model/capitalOnboardingWizard';
const $q = useQuasar();
const {
config,
@@ -101,70 +127,369 @@ const {
generatingDocument,
currentGeneratedDoc,
isOnboardingCompleted,
docDataHash,
hasPendingCouncilDecisions,
isDocParamsReady,
loadState,
refreshState,
handleStepClick,
handleStepSubmit,
handleDocParamsSaved,
} = useCapitalOnboarding();
const {
form,
previews,
saving,
loadingRegistryId,
wizardStepKey,
restoreDraftFromStorage,
persistDraftToStorage,
validateSection,
loadPreview,
ensurePreview,
saveParams,
setWizardStepKey,
} = useCapitalProgramDocParams({
onSaved: () => {
void handleDocParamsSaved();
},
});
const activeWizardStep = wizardStepKey;
const advancing = ref(false);
const dialogOpen = ref(false);
const currentStep = ref<ICouncilOnboardingStep | null>(null);
const currentStepId = ref<string | null>(null);
const councilSteps = computed(() => config.value?.steps ?? []);
const getIcon = (step: ICouncilOnboardingStep) => {
if (step.status === 'completed') return 'task_alt';
if (step.status === 'in_progress') return 'hourglass_top';
return 'radio_button_unchecked';
};
const wizardSteps = computed<StepperStep[]>(() => [
{
key: DOC_WIZARD_STEP_GENERATOR,
label: 'Параметры «ГЕНЕРАТОР»',
description: 'Переменные положения о целевой программе «ГЕНЕРАТОР»',
},
{
key: DOC_WIZARD_STEP_BLAGOROST,
label: 'Параметры «БЛАГОРОСТ»',
description: 'Переменные положения о целевой программе «БЛАГОРОСТ»',
},
...COUNCIL_STEP_GROUP_ORDER.map((groupKey) => ({
key: groupKey,
label: COUNCIL_WIZARD_STEP_META[groupKey].label,
description: COUNCIL_WIZARD_STEP_META[groupKey].description,
disabled: isCouncilGroupDisabled(groupKey),
})),
]);
const getIconColor = (step: ICouncilOnboardingStep) => {
if (step.status === 'completed') return 'green-6';
if (step.status === 'in_progress') return 'orange-6';
return 'grey-6';
};
const wizardStepOrder = computed(() => [
...DOC_WIZARD_STEP_ORDER,
...COUNCIL_STEP_GROUP_ORDER,
]);
const isDependenciesCompleted = (step: ICouncilOnboardingStep) => {
if (!step.depends_on || step.depends_on.length === 0) return true;
if (!config.value?.steps) return true;
const completedWizardSteps = computed(() => {
const completed: string[] = [];
const activeIndex = wizardStepOrder.value.indexOf(activeWizardStep.value);
return step.depends_on.every(depId => {
const depStep = config.value.steps.find(s => s.id === depId);
return depStep && depStep.status === 'completed';
});
};
if (activeIndex > 0 || isDocParamsReady.value) {
completed.push(DOC_WIZARD_STEP_GENERATOR);
}
if (isDocParamsReady.value) {
completed.push(DOC_WIZARD_STEP_BLAGOROST);
}
const showAction = (index: number) => {
if (!config.value?.steps) return false;
const step = config.value.steps[index];
if (!step) return false;
for (const groupKey of COUNCIL_STEP_GROUP_ORDER) {
if (isCouncilGroupCompleted(groupKey)) {
completed.push(groupKey);
}
}
return completed;
});
const footerBusy = computed(() => saving.value || advancing.value || !!loadingRegistryId.value);
const showBackButton = computed(() => {
const index = wizardStepOrder.value.indexOf(activeWizardStep.value);
return index > 0;
});
const showContinueButton = computed(() => isDocWizardStep(activeWizardStep.value));
const showFooter = computed(() => showBackButton.value || showContinueButton.value);
const continueLabel = computed(() =>
activeWizardStep.value === DOC_WIZARD_STEP_BLAGOROST ? 'Сохранить и продолжить' : 'Продолжить',
);
function getCouncilStep(stepId: string): ICouncilOnboardingStep | undefined {
return councilSteps.value.find((step) => step.id === stepId);
}
function getCouncilStepsForGroup(groupKey: CouncilGroupStepKey): ICouncilOnboardingStep[] {
return getCouncilStepIdsForGroup(groupKey)
.map((stepId) => getCouncilStep(stepId))
.filter((step): step is ICouncilOnboardingStep => step != null);
}
function isCouncilGroupCompleted(groupKey: CouncilGroupStepKey): boolean {
return getCouncilStepsForGroup(groupKey).every((step) => step.status === 'completed');
}
function isCouncilGroupDisabled(groupKey: CouncilGroupStepKey): boolean {
if (groupKey === COUNCIL_GROUP_FOUNDATION) {
return !isDocParamsReady.value;
}
if (groupKey === COUNCIL_GROUP_OFFERS) {
return getCouncilStepsForGroup(groupKey).every(
(step) =>
step.status !== 'completed' &&
step.status !== 'in_progress' &&
!canDeclareCouncilStep(step),
);
}
return true;
}
function getFirstAvailableCouncilGroup(): CouncilGroupStepKey | null {
for (const groupKey of COUNCIL_STEP_GROUP_ORDER) {
if (!isCouncilGroupCompleted(groupKey) && !isCouncilGroupDisabled(groupKey)) {
return groupKey;
}
}
return null;
}
function syncWizardStepWithCouncilProgress() {
if (!isDocParamsReady.value || isOnboardingCompleted.value) return;
const firstAvailable = getFirstAvailableCouncilGroup();
if (!firstAvailable) return;
const current = activeWizardStep.value;
if (isDocWizardStep(current)) {
setWizardStepKey(firstAvailable);
return;
}
if (!isCouncilGroupStep(current)) return;
if (isCouncilGroupCompleted(current) && firstAvailable !== current) {
setWizardStepKey(firstAvailable);
}
}
function getDocPreviewHtml(stepKey: string): string {
const registryId = getRegistryIdForDocWizardStep(stepKey);
return registryId ? previews[registryId]?.html ?? '' : '';
}
function isDocStepLoading(stepKey: string): boolean {
const registryId = getRegistryIdForDocWizardStep(stepKey);
return registryId != null && loadingRegistryId.value === registryId;
}
async function refreshDocStep(stepKey: string) {
const registryId = getRegistryIdForDocWizardStep(stepKey);
if (registryId == null) return;
await loadPreview(registryId);
}
function canDeclareCouncilStep(step: ICouncilOnboardingStep): boolean {
if (step.status === 'completed' || step.status === 'in_progress') return false;
return isDependenciesCompleted(step);
};
if (!step.depends_on?.length) return true;
return step.depends_on.every((depId) => getCouncilStep(depId)?.status === 'completed');
}
function onStepperChange(key: string) {
if (isDocWizardStep(key)) {
setWizardStepKey(key);
return;
}
if (isCouncilGroupStep(key) && !isCouncilGroupDisabled(key)) {
setWizardStepKey(key);
}
}
async function goNext() {
if (activeWizardStep.value === DOC_WIZARD_STEP_GENERATOR) {
const validationError = validateSection(994);
if (validationError) {
$q.notify({ type: 'warning', message: validationError });
return;
}
advancing.value = true;
try {
await ensurePreview(994);
setWizardStepKey(DOC_WIZARD_STEP_BLAGOROST);
} finally {
advancing.value = false;
}
return;
}
if (activeWizardStep.value === DOC_WIZARD_STEP_BLAGOROST) {
const validationError = validateSection(998);
if (validationError) {
$q.notify({ type: 'warning', message: validationError });
return;
}
advancing.value = true;
try {
const saved = await saveParams();
if (!saved) return;
const firstCouncilGroup = getFirstAvailableCouncilGroup();
if (firstCouncilGroup) {
setWizardStepKey(firstCouncilGroup);
}
} finally {
advancing.value = false;
}
}
}
function goBack() {
const index = wizardStepOrder.value.indexOf(activeWizardStep.value);
if (index <= 0) return;
setWizardStepKey(wizardStepOrder.value[index - 1]);
}
async function openCouncilDialog(stepId: string) {
const step = getCouncilStep(stepId);
if (!step || !canDeclareCouncilStep(step)) return;
const openDialog = async (step: ICouncilOnboardingStep) => {
currentStep.value = step;
currentStepId.value = step.id;
dialogOpen.value = true;
// Генерируем документ
await handleStepClick(step);
};
}
const closeDialog = () => {
function closeDialog() {
dialogOpen.value = false;
currentStep.value = null;
currentStepId.value = null;
};
}
const submitStep = async () => {
async function submitStep() {
if (!currentStep.value) return;
await handleStepSubmit(currentStep.value);
closeDialog();
};
syncWizardStepWithCouncilProgress();
}
onMounted(loadState);
const shouldPollCouncilState = computed(
() => hasPendingCouncilDecisions.value && !isOnboardingCompleted.value,
);
const { start: startCouncilPoll, stop: stopCouncilPoll } = useDataPoller(
async () => {
await refreshState();
syncWizardStepWithCouncilProgress();
},
{
interval: POLL_INTERVALS.FAST,
immediate: false,
enabled: shouldPollCouncilState,
},
);
function handleVisibilityChange() {
if (document.hidden || !shouldPollCouncilState.value) return;
void refreshState().then(() => syncWizardStepWithCouncilProgress());
}
watch(shouldPollCouncilState, (enabled) => {
if (enabled) {
startCouncilPoll();
} else {
stopCouncilPoll();
}
});
watch(
() => councilSteps.value.map((step) => `${step.id}:${step.status}`).join('|'),
() => {
syncWizardStepWithCouncilProgress();
},
);
watch(
() => activeWizardStep.value,
(stepKey) => {
if (!isDocWizardStep(stepKey)) return;
const registryId = getRegistryIdForDocWizardStep(stepKey);
if (registryId != null) {
void ensurePreview(registryId);
}
},
);
watch(
form,
() => {
persistDraftToStorage();
},
{ deep: true },
);
onMounted(async () => {
await loadState();
restoreDraftFromStorage();
setWizardStepKey(normalizeWizardStepKey(activeWizardStep.value));
syncWizardStepWithCouncilProgress();
if (shouldPollCouncilState.value) {
startCouncilPoll();
}
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
stopCouncilPoll();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script>
<style scoped lang="scss">
.capital-onboarding {
display: flex;
justify-content: center;
min-height: 100%;
}
.capital-onboarding__shell {
width: 100%;
max-width: 920px;
}
.capital-onboarding__banner {
margin-bottom: var(--p-4);
}
.capital-onboarding__stepper {
margin-top: var(--p-2);
}
.capital-onboarding__continue--solo {
margin-left: auto;
}
.capital-onboarding__dialog-section {
padding: var(--p-1) 0;
}
.capital-onboarding__footer {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
justify-content: space-between;
gap: var(--p-3);
margin-top: var(--p-5);
padding: var(--p-4) 0;
background: var(--p-canvas);
border-top: 1px solid var(--p-line);
}
</style>
@@ -2,10 +2,6 @@
BaseCard.capital-doc-params(title='Параметры документов ЦПП')
p.t-body-sm.capital-doc-params__intro
| Заполните параметры положений «ГЕНЕРАТОР» и «БЛАГОРОСТ» прямо в тексте документа.
| Пустые поля подсвечиваются оранжевым подсвеченные области можно редактировать inline.
BaseBanner(v-if='savedHash' type='success' class='q-mb-md')
| Параметры сохранены. Хеш: {{ savedHash }}
q-tabs(v-model='activeTab' dense align='left' class='capital-doc-params__tabs')
q-tab(
@@ -22,8 +18,6 @@ BaseCard.capital-doc-params(title='Параметры документов ЦП
:key='section.registryId'
:name='section.registryId'
)
p.t-caption.text-grey-7.q-mb-md {{ section.hint }}
.capital-doc-params__toolbar
BaseButton(
color='primary'
@@ -64,7 +58,7 @@ import { useQuasar } from 'quasar';
import type { CapitalProgramPrivateData } from 'cooptypes';
import { useSystemStore } from 'src/entities/System/model';
import { useSessionStore } from 'src/entities/Session';
import { BaseBanner, BaseButton, BaseCard } from 'src/shared/ui/base';
import { BaseButton, BaseCard } from 'src/shared/ui/base';
import { api as documentApi } from 'src/shared/lib/document/api';
import { api } from '../api';
import CapitalProgramInlineDocumentPreview from './CapitalProgramInlineDocumentPreview.vue';
@@ -77,6 +71,11 @@ import {
PREVIEW_PLACEHOLDER,
type EditableFieldKey,
} from '../model/capitalProgramDocFields';
import {
getCapitalProgramDocParamsDraftKey,
readCapitalProgramDocParamsDraft,
writeCapitalProgramDocParamsDraft,
} from '../model/useCapitalProgramDocParamsDraft';
const emit = defineEmits<{
saved: [hash: string];
@@ -109,6 +108,29 @@ function createEmptyForm(): Record<EditableFieldKey, string> {
const form = reactive<Record<EditableFieldKey, string>>(createEmptyForm());
const draftStorageKey = computed(() =>
getCapitalProgramDocParamsDraftKey(systemStore.info?.coopname ?? '', sessionStore.username ?? ''),
);
function restoreDraftFromStorage() {
const draft = readCapitalProgramDocParamsDraft(draftStorageKey.value);
if (!draft) return;
Object.assign(form, draft.form);
savedHash.value = draft.savedHash;
Object.assign(previews, draft.previews);
activeTab.value = draft.activeTab;
}
function persistDraftToStorage() {
writeCapitalProgramDocParamsDraft(draftStorageKey.value, {
form: { ...form },
activeTab: activeTab.value,
savedHash: savedHash.value,
previews: { ...previews },
});
}
const editableFields = computed(() =>
ALL_DOC_FIELDS.map((key) => ({
key,
@@ -165,6 +187,7 @@ async function loadPreview(registryId: number, options?: { silent?: boolean }) {
html: result.html,
docDataHash: getDocDataHashFromResult(result),
};
persistDraftToStorage();
return result;
} catch (error) {
@@ -221,13 +244,30 @@ async function saveParams() {
}
watch(activeTab, (registryId) => {
persistDraftToStorage();
if (!previews[registryId]) {
void loadPreview(registryId);
}
});
watch(
form,
() => {
persistDraftToStorage();
},
{ deep: true },
);
watch(savedHash, () => {
persistDraftToStorage();
});
onMounted(() => {
void loadPreview(activeTab.value);
restoreDraftFromStorage();
if (!previews[activeTab.value]) {
void loadPreview(activeTab.value);
}
});
</script>
@@ -0,0 +1,79 @@
<template lang="pug">
.capital-doc-step
.capital-doc-step__preview-shell
.capital-doc-step__toolbar
BaseButton(
variant='ghost'
size='sm'
:disable='loading'
@click='emit("refresh")'
)
q-icon(name='refresh' size='18px')
span.q-ml-xs {{ previewHtml ? 'Обновить предпросмотр' : 'Сформировать предпросмотр' }}
CapitalProgramInlineDocumentPreview(
v-if='previewHtml && !loading'
v-model='form'
:html='previewHtml'
:field-labels='FIELD_LABELS'
)
.capital-doc-step__loading(v-else-if='loading')
q-spinner(size='36px' color='primary')
p.t-body-sm.capital-doc-step__loading-text Формируем предпросмотр документа
EmptyState(
v-else
title='Предпросмотр ещё не сформирован'
body='Нажмите «Сформировать предпросмотр», чтобы увидеть документ с редактируемыми полями.'
)
</template>
<script setup lang="ts">
import { BaseButton, EmptyState } from 'src/shared/ui/base';
import CapitalProgramInlineDocumentPreview from './CapitalProgramInlineDocumentPreview.vue';
import { FIELD_LABELS, type EditableFieldKey } from '../model/capitalProgramDocFields';
defineProps<{
previewHtml: string;
loading: boolean;
}>();
const form = defineModel<Record<EditableFieldKey, string>>({ required: true });
const emit = defineEmits<{
refresh: [];
}>();
</script>
<style scoped lang="scss">
.capital-doc-step__preview-shell {
border: 1px solid var(--p-line);
border-radius: var(--p-r-md);
background: var(--p-surface);
overflow: hidden;
}
.capital-doc-step__toolbar {
display: flex;
justify-content: flex-end;
padding: var(--p-2) var(--p-3);
border-bottom: 1px solid var(--p-line);
background: var(--p-surface-2);
}
.capital-doc-step__loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--p-3);
min-height: 240px;
padding: var(--p-6);
}
.capital-doc-step__loading-text {
margin: 0;
color: var(--p-ink-2);
}
</style>
@@ -1,3 +1,4 @@
export { default as CapitalCouncilStepGroup } from './CapitalCouncilStepGroup.vue';
export { default as CapitalOnboardingCard } from './CapitalOnboardingCard.vue';
export { default as CapitalProgramDocumentParametersWidget } from './CapitalProgramDocumentParametersWidget.vue';
export { default as CapitalProgramDocumentStepPanel } from './CapitalProgramDocumentStepPanel.vue';
export { default as CapitalProgramInlineDocumentPreview } from './CapitalProgramInlineDocumentPreview.vue';
@@ -66,21 +66,20 @@ const maxWidthBySize: Record<BaseDialogSize, string> = {
<style scoped>
.base-dialog {
width: 100%;
display: flex;
flex-direction: column;
max-height: calc(100vh - var(--p-6, 48px));
}
/* Maximized: контейнер растягивается на весь экран, контент скроллится. */
.base-dialog--maximized {
width: 100%;
height: 100%;
max-width: none;
display: flex;
flex-direction: column;
}
.base-dialog--maximized .base-dialog__body {
flex: 1 1 auto;
overflow: auto;
max-height: none;
}
.base-dialog__head {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: var(--p-3, 12px);
@@ -94,13 +93,19 @@ const maxWidthBySize: Record<BaseDialogSize, string> = {
}
.base-dialog__body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow-y: auto;
padding-bottom: var(--p-2, 8px);
}
.base-dialog__body > :first-child + * {
margin-top: var(--p-4, 16px);
}
.base-dialog__foot {
padding-top: var(--p-2, 8px);
flex: 0 0 auto;
padding: var(--p-3, 12px) var(--p-4, 16px);
border-top: 1px solid var(--p-line);
background: var(--p-surface);
}
</style>