From 010f78b1383ce0f8c4fc633d9eb18459d04ef0ea Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 20 Aug 2026 18:15:10 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=B0=20=D0=B2=20HR?= =?UTF-8?q?=20=D0=B4=D0=B5=D0=BF=D0=B0=D1=80=D1=82=D0=B0=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Переносит вопросы из Microsoft Forms в собственный интерфейс: компания, анонимность, ФИО и должность, вид обращения, описание ситуации и предложение. Поля автора скрываются при анонимном обращении, а не блокируются: требовать ФИО у анонима — противоречие, а серое неактивное поле выглядит как ошибка формы. Состояние целиком в useFeedbackForm, правила заполнения — в отдельной чистой функции validateFeedback: её можно покрыть тестами без React. Отправки пока нет — по договорённости делаем только UI, submit пишет данные в консоль (TODO в useFeedbackForm). Co-Authored-By: Claude Opus 5 --- README.md | 66 +++++++++++++++++ src/App.tsx | 9 +++ src/components/Field/Field.module.scss | 30 ++++++++ src/components/Field/Field.tsx | 40 +++++++++++ .../PageShell/PageShell.module.scss | 65 +++++++++++++++++ src/components/PageShell/PageShell.tsx | 25 +++++++ .../feedback/FeedbackForm.module.scss | 5 ++ src/features/feedback/FeedbackForm.tsx | 52 ++++++++++++++ .../feedback/ThankYouScreen.module.scss | 30 ++++++++ src/features/feedback/ThankYouScreen.tsx | 26 +++++++ src/features/feedback/model.ts | 48 +++++++++++++ .../feedback/sections/AppealSection.tsx | 70 ++++++++++++++++++ .../feedback/sections/AuthorSection.tsx | 70 ++++++++++++++++++ .../feedback/sections/ISectionProps.ts | 11 +++ src/features/feedback/texts.ts | 58 +++++++++++++++ src/features/feedback/useFeedbackForm.ts | 71 +++++++++++++++++++ src/features/feedback/validateFeedback.ts | 40 +++++++++++ src/main.tsx | 23 ++++++ 18 files changed, 739 insertions(+) create mode 100644 README.md create mode 100644 src/App.tsx create mode 100644 src/components/Field/Field.module.scss create mode 100644 src/components/Field/Field.tsx create mode 100644 src/components/PageShell/PageShell.module.scss create mode 100644 src/components/PageShell/PageShell.tsx create mode 100644 src/features/feedback/FeedbackForm.module.scss create mode 100644 src/features/feedback/FeedbackForm.tsx create mode 100644 src/features/feedback/ThankYouScreen.module.scss create mode 100644 src/features/feedback/ThankYouScreen.tsx create mode 100644 src/features/feedback/model.ts create mode 100644 src/features/feedback/sections/AppealSection.tsx create mode 100644 src/features/feedback/sections/AuthorSection.tsx create mode 100644 src/features/feedback/sections/ISectionProps.ts create mode 100644 src/features/feedback/texts.ts create mode 100644 src/features/feedback/useFeedbackForm.ts create mode 100644 src/features/feedback/validateFeedback.ts create mode 100644 src/main.tsx diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f82eb8 --- /dev/null +++ b/README.md @@ -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` заполнено одно значение — «ТОО АСТЕЛ-К», единственное +видимое в исходной форме. Полный список нужно уточнить у владельца формы. diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..cd79d54 --- /dev/null +++ b/src/App.tsx @@ -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 = () => ( + + + +); diff --git a/src/components/Field/Field.module.scss b/src/components/Field/Field.module.scss new file mode 100644 index 0000000..2bed1ff --- /dev/null +++ b/src/components/Field/Field.module.scss @@ -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); + } + } +} diff --git a/src/components/Field/Field.tsx b/src/components/Field/Field.tsx new file mode 100644 index 0000000..885d04b --- /dev/null +++ b/src/components/Field/Field.tsx @@ -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 = ({ + label, + htmlFor, + required, + full, + error, + hint, + children +}) => ( + +
{children}
+ {error ? ( +

+ {error} +

+ ) : null} + {!error && hint ?

{hint}

: null} +
+); diff --git a/src/components/PageShell/PageShell.module.scss b/src/components/PageShell/PageShell.module.scss new file mode 100644 index 0000000..ead7609 --- /dev/null +++ b/src/components/PageShell/PageShell.module.scss @@ -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; + } +} diff --git a/src/components/PageShell/PageShell.tsx b/src/components/PageShell/PageShell.tsx new file mode 100644 index 0000000..96aad25 --- /dev/null +++ b/src/components/PageShell/PageShell.tsx @@ -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 = ({ title, subtitle, children }) => ( +
+
+
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+
+
{children}
+
+); diff --git a/src/features/feedback/FeedbackForm.module.scss b/src/features/feedback/FeedbackForm.module.scss new file mode 100644 index 0000000..82f52c8 --- /dev/null +++ b/src/features/feedback/FeedbackForm.module.scss @@ -0,0 +1,5 @@ +@use '../../styles/design-tokens' as *; + +.notice { + margin-bottom: $sp-5; +} diff --git a/src/features/feedback/FeedbackForm.tsx b/src/features/feedback/FeedbackForm.tsx new file mode 100644 index 0000000..56d259f --- /dev/null +++ b/src/features/feedback/FeedbackForm.tsx @@ -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 ; + } + + return ( + +
+ {TEXTS.privacyNotice} +
+ + {formError ? ( +
+ {formError} +
+ ) : null} + + + + + + + + + + + + + +
+ ); +}; diff --git a/src/features/feedback/ThankYouScreen.module.scss b/src/features/feedback/ThankYouScreen.module.scss new file mode 100644 index 0000000..4f056dc --- /dev/null +++ b/src/features/feedback/ThankYouScreen.module.scss @@ -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; +} diff --git a/src/features/feedback/ThankYouScreen.tsx b/src/features/feedback/ThankYouScreen.tsx new file mode 100644 index 0000000..61b2642 --- /dev/null +++ b/src/features/feedback/ThankYouScreen.tsx @@ -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 = ({ isAnonymous, onNewAppeal }) => ( + +
+ + + +

+ {isAnonymous ? TEXTS.thanks.anonymous : TEXTS.thanks.named} +

+ +
+
+); diff --git a/src/features/feedback/model.ts b/src/features/feedback/model.ts new file mode 100644 index 0000000..4ecec0a --- /dev/null +++ b/src/features/feedback/model.ts @@ -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>; diff --git a/src/features/feedback/sections/AppealSection.tsx b/src/features/feedback/sections/AppealSection.tsx new file mode 100644 index 0000000..4e25516 --- /dev/null +++ b/src/features/feedback/sections/AppealSection.tsx @@ -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 = ({ data, errors, set }) => ( + + +