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
Загрузка…
; } if (spLoad.error) { return
Ошибка загрузки: {spLoad.error}
; } 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 (
{renderStep(currentStep, stepProps)}
); } // Рендер текущего шага. Все шаги получают единый контракт props (§5.2). function renderStep(step: FormStep, stepProps: StepProps): JSX.Element { switch (step) { case 1: return ; case 2: return ; case 3: return ; case 4: return ; default: return ; } } // Деривация статуса сущности из цепочки согласования (§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({ isLoading: shouldLoad }); useEffect(() => { if (!shouldLoad || !url.ctId) { return undefined; } let cancelled = false; setState({ isLoading: true }); const run = async (): Promise => { 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; }