4 Commits

Author SHA1 Message Date
Тимур Городецкий 984eb65f5b feat: поле «Время работы» в заявке на приём
Tests / frontend-test (push) Successful in 2m6s
Пункт 4.5 — тот же выбор, что 5.6 в заявке на подбор, с «Другое» и ручным
вводом; подставляется из связанного подбора и обязателен для отправки.
Колонка сайта WorkHours общая с подбором (тот же тип, заголовок и варианты),
в список приёма её подключает провизионинг — до деплоя, иначе заявка не
сохранится. «Сдельная оплата труда» сдвинута на 4.6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 15:59:32 +05:00
Тимур Городецкий b3c46f32d5 feat: подача заявок на отпуск открыта для всех
Оплачиваемый отпуск и отпуск без сохранения з/п добавлены в UNFROZEN_FORMS:
сохранять черновик и отправлять их теперь могут все, а не только группа
«Тестирование». Формы начали передавать formId в SubmitActions — без него
точечное снятие заморозки не срабатывало.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 15:47:04 +05:00
Тимур Городецкий 739f247268 feat: подсветка незаполненных полей в заявке в отдел 1С
Проверки обязательных полей вынесены в validation.ts и показываются под
полями через FieldIssuesProvider, как в приёме, подборе и отпуске: форма
прокручивается к первому полю, над кнопками — кликабельный список.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 15:44:36 +05:00
Тимур Городецкий 4f0d02fe59 feat: в заявке 1С дата запрета по АТП оформляется только на базу АТП
При виде работ «Дата запрета (открытие/закрытие)» и выбранной базе АТП
остальные базы снимаются и добавить их нельзя — дата запрета по АТП
открывается/закрывается отдельно. Над полем «База 1С» при этом виде работ
показывается предупреждение.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 15:44:35 +05:00
18 changed files with 185 additions and 32 deletions
@@ -17,6 +17,8 @@
(`CompanyPickerMulti`), хранится JSON-массивом `{Id,Name}` в Note-колонке `CompaniesJson`. (`CompanyPickerMulti`), хранится JSON-массивом `{Id,Name}` в Note-колонке `CompaniesJson`.
- **График работы (WorkSchedule)** — выпадающий список (набор совпадает с «Заявкой - **График работы (WorkSchedule)** — выпадающий список (набор совпадает с «Заявкой
на подбор»); при выборе «Другое» появляется ручной ввод `WorkScheduleOther`. на подбор»); при выборе «Другое» появляется ручной ввод `WorkScheduleOther`.
- **Время работы (WorkHours)** — так же, как график: набор и колонка сайта общие
с «Заявкой на подбор», оттуда подставляется; «Другое» → `WorkHoursOther`.
- **Испытательный срок (ProbationPeriod)** — выпадающий список частых сроков. - **Испытательный срок (ProbationPeriod)** — выпадающий список частых сроков.
- **Рекрутинговое агентство (условный блок, последняя секция)** — `Toggle` - **Рекрутинговое агентство (условный блок, последняя секция)** — `Toggle`
`RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`) `RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`)
@@ -25,6 +25,7 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
'HireCandidateName', 'HireHireDate', 'Position', 'HireCandidateName', 'HireHireDate', 'Position',
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther', 'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther',
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther', 'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
'WorkHours', 'WorkHoursOther',
'AgencyName', 'AgencyTerms', 'HireSecurityNote', 'HireApproverNote', 'AgencyName', 'AgencyTerms', 'HireSecurityNote', 'HireApproverNote',
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode' 'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
]; ];
@@ -75,6 +75,12 @@ export interface IFormData {
SalaryAfterProbation: string; SalaryAfterProbation: string;
WorkSchedule: string; WorkSchedule: string;
WorkScheduleOther: string; WorkScheduleOther: string;
/**
* Время работы — та же колонка сайта WorkHours (Choice), что в заявке на подбор:
* оттуда и подставляется (см. selectionPrefill).
*/
WorkHours: string;
WorkHoursOther: string;
/** /**
* Сдельная оплата труда — информационная отметка, на обязательность окладов не * Сдельная оплата труда — информационная отметка, на обязательность окладов не
* влияет. Та же колонка сайта PieceworkPay (Boolean), что в заявке на подбор: * влияет. Та же колонка сайта PieceworkPay (Boolean), что в заявке на подбор:
@@ -132,6 +138,8 @@ export const EMPTY_DATA: IFormData = {
SalaryAfterProbation: '', SalaryAfterProbation: '',
WorkSchedule: '', WorkSchedule: '',
WorkScheduleOther: '', WorkScheduleOther: '',
WorkHours: '',
WorkHoursOther: '',
PieceworkPay: false, PieceworkPay: false,
RecruitingAgency: false, RecruitingAgency: false,
AgencyName: '', AgencyName: '',
@@ -201,5 +209,19 @@ export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = [
{ value: OTHER_VALUE, label: OTHER_VALUE } { value: OTHER_VALUE, label: OTHER_VALUE }
]; ];
/**
* Варианты «Время работы» (24-часовой формат) — совпадают с набором в форме
* «Заявка на подбор» (WORK_HOURS_OPTIONS) и с choices колонки WorkHours:
* формы независимы, значения продублированы намеренно.
*/
export const WORK_HOURS_OPTIONS: ISelectOption[] = [
{ value: '08:00-17:00', label: '08:00-17:00' },
{ value: '09:00-18:00', label: '09:00-18:00' },
{ value: '07:00-19:00', label: '07:00-19:00' },
{ value: '08:00-20:00', label: '08:00-20:00' },
{ value: '09:00-21:00', label: '09:00-21:00' },
{ value: OTHER_VALUE, label: OTHER_VALUE }
];
/** Плейсхолдер для полей-людей. */ /** Плейсхолдер для полей-людей. */
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…'; export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
@@ -41,6 +41,8 @@
{ "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 }, { "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 },
{ "internalName": "WorkSchedule", "title": "График работы", "type": "Text" }, { "internalName": "WorkSchedule", "title": "График работы", "type": "Text" },
{ "internalName": "WorkScheduleOther", "title": "График работы (другое)", "type": "Text" }, { "internalName": "WorkScheduleOther", "title": "График работы (другое)", "type": "Text" },
{ "internalName": "WorkHours", "title": "Время работы", "type": "Choice", "choices": ["08:00-17:00", "09:00-18:00", "07:00-19:00", "08:00-20:00", "09:00-21:00", "Другое"] },
{ "internalName": "WorkHoursOther", "title": "Время работы (другое)", "type": "Text" },
{ "internalName": "PieceworkPay", "title": "Сдельная оплата труда", "type": "Boolean" }, { "internalName": "PieceworkPay", "title": "Сдельная оплата труда", "type": "Boolean" },
{ "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" }, { "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" },
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, MoneyInput, Select, Toggle } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, MoneyInput, Select, Toggle } from '../../../shared/ui';
import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model'; import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, WORK_HOURS_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
interface IConditionsSectionProps extends ISectionProps { interface IConditionsSectionProps extends ISectionProps {
/** Начальник расчётной группы правит оклады даже в режиме просмотра (см. approverRights.ts). */ /** Начальник расчётной группы правит оклады даже в режиме просмотра (см. approverRights.ts). */
@@ -67,8 +67,29 @@ export const ConditionsSection: React.FC<IConditionsSectionProps> = ({ data, set
/> />
</FormField> </FormField>
)} )}
<FormField label="4.5 Время работы" htmlFor="WorkHours" required>
<Select
id="WorkHours"
value={data.WorkHours}
onChange={(v) => {
set('WorkHours', v);
if (v !== OTHER_VALUE) set('WorkHoursOther', '');
}}
options={WORK_HOURS_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
{data.WorkHours === OTHER_VALUE && (
<FormField label="Время работы (другое)" htmlFor="WorkHoursOther">
<TextInput
id="WorkHoursOther"
value={data.WorkHoursOther}
onChange={(v) => set('WorkHoursOther', v)}
/>
</FormField>
)}
{/* Просто отметка: обязательность окладов (4.2, 4.3) от неё не зависит. */} {/* Просто отметка: обязательность окладов (4.2, 4.3) от неё не зависит. */}
<FormField label="4.5 Сдельная оплата труда" fieldKey="PieceworkPay"> <FormField label="4.6 Сдельная оплата труда" fieldKey="PieceworkPay">
<Toggle checked={data.PieceworkPay} onChange={(v) => set('PieceworkPay', v)} onLabel="Да" /> <Toggle checked={data.PieceworkPay} onChange={(v) => set('PieceworkPay', v)} onLabel="Да" />
</FormField> </FormField>
</CollapseSection> </CollapseSection>
@@ -34,6 +34,8 @@ export function selectionPrefill(link: ISelectionLink, selection: ISelectionData
Department: selection.Department, Department: selection.Department,
WorkSchedule: selection.WorkSchedule, WorkSchedule: selection.WorkSchedule,
WorkScheduleOther: selection.WorkScheduleOther, WorkScheduleOther: selection.WorkScheduleOther,
WorkHours: selection.WorkHours,
WorkHoursOther: selection.WorkHoursOther,
ProbationPeriod: selection.SelectionProbationPeriod, ProbationPeriod: selection.SelectionProbationPeriod,
ProbationPeriodOther: selection.SelectionProbationPeriodOther, ProbationPeriodOther: selection.SelectionProbationPeriodOther,
SalaryProbation: selection.RateProbation, SalaryProbation: selection.RateProbation,
@@ -25,7 +25,8 @@ describe('RecruitingHire validation', () => {
'4.1 Испытательный срок', '4.1 Испытательный срок',
'4.2 Оклад при испытательном сроке', '4.2 Оклад при испытательном сроке',
'4.3 Оклад по завершении ИС', '4.3 Оклад по завершении ИС',
'4.4 График работы' '4.4 График работы',
'4.5 Время работы'
]); ]);
}); });
@@ -49,7 +49,8 @@ const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
{ field: 'ProbationPeriod', label: '4.1 Испытательный срок', ok: (d) => !!d.ProbationPeriod }, { field: 'ProbationPeriod', label: '4.1 Испытательный срок', ok: (d) => !!d.ProbationPeriod },
{ field: 'SalaryProbation', label: '4.2 Оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() }, { field: 'SalaryProbation', label: '4.2 Оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() },
{ field: 'SalaryAfterProbation', label: '4.3 Оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() }, { field: 'SalaryAfterProbation', label: '4.3 Оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() },
{ field: 'WorkSchedule', label: '4.4 График работы', ok: (d) => !!d.WorkSchedule } { field: 'WorkSchedule', label: '4.4 График работы', ok: (d) => !!d.WorkSchedule },
{ field: 'WorkHours', label: '4.5 Время работы', ok: (d) => !!d.WorkHours }
]; ];
function toIssue({ field, label, message }: IRequiredRule): IFieldIssue { function toIssue({ field, label, message }: IRequiredRule): IFieldIssue {
@@ -3,6 +3,8 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
import styles from './Ticket1CForm.module.scss'; import styles from './Ticket1CForm.module.scss';
import { import {
Alert, Alert,
FieldIssuesProvider,
issueLabels,
FormCard, FormCard,
InitiatorLine, InitiatorLine,
FormModeProvider, FormModeProvider,
@@ -49,7 +51,8 @@ import {
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, hasOtherDatabase } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { formIssues } from './validation';
import { saveTicketRequest, ticket1cApprovalService } from './submit'; import { saveTicketRequest, ticket1cApprovalService } from './submit';
import { loadTicketRequest } from './load'; import { loadTicketRequest } from './load';
import { getApplicantProfile } from './services/profile.service'; import { getApplicantProfile } from './services/profile.service';
@@ -240,20 +243,11 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
); );
const matrixEditor = useMemo(() => ({ ...matrix, rows: matrixRows }), [matrix, matrixRows]); const matrixEditor = useMemo(() => ({ ...matrix, rows: matrixRows }), [matrix, matrixRows]);
const isOtherDatabaseMissing = hasOtherDatabase(data) && !data.DB1COther.trim(); // Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
const areRequiredFilled = !!( const issues = formIssues(data);
data.TaskName.trim() && const isValid = issues.length === 0;
data.WorkType &&
data.Organization &&
data.DB1C.length > 0 &&
data.Question.trim()
);
const isValid = areRequiredFilled && !isOtherDatabaseMissing;
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (!areRequiredFilled) { if (!isValid) return `Заполните или исправьте поля: ${issueLabels(issues)}.`;
return 'Заполните обязательные поля: наименование задачи, вид работ, организация, база 1С, вопрос.';
}
if (isOtherDatabaseMissing) return 'Выбрана база 1С «Другое» — укажите её название.';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined; return undefined;
@@ -391,7 +385,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
}); });
return ( return (
<div> <FieldIssuesProvider draftIssues={issues} submitIssues={issues}>
<button type="button" className={styles.back} onClick={onCancel}> <button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" /> <Icon name="arrow-left" size="sm" />
<span>К списку заявок</span> <span>К списку заявок</span>
@@ -486,7 +480,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
submitLabel="Отправить" submitLabel="Отправить"
/> />
</FormCard> </FormCard>
</div> </FieldIssuesProvider>
); );
}; };
@@ -10,6 +10,13 @@ export const CHOICES_LOADING_PLACEHOLDER = 'Загрузка вариантов
export const CHOICES_EMPTY_PLACEHOLDER = 'Нет вариантов — обратитесь к администратору'; export const CHOICES_EMPTY_PLACEHOLDER = 'Нет вариантов — обратитесь к администратору';
export const CHOICES_ERROR_PREFIX = 'Не удалось загрузить варианты полей'; export const CHOICES_ERROR_PREFIX = 'Не удалось загрузить варианты полей';
// Подсказка в поле «База 1С», когда сработало правило «дата запрета по АТП».
export const DB1C_ATP_ONLY_HINT = 'Для даты запрета по АТП другие базы не выбираются';
// Предупреждение над полем «База 1С» при виде работ «Дата запрета».
export const DB1C_ATP_ONLY_WARNING =
'Для даты запрета (открытие/закрытие) по базе АТП можно выбрать только АТП — ' +
'дополнительные базы недоступны.';
// Значения из property pane старой web part (см. живую страницу SharePoint). // Значения из property pane старой web part (см. живую страницу SharePoint).
export const PLANNER_PLAN_ID = '168WqDV8CEKkW3cL6olCRZgACwYa'; export const PLANNER_PLAN_ID = '168WqDV8CEKkW3cL6olCRZgACwYa';
export const PLANNER_BUCKET_NAME = 'Входящие неотсортированные'; export const PLANNER_BUCKET_NAME = 'Входящие неотсортированные';
@@ -0,0 +1,17 @@
import { DB1C_ATP_OPTION, WORK_TYPE_BAN_DATE, restrictDatabases } from './model';
const ZUP = '1С: Зарплата и управление персоналом (ЗУП)';
describe('Ticket1C restrictDatabases', () => {
it('дата запрета + АТП — остаётся только АТП', () => {
expect(restrictDatabases(WORK_TYPE_BAN_DATE, [ZUP, DB1C_ATP_OPTION])).toEqual([DB1C_ATP_OPTION]);
});
it('дата запрета без АТП — базы не трогаем', () => {
expect(restrictDatabases(WORK_TYPE_BAN_DATE, [ZUP])).toEqual([ZUP]);
});
it('другой вид работ — АТП можно сочетать с другими базами', () => {
expect(restrictDatabases('Прочее', [ZUP, DB1C_ATP_OPTION])).toEqual([ZUP, DB1C_ATP_OPTION]);
});
});
@@ -44,6 +44,27 @@ export function hasOtherDatabase(data: IFormData): boolean {
return data.DB1C.indexOf(DB1C_OTHER_OPTION) !== -1; return data.DB1C.indexOf(DB1C_OTHER_OPTION) !== -1;
} }
/**
* Варианты колонок SP, для которых действует правило «дата запрета по АТП —
* только одна база». Должны дословно совпадать с вариантами колонок
* WorkType и DB1C в SharePoint (варианты читаются из SP, см. useFieldChoices).
*/
export const WORK_TYPE_BAN_DATE = 'Дата запрета (открытие/закрытие)';
export const DB1C_ATP_OPTION = '1С: Автотранспортное предприятие (АТП)';
/**
* Дата запрета в АТП открывается/закрывается отдельно от остальных баз,
* поэтому в такой заявке база АТП может быть только одна.
*/
export function isAtpOnlyBanDate(workType: string, databases: string[]): boolean {
return workType === WORK_TYPE_BAN_DATE && databases.indexOf(DB1C_ATP_OPTION) !== -1;
}
/** Применяет правило isAtpOnlyBanDate: при его срабатывании оставляет только АТП. */
export function restrictDatabases(workType: string, databases: string[]): string[] {
return isAtpOnlyBanDate(workType, databases) ? [DB1C_ATP_OPTION] : databases;
}
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void; export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
@@ -0,0 +1,4 @@
// Строка на обе колонки сетки секции — как FormField с full.
.fullRow {
grid-column: 1 / -1;
}
@@ -1,10 +1,21 @@
import * as React from 'react'; import * as React from 'react';
import styles from './RequestSection.module.scss';
import { Alert, CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui'; import { Alert, CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
import { useFieldChoices, IFieldChoicesState } from '../../../shared/fieldChoices'; import { useFieldChoices, IFieldChoicesState } from '../../../shared/fieldChoices';
import { dataListTitle } from '../../../shared/listTitles'; import { dataListTitle } from '../../../shared/listTitles';
import { DB1C_OTHER_OPTION, FORM_ID, ISectionProps, hasOtherDatabase } from '../model'; import {
DB1C_OTHER_OPTION,
FORM_ID,
WORK_TYPE_BAN_DATE,
ISectionProps,
hasOtherDatabase,
isAtpOnlyBanDate,
restrictDatabases
} from '../model';
import { import {
QUESTION_MAX, QUESTION_MAX,
DB1C_ATP_ONLY_HINT,
DB1C_ATP_ONLY_WARNING,
CHOICES_LOADING_PLACEHOLDER, CHOICES_LOADING_PLACEHOLDER,
CHOICES_EMPTY_PLACEHOLDER, CHOICES_EMPTY_PLACEHOLDER,
CHOICES_ERROR_PREFIX CHOICES_ERROR_PREFIX
@@ -30,6 +41,16 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
const organizations = useFieldChoices(listTitle, 'Organization'); const organizations = useFieldChoices(listTitle, 'Organization');
const databases = useFieldChoices(listTitle, 'DB1C'); const databases = useFieldChoices(listTitle, 'DB1C');
const choicesError = workTypes.error || organizations.error || databases.error; const choicesError = workTypes.error || organizations.error || databases.error;
const isAtpOnly = isAtpOnlyBanDate(data.WorkType, data.DB1C);
// Правило «дата запрета по АТП» зависит и от вида работ, и от баз — поэтому
// применяется при смене любого из двух полей.
const setDatabases = (workType: string, values: string[]): void => {
const restricted = restrictDatabases(workType, values);
set('DB1C', restricted);
// Сняли «Другое» — вписанное название больше не относится к заявке.
if (restricted.indexOf(DB1C_OTHER_OPTION) === -1) set('DB1COther', '');
};
return ( return (
<CollapseSection title="Заявка" icon="clipboard"> <CollapseSection title="Заявка" icon="clipboard">
@@ -41,7 +62,10 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
<Select <Select
id="WorkType" id="WorkType"
value={data.WorkType} value={data.WorkType}
onChange={(v) => set('WorkType', v)} onChange={(v) => {
set('WorkType', v);
setDatabases(v, data.DB1C);
}}
options={withCurrentValue(workTypes.options, data.WorkType)} options={withCurrentValue(workTypes.options, data.WorkType)}
placeholder={choicesPlaceholder(workTypes, '— выберите —')} placeholder={choicesPlaceholder(workTypes, '— выберите —')}
/> />
@@ -55,18 +79,22 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
placeholder={choicesPlaceholder(organizations, '— выберите —')} placeholder={choicesPlaceholder(organizations, '— выберите —')}
/> />
</FormField> </FormField>
{/* Предупреждаем заранее, ещё до выбора баз, чтобы правило не стало сюрпризом. */}
{data.WorkType === WORK_TYPE_BAN_DATE && (
<div className={styles.fullRow}>
<Alert variant="warn">{DB1C_ATP_ONLY_WARNING}</Alert>
</div>
)}
{/* На всю ширину: чипы выбранных баз растят поле по высоте, и соседнее {/* На всю ширину: чипы выбранных баз растят поле по высоте, и соседнее
поле в той же строке сетки съезжало бы относительно него. */} поле в той же строке сетки съезжало бы относительно него. */}
<FormField label="База 1С" fieldKey="DB1C" required full> <FormField label="База 1С" fieldKey="DB1C" required full>
<MultiChoiceField <MultiChoiceField
value={data.DB1C} value={data.DB1C}
onChange={(v) => { onChange={(v) => setDatabases(data.WorkType, v)}
set('DB1C', v); // Пустой список вариантов прячет выпадашку — добавить другую базу нельзя,
// Сняли «Другое» — вписанное название больше не относится к заявке. // пока не уберут АТП или не сменят вид работ.
if (v.indexOf(DB1C_OTHER_OPTION) === -1) set('DB1COther', ''); options={isAtpOnly ? [] : databases.options}
}} placeholder={isAtpOnly ? DB1C_ATP_ONLY_HINT : choicesPlaceholder(databases, 'Выберите одну или несколько баз')}
options={databases.options}
placeholder={choicesPlaceholder(databases, 'Выберите одну или несколько баз')}
/> />
</FormField> </FormField>
{hasOtherDatabase(data) && ( {hasOtherDatabase(data) && (
@@ -0,0 +1,25 @@
import type { IFieldIssue } from '../../shared/ui';
import { IFormData, hasOtherDatabase } from './model';
/** Название базы вручную обязательно, только если в «База 1С» отмечено «Другое». */
function otherDatabaseIssue(data: IFormData): IFieldIssue | undefined {
if (!hasOtherDatabase(data) || data.DB1COther.trim()) return undefined;
return { field: 'DB1COther', label: 'Другая база 1С', message: 'выбрано «Другое» — укажите название базы' };
}
/**
* Всё, что мешает сохранить или отправить заявку, в порядке формы (пусто — можно).
* field — ключ FormField (htmlFor/fieldKey): по нему поле подсвечивается и к нему
* прокручивается форма.
*/
export function formIssues(data: IFormData): IFieldIssue[] {
const issues: Array<IFieldIssue | undefined> = [
data.TaskName.trim() ? undefined : { field: 'TaskName', label: 'Наименование задачи' },
data.WorkType ? undefined : { field: 'WorkType', label: 'Виды работ' },
data.Organization ? undefined : { field: 'Organization', label: 'Организация' },
data.DB1C.length > 0 ? undefined : { field: 'DB1C', label: 'База 1С' },
otherDatabaseIssue(data),
data.Question.trim() ? undefined : { field: 'Question', label: 'Вопрос' }
];
return issues.filter((issue): issue is IFieldIssue => issue !== undefined);
}
@@ -366,6 +366,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
isValid={isValid} isValid={isValid}
formId={FORM_ID}
readOnly={readOnly} readOnly={readOnly}
onCancel={onCancel} onCancel={onCancel}
onSaveDraft={handleSaveDraft} onSaveDraft={handleSaveDraft}
@@ -385,6 +385,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
isValid={isValid} isValid={isValid}
formId={FORM_ID}
readOnly={readOnly} readOnly={readOnly}
onCancel={onCancel} onCancel={onCancel}
onSaveDraft={handleSaveDraft} onSaveDraft={handleSaveDraft}
@@ -25,15 +25,18 @@ const SUBMISSION_FROZEN = true;
* Формы, для которых заморозка снята досрочно (для всех пользователей, не * Формы, для которых заморозка снята досрочно (для всех пользователей, не
* только тестировщиков) — id передаётся через проп formId. Заявка 1С * только тестировщиков) — id передаётся через проп formId. Заявка 1С
* разморожена 2026-09-22, формы рекрутинга (на подбор/на приём/реферал) и * разморожена 2026-09-22, формы рекрутинга (на подбор/на приём/реферал) и
* «Организация рабочего места» — 2026-09-24, все по просьбе Тимура * «Организация рабочего места» — 2026-09-24, обе заявки на отпуск (оплачиваемый
* Городецкого; остальные формы остаются замороженными. * и без сохранения з/п) — 2026-10-16, все по просьбе Тимура Городецкого;
* остальные формы остаются замороженными.
*/ */
const UNFROZEN_FORMS = new Set<string>([ const UNFROZEN_FORMS = new Set<string>([
'ticket1c', 'ticket1c',
'recruiting-selection', 'recruiting-selection',
'recruiting-hire', 'recruiting-hire',
'recruiting-referral', 'recruiting-referral',
'it-workplace-setup' 'it-workplace-setup',
'hr-vacation',
'hr-unpaid-leave'
]); ]);
export interface ISubmitActionsProps { export interface ISubmitActionsProps {