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';
|
} from '../../shared/approval';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
||||||
import {
|
import {
|
||||||
@@ -109,6 +109,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
};
|
};
|
||||||
}, [editId, editListId, notifyCode, setSubmitError]);
|
}, [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/
|
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
||||||
// employeeOrganizations.service.ts; сотрудник может состоять сразу в
|
// employeeOrganizations.service.ts; сотрудник может состоять сразу в
|
||||||
@@ -125,8 +134,8 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
Position: prev.Position || records[0]?.position || prev.Position,
|
...primaryOrgFields(records),
|
||||||
Organization: formatEmployeeOrganizations(records)
|
Organization: formatEmployeeOrganizationsWithDepartment(records)
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
@@ -142,15 +151,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
const handleEmployeeChange = (login: string): void => {
|
const handleEmployeeChange = (login: string): void => {
|
||||||
setData((prev) => ({ ...prev, Employee: login }));
|
setData((prev) => ({ ...prev, Employee: login }));
|
||||||
if (!login) {
|
if (!login) {
|
||||||
setData((prev) => ({ ...prev, Organization: '' }));
|
setData((prev) => ({ ...prev, ...primaryOrgFields([]), Organization: '' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
getEmployeeOrganizations(context, login)
|
getEmployeeOrganizations(context, login)
|
||||||
.then((records) =>
|
.then((records) =>
|
||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
Position: records[0]?.position || prev.Position,
|
...primaryOrgFields(records),
|
||||||
Organization: formatEmployeeOrganizations(records)
|
Organization: formatEmployeeOrganizationsWithDepartment(records)
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
@@ -193,7 +202,6 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
|
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
|
||||||
const isValid = !!(
|
const isValid = !!(
|
||||||
data.Employee &&
|
data.Employee &&
|
||||||
data.Position.trim() &&
|
|
||||||
data.StartDate &&
|
data.StartDate &&
|
||||||
isStartDateValid &&
|
isStartDateValid &&
|
||||||
Number.isFinite(daysNum) &&
|
Number.isFinite(daysNum) &&
|
||||||
@@ -203,7 +211,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (data.StartDate && !isStartDateValid) {
|
if (data.StartDate && !isStartDateValid) {
|
||||||
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
|
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
|
||||||
}
|
}
|
||||||
if (!isValid) return 'Заполните обязательные поля: сотрудник, должность, дата начала, количество дней (> 0).';
|
if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).';
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui';
|
||||||
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
|
import { ProjectPicker } from '../../../shared/dictionary';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
|
||||||
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
||||||
|
|
||||||
/** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */
|
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
|
||||||
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
|
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||||
@@ -15,21 +15,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
|||||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Должность" htmlFor="Position" required>
|
<OrganizationTable value={data.Organization} printIndex={printOrgIndex} />
|
||||||
<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>
|
|
||||||
<FormField label="Проект" htmlFor="Project">
|
<FormField label="Проект" htmlFor="Project">
|
||||||
<ProjectPicker
|
<ProjectPicker
|
||||||
id="Project"
|
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,
|
getCompanyDirectorEmail,
|
||||||
getManagerEmail
|
getManagerEmail
|
||||||
} from './employeeLookup.service';
|
} 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;
|
font-size: 13.5px;
|
||||||
color: $gray-700;
|
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 { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||||
export { PeopleTextField } from './PeopleField/PeopleTextField';
|
export { PeopleTextField } from './PeopleField/PeopleTextField';
|
||||||
export { OrganizationField } from './OrganizationField/OrganizationField';
|
export { OrganizationField } from './OrganizationField/OrganizationField';
|
||||||
|
export { OrganizationTable } from './OrganizationField/OrganizationTable';
|
||||||
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
|
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
|
||||||
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
|
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
|
||||||
export { LinkField } from './LinkField/LinkField';
|
export { LinkField } from './LinkField/LinkField';
|
||||||
|
|||||||
Reference in New Issue
Block a user