feat: implemented form lifecycle hool and state machine

This commit is contained in:
Тимур Городецкий
2026-08-04 17:00:04 +05:00
parent ed21944628
commit ba4e3feee5
25 changed files with 504 additions and 171 deletions
@@ -4,6 +4,8 @@ export {
REQUEST_STATUS_CHOICES,
APPROVAL_PHASE,
APPROVAL_PHASE_CHOICES,
EDITABLE_STATUSES,
isEditableStatus,
} from './models/statuses';
export type { ISiteUser } from './models/ISiteUser';
export type { IMatrixRow } from './models/IMatrixRow';
@@ -26,3 +28,4 @@ export type { IUseMatrixEditor } from './useMatrixEditor';
export { ApprovalMatrix } from './ui/ApprovalMatrix';
export { UserPicker } from './ui/UserPicker';
export { DecisionPanel } from './ui/DecisionPanel';
export { StatusBadge } from './ui/StatusBadge';
@@ -13,6 +13,20 @@ export const REQUEST_STATUS = {
closed: 'Закрыта',
} as const;
// Статусы, при которых заявку ещё можно править (форма редактируема).
// Всё остальное (На согласовании / Согласовано / Выполнение / Закрыта) — только чтение.
// «Отклонено» здесь по решению 2026-08-04: отклонённую можно доработать и переотправить.
export const EDITABLE_STATUSES: string[] = [
REQUEST_STATUS.draft,
REQUEST_STATUS.returned,
REQUEST_STATUS.rejected,
];
// Новая (без статуса) или заявка в редактируемом статусе → форму можно менять.
export function isEditableStatus(status: string | undefined): boolean {
return !status || EDITABLE_STATUSES.indexOf(status) !== -1;
}
// Полный набор значений для Choice-колонки при провижининге.
export const REQUEST_STATUS_CHOICES: string[] = [
REQUEST_STATUS.draft,
@@ -0,0 +1,48 @@
@import '../../../../../styles/design-tokens';
.badge {
display: inline-flex;
align-items: center;
padding: 5px 12px;
border-radius: $r-sm;
font-family: $font-sans;
font-size: 12.5px;
font-weight: 600;
letter-spacing: .2px;
white-space: nowrap;
}
.draft {
background: $gray-100;
color: $gray-700;
}
.onApproval {
background: $blue-50;
color: $blue-600;
}
.approved {
background: $green-50;
color: $green-600;
}
.rejected {
background: $red-50;
color: $red-500;
}
.returned {
background: $gold-50;
color: $amber-700;
}
.execution {
background: $navy-50;
color: $navy-800;
}
.closed {
background: $gray-100;
color: $gray-500;
}
@@ -0,0 +1,19 @@
import * as React from 'react';
import styles from './StatusBadge.module.scss';
import { REQUEST_STATUS } from '../models/statuses';
// Цвет бейджа по статусу заявки. Неизвестный статус → нейтральный (как черновик).
const VARIANT: Record<string, string> = {
[REQUEST_STATUS.draft]: styles.draft,
[REQUEST_STATUS.onApproval]: styles.onApproval,
[REQUEST_STATUS.approved]: styles.approved,
[REQUEST_STATUS.rejected]: styles.rejected,
[REQUEST_STATUS.returned]: styles.returned,
[REQUEST_STATUS.execution]: styles.execution,
[REQUEST_STATUS.closed]: styles.closed,
};
/** Цветная плашка статуса заявки (шапка формы / просмотра). */
export const StatusBadge: React.FC<{ status: string }> = ({ status }) => (
<span className={`${styles.badge} ${VARIANT[status] || styles.draft}`}>{status}</span>
);
@@ -8,6 +8,13 @@
padding: 32px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.title {
font-family: $font-serif;
font-size: $fs-form-title;
@@ -15,6 +22,13 @@
color: $navy-900;
}
.headerRight {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
}
.subtitle {
font-size: 12px;
color: $gray-400;
@@ -4,15 +4,22 @@ import styles from './FormCard.module.scss';
interface IFormCardProps {
title: string;
subtitle?: string;
/** Контент в правом верхнем углу шапки (например, номер заявки). */
headerRight?: React.ReactNode;
children: React.ReactNode;
}
/** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */
export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, children }) => {
export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, headerRight, children }) => {
return (
<div className={styles.card}>
<div className={styles.title}>{title}</div>
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
<div className={styles.header}>
<div>
<div className={styles.title}>{title}</div>
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
</div>
{headerRight && <div className={styles.headerRight}>{headerRight}</div>}
</div>
<hr className={styles.hairline} />
{children}
</div>
@@ -0,0 +1,16 @@
@import '../../../../../styles/design-tokens';
.badge {
display: inline-flex;
align-items: center;
padding: 5px 12px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $navy-50;
color: $navy-800;
font-family: $font-sans;
font-size: 13px;
font-weight: 600;
letter-spacing: .3px;
white-space: nowrap;
}
@@ -0,0 +1,7 @@
import * as React from 'react';
import styles from './RequestNumber.module.scss';
/** Номер заявки (Id элемента списка) — плашка в правом углу шапки формы. */
export const RequestNumber: React.FC<{ id: number }> = ({ id }) => (
<span className={styles.badge}>№{id}</span>
);
@@ -16,4 +16,5 @@
transition: .18s;
@include input-focus-gold;
@include readonly-field(':disabled');
}
@@ -0,0 +1,50 @@
import * as React from 'react';
import { Button } from '../Button/Button';
import { Icon } from '../Icon/Icon';
import { FormActions } from '../FormActions/FormActions';
import { IRequestSubmit } from '../useRequestSubmit';
export interface ISubmitActionsProps {
submit: IRequestSubmit;
isValid: boolean;
/** Заявка в нередактируемом статусе (На согласовании/…): сохранять нельзя. */
readOnly: boolean;
onCancel: () => void;
onSaveDraft: () => void;
onSubmit: () => void;
/** Подпись основной кнопки. По умолчанию — «Отправить на согласование». */
submitLabel?: string;
}
// Единый ряд кнопок форм подачи: Отмена / Сохранить черновик / Отправить.
// Логика доступности — здесь, в одном месте, чтобы все формы вели себя одинаково:
// • сохранять/отправлять нельзя, пока идёт сохранение или заявка уже read-only;
// • «Отмена» доступна всегда (возврат к списку), кроме момента сохранения.
export const SubmitActions: React.FC<ISubmitActionsProps> = ({
submit,
isValid,
readOnly,
onCancel,
onSaveDraft,
onSubmit,
submitLabel = 'Отправить на согласование',
}) => {
const { saving, kind } = submit;
const locked = saving || readOnly;
return (
<FormActions>
<Button variant="secondary" onClick={onCancel} disabled={saving}>
Отмена
</Button>
<Button variant="secondary" onClick={onSaveDraft} disabled={!isValid || locked}>
<Icon name="document" size="sm" />
Сохранить черновик
</Button>
<Button variant="accent" onClick={onSubmit} disabled={!isValid || locked}>
<Icon name="send" size="sm" />
{saving && kind === 'submit' ? 'Отправка…' : submitLabel}
</Button>
</FormActions>
);
};
@@ -13,6 +13,7 @@
transition: .18s;
@include input-focus-gold;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
@@ -11,6 +11,7 @@
transition: .18s;
@include input-focus-gold;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
@@ -49,6 +49,16 @@
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
}
// Только чтение: приглушаем переключатель и подпись.
.input:disabled + .track {
opacity: .5;
cursor: default;
}
.input:disabled ~ .text {
color: $gray-400;
}
.text {
font-size: 13px;
color: $gray-700;
@@ -19,3 +19,7 @@ export { PeopleField } from './PeopleField/PeopleField';
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { FormModeProvider, useFormReadOnly } from './FormMode';
export { Icon } from './Icon/Icon';
export { SubmitActions } from './SubmitActions/SubmitActions';
export { RequestNumber } from './RequestNumber/RequestNumber';
export { useRequestSubmit } from './useRequestSubmit';
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';
@@ -0,0 +1,60 @@
import { useState } from 'react';
// Общая машина сохранения заявки для ВСЕХ форм портала.
// Отвечает ТОЛЬКО за транзиентное состояние операции (идёт сохранение / ошибка /
// что сохранили). Блокировка формы (readOnly) считается из СТАТУСА заявки на
// стороне формы через isEditableStatus — не здесь: так одинаково блокируются
// и «после отправки», и «открыли уже отправленную».
export type SubmitKind = 'draft' | 'submit';
export interface IRequestSubmit {
saving: boolean; // идёт сохранение
kind: SubmitKind | undefined; // текущее/последнее действие (для подписи кнопки)
savedKind: SubmitKind | undefined; // последнее УСПЕШНОЕ сохранение (для плашки)
error: string | undefined;
/**
* Запустить сохранение. validate (опц.) возвращает текст ошибки или undefined.
* Повторный вызов во время сохранения игнорируется (защита от дабл-клика).
*/
run: (
kind: SubmitKind,
save: () => Promise<void>,
validate?: () => string | undefined
) => Promise<void>;
setError: (message: string | undefined) => void;
}
export function useRequestSubmit(): IRequestSubmit {
const [saving, setSaving] = useState<boolean>(false);
const [kind, setKind] = useState<SubmitKind | undefined>(undefined);
const [savedKind, setSavedKind] = useState<SubmitKind | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const run = async (
k: SubmitKind,
save: () => Promise<void>,
validate?: () => string | undefined
): Promise<void> => {
if (saving) {
return;
}
setKind(k);
const validationError = validate ? validate() : undefined;
if (validationError) {
setError(validationError);
return;
}
setError(undefined);
setSaving(true);
try {
await save();
setSavedKind(k);
} catch (e) {
setError(`Ошибка сохранения: ${(e as Error).message}`);
} finally {
setSaving(false);
}
};
return { saving, kind, savedKind, error, run, setError };
}