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