feat: форма обращения сотрудника в HR департамент
Переносит вопросы из Microsoft Forms в собственный интерфейс: компания, анонимность, ФИО и должность, вид обращения, описание ситуации и предложение. Поля автора скрываются при анонимном обращении, а не блокируются: требовать ФИО у анонима — противоречие, а серое неактивное поле выглядит как ошибка формы. Состояние целиком в useFeedbackForm, правила заполнения — в отдельной чистой функции validateFeedback: её можно покрыть тестами без React. Отправки пока нет — по договорённости делаем только UI, submit пишет данные в консоль (TODO в useFeedbackForm). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# hr-appeals
|
||||
|
||||
Форма обращений сотрудников в HR департамент.
|
||||
|
||||
Заменяет встроенную Microsoft Forms: даёт собственный интерфейс в фирменном стиле
|
||||
(общие токены и компоненты, что и `request-portal`) без чужого брендинга и служебных
|
||||
плашек в подвале.
|
||||
|
||||
## Стек
|
||||
|
||||
- React 17 + TypeScript (строгий режим)
|
||||
- Vite 6
|
||||
- SCSS Modules
|
||||
- [`@tnsu/ui-kit`](http://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit) — общие UI-компоненты
|
||||
|
||||
React именно 17-й: `@tnsu/ui-kit` объявляет его в `peerDependencies`.
|
||||
|
||||
## Запуск
|
||||
|
||||
Пакеты `@tnsu/*` лежат в приватном реестре — он прописан в `.npmrc`, отдельная
|
||||
настройка не нужна, но нужен доступ к `git.tnsu.kz`.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Прочие команды:
|
||||
|
||||
```bash
|
||||
npm run build # проверка типов + production-сборка в dist/
|
||||
npm run typecheck # только проверка типов
|
||||
npm run preview # просмотр собранной версии
|
||||
```
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
src/
|
||||
components/ переиспользуемые UI-обёртки
|
||||
Field/ FormField из ui-kit + текст ошибки валидации
|
||||
PageShell/ шапка страницы и центрированный контент
|
||||
features/feedback/ собственно форма обращения
|
||||
model.ts типы данных, справочники, пустое состояние
|
||||
texts.ts все пользовательские строки (задел под локализацию)
|
||||
validateFeedback.ts правила заполнения — чистая функция
|
||||
useFeedbackForm.ts состояние формы, валидация, отправка
|
||||
FeedbackForm.tsx сборка формы из секций
|
||||
ThankYouScreen.tsx экран после отправки
|
||||
sections/ секции формы
|
||||
styles/
|
||||
_design-tokens.scss фирменные токены (копия из request-portal)
|
||||
global.scss
|
||||
```
|
||||
|
||||
Состояние формы целиком живёт в `useFeedbackForm`; секции получают срез данных и
|
||||
колбэк `set`, но своим состоянием не владеют.
|
||||
|
||||
## Что не сделано
|
||||
|
||||
**Отправка данных.** Бэкенда пока нет: `useFeedbackForm.submit` валидирует форму и
|
||||
пишет результат в консоль (см. `TODO` в файле). Реальную интеграцию нужно подключить
|
||||
отдельно — только там, без правок в компонентах.
|
||||
|
||||
**Список компаний.** В `model.ts` заполнено одно значение — «ТОО АСТЕЛ-К», единственное
|
||||
видимое в исходной форме. Полный список нужно уточнить у владельца формы.
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PageShell } from './components/PageShell/PageShell';
|
||||
import { FeedbackForm } from './features/feedback/FeedbackForm';
|
||||
import { TEXTS } from './features/feedback/texts';
|
||||
|
||||
export const App: React.FC = () => (
|
||||
<PageShell title={TEXTS.pageTitle} subtitle={TEXTS.pageSubtitle}>
|
||||
<FeedbackForm />
|
||||
</PageShell>
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
@use '../../styles/design-tokens' as *;
|
||||
|
||||
.error {
|
||||
margin: $sp-1 0 0;
|
||||
color: $red-500;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-caption;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: $sp-1 0 0;
|
||||
color: $gray-500;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-caption;
|
||||
}
|
||||
|
||||
// Красная рамка у контрола внутри — ui-kit не знает о состоянии ошибки,
|
||||
// поэтому подсвечиваем его снаружи через вложенные селекторы.
|
||||
.invalid {
|
||||
:global(input),
|
||||
:global(textarea),
|
||||
:global(select) {
|
||||
border-color: $red-500;
|
||||
|
||||
&:focus {
|
||||
border-color: $red-500;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { FormField } from '@tnsu/ui-kit';
|
||||
import type { ReactNode } from 'react';
|
||||
import styles from './Field.module.scss';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
required?: boolean;
|
||||
full?: boolean;
|
||||
/** Текст ошибки валидации. Пусто — поле считается корректным. */
|
||||
error?: string;
|
||||
/** Поясняющая подпись под полем (показывается, когда нет ошибки). */
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Поле формы с сообщением об ошибке.
|
||||
* FormField из @tnsu/ui-kit не умеет показывать ошибку, поэтому оборачиваем его
|
||||
* здесь, а не правим общий пакет — правило валидации специфично для этой формы.
|
||||
*/
|
||||
export const Field: React.FC<Props> = ({
|
||||
label,
|
||||
htmlFor,
|
||||
required,
|
||||
full,
|
||||
error,
|
||||
hint,
|
||||
children
|
||||
}) => (
|
||||
<FormField label={label} htmlFor={htmlFor} required={required} full={full}>
|
||||
<div className={error ? styles.invalid : undefined}>{children}</div>
|
||||
{error ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{!error && hint ? <p className={styles.hint}>{hint}</p> : null}
|
||||
</FormField>
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
@use '../../styles/design-tokens' as *;
|
||||
|
||||
// Максимальная ширина колонки с формой: длинные строки текста читать тяжело.
|
||||
$content-width: 900px;
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $navy-50;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: $grad-header;
|
||||
position: relative;
|
||||
padding: $sp-8 $sp-5;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, $gold-500, transparent 60%);
|
||||
}
|
||||
}
|
||||
|
||||
.headerInner {
|
||||
max-width: $content-width;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
color: $white;
|
||||
font-family: $font-serif;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: $sp-2 0 0;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-body;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: $content-width;
|
||||
margin: 0 auto;
|
||||
padding: $sp-7 $sp-5 $sp-8;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header {
|
||||
padding: $sp-6 $sp-4;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: $sp-5 $sp-4 $sp-7;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import styles from './PageShell.module.scss';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Оболочка страницы: фирменная шапка (navy + золотой hairline) и центрированный
|
||||
* контент. AppLayout из ui-kit не подошёл — он рассчитан на боковое меню,
|
||||
* а здесь одна самостоятельная страница без навигации.
|
||||
*/
|
||||
export const PageShell: React.FC<Props> = ({ title, subtitle, children }) => (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerInner}>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{subtitle ? <p className={styles.subtitle}>{subtitle}</p> : null}
|
||||
</div>
|
||||
</header>
|
||||
<main className={styles.content}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
@use '../../styles/design-tokens' as *;
|
||||
|
||||
.notice {
|
||||
margin-bottom: $sp-5;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Alert, Button, CollapseSection, FormActions, FormCard } from '@tnsu/ui-kit';
|
||||
import { AppealSection } from './sections/AppealSection';
|
||||
import { AuthorSection } from './sections/AuthorSection';
|
||||
import { ThankYouScreen } from './ThankYouScreen';
|
||||
import { TEXTS } from './texts';
|
||||
import { useFeedbackForm } from './useFeedbackForm';
|
||||
import styles from './FeedbackForm.module.scss';
|
||||
|
||||
/** Форма обращения в HR: сбор данных, валидация, экран благодарности. */
|
||||
export const FeedbackForm: React.FC = () => {
|
||||
const { data, errors, status, formError, set, submit, reset } = useFeedbackForm();
|
||||
|
||||
if (status === 'done') {
|
||||
return <ThankYouScreen isAnonymous={data.isAnonymous} onNewAppeal={reset} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormCard title={TEXTS.formTitle} subtitle={TEXTS.pageSubtitle}>
|
||||
<div className={styles.notice}>
|
||||
<Alert variant="warn">{TEXTS.privacyNotice}</Alert>
|
||||
</div>
|
||||
|
||||
{formError ? (
|
||||
<div className={styles.notice}>
|
||||
<Alert variant="err">{formError}</Alert>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CollapseSection title="Кто обращается" icon="people">
|
||||
<AuthorSection data={data} errors={errors} set={set} />
|
||||
</CollapseSection>
|
||||
|
||||
<CollapseSection title="Суть обращения" icon="chat">
|
||||
<AppealSection data={data} errors={errors} set={set} />
|
||||
</CollapseSection>
|
||||
|
||||
<FormActions>
|
||||
<Button variant="ghost" type="button" onClick={reset}>
|
||||
{TEXTS.actions.reset}
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={status === 'submitting'}
|
||||
>
|
||||
{TEXTS.actions.submit}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</FormCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
@use '../../styles/design-tokens' as *;
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $sp-5;
|
||||
padding: $sp-8 $sp-4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: $r-full;
|
||||
background: $green-50;
|
||||
color: $green-600;
|
||||
}
|
||||
|
||||
.text {
|
||||
max-width: 460px;
|
||||
margin: 0;
|
||||
color: $gray-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-body;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Button, FormCard, Icon } from '@tnsu/ui-kit';
|
||||
import { TEXTS } from './texts';
|
||||
import styles from './ThankYouScreen.module.scss';
|
||||
|
||||
interface Props {
|
||||
/** От анонимности зависит текст: по анонимному обращению ответа не будет. */
|
||||
isAnonymous: boolean;
|
||||
onNewAppeal: () => void;
|
||||
}
|
||||
|
||||
/** Экран после успешной отправки обращения. */
|
||||
export const ThankYouScreen: React.FC<Props> = ({ isAnonymous, onNewAppeal }) => (
|
||||
<FormCard title={TEXTS.thanks.title}>
|
||||
<div className={styles.root}>
|
||||
<span className={styles.icon}>
|
||||
<Icon name="check" />
|
||||
</span>
|
||||
<p className={styles.text}>
|
||||
{isAnonymous ? TEXTS.thanks.anonymous : TEXTS.thanks.named}
|
||||
</p>
|
||||
<Button variant="secondary" type="button" onClick={onNewAppeal}>
|
||||
{TEXTS.actions.newAppeal}
|
||||
</Button>
|
||||
</div>
|
||||
</FormCard>
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ISelectOption } from '@tnsu/ui-kit';
|
||||
|
||||
/** Вид обращения. Union вместо строк, чтобы опечатку поймал компилятор. */
|
||||
export type AppealKind = 'complaint' | 'suggestion' | 'other';
|
||||
|
||||
/** Данные формы обращения. Одна плоская структура — один источник истины. */
|
||||
export interface IFeedbackData {
|
||||
company: string;
|
||||
/** true — автор не указывает себя и не получит ответ. */
|
||||
isAnonymous: boolean;
|
||||
fullName: string;
|
||||
position: string;
|
||||
wantsFeedback: boolean;
|
||||
kind: AppealKind;
|
||||
/** Заполняется только при kind === 'other'. */
|
||||
kindOther: string;
|
||||
situation: string;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Список компаний. ПРЕДПОЛОЖЕНИЕ: в исходной форме была видна только
|
||||
* «ТОО АСТЕЛ-К»; остальные значения нужно уточнить у заказчика формы.
|
||||
*/
|
||||
export const COMPANY_OPTIONS: ISelectOption[] = [
|
||||
{ value: 'astel-k', label: 'ТОО АСТЕЛ-К' }
|
||||
];
|
||||
|
||||
export const KIND_OPTIONS: ISelectOption[] = [
|
||||
{ value: 'complaint', label: 'Жалоба' },
|
||||
{ value: 'suggestion', label: 'Предложение' },
|
||||
{ value: 'other', label: 'Другое' }
|
||||
];
|
||||
|
||||
export const EMPTY_FEEDBACK: IFeedbackData = {
|
||||
company: '',
|
||||
isAnonymous: false,
|
||||
fullName: '',
|
||||
position: '',
|
||||
wantsFeedback: false,
|
||||
kind: 'complaint',
|
||||
kindOther: '',
|
||||
situation: '',
|
||||
suggestion: ''
|
||||
};
|
||||
|
||||
/** Ключи полей, по которым показываем ошибку валидации. */
|
||||
export type FeedbackErrors = Partial<Record<keyof IFeedbackData, string>>;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { FormGrid, Select, TextArea, TextInput } from '@tnsu/ui-kit';
|
||||
import { Field } from '../../../components/Field/Field';
|
||||
import { KIND_OPTIONS, type AppealKind } from '../model';
|
||||
import { TEXTS } from '../texts';
|
||||
import type { ISectionProps } from './ISectionProps';
|
||||
|
||||
const SITUATION_ROWS = 5;
|
||||
const SUGGESTION_ROWS = 4;
|
||||
|
||||
/** Суть обращения: вид, описание ситуации и предложение по улучшению. */
|
||||
export const AppealSection: React.FC<ISectionProps> = ({ data, errors, set }) => (
|
||||
<FormGrid>
|
||||
<Field label={TEXTS.labels.kind} htmlFor="kind" required error={errors.kind}>
|
||||
<Select
|
||||
id="kind"
|
||||
value={data.kind}
|
||||
onChange={(v) => set('kind', v as AppealKind)}
|
||||
options={KIND_OPTIONS}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{data.kind === 'other' ? (
|
||||
<Field
|
||||
label={TEXTS.labels.kindOther}
|
||||
htmlFor="kindOther"
|
||||
required
|
||||
error={errors.kindOther}
|
||||
>
|
||||
<TextInput
|
||||
id="kindOther"
|
||||
value={data.kindOther}
|
||||
onChange={(v) => set('kindOther', v)}
|
||||
placeholder={TEXTS.placeholders.kindOther}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label={TEXTS.labels.situation}
|
||||
htmlFor="situation"
|
||||
required
|
||||
full
|
||||
error={errors.situation}
|
||||
>
|
||||
<TextArea
|
||||
id="situation"
|
||||
value={data.situation}
|
||||
onChange={(v) => set('situation', v)}
|
||||
placeholder={TEXTS.placeholders.situation}
|
||||
rows={SITUATION_ROWS}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={TEXTS.labels.suggestion}
|
||||
htmlFor="suggestion"
|
||||
required
|
||||
full
|
||||
error={errors.suggestion}
|
||||
>
|
||||
<TextArea
|
||||
id="suggestion"
|
||||
value={data.suggestion}
|
||||
onChange={(v) => set('suggestion', v)}
|
||||
placeholder={TEXTS.placeholders.suggestion}
|
||||
rows={SUGGESTION_ROWS}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { FormGrid, Select, TextInput, Toggle } from '@tnsu/ui-kit';
|
||||
import { Field } from '../../../components/Field/Field';
|
||||
import { COMPANY_OPTIONS } from '../model';
|
||||
import { TEXTS } from '../texts';
|
||||
import type { ISectionProps } from './ISectionProps';
|
||||
|
||||
/** Кто обращается: компания, анонимность, ФИО и должность. */
|
||||
export const AuthorSection: React.FC<ISectionProps> = ({ data, errors, set }) => (
|
||||
<FormGrid>
|
||||
<Field
|
||||
label={TEXTS.labels.company}
|
||||
htmlFor="company"
|
||||
required
|
||||
error={errors.company}
|
||||
>
|
||||
<Select
|
||||
id="company"
|
||||
value={data.company}
|
||||
onChange={(v) => set('company', v)}
|
||||
options={COMPANY_OPTIONS}
|
||||
placeholder={TEXTS.placeholders.company}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={TEXTS.labels.isAnonymous} hint={TEXTS.anonymousHint} full>
|
||||
<Toggle
|
||||
checked={data.isAnonymous}
|
||||
onChange={(v) => set('isAnonymous', v)}
|
||||
onLabel="Да"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Анонимное обращение не собирает данные автора — поля просто скрываем,
|
||||
чтобы не создавать впечатление, что их «всё равно надо» заполнить. */}
|
||||
{!data.isAnonymous ? (
|
||||
<>
|
||||
<Field
|
||||
label={TEXTS.labels.fullName}
|
||||
htmlFor="fullName"
|
||||
required
|
||||
error={errors.fullName}
|
||||
>
|
||||
<TextInput
|
||||
id="fullName"
|
||||
value={data.fullName}
|
||||
onChange={(v) => set('fullName', v)}
|
||||
placeholder={TEXTS.placeholders.fullName}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={TEXTS.labels.position} htmlFor="position">
|
||||
<TextInput
|
||||
id="position"
|
||||
value={data.position}
|
||||
onChange={(v) => set('position', v)}
|
||||
placeholder={TEXTS.placeholders.position}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={TEXTS.labels.wantsFeedback} full>
|
||||
<Toggle
|
||||
checked={data.wantsFeedback}
|
||||
onChange={(v) => set('wantsFeedback', v)}
|
||||
onLabel="Да"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
</FormGrid>
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { FeedbackErrors, IFeedbackData } from '../model';
|
||||
|
||||
/**
|
||||
* Общий контракт секций формы: секция получает срез состояния и колбэк,
|
||||
* но не владеет состоянием — оно живёт в useFeedbackForm.
|
||||
*/
|
||||
export interface ISectionProps {
|
||||
data: IFeedbackData;
|
||||
errors: FeedbackErrors;
|
||||
set: <K extends keyof IFeedbackData>(key: K, value: IFeedbackData[K]) => void;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Все пользовательские строки формы в одном месте — задел под локализацию
|
||||
* и защита от расползания текста по JSX.
|
||||
*/
|
||||
export const TEXTS = {
|
||||
pageTitle: 'Обращение',
|
||||
pageSubtitle: 'Данное обращение будет направлено в HR департамент',
|
||||
|
||||
formTitle: 'Обращение в HR департамент',
|
||||
|
||||
privacyNotice:
|
||||
'Форма не собирает автоматически ваше имя и адрес электронной почты — только то, что вы укажете сами.',
|
||||
|
||||
anonymousHint:
|
||||
'Анонимное обращение: мы не сможем дать вам обратную связь. Именные обращения обрабатываются в течение 3–5 рабочих дней, если вопрос не требует большего срока.',
|
||||
|
||||
labels: {
|
||||
company: 'Компания',
|
||||
isAnonymous: 'Обращение анонимное',
|
||||
fullName: 'Фамилия Имя Отчество',
|
||||
position: 'Должность',
|
||||
wantsFeedback: 'Хочу получить обратную связь',
|
||||
kind: 'Вид обращения',
|
||||
kindOther: 'Уточните вид обращения',
|
||||
situation: 'Описание ситуации',
|
||||
suggestion: 'Предложение по улучшению'
|
||||
},
|
||||
|
||||
placeholders: {
|
||||
company: 'Выберите компанию',
|
||||
fullName: 'Иванов Иван Иванович',
|
||||
position: 'Например: инженер-проектировщик',
|
||||
kindOther: 'Свой вариант',
|
||||
situation: 'Опишите, что произошло: когда, где, кто участвовал',
|
||||
suggestion: 'Что, по вашему мнению, стоит изменить'
|
||||
},
|
||||
|
||||
actions: {
|
||||
submit: 'Отправить',
|
||||
reset: 'Очистить',
|
||||
newAppeal: 'Отправить ещё одно обращение'
|
||||
},
|
||||
|
||||
errors: {
|
||||
required: 'Заполните поле',
|
||||
selectRequired: 'Выберите значение',
|
||||
tooShort: 'Слишком короткий ответ — опишите подробнее',
|
||||
hasErrors: 'Проверьте выделенные поля — что-то заполнено не полностью.'
|
||||
},
|
||||
|
||||
thanks: {
|
||||
title: 'Обращение отправлено',
|
||||
named:
|
||||
'Спасибо! Мы рассмотрим обращение в течение 3–5 рабочих дней и вернёмся с ответом.',
|
||||
anonymous:
|
||||
'Спасибо! Обращение отправлено анонимно, поэтому ответ по нему не придёт — но мы его обязательно рассмотрим.'
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { EMPTY_FEEDBACK, type FeedbackErrors, type IFeedbackData } from './model';
|
||||
import { TEXTS } from './texts';
|
||||
import { validateFeedback } from './validateFeedback';
|
||||
|
||||
export type SubmitStatus = 'idle' | 'submitting' | 'done';
|
||||
|
||||
export interface IFeedbackForm {
|
||||
data: IFeedbackData;
|
||||
errors: FeedbackErrors;
|
||||
status: SubmitStatus;
|
||||
/** Общая ошибка формы (показывается над полями). */
|
||||
formError: string;
|
||||
set: <K extends keyof IFeedbackData>(key: K, value: IFeedbackData[K]) => void;
|
||||
submit: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояние формы обращения: данные, валидация, отправка.
|
||||
* Бэкенда пока нет — submit только имитирует отправку (см. TODO ниже),
|
||||
* поэтому вся логика синхронная и живёт здесь, без сервисного слоя.
|
||||
*/
|
||||
export function useFeedbackForm(): IFeedbackForm {
|
||||
const [data, setData] = useState<IFeedbackData>(EMPTY_FEEDBACK);
|
||||
const [errors, setErrors] = useState<FeedbackErrors>({});
|
||||
const [formError, setFormError] = useState('');
|
||||
const [status, setStatus] = useState<SubmitStatus>('idle');
|
||||
|
||||
const set = useCallback(
|
||||
<K extends keyof IFeedbackData>(key: K, value: IFeedbackData[K]): void => {
|
||||
setData((prev) => ({ ...prev, [key]: value }));
|
||||
// Ошибку поля снимаем сразу при правке — иначе она висит до повторной отправки.
|
||||
setErrors((prev) => {
|
||||
if (!prev[key]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const submit = useCallback((): void => {
|
||||
const found = validateFeedback(data);
|
||||
setErrors(found);
|
||||
|
||||
if (Object.keys(found).length > 0) {
|
||||
setFormError(TEXTS.errors.hasErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setFormError('');
|
||||
setStatus('submitting');
|
||||
|
||||
// TODO: заменить на реальную отправку, когда определимся с бэкендом.
|
||||
// Сейчас это заглушка по договорённости: делаем только UI.
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('Обращение (заглушка отправки):', data);
|
||||
setStatus('done');
|
||||
}, [data]);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setData(EMPTY_FEEDBACK);
|
||||
setErrors({});
|
||||
setFormError('');
|
||||
setStatus('idle');
|
||||
}, []);
|
||||
|
||||
return { data, errors, status, formError, set, submit, reset };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FeedbackErrors, IFeedbackData } from './model';
|
||||
import { TEXTS } from './texts';
|
||||
|
||||
/** Ниже этой длины текст не считаем содержательным описанием. */
|
||||
const MIN_TEXT_LENGTH = 10;
|
||||
|
||||
/**
|
||||
* Правила заполнения обращения.
|
||||
* Чистая функция: получает данные, возвращает ошибки — её легко покрыть тестами
|
||||
* и она не зависит от React.
|
||||
*/
|
||||
export function validateFeedback(data: IFeedbackData): FeedbackErrors {
|
||||
const errors: FeedbackErrors = {};
|
||||
|
||||
if (!data.company) {
|
||||
errors.company = TEXTS.errors.selectRequired;
|
||||
}
|
||||
|
||||
// Имя и должность спрашиваем только у неанонимных обращений: у анонимного
|
||||
// эти поля скрыты, требовать их было бы противоречием.
|
||||
if (!data.isAnonymous && !data.fullName.trim()) {
|
||||
errors.fullName = TEXTS.errors.required;
|
||||
}
|
||||
|
||||
if (data.kind === 'other' && !data.kindOther.trim()) {
|
||||
errors.kindOther = TEXTS.errors.required;
|
||||
}
|
||||
|
||||
if (!data.situation.trim()) {
|
||||
errors.situation = TEXTS.errors.required;
|
||||
} else if (data.situation.trim().length < MIN_TEXT_LENGTH) {
|
||||
errors.situation = TEXTS.errors.tooShort;
|
||||
}
|
||||
|
||||
if (!data.suggestion.trim()) {
|
||||
errors.suggestion = TEXTS.errors.required;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { IconSprite } from '@tnsu/ui-kit';
|
||||
import { App } from './App';
|
||||
|
||||
import '@tnsu/ui-kit/styles.css';
|
||||
import './styles/global.scss';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error('Не найден контейнер #root — проверьте index.html');
|
||||
}
|
||||
|
||||
// React 17 API: ui-kit собран под react@17, поэтому createRoot здесь недоступен.
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
{/* Спрайт с иконками ui-kit монтируется один раз на всё приложение. */}
|
||||
<IconSprite />
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
rootElement
|
||||
);
|
||||
Reference in New Issue
Block a user