feat: working version
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user