feat: working version

This commit is contained in:
Тимур Городецкий
2026-07-27 17:50:31 +05:00
commit 8a79c259b4
86 changed files with 29298 additions and 0 deletions
@@ -0,0 +1,198 @@
import * as React from 'react';
import { useEffect, useMemo, useState } from 'react';
import styles from './KpsCtForm.module.scss';
import { IKpsCtFormProps } from './IKpsCtFormProps';
import { KpsCtFormService } from '../services/KpsCtFormService';
import { useKpsCtForm } from './hooks/useKpsCtForm';
import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
import { FormHeader } from './common/FormHeader';
import { Step1SelectPositions } from './Step1SelectPositions/Step1SelectPositions';
import { Step2LoadKp } from './Step2LoadKp/Step2LoadKp';
import { Step3BuildCt } from './Step3BuildCt/Step3BuildCt';
import { Step4Approval } from './Step4Approval/Step4Approval';
import { loadFormState, readEntityFromUrl, sessionKey } from './utils/session';
import {
CtFormData,
CtStatus,
CT_STATUS_META,
EMPTY_FORM_DATA,
FORM_STEPS,
FormStep,
READ_ONLY_STATUSES,
StepProps
} from './types/domain';
// Главный контейнер (§4): мемоизирует сервис, держит стейт через хук, рендерит шаги.
export default function KpsCtForm(props: IKpsCtFormProps): JSX.Element {
const { context, userDisplayName } = props;
// Единственное место создания сервиса — мемоизируем на context (§5.3).
const service = useMemo(() => new KpsCtFormService(context), [context]);
// id сущности и признак ссылки из уведомления читаем из URL один раз (§5.4).
const url = useMemo(() => readEntityFromUrl(), []);
const form = useKpsCtForm(url.ctId);
const { formData, currentStep, completedSteps, highestStep } = form;
// Загружаем свежие данные из SharePoint только если это ссылка из уведомления
// ИЛИ в сессии пусто — иначе не затираем несохранённые правки (§5.4).
const shouldLoadFromSp = useMemo(
() => !!url.ctId && (url.isNotificationLink || loadFormState(sessionKey(url.ctId)) === undefined),
[url.ctId, url.isNotificationLink]
);
const spLoad = useSharePointLoad(service, form.replaceFormData, form.goToStep, form.markStepComplete, url, shouldLoadFromSp);
// Режимы отображения (§5.5): read-only по статусу + отдельный флаг ограниченного доступа.
const status: CtStatus = deriveCtStatus(formData);
const isReadOnly = READ_ONLY_STATUSES.indexOf(status) !== -1;
const isRestrictedView = false; // TODO: включать по компании/роли пользователя.
if (spLoad.isLoading) {
return <div className={styles.state}>Загрузка…</div>;
}
if (spLoad.error) {
return <div className={`${styles.state} ${styles.error}`}>Ошибка загрузки: {spLoad.error}</div>;
}
const stepProps: StepProps = {
service,
formData,
onSave: form.updateFormData,
onNext: form.next,
onPrev: form.prev,
onStepComplete: form.markStepComplete,
isReadOnly,
isRestrictedView,
userDisplayName,
isNotificationLink: url.isNotificationLink
};
const title = formData.ctId ? `Сравнительная таблица №${formData.ctId}` : 'Новая сравнительная таблица';
return (
<section className={styles.root}>
<div className={styles.inner}>
<FormHeader title={title} statusLabel={CT_STATUS_META[status].label} statusKind={CT_STATUS_META[status].kind} />
<Breadcrumbs
steps={FORM_STEPS}
currentStep={currentStep}
completedSteps={completedSteps}
highestStep={highestStep}
onStepClick={form.goToStep}
/>
{renderStep(currentStep, stepProps)}
</div>
</section>
);
}
// Рендер текущего шага. Все шаги получают единый контракт props (§5.2).
function renderStep(step: FormStep, stepProps: StepProps): JSX.Element {
switch (step) {
case 1:
return <Step1SelectPositions {...stepProps} />;
case 2:
return <Step2LoadKp {...stepProps} />;
case 3:
return <Step3BuildCt {...stepProps} />;
case 4:
return <Step4Approval {...stepProps} />;
default:
return <React.Fragment />;
}
}
// Деривация статуса сущности из цепочки согласования (§12).
// TODO: реальная логика (pending/recommends/approved/rejected → статус СТ). Пока — статус из formData.
function deriveCtStatus(formData: CtFormData): CtStatus {
return formData.status;
}
// Самый дальний «заполненный» шаг существующей СТ — для приземления при входе по URL (режим редактирования).
// По фактическим данным реконструкции: цепочка/на согласовании → 4, есть офферы → 3, есть поставщики → 2,
// иначе (есть позиции) → 1.
function furthestFilledStep(data: CtFormData): FormStep {
if (data.approvers.length > 0 || data.status === 'on_approval' || data.status === 'approved' || data.status === 'rejected') {
return 4;
}
if (data.offers.length > 0) {
return 3;
}
if (data.suppliers.length > 0) {
return 2;
}
return 1;
}
interface SpLoadState {
isLoading: boolean;
error?: string;
}
// Подгрузка сущности из SharePoint (ссылка из уведомления / восстановление при пустой сессии).
// Отменяет setState на unmount (§8). По уведомлению открывает Шаг 3 — он главный (§12).
function useSharePointLoad(
service: KpsCtFormService,
replaceFormData: (data: CtFormData) => void,
goToStep: (step: FormStep) => void,
markStepComplete: (step: FormStep) => void,
url: { ctId?: number; isNotificationLink: boolean },
shouldLoad: boolean
): SpLoadState {
const [state, setState] = useState<SpLoadState>({ isLoading: shouldLoad });
useEffect(() => {
if (!shouldLoad || !url.ctId) {
return undefined;
}
let cancelled = false;
setState({ isLoading: true });
const run = async (): Promise<void> => {
try {
const data = await service.loadCt(url.ctId as number);
if (cancelled) {
return;
}
const loaded = { ...EMPTY_FORM_DATA, ...data, ctId: url.ctId };
replaceFormData(loaded);
if (url.isNotificationLink) {
// Согласующий по ссылке: открываем Шаг 3 (главный) и открываем навигацию — Шаги 1–3 к этому
// моменту уже пройдены инициатором (СТ «на согласовании»), поэтому помечаем их пройденными
// (→ highestStep=4, доступны все шаги), как в эталоне.
goToStep(3);
markStepComplete(1);
markStepComplete(2);
markStepComplete(3);
} else {
// Обычный вход по URL (режим редактирования): открываем навигацию по фактическому прогрессу
// и приземляем на самый дальний заполненный шаг.
const furthest = furthestFilledStep(loaded);
for (let s = 1; s <= Math.min(furthest, 3); s++) {
markStepComplete(s as FormStep);
}
if (loaded.status === 'approved' || loaded.status === 'rejected') {
markStepComplete(4);
}
goToStep(furthest);
}
setState({ isLoading: false });
} catch (error) {
if (!cancelled) {
setState({ isLoading: false, error: (error as Error).message });
}
}
};
// run() сам ловит ошибки внутрь state; .catch — чтобы промис не «висел» необработанным.
run().catch(() => undefined);
return () => {
cancelled = true;
};
}, [service, replaceFormData, goToStep, markStepComplete, url.ctId, url.isNotificationLink, shouldLoad]);
return state;
}