feat: отпуск — должность и подразделение из справочника, таблица организаций с подразделением
Убраны редактируемые поля «Должность» и «Подразделение». Должность и подразделение подтягиваются из Employee/ByEmail и показываются таблицей «Организация · Должность · Подразделение» — своё подразделение на каждую организацию. В скалярные колонки Position/Department/DepartmentId пишется основная (первая) запись — их читают документ и ЭДО. Должность больше не обязательное поле формы. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user