This commit is contained in:
2026-08-20 18:29:27 +05:00
parent 010f78b138
commit 51339b3f9a
8 changed files with 177 additions and 83 deletions
+24 -5
View File
@@ -1,4 +1,13 @@
import { Alert, Button, CollapseSection, FormActions, FormCard } from '@tnsu/ui-kit';
import {
Alert,
Button,
CollapseSection,
FormActions,
FormCard,
FormGrid,
Toggle
} from '@tnsu/ui-kit';
import { Field } from '../../components/Field/Field';
import { AppealSection } from './sections/AppealSection';
import { AuthorSection } from './sections/AuthorSection';
import { ThankYouScreen } from './ThankYouScreen';
@@ -16,10 +25,6 @@ export const FeedbackForm: React.FC = () => {
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>
@@ -34,6 +39,20 @@ export const FeedbackForm: React.FC = () => {
<AppealSection data={data} errors={errors} set={set} />
</CollapseSection>
{/* Спрашиваем в самом конце и только у именных обращений: у анонимного
ответа всё равно не будет, вопрос был бы бессмысленным. */}
{!data.isAnonymous ? (
<FormGrid>
<Field label={TEXTS.labels.wantsFeedback} full>
<Toggle
checked={data.wantsFeedback}
onChange={(v) => set('wantsFeedback', v)}
onLabel="Да"
/>
</Field>
</FormGrid>
) : null}
<FormActions>
<Button variant="ghost" type="button" onClick={reset}>
{TEXTS.actions.reset}
+40 -3
View File
@@ -19,11 +19,24 @@ export interface IFeedbackData {
}
/**
* Список компаний. ПРЕДПОЛОЖЕНИЕ: в исходной форме была видна только
* «ТОО АСТЕЛ-К»; остальные значения нужно уточнить у заказчика формы.
* Компании группы. В value — реальный Id из справочника, а не slug: именно его
* ждёт бэкенд, когда появится отправка. Порядок как в источнике (по алфавиту).
*/
export const COMPANY_OPTIONS: ISelectOption[] = [
{ value: 'astel-k', label: 'ТОО АСТЕЛ-К' }
{ value: '8028abac-ac23-8fd8-11ef-cb57631a9ab0', label: 'ТОО AFL Engineering' },
{ value: '9de064a4-44c8-88ef-11ec-831c058b1141', label: 'ТОО CL MANAGEMENT' },
{ value: '9de064a4-44c8-88ef-11ec-8287fb7737a2', label: 'ТОО KazPromService' },
{ value: 'ec3c78a7-29ef-4725-11ed-a6d8fcd0911d', label: 'ТОО QAZGEOMAX' },
{ value: '15009595-005d-00fa-11ef-11309f5e395d', label: 'ТОО Selin Invest' },
{ value: '03f4deac-f543-e8cf-11f1-84eb25329fc3', label: 'ТОО Selin Studio' },
{ value: '9de064a4-44c8-88ef-11ec-8284930fad96', label: 'ТОО TANSU Construction' },
{ value: '9de064a4-44c8-88ef-11ec-8285925d83d2', label: 'ТОО АСТЕЛ-К' },
{ value: '15009595-005d-00fa-11ef-0b8bb5ec1a88', label: 'ТОО ГеоТрек' },
{ value: '15009595-005d-00fa-11ef-0b8714483372', label: 'ТОО Монтажник-Т' },
{ value: '8028beac-ac23-8fd8-11f0-5351706e5996', label: 'ТОО ОСН Беркут' },
{ value: '8028b0ac-ac23-8fd8-11ef-dcdb24b82e02', label: 'ТОО СТ Global' },
{ value: '15009595-005d-00fa-11ef-1131801d9f3e', label: 'ТОО ТехЛаб' },
{ value: '03f4deac-f543-e8cf-11f1-84f51483f59e', label: 'Частная компания Seline Holding Ltd' }
];
export const KIND_OPTIONS: ISelectOption[] = [
@@ -32,6 +45,30 @@ export const KIND_OPTIONS: ISelectOption[] = [
{ value: 'other', label: 'Другое' }
];
/** Какие текстовые поля показываем для выбранного вида обращения. */
export interface IVisibleAppealFields {
situation: boolean;
suggestion: boolean;
}
/**
* Состав полей зависит от вида обращения:
* жалоба — что случилось и что предлагаете изменить;
* предложение — только само предложение, «ситуации» ещё нет;
* другое — свободное описание, без предложения.
* Одно правило на форму и на валидацию, чтобы они не разошлись.
*/
export function getVisibleAppealFields(kind: AppealKind): IVisibleAppealFields {
switch (kind) {
case 'complaint':
return { situation: true, suggestion: true };
case 'suggestion':
return { situation: false, suggestion: true };
case 'other':
return { situation: true, suggestion: false };
}
}
export const EMPTY_FEEDBACK: IFeedbackData = {
company: '',
isAnonymous: false,
@@ -1,70 +1,81 @@
import { FormGrid, Select, TextArea, TextInput } from '@tnsu/ui-kit';
import { Field } from '../../../components/Field/Field';
import { KIND_OPTIONS, type AppealKind } from '../model';
import { getVisibleAppealFields, 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>
/** Суть обращения: вид и зависящие от него текстовые поля. */
export const AppealSection: React.FC<ISectionProps> = ({ data, errors, set }) => {
const visible = getVisibleAppealFields(data.kind);
const isOther = data.kind === 'other';
{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}
return (
<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>
) : 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>
{isOther ? (
<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.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>
);
{visible.situation ? (
<Field
label={isOther ? TEXTS.labels.situationOther : TEXTS.labels.situation}
htmlFor="situation"
required
full
error={errors.situation}
>
<TextArea
id="situation"
value={data.situation}
onChange={(v) => set('situation', v)}
placeholder={
isOther ? TEXTS.placeholders.situationOther : TEXTS.placeholders.situation
}
rows={SITUATION_ROWS}
/>
</Field>
) : null}
{visible.suggestion ? (
<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>
) : null}
</FormGrid>
);
};
@@ -56,14 +56,6 @@ export const AuthorSection: React.FC<ISectionProps> = ({ data, errors, set }) =>
placeholder={TEXTS.placeholders.position}
/>
</Field>
<Field label={TEXTS.labels.wantsFeedback} full>
<Toggle
checked={data.wantsFeedback}
onChange={(v) => set('wantsFeedback', v)}
onLabel="Да"
/>
</Field>
</>
) : null}
</FormGrid>
+3 -3
View File
@@ -8,9 +8,6 @@ export const TEXTS = {
formTitle: 'Обращение в HR департамент',
privacyNotice:
'Форма не собирает автоматически ваше имя и адрес электронной почты — только то, что вы укажете сами.',
anonymousHint:
'Анонимное обращение: мы не сможем дать вам обратную связь. Именные обращения обрабатываются в течение 3–5 рабочих дней, если вопрос не требует большего срока.',
@@ -23,6 +20,8 @@ export const TEXTS = {
kind: 'Вид обращения',
kindOther: 'Уточните вид обращения',
situation: 'Описание ситуации',
/** Для вида «Другое» речь не о ситуации, а о произвольном описании. */
situationOther: 'Описание',
suggestion: 'Предложение по улучшению'
},
@@ -32,6 +31,7 @@ export const TEXTS = {
position: 'Например: инженер-проектировщик',
kindOther: 'Свой вариант',
situation: 'Опишите, что произошло: когда, где, кто участвовал',
situationOther: 'Опишите суть обращения',
suggestion: 'Что, по вашему мнению, стоит изменить'
},
+17 -7
View File
@@ -1,4 +1,4 @@
import type { FeedbackErrors, IFeedbackData } from './model';
import { getVisibleAppealFields, type FeedbackErrors, type IFeedbackData } from './model';
import { TEXTS } from './texts';
/** Ниже этой длины текст не считаем содержательным описанием. */
@@ -26,14 +26,24 @@ export function validateFeedback(data: IFeedbackData): FeedbackErrors {
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;
// Спрашиваем только то, что реально показано для выбранного вида обращения:
// иначе форма ругалась бы на скрытое поле, и пользователь не понял бы, где ошибка.
const visible = getVisibleAppealFields(data.kind);
if (visible.situation) {
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;
if (visible.suggestion) {
if (!data.suggestion.trim()) {
errors.suggestion = TEXTS.errors.required;
} else if (data.suggestion.trim().length < MIN_TEXT_LENGTH) {
errors.suggestion = TEXTS.errors.tooShort;
}
}
return errors;