feat: подсветка незаполненных полей и переход к ним в приёме и подборе

Пользователи не понимали, почему кнопка «Отправить на согласование» серая.
Теперь в заявках на приём и подбор кнопки активны: нажатие при незаполненных
или неверных полях ничего не сохраняет, а подсвечивает эти поля с подсказкой
под ними и прокручивает к первому. Над кнопками — список проблем, пункты ведут
к полю. Механизм общий (FieldIssuesProvider + FormField), остальные формы
пока работают по-старому и переводятся по тому же паттерну.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-30 17:15:16 +05:00
parent c95118a216
commit 5702939b52
14 changed files with 485 additions and 125 deletions
@@ -2,6 +2,8 @@ import * as React from 'react';
import { Button, Icon, FormActions, Alert } from '@tnsu/ui-kit';
import { IRequestSubmit } from '../useRequestSubmit';
import { isCurrentUserTester } from './testerGroupAccess';
import { SubmitIssues } from './SubmitIssues';
import { useFieldIssuesControl } from '../FieldIssues/FieldIssuesContext';
/**
* ВРЕМЕННАЯ ЗАМОРОЗКА (включена 2026-09-21 по просьбе Тимура Городецкого):
@@ -65,6 +67,11 @@ export interface ISubmitActionsProps {
// Логика доступности — здесь, в одном месте, чтобы все формы вели себя одинаково:
// • сохранять/отправлять нельзя, пока идёт сохранение или заявка уже read-only;
// • «Отмена» доступна всегда (возврат к списку), кроме момента сохранения.
//
// Форма внутри FieldIssuesProvider (см. shared/ui/FieldIssues) ведёт себя иначе:
// кнопки сохранения не гаснут из-за незаполненных полей — нажатие при проблемах
// ничего не сохраняет, а подсвечивает поля и прокручивает к первому. Над кнопками
// список проблем со ссылками на поля. Формы без провайдера — как раньше, по isValid.
export const SubmitActions: React.FC<ISubmitActionsProps> = ({
submit,
isValid,
@@ -98,11 +105,33 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
const frozen = SUBMISSION_FROZEN && !unfrozenForm && !isTester;
const locked = saving || readOnly || frozen;
const canSubmit = submitValid ?? isValid;
const issuesControl = useFieldIssuesControl();
// С провайдером кнопки не гаснут по полям: проблемы показываются по нажатию.
const canSaveDraft = issuesControl ? true : isValid;
const canSubmit = issuesControl ? true : submitValid ?? isValid;
const handleSaveDraft = (): void => {
if (issuesControl && issuesControl.draftIssues.length > 0) {
issuesControl.reveal('draft');
return;
}
onSaveDraft();
};
const handleSubmit = (): void => {
if (issuesControl && issuesControl.submitIssues.length > 0) {
issuesControl.reveal('submit');
return;
}
onSubmit();
};
return (
<>
{frozen && !readOnly && <Alert variant="warn">Подача заявок временно недоступна.</Alert>}
{/* При заморозке кнопки неактивны не из-за полей — список только сбил бы с толку. */}
{issuesControl && !frozen && !readOnly && (
<SubmitIssues issues={issuesControl.submitIssues} onSelect={(field) => issuesControl.reveal('submit', field)} />
)}
<FormActions>
{onDelete && (
<Button variant="danger" onClick={onDelete} disabled={saving}>
@@ -113,11 +142,11 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
<Button variant="secondary" onClick={onCancel} disabled={saving}>
Отмена
</Button>
<Button variant="secondary" onClick={onSaveDraft} disabled={!isValid || locked}>
<Button variant="secondary" onClick={handleSaveDraft} disabled={!canSaveDraft || locked}>
<Icon name="document" size="sm" />
Сохранить черновик
</Button>
<Button variant="accent" onClick={onSubmit} disabled={!canSubmit || locked}>
<Button variant="accent" onClick={handleSubmit} disabled={!canSubmit || locked}>
<Icon name="send" size="sm" />
{saving && kind === 'submit' ? 'Отправка…' : submitLabel}
</Button>
@@ -0,0 +1,24 @@
@import '../../../../../styles/design-tokens';
.title {
font-weight: 600;
}
.list {
margin: $sp-1 0 0;
padding-left: $sp-5;
}
// Пункт списка — ссылка на поле: выглядит как текст с подчёркиванием, без рамки кнопки.
.link {
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
overflow-wrap: anywhere;
}
@@ -0,0 +1,39 @@
import * as React from 'react';
import { Alert } from '@tnsu/ui-kit';
import styles from './SubmitIssues.module.scss';
import { IFieldIssue, ISSUES_ANCHOR_ATTR, issueListText } from '../FieldIssues/fieldIssues';
interface ISubmitIssuesProps {
/** Что мешает отправке — в порядке формы. */
issues: IFieldIssue[];
/** Клик по пункту — прокрутка к полю. */
onSelect: (field: string) => void;
}
const TITLE = 'Чтобы отправить на согласование, заполните или исправьте:';
/**
* Список причин, по которым заявку пока нельзя отправить. Пункт кликабелен —
* ведёт к полю: на длинной форме иначе пришлось бы искать его самому.
*/
export const SubmitIssues: React.FC<ISubmitIssuesProps> = ({ issues, onSelect }) => {
if (issues.length === 0) return null;
const anchorProps = { [ISSUES_ANCHOR_ATTR]: '' };
return (
<div {...anchorProps}>
<Alert variant="warn">
<div className={styles.title}>{TITLE}</div>
<ul className={styles.list}>
{issues.map((issue) => (
<li key={`${issue.field}-${issue.label}`}>
<button type="button" className={styles.link} onClick={() => onSelect(issue.field)}>
{issueListText(issue)}
</button>
</li>
))}
</ul>
</Alert>
</div>
);
};