feat: отпуск — должность и подразделение из справочника, таблица организаций с подразделением

Убраны редактируемые поля «Должность» и «Подразделение». Должность и подразделение
подтягиваются из Employee/ByEmail и показываются таблицей «Организация · Должность ·
Подразделение» — своё подразделение на каждую организацию. В скалярные колонки
Position/Department/DepartmentId пишется основная (первая) запись — их читают документ и ЭДО.
Должность больше не обязательное поле формы.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-02 13:56:59 +05:00
parent 2009535f1a
commit 58e4d3c868
8 changed files with 156 additions and 26 deletions
@@ -34,7 +34,7 @@ import {
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
import {
@@ -109,6 +109,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
};
}, [editId, editListId, notifyCode, setSubmitError]);
// Должность и подразделение больше не вводятся руками: берутся из ПЕРВОЙ (основной)
// записи Employee/ByEmail и пишутся в скалярные колонки Position/Department (их
// читают документ и ЭДО); по каждой организации то же видно в таблице (Organization).
const primaryOrgFields = (records: IEmployeeOrganization[]): Pick<IFormData, 'Position' | 'Department' | 'DepartmentId'> => ({
Position: records[0]?.position || '',
Department: records[0]?.section || '',
DepartmentId: records[0]?.departmentId || ''
});
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
// employeeOrganizations.service.ts; сотрудник может состоять сразу в
@@ -125,8 +134,8 @@ const VacationForm: React.FC<IRequestFormProps> = ({
if (!alive) return;
setData((prev) => ({
...prev,
Position: prev.Position || records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
...primaryOrgFields(records),
Organization: formatEmployeeOrganizationsWithDepartment(records)
}));
})
.catch(() => undefined);
@@ -142,15 +151,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
const handleEmployeeChange = (login: string): void => {
setData((prev) => ({ ...prev, Employee: login }));
if (!login) {
setData((prev) => ({ ...prev, Organization: '' }));
setData((prev) => ({ ...prev, ...primaryOrgFields([]), Organization: '' }));
return;
}
getEmployeeOrganizations(context, login)
.then((records) =>
setData((prev) => ({
...prev,
Position: records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
...primaryOrgFields(records),
Organization: formatEmployeeOrganizationsWithDepartment(records)
}))
)
.catch(() => undefined);
@@ -193,7 +202,6 @@ const VacationForm: React.FC<IRequestFormProps> = ({
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
const isValid = !!(
data.Employee &&
data.Position.trim() &&
data.StartDate &&
isStartDateValid &&
Number.isFinite(daysNum) &&
@@ -203,7 +211,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
if (data.StartDate && !isStartDateValid) {
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
}
if (!isValid) return 'Заполните обязательные поля: сотрудник, должность, дата начала, количество дней (> 0).';
if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
@@ -1,10 +1,10 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
@@ -15,21 +15,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
placeholder={EMPLOYEE_PLACEHOLDER}
/>
</FormField>
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Подразделение" htmlFor="Department">
<DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField>
<OrganizationTable value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Проект" htmlFor="Project">
<ProjectPicker
id="Project"
@@ -0,0 +1,28 @@
import { IEmployeeOrganization } from './models/IEmployeeOrganization';
import { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
const rec = (p: Partial<IEmployeeOrganization>): IEmployeeOrganization => ({
id: '', fio: '', email: '', iin: '', company: 'ТОО А', companyId: '', section: '', departmentId: '', position: '', mobile: '', ...p
});
describe('организации с подразделением', () => {
it('на каждую организацию своё подразделение', () => {
const text = formatEmployeeOrganizationsWithDepartment([
rec({ company: 'ТОО А', position: 'Директор', section: 'Дирекция' }),
rec({ company: 'ТОО Б', position: 'Казначей', section: 'Финансы' })
]);
expect(text.split('\n').map(parseOrganizationLine)).toEqual([
{ company: 'ТОО А', position: 'Директор', department: 'Дирекция' },
{ company: 'ТОО Б', position: 'Казначей', department: 'Финансы' }
]);
});
it('без подразделения формат прежний «Компания — должность»', () => {
expect(formatEmployeeOrganizationsWithDepartment([rec({ position: 'Директор' })])).toBe('ТОО А — Директор');
});
it('старые строки разбираются с пустым подразделением', () => {
expect(parseOrganizationLine('ТОО А — Директор')).toEqual({ company: 'ТОО А', position: 'Директор', department: '' });
expect(parseOrganizationLine('ТОО А')).toEqual({ company: 'ТОО А', position: '', department: '' });
});
});
@@ -28,3 +28,5 @@ export {
getCompanyDirectorEmail,
getManagerEmail
} from './employeeLookup.service';
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
export type { IOrganizationLine } from './organizationLines';
@@ -0,0 +1,29 @@
import { IEmployeeOrganization } from './models/IEmployeeOrganization';
/**
* Организации сотрудника с подразделением: «Компания — должность — подразделение»
* на строку. Нужен формам, что показывают организации таблицей (см.
* OrganizationTable). Первый сегмент по-прежнему компания — остальные разборщики
* (OrgLetterhead и др.) берут только его и не ломаются. Нет подразделения —
* формат прежний, как у formatEmployeeOrganizations.
*/
export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string {
return records
.map((r) => {
if (r.section) return [r.company, r.position, r.section].join(' — ');
return r.position ? `${r.company} — ${r.position}` : r.company;
})
.join('\n');
}
export interface IOrganizationLine {
company: string;
position: string;
department: string;
}
/** Разбор строки «Компания — должность[ — подразделение]»; старые строки без подразделения дают пустое. */
export function parseOrganizationLine(line: string): IOrganizationLine {
const [company = '', position = '', department = ''] = line.split(' — ').map((p) => p.trim());
return { company, position, department };
}
@@ -19,3 +19,34 @@
font-size: 13.5px;
color: $gray-700;
}
// Таблица организаций: те же цвета, что у .line — «подставлено автоматически».
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $gray-50;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-700;
th,
td {
padding: 9px 13px;
text-align: left;
vertical-align: top;
}
th {
font-weight: 600;
font-size: 12px;
color: $gray-500;
border-bottom: 1px solid $gray-200;
}
tr + tr td {
border-top: 1px solid $gray-200;
}
}
@@ -0,0 +1,45 @@
import * as React from 'react';
import styles from './OrganizationField.module.scss';
import { FormField } from '@tnsu/ui-kit';
import { parseOrganizationLine } from '../../dictionary/organizationLines';
interface IOrganizationTableProps {
/** `\n`-joined «Компания — должность — подразделение» на организацию (см. formatEmployeeOrganizationsWithDepartment). */
value: string;
label?: string;
/** Индекс строки, остающейся в печати (см. OrganizationField.printIndex); остальные — `rp-no-print`. */
printIndex?: number;
}
/**
* Организации сотрудника таблицей «Организация · Должность · Подразделение» —
* read-only, подтягивается из Employee/ByEmail. На каждую организацию своё
* подразделение (сотрудник может состоять в нескольких). Нет данных — ничего не рендерим.
*/
export const OrganizationTable: React.FC<IOrganizationTableProps> = ({ value, label = 'Организация', printIndex }) => {
const rows = value ? value.split('\n').map(parseOrganizationLine) : [];
if (rows.length === 0) return null;
return (
<FormField label={label} full>
<table className={styles.table}>
<thead>
<tr>
<th>Организация</th>
<th>Должность</th>
<th>Подразделение</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className={printIndex !== undefined && i !== printIndex ? 'rp-no-print' : undefined}>
<td>{r.company}</td>
<td>{r.position || '—'}</td>
<td>{r.department || '—'}</td>
</tr>
))}
</tbody>
</table>
</FormField>
);
};
@@ -47,6 +47,7 @@ export { PeopleField } from './PeopleField/PeopleField';
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { PeopleTextField } from './PeopleField/PeopleTextField';
export { OrganizationField } from './OrganizationField/OrganizationField';
export { OrganizationTable } from './OrganizationField/OrganizationTable';
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
export { LinkField } from './LinkField/LinkField';