From 0b7729a6b910fa6943ac8fda2852c2112f61d9b1 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 26 Aug 2026 09:39:10 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=B5=D0=BF=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D0=BD=D1=82=20=D0=B2=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D0=B5=20=D0=BE=D1=80=D0=B3=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D0=B9,=20=D0=BC=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=D1=83=D0=BC=20=D0=BC=D0=B5=D1=81=D1=8F=D1=86=20=D0=B4?= =?UTF-8?q?=D0=BE=20=D1=83=D0=B2=D0=BE=D0=BB=D1=8C=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заявление на увольнение: 1. Последний рабочий день — минимум через MIN_NOTICE_MONTHS (1) КАЛЕНДАРНЫЙ месяц от сегодня (новый isoDatePlusMonths в shared/dateUtils.ts, а не приближение через 30 дней — setMonth сам разносит месяцы разной длины). 2. Департамент (Section из Employee/ByEmail) — колонка в OrganizationsTable вместо отдельного профильного поля: источник сменился со SharePoint-профиля (SPS-Department) на тот же справочник api.tnsu.kz, что уже даёт компанию и должность. Заодно убраны отдельные «Должность»/«Структурное подразделение» над таблицей — дублировали то, что таблица и так показывает. Редактируется по-прежнему только должность ПЕРВОЙ (основной) компании — она и есть data.Position, читает её маршрут; должности остальных компаний и департамент — информационные. Пустой справочник (сотрудник не найден) — таблица не исчезает, а показывает одну строку с прочерками и редактируемой должностью, иначе заявление на такого человека нельзя было бы подать. Режим просмотра (DismissalView) не тронут — там по-прежнему простые read-only поля, без live-запроса к справочнику. На тенанте: Title колонки Department синхронизирован под «Департамент» — ТОЛЬКО на списке «Заявление на увольнение», списочным override, а не через общий столбец сайта. Через -UpdateExistingLists один раз случайно затёрло Department и на «Обходном листе», и на «Заявке в 1С» (internalName у них общий, Title разный) — заметил и починил в этой же сессии; см. memory provisioning-shared-site-columns для деталей на будущее. Co-Authored-By: Claude Opus 5 --- .../forms/Dismissal/DismissalForm.tsx | 34 +++---- .../requestPortal/forms/Dismissal/README.md | 43 ++++++--- .../requestPortal/forms/Dismissal/model.ts | 13 ++- .../forms/Dismissal/provisioning.json | 2 +- .../sections/OrganizationsTable.module.scss | 30 +++++- .../Dismissal/sections/OrganizationsTable.tsx | 91 ++++++++++++++----- .../Dismissal/sections/RequestSection.tsx | 75 +++++++-------- .../requestPortal/shared/dateUtils.ts | 16 ++++ 8 files changed, 195 insertions(+), 109 deletions(-) diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx index c19815e..bf64cbd 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx @@ -32,14 +32,14 @@ import { IEmployeeOrganization } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; -import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils'; +import { isoDatePlusMonths, formatIsoDate } from '../../shared/dateUtils'; import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListTitleForListId } from '../../shared/listTitles'; -import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_MONTHS } from './model'; import { saveDismissalRequest, dismissalApprovalService } from './submit'; import { loadDismissalRequest } from './load'; import { RequestSection } from './sections/RequestSection'; @@ -116,9 +116,10 @@ const DismissalForm: React.FC = ({ }, [editId, editListId]); // Новая заявка — по умолчанию на себя: подставляем текущего пользователя, его - // должность и организации (Employee/ByEmail — см. shared/dictionary/ + // должность и департамент (Employee/ByEmail — см. shared/dictionary/ // employeeOrganizations.service.ts; сотрудник может состоять сразу в нескольких - // организациях, должность берём из ПЕРВОЙ записи), подразделение — из профиля. + // организациях, должность/департамент берём из ПЕРВОЙ записи — см. + // OrganizationsTable, там же их можно поправить). useEffect(() => { if (editId) { return; @@ -133,40 +134,31 @@ const DismissalForm: React.FC = ({ setData((prev) => ({ ...prev, Position: prev.Position || records[0]?.position || prev.Position, + Department: prev.Department || records[0]?.section || prev.Department, Organization: formatEmployeeOrganizations(records) })); }) .catch(() => undefined); - approval - .getProfileFields(user.email) - .then((profile) => { - if (!alive) return; - setData((prev) => ({ ...prev, Department: prev.Department || profile.department })); - }) - .catch(() => undefined); return () => { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [editId]); - // Выбор другого сотрудника подтягивает его должность, подразделение и организации. + // Выбор другого сотрудника подтягивает его должность, департамент и организации. const handleEmployeeChange = (login: string): void => { setData((prev) => ({ ...prev, Employee: login, Department: '', Organization: '' })); setOrgRecords([]); if (!login) { return; } - approval - .getProfileFields(login) - .then((profile) => setData((prev) => ({ ...prev, Department: profile.department }))) - .catch(() => undefined); getEmployeeOrganizations(context, login) .then((records) => { setOrgRecords(records); setData((prev) => ({ ...prev, Position: records[0]?.position || prev.Position, + Department: records[0]?.section || prev.Department, Organization: formatEmployeeOrganizations(records) })); }) @@ -222,11 +214,11 @@ const DismissalForm: React.FC = ({ .catch(() => undefined); }; - // Последний рабочий день не может быть в прошлом: заявление подаётся заранее. - // Календарь (min у input type=date, см. RequestSection) не даёт выбрать более + // Последний рабочий день нужно назначать минимум за MIN_NOTICE_MONTHS месяц — + // календарь (min у input type=date, см. RequestSection) не даёт выбрать более // раннюю дату, но проверяем и здесь на случай обхода UI. - const today = isoDatePlusDays(0); - const isLastDayValid = !data.LastWorkingDay || data.LastWorkingDay >= today; + const minLastWorkingDay = isoDatePlusMonths(MIN_NOTICE_MONTHS); + const isLastDayValid = !data.LastWorkingDay || data.LastWorkingDay >= minLastWorkingDay; const isValid = !!( data.Employee && data.Position.trim() && @@ -236,7 +228,7 @@ const DismissalForm: React.FC = ({ ); const validate = (): string | undefined => { if (data.LastWorkingDay && !isLastDayValid) { - return `Последний рабочий день не может быть раньше сегодняшней даты (${formatIsoDate(today)}).`; + return `Последний рабочий день нужно назначать минимум за ${MIN_NOTICE_MONTHS} месяц — минимально доступная дата: ${formatIsoDate(minLastWorkingDay)}.`; } if (!data.Employee || !data.Position.trim() || !data.LastWorkingDay || !data.Phone.trim()) { return 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.'; diff --git a/src/webparts/requestPortal/forms/Dismissal/README.md b/src/webparts/requestPortal/forms/Dismissal/README.md index f09da7f..90cae31 100644 --- a/src/webparts/requestPortal/forms/Dismissal/README.md +++ b/src/webparts/requestPortal/forms/Dismissal/README.md @@ -27,17 +27,31 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app - **По умолчанию заявка на себя**, но подать можно и за другого: «Сотрудник» — People-picker (`Employee`), маршрут резолвится по e-mail **выбранного** сотрудника, reloadKey матрицы включает `data.Employee` и `data.Position`. -- **Автоподстановка при выборе сотрудника:** должность и организации — из справочника - `Employee/ByEmail` (`shared/dictionary`, должность из ПЕРВОЙ записи, чтобы должность - и организация были парой из одной записи); структурное подразделение — из профиля - SharePoint (`SPS-Department` через `approval.getProfileFields`). -- **Структурное подразделение** (`Department`) — read-only, только из профиля: чтобы - не расходиться с профилем (как в «Обходном листе»). **Самостоятельное - подразделение** (`StandaloneUnit`) в профиле не хранится — свободный ввод. -- **Последний рабочий день** (`LastWorkingDay`) — обязателен, не может быть в прошлом - (`min` у `input type=date` + проверка в `validate` на случай обхода UI). - Срок предупреждения об увольнении (обычно месяц) **не** проверяется — договорную - дату согласуют с работодателем, форма её только фиксирует. +- **Автоподстановка при выборе сотрудника:** должность, департамент и организации — + всё из ОДНОГО справочника `Employee/ByEmail` (`shared/dictionary`), должность и + департамент — из ПЕРВОЙ записи (основная компания), чтобы были парой из одной + записи. Совместитель с должностями/департаментами, различающимися по компаниям, — + см. ниже «Организация · подразделение · должность». +- **«Организация · подразделение · должность»** (`OrganizationsTable`, + `forms/Dismissal/sections/`) — на форме подачи ЕДИНСТВЕННОЕ место для компании, + департамента и должности; отдельных полей для них больше нет (были дублированием + того же, что уже показывала эта таблица). Компания и департамент по каждой + компании — read-only; редактируется только должность ПЕРВОЙ (основной) компании — + она и есть `data.Position`, используется маршрутом (условие «IsManager») и пишется + в заявку; должности остальных компаний — информационные, не редактируются. + Справочник ничего не вернул (сотрудник не найден в api.tnsu.kz) — таблица всё равно + рендерится одной строкой с прочерками вместо компании/департамента и редактируемой + должностью: иначе такому человеку нечем было бы заполнить обязательное поле. + В режиме просмотра (`DismissalView`) — не таблица, а простые read-only поля + «Должность»/«Департамент» + список организаций, как раньше (без live-запроса к + справочнику: показывается то, что реально сохранено в заявке). +- **Самостоятельное подразделение** (`StandaloneUnit`) — в справочнике его нет, + свободный ввод, единственное текстовое поле, оставшееся отдельно от таблицы. +- **Последний рабочий день** (`LastWorkingDay`) — обязателен, не раньше чем через + `MIN_NOTICE_MONTHS` (1) календарный месяц от сегодня (`isoDatePlusMonths`, + `min` у `input type=date` + проверка в `validate` на случай обхода UI) — срок + предупреждения об увольнении по ТК. Более позднюю дату (по договорённости с + работодателем) форма не ограничивает. - **Контактный телефон** (`Phone`) — обязателен, `PhoneInput` (маска). - **Комментарий** (`Comment`) — необязательный свободный текст. Причины увольнения отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»). @@ -152,9 +166,10 @@ approval-route (`getPlannedMatrix` для фаз «Согласование» и полей всего восемь, отметок подразделений нет. ## Секции формы -RequestSection (сотрудник · должность · структурное подразделение · самостоятельное -подразделение · организация (таблица у совместителей) · последний рабочий день · -телефон · комментарий · ссылка на обходной лист · лицо, принимающее ОС/НМА) +RequestSection (сотрудник · организация/департамент/должность (таблица — форма +подачи; должность/департамент/список компаний — просмотр) · самостоятельное +подразделение · последний рабочий день · телефон · комментарий · ссылка на +обходной лист · лицо, принимающее ОС/НМА) ## Точки входа - rptype: `hr-dismissal` diff --git a/src/webparts/requestPortal/forms/Dismissal/model.ts b/src/webparts/requestPortal/forms/Dismissal/model.ts index d12bfa3..91b1e99 100644 --- a/src/webparts/requestPortal/forms/Dismissal/model.ts +++ b/src/webparts/requestPortal/forms/Dismissal/model.ts @@ -22,9 +22,16 @@ export interface IFormData { /** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */ Employee: string; Position: string; - /** Структурное подразделение — из профиля (SPS-Department), read-only. */ + /** + * Департамент — поле Section из Employee/ByEmail (справочник api.tnsu.kz), + * ПЕРВОЙ компании сотрудника (см. shared/dictionary/employeeOrganizations. + * service.ts). Read-only, как раньше со «Структурным подразделением» из + * профиля SharePoint — просто источник сменился на 1С/справочник портала. + * На форме подачи виден внутри OrganizationsTable (колонка «Департамент» по + * каждой компании), отдельного поля для него больше нет. + */ Department: string; - /** Самостоятельное подразделение — в профиле его нет, вводится вручную. */ + /** Самостоятельное подразделение — в справочнике его нет, вводится вручную. */ StandaloneUnit: string; /** Последний рабочий день, ISO YYYY-MM-DD (input type=date). */ LastWorkingDay: string; @@ -86,3 +93,5 @@ export const EMPTY_DATA: IFormData = { /** Плейсхолдер для поля выбора сотрудника. */ export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…'; +/** Заявление нужно подавать не позднее чем за столько месяцев до последнего рабочего дня. */ +export const MIN_NOTICE_MONTHS = 1; diff --git a/src/webparts/requestPortal/forms/Dismissal/provisioning.json b/src/webparts/requestPortal/forms/Dismissal/provisioning.json index ea872ae..af497c4 100644 --- a/src/webparts/requestPortal/forms/Dismissal/provisioning.json +++ b/src/webparts/requestPortal/forms/Dismissal/provisioning.json @@ -6,7 +6,7 @@ { "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, { "internalName": "Position", "title": "Должность", "type": "Text" }, - { "internalName": "Department", "title": "Структурное подразделение", "type": "Text" }, + { "internalName": "Department", "title": "Департамент", "type": "Text" }, { "internalName": "StandaloneUnit", "title": "Самостоятельное подразделение", "type": "Text" }, { "internalName": "LastWorkingDay", "title": "Последний рабочий день", "type": "DateTime", "dateOnly": true }, { "internalName": "Phone", "title": "Контактный телефон", "type": "Text" }, diff --git a/src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.module.scss b/src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.module.scss index 19a6ca3..ae32ded 100644 --- a/src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.module.scss +++ b/src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.module.scss @@ -7,22 +7,46 @@ font-size: 13.5px; } +thead th { + box-sizing: border-box; + padding: 6px 13px; + text-align: left; + font-weight: 500; + font-size: 12px; + color: $gray-500; +} + .company, +.department, .position { box-sizing: border-box; padding: 11px 13px; border: 1px solid $gray-200; + border-right: 0; background: $gray-50; color: $gray-700; } .company { - border-right: 0; - width: 55%; + width: 35%; } -.position { +.department { + width: 30%; +} + +// Ячейка должности ПЕРВОЙ строки хранит TextInput (у него свой паддинг/рамка) — +// у самой ячейки паддинг убран, иначе поле ввода дублировало бы рамку карточки. +.positionInput { + box-sizing: border-box; + border: 1px solid $gray-200; border-right: 0; + padding: 0; + + input { + width: 100%; + box-sizing: border-box; + } } .action { diff --git a/src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.tsx b/src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.tsx index aa13e40..718f55f 100644 --- a/src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.tsx @@ -1,10 +1,20 @@ import * as React from 'react'; -import { FormField, Icon } from '../../../shared/ui'; +import { FormField, Icon, TextInput } from '../../../shared/ui'; import { IEmployeeOrganization } from '../../../shared/dictionary'; import styles from './OrganizationsTable.module.scss'; interface IOrganizationsTableProps { records: IEmployeeOrganization[]; + /** + * Должность из ПЕРВОЙ (основной) компании — сюда же пишет форма (data.Position), + * единственная ячейка «Должность», которая реально редактируется. У остальных + * компаний (если сотрудник — совместитель) своя должность показывается как + * есть в справочнике, но не редактируется: маршрут согласования резолвится по + * ОДНОЙ должности основной компании (см. DismissalForm — fieldValues.Position), + * ставить в соответствие ей должности других компаний незачем. + */ + position: string; + onChangePosition: (value: string) => void; /** * Убрать компанию из заявления — есть только на форме подачи (см. * DismissalForm.handleRemoveOrganization), не в режиме просмотра: там состав @@ -14,37 +24,68 @@ interface IOrganizationsTableProps { } /** - * Организации совместителя — интерактивная таблица (не просто список строк, как - * OrganizationField): у сотрудника, оформленного в НЕСКОЛЬКИХ компаниях - * одновременно, увольнение затрагивает директора/должностного эквивалента КАЖДОЙ - * из них (маршрут `HeadType.CompanyDirectors` в approval-route резолвит их всех). - * Если реально уходит только из одной — заявитель убирает лишние компании ЗДЕСЬ, - * и соответствующий согласующий убирается из матрицы автоматически (см. - * DismissalForm.handleRemoveOrganization) — не нужно отдельно искать его в матрице - * и удалять там вручную. + * Организации сотрудника — ЕДИНСТВЕННОЕ место на форме подачи, где видны и + * правятся должность и подразделение (раньше были отдельными полями над этой + * таблицей — убраны как дублирование, см. историю формы). Интерактивная + * таблица, а не список строк (как OrganizationField в режиме просмотра): у + * сотрудника, оформленного в НЕСКОЛЬКИХ компаниях одновременно, увольнение + * затрагивает директора/должностного эквивалента КАЖДОЙ из них (маршрут + * `HeadType.CompanyDirectors` в approval-route резолвит их всех). Если реально + * уходит только из одной — заявитель убирает лишние компании ЗДЕСЬ, и + * соответствующий согласующий убирается из матрицы автоматически (см. + * DismissalForm.handleRemoveOrganization) — не нужно отдельно искать его в + * матрице и удалять там вручную. + * + * Пустой records (справочник api.tnsu.kz не нашёл сотрудника, см. + * employeeOrganizations.service) — не «ничего не показываем», а ОДНА строка с + * прочерками и редактируемой должностью: без этого заявление на человека без + * карточки в справочнике нельзя было бы подать вовсе (некуда вписать должность + * вручную — а маршрут согласования без неё не резолвит условие «IsManager»). */ -export const OrganizationsTable: React.FC = ({ records, onRemove }) => { - if (records.length === 0) return null; +export const OrganizationsTable: React.FC = ({ + records, + position, + onChangePosition, + onRemove +}) => { + const rows = records.length > 0 ? records : [undefined]; return ( - + + + + + + + {onRemove && + - {records.map((r) => ( - - - + {rows.map((r, i) => ( + + + + {i === 0 ? ( + + ) : ( + + )} {onRemove && ( )} diff --git a/src/webparts/requestPortal/forms/Dismissal/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Dismissal/sections/RequestSection.tsx index 786dc58..6281066 100644 --- a/src/webparts/requestPortal/forms/Dismissal/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/sections/RequestSection.tsx @@ -10,22 +10,19 @@ import { LinkField } from '../../../shared/ui'; import { IEmployeeOrganization } from '../../../shared/dictionary'; -import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model'; -import { isoDatePlusDays } from '../../../shared/dateUtils'; +import { ISectionProps, EMPLOYEE_PLACEHOLDER, MIN_NOTICE_MONTHS } from '../model'; +import { isoDatePlusMonths } from '../../../shared/dateUtils'; import { OrganizationsTable } from './OrganizationsTable'; -// Структурное подразделение подтягивается из профиля при выборе сотрудника (см. -// DismissalForm.handleEmployeeChange — SharePoint User Profile), свободный ввод -// намеренно убран, чтобы не расходиться с профилем — как в «Обходном листе». -const NOOP = (): void => undefined; - interface IRequestSectionProps extends ISectionProps { /** * Организации сотрудника СПИСКОМ (не joined-строкой из data.Organization) — - * нужны, только когда таблицу можно редактировать (форма подачи): без них - * пришлось бы разбирать обратно уже отформатированный текст. Не задано — - * секция показывает data.Organization как раньше, одной read-only колонкой - * (OrganizationField) — так работает режим просмотра (DismissalView). + * заданы ТОЛЬКО на форме подачи (DismissalForm), где вместо read-only + * OrganizationField показывается интерактивная OrganizationsTable: она же + * теперь единственное место для должности и подразделения (см. её комментарий) — + * отдельных полей выше больше нет, дублировать одно и то же дважды незачем. + * Не задано (DismissalView, режим просмотра) — секция показывает Должность, + * Подразделение и Организацию как раньше, простыми read-only полями. */ orgRecords?: IEmployeeOrganization[]; /** Убрать компанию — см. OrganizationsTable. Только форма подачи. */ @@ -57,47 +54,39 @@ export const RequestSection: React.FC = ({ placeholder={EMPLOYEE_PLACEHOLDER} /> - {/* Подставляется по выбранному сотруднику, но остаётся редактируемым: если - справочник организаций не заполнен, должность нужно ввести вручную — - иначе шаг маршрута с условием по должности не соберётся. */} - - set('Position', v)} - placeholder="Подставится автоматически по выбранному сотруднику" + {orgRecords ? ( + // Форма подачи: должность, департамент и компании — только здесь, ниже. + // Стоит ПЕРЕД «Самостоятельным подразделением» специально: остальные поля + // формы читают уже подставленную должность/компанию, а не наоборот. + set('Position', v)} + onRemove={onRemoveOrganization} /> - - - - - - set('StandaloneUnit', v)} - /> - - {orgRecords && orgRecords.length > 1 ? ( - ) : ( - + <> + + set('Position', v)} /> + + + set('Department', v)} /> + + + )} - {/* Датой увольнения нельзя назначить прошедший день: заявление подаётся - заранее, а не задним числом (min = сегодня). */} + + set('StandaloneUnit', v)} /> + + {/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе, + чем через MIN_NOTICE_MONTHS месяц от сегодня (min). */} set('LastWorkingDay', v)} - min={isoDatePlusDays(0)} + min={isoDatePlusMonths(MIN_NOTICE_MONTHS)} /> diff --git a/src/webparts/requestPortal/shared/dateUtils.ts b/src/webparts/requestPortal/shared/dateUtils.ts index b7fe35a..88a4d1b 100644 --- a/src/webparts/requestPortal/shared/dateUtils.ts +++ b/src/webparts/requestPortal/shared/dateUtils.ts @@ -9,6 +9,22 @@ export function isoDatePlusDays(days: number): string { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } +/** + * Сегодня + n КАЛЕНДАРНЫХ месяцев в формате YYYY-MM-DD (для min/max у input + * type=date) — не приближение через 30 дней: `setMonth` сам разносит месяцы + * разной длины (28–31 день) и года на границе декабря/января. + * + * 31 января + 1 месяц → JS даёт 3 марта (переносит лишние дни на следующий + * месяц, а не откатывает до последнего числа) — для месячного уведомления это + * не проблема (крайняя ранняя дата и так сдвигается на несколько дней позже, + * не раньше), но не считать это «ровно через месяц» в общем случае. + */ +export function isoDatePlusMonths(months: number): string { + const d = new Date(); + d.setMonth(d.getMonth() + months); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + /** YYYY-MM-DD → дд.мм.гггг, для текста ошибок валидации. Невалидную строку возвращает как есть. */ export function formatIsoDate(iso: string): string { const [y, m, d] = iso.split('-');
КомпанияДепартаментДолжность} +
{r.company}{r.position}
{r?.company || '—'}{r?.section || '—'} + + {r?.position || '—'} - + {r && records.length > 1 && ( + + )}