feat: подсказка о недопустимой дате увольнения

Если последний рабочий день раньше, чем через месяц от сегодня, под полем
показывается причина и минимальная дата. Раньше кнопки отправки просто
становились неактивными без объяснения — это случалось у заявлений,
пролежавших на доработке: дата, допустимая при подаче, переставала
подходить. Правило и текст вынесены в lastWorkingDay.ts и общие для
подсказки и проверки перед отправкой.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-29 12:42:13 +05:00
parent 7ad5f79cdf
commit 5b1e94bf42
4 changed files with 115 additions and 75 deletions
@@ -28,14 +28,14 @@ import {
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary'; import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
import { isoDatePlusMonths, formatIsoDate } from '../../shared/dateUtils'; import { lastWorkingDayError } from './lastWorkingDay';
import { import {
approvalListTitle, approvalListTitle,
approvalListTitleForListId, approvalListTitleForListId,
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_MONTHS, IOrganizationRecord, primaryPosition } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, IOrganizationRecord, primaryPosition } from './model';
import { saveDismissalRequest, dismissalApprovalService } from './submit'; import { saveDismissalRequest, dismissalApprovalService } from './submit';
import { loadDismissalRequest } from './load'; import { loadDismissalRequest } from './load';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
@@ -197,15 +197,13 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
// Последний рабочий день нужно назначать минимум за MIN_NOTICE_MONTHS месяц — // Последний рабочий день нужно назначать минимум за MIN_NOTICE_MONTHS месяц —
// календарь (min у input type=date, см. RequestSection) не даёт выбрать более // календарь (min у input type=date, см. RequestSection) не даёт выбрать более
// раннюю дату, но проверяем и здесь на случай обхода UI. // раннюю дату, но проверяем и здесь на случай обхода UI. Тот же текст
const minLastWorkingDay = isoDatePlusMonths(MIN_NOTICE_MONTHS); // RequestSection показывает подсказкой под полем (см. lastWorkingDay.ts).
const isLastDayValid = !data.LastWorkingDay || data.LastWorkingDay >= minLastWorkingDay; const lastDayError = lastWorkingDayError(data.LastWorkingDay);
const position = primaryPosition(data.Organization); const position = primaryPosition(data.Organization);
const isValid = !!(data.Employee && position.trim() && data.LastWorkingDay && isLastDayValid && data.Phone.trim()); const isValid = !!(data.Employee && position.trim() && data.LastWorkingDay && !lastDayError && data.Phone.trim());
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (data.LastWorkingDay && !isLastDayValid) { if (lastDayError) return lastDayError;
return `Последний рабочий день нужно назначать минимум за ${MIN_NOTICE_MONTHS} месяц — минимально доступная дата: ${formatIsoDate(minLastWorkingDay)}.`;
}
if (!data.Employee || !position.trim() || !data.LastWorkingDay || !data.Phone.trim()) { if (!data.Employee || !position.trim() || !data.LastWorkingDay || !data.Phone.trim()) {
return 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.'; return 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.';
} }
@@ -0,0 +1,23 @@
import { formatIsoDate, isoDatePlusMonths } from '../../shared/dateUtils';
import { MIN_NOTICE_MONTHS } from './model';
/** Самый ранний допустимый последний рабочий день ('YYYY-MM-DD'): сегодня + MIN_NOTICE_MONTHS. */
export function minLastWorkingDay(): string {
return isoDatePlusMonths(MIN_NOTICE_MONTHS);
}
/**
* Почему последний рабочий день не подходит; undefined — подходит или ещё не
* выбран. Одна функция и для подсказки под полем (RequestSection), и для
* проверки перед отправкой (DismissalForm) — чтобы текст и правило не разошлись.
*
* Правило считается от СЕГОДНЯ: у заявления, пролежавшего на доработке, дата,
* бывшая допустимой при подаче, может перестать подходить — подсказка сразу
* объясняет, почему кнопки отправки неактивны.
*/
export function lastWorkingDayError(value: string): string | undefined {
if (!value) return undefined;
const min = minLastWorkingDay();
if (value >= min) return undefined;
return `Последний рабочий день нужно назначать минимум за ${MIN_NOTICE_MONTHS} месяц — минимально доступная дата: ${formatIsoDate(min)}.`;
}
@@ -0,0 +1,8 @@
@import '../../../../../styles/design-tokens';
// Пояснение под полем, почему значение не подходит (см. lastWorkingDayError).
.fieldError {
margin-top: $sp-1;
font-size: $fs-label;
color: $red-500;
}
@@ -1,8 +1,10 @@
import * as React from 'react'; import * as React from 'react';
import { FormGrid, FormField, TextInput, TextArea, PhoneInput, PeopleField, LinkField } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, TextArea, PhoneInput, PeopleField, LinkField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, MIN_NOTICE_MONTHS } from '../model'; import { useFormReadOnly } from '@tnsu/ui-kit';
import { isoDatePlusMonths } from '../../../shared/dateUtils'; import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
import { lastWorkingDayError, minLastWorkingDay } from '../lastWorkingDay';
import { OrganizationsTable } from './OrganizationsTable'; import { OrganizationsTable } from './OrganizationsTable';
import styles from './RequestSection.module.scss';
interface IRequestSectionProps extends ISectionProps { interface IRequestSectionProps extends ISectionProps {
/** /**
@@ -27,69 +29,78 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
onEmployeeChange, onEmployeeChange,
onRemoveOrganization, onRemoveOrganization,
assetsAcceptorEditable assetsAcceptorEditable
}) => ( }) => {
<FormGrid> // В просмотре (DismissalView) секция только для чтения — подсказка там ни к чему.
<FormField label="Сотрудник" htmlFor="Employee" required full> const readOnly = useFormReadOnly();
<PeopleField const lastDayError = lastWorkingDayError(data.LastWorkingDay);
context={context}
value={data.Employee} return (
onChange={onEmployeeChange ?? ((v) => set('Employee', v))} <FormGrid>
placeholder={EMPLOYEE_PLACEHOLDER} <FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
context={context}
value={data.Employee}
onChange={onEmployeeChange ?? ((v) => set('Employee', v))}
placeholder={EMPLOYEE_PLACEHOLDER}
/>
</FormField>
{/* ЕДИНСТВЕННОЕ место для компании/подразделения/должности — и на форме
подачи (редактируемая, onRemoveOrganization задан), и в просмотре
(read-only, ничего не передано). Отдельных полей для них нет — были бы
дублированием того, что уже показывает эта таблица. */}
<OrganizationsTable
records={data.Organization}
onChangePosition={
onRemoveOrganization &&
((i, v) =>
set(
'Organization',
data.Organization.length > 0
? data.Organization.map((r, ri) => (ri === i ? { ...r, position: v } : r))
: [{ company: '', department: '', position: v }]
))
}
onRemove={onRemoveOrganization}
/> />
</FormField> {/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе,
{/* ЕДИНСТВЕННОЕ место для компании/подразделения/должности — и на форме чем через MIN_NOTICE_MONTHS месяц от сегодня (min). Если дата уже стоит
подачи (редактируемая, onRemoveOrganization задан), и в просмотре раньше (заявление пролежало на доработке) — подсказка под полем, иначе
(read-only, ничего не передано). Отдельных полей для них нет — были бы непонятно, почему кнопки отправки неактивны. */}
дублированием того, что уже показывает эта таблица. */} <FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required>
<OrganizationsTable <TextInput
records={data.Organization} id="LastWorkingDay"
onChangePosition={ type="date"
onRemoveOrganization && value={data.LastWorkingDay}
((i, v) => onChange={(v) => set('LastWorkingDay', v)}
set( min={minLastWorkingDay()}
'Organization', />
data.Organization.length > 0 {!readOnly && lastDayError && <div className={styles.fieldError}>{lastDayError}</div>}
? data.Organization.map((r, ri) => (ri === i ? { ...r, position: v } : r)) </FormField>
: [{ company: '', department: '', position: v }] <FormField label="Контактный телефон" htmlFor="Phone" required>
)) <PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
} </FormField>
onRemove={onRemoveOrganization} <FormField label="Комментарий" htmlFor="Comment" full>
/> <TextArea id="Comment" value={data.Comment} onChange={(v) => set('Comment', v)} />
{/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе, </FormField>
чем через MIN_NOTICE_MONTHS месяц от сегодня (min). */} {/* Появляется, только когда обходной лист уже создан (автоматически после
<FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required> согласования заявления — см. createClearance.ts). */}
<TextInput <LinkField
id="LastWorkingDay" label="Обходной лист"
type="date" url={data.ClearanceUrl}
value={data.LastWorkingDay} text="Открыть обходной лист по этому увольнению"
onChange={(v) => set('LastWorkingDay', v)}
min={isoDatePlusMonths(MIN_NOTICE_MONTHS)}
/> />
</FormField> {/* Заполняет НЕ заявитель, а непосредственный руководитель увольняемого на
<FormField label="Контактный телефон" htmlFor="Phone" required> своём шаге согласования (см. DismissalView) — здесь всегда read-only,
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} /> кроме как для него самого в момент его решения (assetsAcceptorEditable). */}
</FormField> <FormField label="Лицо, принимающее ОС/НМА" htmlFor="AssetsAcceptor" required={assetsAcceptorEditable} full>
<FormField label="Комментарий" htmlFor="Comment" full> <PeopleField
<TextArea id="Comment" value={data.Comment} onChange={(v) => set('Comment', v)} /> context={context}
</FormField> value={data.AssetsAcceptor}
{/* Появляется, только когда обходной лист уже создан (автоматически после onChange={(v) => set('AssetsAcceptor', v)}
согласования заявления — см. createClearance.ts). */} placeholder={assetsAcceptorEditable ? 'Начните вводить Ф.И.О. сотрудника…' : undefined}
<LinkField readOnly={assetsAcceptorEditable ? false : true}
label="Обходной лист" />
url={data.ClearanceUrl} </FormField>
text="Открыть обходной лист по этому увольнению" </FormGrid>
/> );
{/* Заполняет НЕ заявитель, а непосредственный руководитель увольняемого на };
своём шаге согласования (см. DismissalView) — здесь всегда read-only,
кроме как для него самого в момент его решения (assetsAcceptorEditable). */}
<FormField label="Лицо, принимающее ОС/НМА" htmlFor="AssetsAcceptor" required={assetsAcceptorEditable} full>
<PeopleField
context={context}
value={data.AssetsAcceptor}
onChange={(v) => set('AssetsAcceptor', v)}
placeholder={assetsAcceptorEditable ? 'Начните вводить Ф.И.О. сотрудника…' : undefined}
readOnly={assetsAcceptorEditable ? false : true}
/>
</FormField>
</FormGrid>
);