appeals
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="theme-color" content="#1B3A5C" />
|
||||||
<title>Обращение в HR</title>
|
<title>Обращение в HR</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Обращения в HR">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0A1B30"/>
|
||||||
|
<stop offset="1" stop-color="#1B3A5C"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#E5B635"/>
|
||||||
|
<stop offset="1" stop-color="#D4A01E"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="64" height="64" rx="14" fill="url(#bg)"/>
|
||||||
|
|
||||||
|
<!-- Реплика диалога: обращение сотрудника. -->
|
||||||
|
<path
|
||||||
|
fill="url(#accent)"
|
||||||
|
d="M18 16h28a6 6 0 0 1 6 6v16a6 6 0 0 1-6 6H30l-9 8v-8h-3a6 6 0 0 1-6-6V22a6 6 0 0 1 6-6z"/>
|
||||||
|
|
||||||
|
<!-- Строки текста внутри реплики. -->
|
||||||
|
<rect x="21" y="25" width="22" height="3.4" rx="1.7" fill="#0A1B30" opacity=".85"/>
|
||||||
|
<rect x="21" y="32" width="15" height="3.4" rx="1.7" fill="#0A1B30" opacity=".85"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 992 B |
@@ -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 { AppealSection } from './sections/AppealSection';
|
||||||
import { AuthorSection } from './sections/AuthorSection';
|
import { AuthorSection } from './sections/AuthorSection';
|
||||||
import { ThankYouScreen } from './ThankYouScreen';
|
import { ThankYouScreen } from './ThankYouScreen';
|
||||||
@@ -16,10 +25,6 @@ export const FeedbackForm: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FormCard title={TEXTS.formTitle} subtitle={TEXTS.pageSubtitle}>
|
<FormCard title={TEXTS.formTitle} subtitle={TEXTS.pageSubtitle}>
|
||||||
<div className={styles.notice}>
|
|
||||||
<Alert variant="warn">{TEXTS.privacyNotice}</Alert>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formError ? (
|
{formError ? (
|
||||||
<div className={styles.notice}>
|
<div className={styles.notice}>
|
||||||
<Alert variant="err">{formError}</Alert>
|
<Alert variant="err">{formError}</Alert>
|
||||||
@@ -34,6 +39,20 @@ export const FeedbackForm: React.FC = () => {
|
|||||||
<AppealSection data={data} errors={errors} set={set} />
|
<AppealSection data={data} errors={errors} set={set} />
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
|
|
||||||
|
{/* Спрашиваем в самом конце и только у именных обращений: у анонимного
|
||||||
|
ответа всё равно не будет, вопрос был бы бессмысленным. */}
|
||||||
|
{!data.isAnonymous ? (
|
||||||
|
<FormGrid>
|
||||||
|
<Field label={TEXTS.labels.wantsFeedback} full>
|
||||||
|
<Toggle
|
||||||
|
checked={data.wantsFeedback}
|
||||||
|
onChange={(v) => set('wantsFeedback', v)}
|
||||||
|
onLabel="Да"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<FormActions>
|
<FormActions>
|
||||||
<Button variant="ghost" type="button" onClick={reset}>
|
<Button variant="ghost" type="button" onClick={reset}>
|
||||||
{TEXTS.actions.reset}
|
{TEXTS.actions.reset}
|
||||||
|
|||||||
@@ -19,11 +19,24 @@ export interface IFeedbackData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Список компаний. ПРЕДПОЛОЖЕНИЕ: в исходной форме была видна только
|
* Компании группы. В value — реальный Id из справочника, а не slug: именно его
|
||||||
* «ТОО АСТЕЛ-К»; остальные значения нужно уточнить у заказчика формы.
|
* ждёт бэкенд, когда появится отправка. Порядок как в источнике (по алфавиту).
|
||||||
*/
|
*/
|
||||||
export const COMPANY_OPTIONS: ISelectOption[] = [
|
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[] = [
|
export const KIND_OPTIONS: ISelectOption[] = [
|
||||||
@@ -32,6 +45,30 @@ export const KIND_OPTIONS: ISelectOption[] = [
|
|||||||
{ value: 'other', label: 'Другое' }
|
{ 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 = {
|
export const EMPTY_FEEDBACK: IFeedbackData = {
|
||||||
company: '',
|
company: '',
|
||||||
isAnonymous: false,
|
isAnonymous: false,
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { FormGrid, Select, TextArea, TextInput } from '@tnsu/ui-kit';
|
import { FormGrid, Select, TextArea, TextInput } from '@tnsu/ui-kit';
|
||||||
import { Field } from '../../../components/Field/Field';
|
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 { TEXTS } from '../texts';
|
||||||
import type { ISectionProps } from './ISectionProps';
|
import type { ISectionProps } from './ISectionProps';
|
||||||
|
|
||||||
const SITUATION_ROWS = 5;
|
const SITUATION_ROWS = 5;
|
||||||
const SUGGESTION_ROWS = 4;
|
const SUGGESTION_ROWS = 4;
|
||||||
|
|
||||||
/** Суть обращения: вид, описание ситуации и предложение по улучшению. */
|
/** Суть обращения: вид и зависящие от него текстовые поля. */
|
||||||
export const AppealSection: React.FC<ISectionProps> = ({ data, errors, set }) => (
|
export const AppealSection: React.FC<ISectionProps> = ({ data, errors, set }) => {
|
||||||
|
const visible = getVisibleAppealFields(data.kind);
|
||||||
|
const isOther = data.kind === 'other';
|
||||||
|
|
||||||
|
return (
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label={TEXTS.labels.kind} htmlFor="kind" required error={errors.kind}>
|
<Field label={TEXTS.labels.kind} htmlFor="kind" required error={errors.kind}>
|
||||||
<Select
|
<Select
|
||||||
@@ -19,7 +23,7 @@ export const AppealSection: React.FC<ISectionProps> = ({ data, errors, set }) =>
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{data.kind === 'other' ? (
|
{isOther ? (
|
||||||
<Field
|
<Field
|
||||||
label={TEXTS.labels.kindOther}
|
label={TEXTS.labels.kindOther}
|
||||||
htmlFor="kindOther"
|
htmlFor="kindOther"
|
||||||
@@ -35,8 +39,9 @@ export const AppealSection: React.FC<ISectionProps> = ({ data, errors, set }) =>
|
|||||||
</Field>
|
</Field>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{visible.situation ? (
|
||||||
<Field
|
<Field
|
||||||
label={TEXTS.labels.situation}
|
label={isOther ? TEXTS.labels.situationOther : TEXTS.labels.situation}
|
||||||
htmlFor="situation"
|
htmlFor="situation"
|
||||||
required
|
required
|
||||||
full
|
full
|
||||||
@@ -46,11 +51,15 @@ export const AppealSection: React.FC<ISectionProps> = ({ data, errors, set }) =>
|
|||||||
id="situation"
|
id="situation"
|
||||||
value={data.situation}
|
value={data.situation}
|
||||||
onChange={(v) => set('situation', v)}
|
onChange={(v) => set('situation', v)}
|
||||||
placeholder={TEXTS.placeholders.situation}
|
placeholder={
|
||||||
|
isOther ? TEXTS.placeholders.situationOther : TEXTS.placeholders.situation
|
||||||
|
}
|
||||||
rows={SITUATION_ROWS}
|
rows={SITUATION_ROWS}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{visible.suggestion ? (
|
||||||
<Field
|
<Field
|
||||||
label={TEXTS.labels.suggestion}
|
label={TEXTS.labels.suggestion}
|
||||||
htmlFor="suggestion"
|
htmlFor="suggestion"
|
||||||
@@ -66,5 +75,7 @@ export const AppealSection: React.FC<ISectionProps> = ({ data, errors, set }) =>
|
|||||||
rows={SUGGESTION_ROWS}
|
rows={SUGGESTION_ROWS}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
) : null}
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -56,14 +56,6 @@ export const AuthorSection: React.FC<ISectionProps> = ({ data, errors, set }) =>
|
|||||||
placeholder={TEXTS.placeholders.position}
|
placeholder={TEXTS.placeholders.position}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label={TEXTS.labels.wantsFeedback} full>
|
|
||||||
<Toggle
|
|
||||||
checked={data.wantsFeedback}
|
|
||||||
onChange={(v) => set('wantsFeedback', v)}
|
|
||||||
onLabel="Да"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ export const TEXTS = {
|
|||||||
|
|
||||||
formTitle: 'Обращение в HR департамент',
|
formTitle: 'Обращение в HR департамент',
|
||||||
|
|
||||||
privacyNotice:
|
|
||||||
'Форма не собирает автоматически ваше имя и адрес электронной почты — только то, что вы укажете сами.',
|
|
||||||
|
|
||||||
anonymousHint:
|
anonymousHint:
|
||||||
'Анонимное обращение: мы не сможем дать вам обратную связь. Именные обращения обрабатываются в течение 3–5 рабочих дней, если вопрос не требует большего срока.',
|
'Анонимное обращение: мы не сможем дать вам обратную связь. Именные обращения обрабатываются в течение 3–5 рабочих дней, если вопрос не требует большего срока.',
|
||||||
|
|
||||||
@@ -23,6 +20,8 @@ export const TEXTS = {
|
|||||||
kind: 'Вид обращения',
|
kind: 'Вид обращения',
|
||||||
kindOther: 'Уточните вид обращения',
|
kindOther: 'Уточните вид обращения',
|
||||||
situation: 'Описание ситуации',
|
situation: 'Описание ситуации',
|
||||||
|
/** Для вида «Другое» речь не о ситуации, а о произвольном описании. */
|
||||||
|
situationOther: 'Описание',
|
||||||
suggestion: 'Предложение по улучшению'
|
suggestion: 'Предложение по улучшению'
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -32,6 +31,7 @@ export const TEXTS = {
|
|||||||
position: 'Например: инженер-проектировщик',
|
position: 'Например: инженер-проектировщик',
|
||||||
kindOther: 'Свой вариант',
|
kindOther: 'Свой вариант',
|
||||||
situation: 'Опишите, что произошло: когда, где, кто участвовал',
|
situation: 'Опишите, что произошло: когда, где, кто участвовал',
|
||||||
|
situationOther: 'Опишите суть обращения',
|
||||||
suggestion: 'Что, по вашему мнению, стоит изменить'
|
suggestion: 'Что, по вашему мнению, стоит изменить'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FeedbackErrors, IFeedbackData } from './model';
|
import { getVisibleAppealFields, type FeedbackErrors, type IFeedbackData } from './model';
|
||||||
import { TEXTS } from './texts';
|
import { TEXTS } from './texts';
|
||||||
|
|
||||||
/** Ниже этой длины текст не считаем содержательным описанием. */
|
/** Ниже этой длины текст не считаем содержательным описанием. */
|
||||||
@@ -26,14 +26,24 @@ export function validateFeedback(data: IFeedbackData): FeedbackErrors {
|
|||||||
errors.kindOther = TEXTS.errors.required;
|
errors.kindOther = TEXTS.errors.required;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Спрашиваем только то, что реально показано для выбранного вида обращения:
|
||||||
|
// иначе форма ругалась бы на скрытое поле, и пользователь не понял бы, где ошибка.
|
||||||
|
const visible = getVisibleAppealFields(data.kind);
|
||||||
|
|
||||||
|
if (visible.situation) {
|
||||||
if (!data.situation.trim()) {
|
if (!data.situation.trim()) {
|
||||||
errors.situation = TEXTS.errors.required;
|
errors.situation = TEXTS.errors.required;
|
||||||
} else if (data.situation.trim().length < MIN_TEXT_LENGTH) {
|
} else if (data.situation.trim().length < MIN_TEXT_LENGTH) {
|
||||||
errors.situation = TEXTS.errors.tooShort;
|
errors.situation = TEXTS.errors.tooShort;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visible.suggestion) {
|
||||||
if (!data.suggestion.trim()) {
|
if (!data.suggestion.trim()) {
|
||||||
errors.suggestion = TEXTS.errors.required;
|
errors.suggestion = TEXTS.errors.required;
|
||||||
|
} else if (data.suggestion.trim().length < MIN_TEXT_LENGTH) {
|
||||||
|
errors.suggestion = TEXTS.errors.tooShort;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
|
|||||||
Reference in New Issue
Block a user