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:
2026-08-20 18:15:10 +05:00
parent e921062e3b
commit 010f78b138
18 changed files with 739 additions and 0 deletions
+66
View File
@@ -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` заполнено одно значение — «ТОО АСТЕЛ-К», единственное
видимое в исходной форме. Полный список нужно уточнить у владельца формы.
+9
View File
@@ -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>
);
+30
View File
@@ -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);
}
}
}
+40
View File
@@ -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;
}
}
+25
View File
@@ -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;
}
+52
View File
@@ -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;
}
+26
View File
@@ -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>
);
+48
View File
@@ -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;
}
+58
View File
@@ -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;
+71
View File
@@ -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 };
}
+40
View File
@@ -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;
}
+23
View File
@@ -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
);