feat: добавил поле организация во все заявки, а также расширил компонет выбора даты для ui-kit
This commit is contained in:
Generated
+4
-4
@@ -17,7 +17,7 @@
|
|||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@pnp/spfx-controls-react": "^3.25.0",
|
"@pnp/spfx-controls-react": "^3.25.0",
|
||||||
"@tnsu/ui-kit": "^0.4.0",
|
"@tnsu/ui-kit": "^0.9.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
@@ -9870,9 +9870,9 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/@tnsu/ui-kit": {
|
"node_modules/@tnsu/ui-kit": {
|
||||||
"version": "0.4.0",
|
"version": "0.9.0",
|
||||||
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.4.0/ui-kit-0.4.0.tgz",
|
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.9.0/ui-kit-0.9.0.tgz",
|
||||||
"integrity": "sha512-J3cBvoR2vV0SC5GihOagLAqvC0txQE342qIh6viabMt+Um6Jg4mhTff+ZkVahG0g+bF6sqSCUl3x813aO82KuQ==",
|
"integrity": "sha512-q4eLdsAd0FW7Jem1sVazztuFrUQxdP72d6KwBa7L/6YKqJzB2PzdaZqUWaFkp7+qgtxhK9Zotx18PHjG2i5Zmw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@fluentui/react": "^8.106.4",
|
"@fluentui/react": "^8.106.4",
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@pnp/spfx-controls-react": "^3.25.0",
|
"@pnp/spfx-controls-react": "^3.25.0",
|
||||||
"@tnsu/ui-kit": "^0.4.0",
|
"@tnsu/ui-kit": "^0.9.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ export const REQUEST_TYPES: IRequestType[] = [
|
|||||||
{ id: 'it-1c-ticket', categoryId: 'it', title: 'Заявка в отдел 1С', icon: 'clipboard' },
|
{ id: 'it-1c-ticket', categoryId: 'it', title: 'Заявка в отдел 1С', icon: 'clipboard' },
|
||||||
|
|
||||||
// Отдел кадров
|
// Отдел кадров
|
||||||
{ id: 'hr-vacation', categoryId: 'hr', title: 'Заявка на отпуск', icon: 'calendar' },
|
{ id: 'hr-vacation', categoryId: 'hr', title: 'Заявка на трудовой отпуск', icon: 'calendar' },
|
||||||
{ id: 'hr-unpaid-leave', categoryId: 'hr', title: 'Отпуск без содержания', icon: 'calendar' },
|
{ id: 'hr-unpaid-leave', categoryId: 'hr', title: 'Заявка на отпуск без содержания', icon: 'document' },
|
||||||
{ id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard' },
|
{ id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard' },
|
||||||
{ id: 'hr-transfer', categoryId: 'hr', title: 'на перемещение сотрудников', icon: 'send' },
|
{ id: 'hr-transfer', categoryId: 'hr', title: 'Заявка на перемещение сотрудников', icon: 'people' },
|
||||||
|
|
||||||
// Склад / Снабжение
|
// Склад / Снабжение
|
||||||
{ id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send' }
|
{ id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send' }
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
unassignedStepMessage
|
unassignedStepMessage
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
@@ -100,19 +101,29 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData((prev) => ({ ...prev, TerminationDate: prev.TerminationDate || today }));
|
setData((prev) => ({ ...prev, TerminationDate: prev.TerminationDate || today }));
|
||||||
}, [editId]);
|
}, [editId]);
|
||||||
|
|
||||||
// Должность/подразделение больше не вводятся вручную — подтягиваются из
|
// Подразделение подтягивается из SharePoint User Profile (SPS-Department) при
|
||||||
// SharePoint User Profile (SPS-JobTitle/SPS-Department) при выборе сотрудника.
|
// выборе сотрудника — если профиль не синхронизирован, поле остаётся пустым.
|
||||||
// Если профиль не синхронизирован и поле там пусто — пока просто остаётся
|
// Должность и организации — из Employee/ByEmail (см. shared/dictionary/
|
||||||
// пустым (TODO: фолбэк на справочник api.tnsu.kz, если это окажется частой
|
// employeeOrganizations.service.ts), а не из профиля: сотрудник может состоять
|
||||||
// проблемой на практике).
|
// сразу в нескольких организациях, должность берём из ПЕРВОЙ записи, чтобы
|
||||||
|
// Должность и Организация оставались парой из одной записи.
|
||||||
const handleEmployeeChange = (login: string): void => {
|
const handleEmployeeChange = (login: string): void => {
|
||||||
setData((prev) => ({ ...prev, Employee: login, Position: '', Department: '' }));
|
setData((prev) => ({ ...prev, Employee: login, Position: '', Department: '', Organization: '' }));
|
||||||
if (!login) {
|
if (!login) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
approval
|
approval
|
||||||
.getProfileFields(login)
|
.getProfileFields(login)
|
||||||
.then((profile) => setData((prev) => ({ ...prev, Position: profile.jobTitle, Department: profile.department })))
|
.then((profile) => setData((prev) => ({ ...prev, Department: profile.department })))
|
||||||
|
.catch(() => undefined);
|
||||||
|
getEmployeeOrganizations(context, login)
|
||||||
|
.then((records) =>
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
Position: records[0]?.position || prev.Position,
|
||||||
|
Organization: formatEmployeeOrganizations(records)
|
||||||
|
}))
|
||||||
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
|
|||||||
'ParkingCard',
|
'ParkingCard',
|
||||||
'AccessCard',
|
'AccessCard',
|
||||||
'TrainingAmount',
|
'TrainingAmount',
|
||||||
|
'Organization',
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode',
|
'RequestUniqueCode',
|
||||||
'Employee/Title',
|
'Employee/Title',
|
||||||
@@ -50,7 +51,8 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
|
|||||||
TerminationDate: termination.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
TerminationDate: termination.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
||||||
ParkingCard: !!item.ParkingCard,
|
ParkingCard: !!item.ParkingCard,
|
||||||
AccessCard: !!item.AccessCard,
|
AccessCard: !!item.AccessCard,
|
||||||
TrainingAmount: amount === null || amount === undefined ? '' : String(amount)
|
TrainingAmount: amount === null || amount === undefined ? '' : String(amount),
|
||||||
|
Organization: (item.Organization as string) || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export interface IFormData {
|
|||||||
AccessCard: boolean;
|
AccessCard: boolean;
|
||||||
/** Сумма обучения — «сырые» цифры без пробелов (см. MoneyInput). Пусто = не указана. */
|
/** Сумма обучения — «сырые» цифры без пробелов (см. MoneyInput). Пусто = не указана. */
|
||||||
TrainingAmount: string;
|
TrainingAmount: string;
|
||||||
|
/**
|
||||||
|
* Организации сотрудника (`\n`-joined «Компания — должность» на строку) —
|
||||||
|
* подтягивается автоматически из Employee/ByEmail при выборе Employee,
|
||||||
|
* read-only (см. shared/ui/OrganizationField).
|
||||||
|
*/
|
||||||
|
Organization: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обновление одного поля формы с сохранением типа значения. */
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
@@ -49,7 +55,8 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
TerminationDate: '',
|
TerminationDate: '',
|
||||||
ParkingCard: false,
|
ParkingCard: false,
|
||||||
AccessCard: false,
|
AccessCard: false,
|
||||||
TrainingAmount: ''
|
TrainingAmount: '',
|
||||||
|
Organization: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Плейсхолдер для поля выбора сотрудника. */
|
/** Плейсхолдер для поля выбора сотрудника. */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
{ "internalName": "ParkingCard", "title": "Парковочная карта", "type": "Boolean" },
|
{ "internalName": "ParkingCard", "title": "Парковочная карта", "type": "Boolean" },
|
||||||
{ "internalName": "AccessCard", "title": "Карта СКУД", "type": "Boolean" },
|
{ "internalName": "AccessCard", "title": "Карта СКУД", "type": "Boolean" },
|
||||||
{ "internalName": "TrainingAmount", "title": "Обучение: сумма", "type": "Number", "decimals": 2 }
|
{ "internalName": "TrainingAmount", "title": "Обучение: сумма", "type": "Number", "decimals": 2 },
|
||||||
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField } from '../../../shared/ui';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
||||||
|
|
||||||
// Подразделение подтягивается автоматически при выборе сотрудника (см.
|
// Подразделение подтягивается автоматически при выборе сотрудника (см.
|
||||||
@@ -54,6 +54,7 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
placeholder="Подставится автоматически по выбранному сотруднику"
|
placeholder="Подставится автоматически по выбранному сотруднику"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<OrganizationField value={data.Organization} />
|
||||||
<FormField label="Дата расторжения договора" htmlFor="TerminationDate" required>
|
<FormField label="Дата расторжения договора" htmlFor="TerminationDate" required>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="TerminationDate"
|
id="TerminationDate"
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
|||||||
TerminationDate: data.TerminationDate || null,
|
TerminationDate: data.TerminationDate || null,
|
||||||
ParkingCard: data.ParkingCard,
|
ParkingCard: data.ParkingCard,
|
||||||
AccessCard: data.AccessCard,
|
AccessCard: data.AccessCard,
|
||||||
TrainingAmount: Number.isFinite(amount) && data.TrainingAmount.trim() !== '' ? amount : null
|
TrainingAmount: Number.isFinite(amount) && data.TrainingAmount.trim() !== '' ? amount : null,
|
||||||
|
Organization: data.Organization || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapseSection title="Заявка" icon="clipboard">
|
<CollapseSection title="Заявка" icon="clipboard">
|
||||||
<FormField label="Компания" htmlFor="Companies" full>
|
<FormField label="Организация" htmlFor="Companies" full>
|
||||||
<CompanyPickerMulti
|
<CompanyPickerMulti
|
||||||
id="Companies"
|
id="Companies"
|
||||||
context={context}
|
context={context}
|
||||||
value={data.Companies}
|
value={data.Companies}
|
||||||
onChange={(companies) => set('Companies', companies)}
|
onChange={(companies) => set('Companies', companies)}
|
||||||
placeholder="Начните вводить название компании…"
|
placeholder="Начните вводить название организации…"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Заявка на подбор" full>
|
<FormField label="Заявка на подбор" full>
|
||||||
|
|||||||
+2
-2
@@ -58,13 +58,13 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
<FormField label="1.5 Компания" htmlFor="Companies" full>
|
<FormField label="1.5 Организация" htmlFor="Companies" full>
|
||||||
<CompanyPickerMulti
|
<CompanyPickerMulti
|
||||||
context={context}
|
context={context}
|
||||||
id="Companies"
|
id="Companies"
|
||||||
value={data.Companies}
|
value={data.Companies}
|
||||||
onChange={(v) => set('Companies', v)}
|
onChange={(v) => set('Companies', v)}
|
||||||
placeholder="Начните вводить название компании…"
|
placeholder="Начните вводить название организации…"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="1.6 Объект/Проект" htmlFor="ObjectProject" full>
|
<FormField label="1.6 Объект/Проект" htmlFor="ObjectProject" full>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
initialSubmitStatus
|
initialSubmitStatus
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
@@ -94,9 +95,10 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [editId, editListId]);
|
}, [editId, editListId]);
|
||||||
|
|
||||||
// Новая заявка: рекомендатель — сам подающий (подставляем его, должность и
|
// Новая заявка: рекомендатель — сам подающий (подставляем его; подразделение —
|
||||||
// подразделение из профиля SharePoint — SPS-JobTitle/SPS-Department, как в
|
// из профиля SharePoint SPS-Department, как в «Обходном листе»; должность и
|
||||||
// «Обходном листе»), дата подачи — сегодня.
|
// организации — из Employee/ByEmail, см. shared/dictionary/
|
||||||
|
// employeeOrganizations.service.ts), дата подачи — сегодня.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editId) {
|
if (editId) {
|
||||||
return;
|
return;
|
||||||
@@ -114,24 +116,35 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (alive) {
|
if (alive) {
|
||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
ReferrerPosition: prev.ReferrerPosition || profile.jobTitle,
|
|
||||||
ReferrerDepartment: prev.ReferrerDepartment || profile.department
|
ReferrerDepartment: prev.ReferrerDepartment || profile.department
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
getEmployeeOrganizations(context, user.email)
|
||||||
|
.then((records) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
ReferrerPosition: prev.ReferrerPosition || records[0]?.position || prev.ReferrerPosition,
|
||||||
|
Organization: formatEmployeeOrganizations(records)
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [editId]);
|
}, [editId]);
|
||||||
|
|
||||||
// Выбор другого рекомендателя подтягивает его должность и подразделение из
|
// Выбор другого рекомендателя подтягивает его подразделение из профиля,
|
||||||
// профиля — оба поля остаются редактируемыми (фолбэк, если профиль не
|
// должность и организации — из Employee/ByEmail. Оба поля (Position/
|
||||||
// синхронизирован), поэтому не затираем то, что уже введено, пустым значением.
|
// Department) остаются редактируемыми (фолбэк, если источник недоступен),
|
||||||
|
// поэтому не затираем то, что уже введено, пустым значением.
|
||||||
const handleReferrerChange = (login: string): void => {
|
const handleReferrerChange = (login: string): void => {
|
||||||
setData((prev) => ({ ...prev, Referrer: login }));
|
setData((prev) => ({ ...prev, Referrer: login }));
|
||||||
if (!login) {
|
if (!login) {
|
||||||
|
setData((prev) => ({ ...prev, Organization: '' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
approval
|
approval
|
||||||
@@ -139,11 +152,19 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
.then((profile) =>
|
.then((profile) =>
|
||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
ReferrerPosition: profile.jobTitle || prev.ReferrerPosition,
|
|
||||||
ReferrerDepartment: profile.department || prev.ReferrerDepartment
|
ReferrerDepartment: profile.department || prev.ReferrerDepartment
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
getEmployeeOrganizations(context, login)
|
||||||
|
.then((records) =>
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
ReferrerPosition: records[0]?.position || prev.ReferrerPosition,
|
||||||
|
Organization: formatEmployeeOrganizations(records)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Планируемый маршрут резолвится по e-mail РЕКОМЕНДАТЕЛЯ (кандидата в тенанте
|
// Планируемый маршрут резолвится по e-mail РЕКОМЕНДАТЕЛЯ (кандидата в тенанте
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export async function loadReferralRequest(itemId: number, listId?: string): Prom
|
|||||||
'ReferrerPosition',
|
'ReferrerPosition',
|
||||||
'ReferrerDepartment',
|
'ReferrerDepartment',
|
||||||
'ReferrerPhone',
|
'ReferrerPhone',
|
||||||
|
'Organization',
|
||||||
'CandidateName',
|
'CandidateName',
|
||||||
'CandidatePhone',
|
'CandidatePhone',
|
||||||
'CandidateEmail',
|
'CandidateEmail',
|
||||||
@@ -62,6 +63,7 @@ export async function loadReferralRequest(itemId: number, listId?: string): Prom
|
|||||||
ReferrerPosition: (item.ReferrerPosition as string) || '',
|
ReferrerPosition: (item.ReferrerPosition as string) || '',
|
||||||
ReferrerDepartment: (item.ReferrerDepartment as string) || '',
|
ReferrerDepartment: (item.ReferrerDepartment as string) || '',
|
||||||
ReferrerPhone: (item.ReferrerPhone as string) || '',
|
ReferrerPhone: (item.ReferrerPhone as string) || '',
|
||||||
|
Organization: (item.Organization as string) || '',
|
||||||
CandidateName: (item.CandidateName as string) || '',
|
CandidateName: (item.CandidateName as string) || '',
|
||||||
CandidatePhone: (item.CandidatePhone as string) || '',
|
CandidatePhone: (item.CandidatePhone as string) || '',
|
||||||
CandidateEmail: (item.CandidateEmail as string) || '',
|
CandidateEmail: (item.CandidateEmail as string) || '',
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export interface IFormData {
|
|||||||
ReferrerPosition: string;
|
ReferrerPosition: string;
|
||||||
ReferrerDepartment: string;
|
ReferrerDepartment: string;
|
||||||
ReferrerPhone: string;
|
ReferrerPhone: string;
|
||||||
|
/**
|
||||||
|
* Организации рекомендателя (`\n`-joined «Компания — должность» на строку) —
|
||||||
|
* подтягивается автоматически из Employee/ByEmail при выборе Referrer,
|
||||||
|
* read-only (см. shared/ui/OrganizationField).
|
||||||
|
*/
|
||||||
|
Organization: string;
|
||||||
|
|
||||||
CandidateName: string;
|
CandidateName: string;
|
||||||
CandidatePhone: string;
|
CandidatePhone: string;
|
||||||
@@ -87,6 +93,7 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
ReferrerPosition: '',
|
ReferrerPosition: '',
|
||||||
ReferrerDepartment: '',
|
ReferrerDepartment: '',
|
||||||
ReferrerPhone: '',
|
ReferrerPhone: '',
|
||||||
|
Organization: '',
|
||||||
CandidateName: '',
|
CandidateName: '',
|
||||||
CandidatePhone: '',
|
CandidatePhone: '',
|
||||||
CandidateEmail: '',
|
CandidateEmail: '',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
{ "internalName": "ReferrerPosition", "title": "Должность рекомендателя", "type": "Text" },
|
{ "internalName": "ReferrerPosition", "title": "Должность рекомендателя", "type": "Text" },
|
||||||
{ "internalName": "ReferrerDepartment", "title": "Подразделение / Объект рекомендателя", "type": "Text" },
|
{ "internalName": "ReferrerDepartment", "title": "Подразделение / Объект рекомендателя", "type": "Text" },
|
||||||
{ "internalName": "ReferrerPhone", "title": "Контактный телефон рекомендателя", "type": "Text" },
|
{ "internalName": "ReferrerPhone", "title": "Контактный телефон рекомендателя", "type": "Text" },
|
||||||
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||||
|
|
||||||
{ "internalName": "CandidateName", "title": "ФИО кандидата", "type": "Text", "required": true },
|
{ "internalName": "CandidateName", "title": "ФИО кандидата", "type": "Text", "required": true },
|
||||||
{ "internalName": "CandidatePhone", "title": "Контактный телефон кандидата", "type": "Text" },
|
{ "internalName": "CandidatePhone", "title": "Контактный телефон кандидата", "type": "Text" },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, PhoneInput, PeopleField } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, PhoneInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
||||||
import { ISectionProps, REFERRER_PLACEHOLDER } from '../model';
|
import { ISectionProps, REFERRER_PLACEHOLDER } from '../model';
|
||||||
|
|
||||||
/** Секция 1: кто рекомендует — дата подачи, сам рекомендатель и его контакты. */
|
/** Секция 1: кто рекомендует — дата подачи, сам рекомендатель и его контакты. */
|
||||||
@@ -31,6 +31,7 @@ export const ReferrerSection: React.FC<ISectionProps> = ({ data, set, context, o
|
|||||||
placeholder="Подставится автоматически по выбранному сотруднику"
|
placeholder="Подставится автоматически по выбранному сотруднику"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<OrganizationField value={data.Organization} />
|
||||||
<FormField label="Контактный телефон" htmlFor="ReferrerPhone" required>
|
<FormField label="Контактный телефон" htmlFor="ReferrerPhone" required>
|
||||||
<PhoneInput id="ReferrerPhone" value={data.ReferrerPhone} onChange={(v) => set('ReferrerPhone', v)} />
|
<PhoneInput id="ReferrerPhone" value={data.ReferrerPhone} onChange={(v) => set('ReferrerPhone', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function buildItem(data: IFormData, status: string, referrerId: number): Record<
|
|||||||
ReferrerPosition: data.ReferrerPosition || null,
|
ReferrerPosition: data.ReferrerPosition || null,
|
||||||
ReferrerDepartment: data.ReferrerDepartment || null,
|
ReferrerDepartment: data.ReferrerDepartment || null,
|
||||||
ReferrerPhone: data.ReferrerPhone || null,
|
ReferrerPhone: data.ReferrerPhone || null,
|
||||||
|
Organization: data.Organization || null,
|
||||||
CandidateName: data.CandidateName || null,
|
CandidateName: data.CandidateName || null,
|
||||||
CandidatePhone: data.CandidatePhone || null,
|
CandidatePhone: data.CandidatePhone || null,
|
||||||
CandidateEmail: data.CandidateEmail || null,
|
CandidateEmail: data.CandidateEmail || null,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
IAttachmentInfo
|
IAttachmentInfo
|
||||||
} from '../../shared/attachments/attachments.service';
|
} from '../../shared/attachments/attachments.service';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import {
|
import {
|
||||||
@@ -77,15 +78,28 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
}
|
}
|
||||||
const user = context.pageContext.user;
|
const user = context.pageContext.user;
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const requesterEmail = effectiveUserEmail(context);
|
||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
// email — через effectiveUserEmail (учитывает отладочную подмену личности);
|
// email — через effectiveUserEmail (учитывает отладочную подмену личности);
|
||||||
// displayName подменить нечем, справочник ФИО тут не читается — при
|
// displayName подменить нечем, справочник ФИО тут не читается — при
|
||||||
// подмене имя останется настоящим, для отладки это неважно.
|
// подмене имя останется настоящим, для отладки это неважно.
|
||||||
RequesterEmail: effectiveUserEmail(context),
|
RequesterEmail: requesterEmail,
|
||||||
AuthorName: user.displayName || '',
|
AuthorName: user.displayName || '',
|
||||||
RequestDate: prev.RequestDate || today
|
RequestDate: prev.RequestDate || today
|
||||||
}));
|
}));
|
||||||
|
// Организации заявителя (Employee/ByEmail) — read-only, см. shared/ui/OrganizationField.
|
||||||
|
let alive = true;
|
||||||
|
getEmployeeOrganizations(context, requesterEmail)
|
||||||
|
.then((records) => {
|
||||||
|
if (alive) {
|
||||||
|
setData((prev) => ({ ...prev, Organization: formatEmployeeOrganizations(records) }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
}, [editId, context]);
|
}, [editId, context]);
|
||||||
|
|
||||||
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и вложения.
|
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и вложения.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P
|
|||||||
'Recipient',
|
'Recipient',
|
||||||
'RequesterEmail',
|
'RequesterEmail',
|
||||||
'AuthorName',
|
'AuthorName',
|
||||||
|
'Organization',
|
||||||
'Subject',
|
'Subject',
|
||||||
'RequestDate',
|
'RequestDate',
|
||||||
'SourceProject',
|
'SourceProject',
|
||||||
@@ -60,6 +61,7 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P
|
|||||||
Recipient: (item.Recipient as string) || '',
|
Recipient: (item.Recipient as string) || '',
|
||||||
RequesterEmail: (item.RequesterEmail as string) || '',
|
RequesterEmail: (item.RequesterEmail as string) || '',
|
||||||
AuthorName: (item.AuthorName as string) || '',
|
AuthorName: (item.AuthorName as string) || '',
|
||||||
|
Organization: (item.Organization as string) || '',
|
||||||
Subject: (item.Subject as string) || '',
|
Subject: (item.Subject as string) || '',
|
||||||
RequestDate: requestDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
RequestDate: requestDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
||||||
SourceProject: (item.SourceProject as string) || '',
|
SourceProject: (item.SourceProject as string) || '',
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export interface IFormData {
|
|||||||
// NB: не «Author» — это зарезервированное системное имя колонки SP (Created By).
|
// NB: не «Author» — это зарезервированное системное имя колонки SP (Created By).
|
||||||
RequesterEmail: string; // От — email заявителя (текущий пользователь)
|
RequesterEmail: string; // От — email заявителя (текущий пользователь)
|
||||||
AuthorName: string; // От — Ф.И.О. заявителя (для печати)
|
AuthorName: string; // От — Ф.И.О. заявителя (для печати)
|
||||||
|
/**
|
||||||
|
* Организации заявителя (`\n`-joined «Компания — должность» на строку) —
|
||||||
|
* подтягивается автоматически из Employee/ByEmail, read-only (см.
|
||||||
|
* shared/ui/OrganizationField).
|
||||||
|
*/
|
||||||
|
Organization: string;
|
||||||
Subject: string; // Тема (по умолч. «Перемещение ТМЦ»)
|
Subject: string; // Тема (по умолч. «Перемещение ТМЦ»)
|
||||||
RequestDate: string; // Дата (YYYY-MM-DD)
|
RequestDate: string; // Дата (YYYY-MM-DD)
|
||||||
|
|
||||||
@@ -58,6 +64,7 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Recipient: DEFAULT_RECIPIENT,
|
Recipient: DEFAULT_RECIPIENT,
|
||||||
RequesterEmail: '',
|
RequesterEmail: '',
|
||||||
AuthorName: '',
|
AuthorName: '',
|
||||||
|
Organization: '',
|
||||||
Subject: DEFAULT_SUBJECT,
|
Subject: DEFAULT_SUBJECT,
|
||||||
RequestDate: '',
|
RequestDate: '',
|
||||||
SourceProject: '',
|
SourceProject: '',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
{ "internalName": "Recipient", "title": "Кому", "type": "Text" },
|
{ "internalName": "Recipient", "title": "Кому", "type": "Text" },
|
||||||
{ "internalName": "RequesterEmail", "title": "От (email заявителя)", "type": "Text" },
|
{ "internalName": "RequesterEmail", "title": "От (email заявителя)", "type": "Text" },
|
||||||
{ "internalName": "AuthorName", "title": "От (Ф.И.О.)", "type": "Text" },
|
{ "internalName": "AuthorName", "title": "От (Ф.И.О.)", "type": "Text" },
|
||||||
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||||
{ "internalName": "Subject", "title": "Тема", "type": "Text" },
|
{ "internalName": "Subject", "title": "Тема", "type": "Text" },
|
||||||
{ "internalName": "RequestDate", "title": "Дата", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "RequestDate", "title": "Дата", "type": "DateTime", "dateOnly": true },
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormGrid, FormField, TextInput, TextArea } from '../../../shared/ui';
|
import { FormGrid, FormField, TextInput, TextArea, OrganizationField } from '../../../shared/ui';
|
||||||
import { ProjectPicker } from '../../../shared/dictionary';
|
import { ProjectPicker } from '../../../shared/dictionary';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import { ISectionProps } from '../model';
|
|||||||
*/
|
*/
|
||||||
export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
|
<OrganizationField value={data.Organization} />
|
||||||
<FormField label="Откуда (проект-источник)" htmlFor="SourceProject" required>
|
<FormField label="Откуда (проект-источник)" htmlFor="SourceProject" required>
|
||||||
<ProjectPicker
|
<ProjectPicker
|
||||||
id="SourceProject"
|
id="SourceProject"
|
||||||
|
|||||||
@@ -25,14 +25,16 @@ import {
|
|||||||
resolveWriteListId
|
resolveWriteListId
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
approvalListTitleForListId,
|
approvalListTitleForListId,
|
||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './model';
|
||||||
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
||||||
import { loadUnpaidLeaveRequest } from './load';
|
import { loadUnpaidLeaveRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
@@ -88,7 +90,10 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [editId, editListId]);
|
}, [editId, editListId]);
|
||||||
|
|
||||||
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность.
|
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
||||||
|
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
||||||
|
// employeeOrganizations.service.ts; сотрудник может состоять сразу в
|
||||||
|
// нескольких организациях, должность берём из ПЕРВОЙ записи).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editId) {
|
if (editId) {
|
||||||
return;
|
return;
|
||||||
@@ -96,12 +101,14 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const user = context.pageContext.user;
|
const user = context.pageContext.user;
|
||||||
setData((prev) => ({ ...prev, Employee: prev.Employee || user.email }));
|
setData((prev) => ({ ...prev, Employee: prev.Employee || user.email }));
|
||||||
approval
|
getEmployeeOrganizations(context, user.email)
|
||||||
.getJobTitle(user.loginName)
|
.then((records) => {
|
||||||
.then((title) => {
|
if (!alive) return;
|
||||||
if (alive && title) {
|
setData((prev) => ({
|
||||||
setData((prev) => ({ ...prev, Position: prev.Position || title }));
|
...prev,
|
||||||
}
|
Position: prev.Position || records[0]?.position || prev.Position,
|
||||||
|
Organization: formatEmployeeOrganizations(records)
|
||||||
|
}));
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -110,15 +117,21 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [editId]);
|
}, [editId]);
|
||||||
|
|
||||||
// Выбор другого сотрудника подтягивает его должность.
|
// Выбор другого сотрудника подтягивает его должность и организации.
|
||||||
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: '' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
approval
|
getEmployeeOrganizations(context, login)
|
||||||
.getJobTitle(login)
|
.then((records) =>
|
||||||
.then((title) => setData((prev) => ({ ...prev, Position: title || prev.Position })))
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
Position: records[0]?.position || prev.Position,
|
||||||
|
Organization: formatEmployeeOrganizations(records)
|
||||||
|
}))
|
||||||
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,13 +155,22 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
// Даты корректны, если конец не раньше начала (обе обязательны).
|
// Даты корректны, если конец не раньше начала (обе обязательны).
|
||||||
const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate);
|
const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate);
|
||||||
|
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
|
||||||
|
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
|
||||||
|
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
|
||||||
|
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
|
||||||
|
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
|
||||||
const isValid = !!(
|
const isValid = !!(
|
||||||
data.Employee &&
|
data.Employee &&
|
||||||
data.Position.trim() &&
|
data.Position.trim() &&
|
||||||
datesValid &&
|
datesValid &&
|
||||||
|
isStartDateValid &&
|
||||||
data.Reason.trim()
|
data.Reason.trim()
|
||||||
);
|
);
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
|
if (data.StartDate && !isStartDateValid) {
|
||||||
|
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
|
||||||
|
}
|
||||||
if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.EndDate || !data.Reason.trim()) {
|
if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.EndDate || !data.Reason.trim()) {
|
||||||
return 'Заполните обязательные поля: сотрудник, должность, дата начала, дата окончания, основание.';
|
return 'Заполните обязательные поля: сотрудник, должность, дата начала, дата окончания, основание.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Отпуск без содержания"
|
title="Заявка на отпуск без содержания"
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
|
|||||||
'StartDate',
|
'StartDate',
|
||||||
'EndDate',
|
'EndDate',
|
||||||
'Reason',
|
'Reason',
|
||||||
|
'Organization',
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode',
|
'RequestUniqueCode',
|
||||||
'Employee/Title',
|
'Employee/Title',
|
||||||
@@ -49,7 +50,8 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
|
|||||||
StartDate: start.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
StartDate: start.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
||||||
EndDate: end.slice(0, 10),
|
EndDate: end.slice(0, 10),
|
||||||
Reason: (item.Reason as string) || '',
|
Reason: (item.Reason as string) || '',
|
||||||
Substitute: substitute?.EMail || ''
|
Substitute: substitute?.EMail || '',
|
||||||
|
Organization: (item.Organization as string) || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export interface IFormData {
|
|||||||
Reason: string;
|
Reason: string;
|
||||||
/** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */
|
/** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */
|
||||||
Substitute: string;
|
Substitute: string;
|
||||||
|
/**
|
||||||
|
* Организации сотрудника (`\n`-joined «Компания — должность» на строку) —
|
||||||
|
* подтягивается автоматически из Employee/ByEmail при выборе Employee,
|
||||||
|
* read-only (см. shared/ui/OrganizationField).
|
||||||
|
*/
|
||||||
|
Organization: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обновление одного поля формы с сохранением типа значения. */
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
@@ -50,10 +56,13 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
StartDate: '',
|
StartDate: '',
|
||||||
EndDate: '',
|
EndDate: '',
|
||||||
Reason: '',
|
Reason: '',
|
||||||
Substitute: ''
|
Substitute: '',
|
||||||
|
Organization: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Плейсхолдер для поля выбора сотрудника. */
|
/** Плейсхолдер для поля выбора сотрудника. */
|
||||||
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
||||||
/** Плейсхолдер для поля выбора замещающего. */
|
/** Плейсхолдер для поля выбора замещающего. */
|
||||||
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
|
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
|
||||||
|
/** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */
|
||||||
|
export const MIN_NOTICE_DAYS = 14;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
|
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
|
||||||
{ "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" },
|
{ "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" },
|
||||||
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" }
|
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, TextArea, PeopleField } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, TextArea, PeopleField, OrganizationField } from '../../../shared/ui';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER } from '../model';
|
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 }) => (
|
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => (
|
||||||
<CollapseSection title="Отпуск без содержания" icon="people">
|
<CollapseSection title="Заявка на отпуск без содержания" icon="people">
|
||||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
@@ -16,8 +17,15 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
|||||||
<FormField label="Должность" htmlFor="Position" required>
|
<FormField label="Должность" htmlFor="Position" required>
|
||||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<OrganizationField value={data.Organization} />
|
||||||
<FormField label="Дата начала" htmlFor="StartDate" required>
|
<FormField label="Дата начала" htmlFor="StartDate" required>
|
||||||
<TextInput id="StartDate" type="date" value={data.StartDate} onChange={(v) => set('StartDate', v)} />
|
<TextInput
|
||||||
|
id="StartDate"
|
||||||
|
type="date"
|
||||||
|
value={data.StartDate}
|
||||||
|
onChange={(v) => set('StartDate', v)}
|
||||||
|
min={isoDatePlusDays(MIN_NOTICE_DAYS)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Дата окончания" htmlFor="EndDate" required>
|
<FormField label="Дата окончания" htmlFor="EndDate" required>
|
||||||
<TextInput id="EndDate" type="date" value={data.EndDate} onChange={(v) => set('EndDate', v)} />
|
<TextInput id="EndDate" type="date" value={data.EndDate} onChange={(v) => set('EndDate', v)} />
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ function buildItem(
|
|||||||
EndDate: data.EndDate || null,
|
EndDate: data.EndDate || null,
|
||||||
DaysCount: days ?? null,
|
DaysCount: days ?? null,
|
||||||
Reason: data.Reason || null,
|
Reason: data.Reason || null,
|
||||||
SubstituteId: substituteId ?? null
|
SubstituteId: substituteId ?? null,
|
||||||
|
Organization: data.Organization || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,14 +25,16 @@ import {
|
|||||||
unassignedStepMessage
|
unassignedStepMessage
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
approvalListTitleForListId,
|
approvalListTitleForListId,
|
||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './model';
|
||||||
import { saveVacationRequest, vacationApprovalService } from './submit';
|
import { saveVacationRequest, vacationApprovalService } from './submit';
|
||||||
import { loadVacationRequest } from './load';
|
import { loadVacationRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
@@ -88,7 +90,10 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [editId, editListId]);
|
}, [editId, editListId]);
|
||||||
|
|
||||||
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность.
|
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
||||||
|
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
||||||
|
// employeeOrganizations.service.ts; сотрудник может состоять сразу в
|
||||||
|
// нескольких организациях, должность берём из ПЕРВОЙ записи).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editId) {
|
if (editId) {
|
||||||
return;
|
return;
|
||||||
@@ -96,12 +101,14 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const user = context.pageContext.user;
|
const user = context.pageContext.user;
|
||||||
setData((prev) => ({ ...prev, Employee: prev.Employee || user.email }));
|
setData((prev) => ({ ...prev, Employee: prev.Employee || user.email }));
|
||||||
approval
|
getEmployeeOrganizations(context, user.email)
|
||||||
.getJobTitle(user.loginName)
|
.then((records) => {
|
||||||
.then((title) => {
|
if (!alive) return;
|
||||||
if (alive && title) {
|
setData((prev) => ({
|
||||||
setData((prev) => ({ ...prev, Position: prev.Position || title }));
|
...prev,
|
||||||
}
|
Position: prev.Position || records[0]?.position || prev.Position,
|
||||||
|
Organization: formatEmployeeOrganizations(records)
|
||||||
|
}));
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -110,15 +117,22 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [editId]);
|
}, [editId]);
|
||||||
|
|
||||||
// Выбор другого сотрудника подтягивает его должность (как в исходной pto-tracker).
|
// Выбор другого сотрудника подтягивает его должность и организации (как в
|
||||||
|
// исходной pto-tracker, но источник — Employee/ByEmail вместо SPS-JobTitle).
|
||||||
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: '' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
approval
|
getEmployeeOrganizations(context, login)
|
||||||
.getJobTitle(login)
|
.then((records) =>
|
||||||
.then((title) => setData((prev) => ({ ...prev, Position: title || prev.Position })))
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
Position: records[0]?.position || prev.Position,
|
||||||
|
Organization: formatEmployeeOrganizations(records)
|
||||||
|
}))
|
||||||
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,14 +155,23 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const daysNum = Number(data.DaysCount);
|
const daysNum = Number(data.DaysCount);
|
||||||
|
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
|
||||||
|
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
|
||||||
|
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
|
||||||
|
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
|
||||||
|
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
|
||||||
const isValid = !!(
|
const isValid = !!(
|
||||||
data.Employee &&
|
data.Employee &&
|
||||||
data.Position.trim() &&
|
data.Position.trim() &&
|
||||||
data.StartDate &&
|
data.StartDate &&
|
||||||
|
isStartDateValid &&
|
||||||
Number.isFinite(daysNum) &&
|
Number.isFinite(daysNum) &&
|
||||||
daysNum > 0
|
daysNum > 0
|
||||||
);
|
);
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
|
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 (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на отпуск"
|
title="Заявка на трудовой отпуск"
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -28,14 +28,17 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom
|
|||||||
'Position',
|
'Position',
|
||||||
'StartDate',
|
'StartDate',
|
||||||
'DaysCount',
|
'DaysCount',
|
||||||
|
'Organization',
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode',
|
'RequestUniqueCode',
|
||||||
'Employee/Title',
|
'Employee/Title',
|
||||||
'Employee/EMail'
|
'Employee/EMail',
|
||||||
|
'Substitute/EMail'
|
||||||
)
|
)
|
||||||
.expand('Employee')()) as Record<string, unknown>;
|
.expand('Employee', 'Substitute')()) as Record<string, unknown>;
|
||||||
|
|
||||||
const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
|
const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
|
||||||
|
const substitute = item.Substitute as { EMail?: string } | undefined;
|
||||||
const start = (item.StartDate as string) || '';
|
const start = (item.StartDate as string) || '';
|
||||||
const days = item.DaysCount;
|
const days = item.DaysCount;
|
||||||
|
|
||||||
@@ -44,7 +47,9 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom
|
|||||||
Employee: employee?.EMail || '',
|
Employee: employee?.EMail || '',
|
||||||
Position: (item.Position as string) || '',
|
Position: (item.Position as string) || '',
|
||||||
StartDate: start.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
StartDate: start.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
||||||
DaysCount: days === null || days === undefined ? '' : String(days)
|
DaysCount: days === null || days === undefined ? '' : String(days),
|
||||||
|
Substitute: substitute?.EMail || '',
|
||||||
|
Organization: (item.Organization as string) || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ export interface IFormData {
|
|||||||
StartDate: string;
|
StartDate: string;
|
||||||
/** Строка из input type=number; при записи → number. */
|
/** Строка из input type=number; при записи → number. */
|
||||||
DaysCount: string;
|
DaysCount: string;
|
||||||
|
/** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */
|
||||||
|
Substitute: string;
|
||||||
|
/**
|
||||||
|
* Организации сотрудника (`\n`-joined «Компания — должность» на строку) —
|
||||||
|
* подтягивается автоматически из Employee/ByEmail (см.
|
||||||
|
* shared/dictionary/employeeOrganizations.service.ts) при выборе Employee,
|
||||||
|
* read-only (см. shared/ui/OrganizationField). Сотрудник может состоять
|
||||||
|
* сразу в нескольких организациях — тогда строк несколько.
|
||||||
|
*/
|
||||||
|
Organization: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обновление одного поля формы с сохранением типа значения. */
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
@@ -43,8 +53,14 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Employee: '',
|
Employee: '',
|
||||||
Position: '',
|
Position: '',
|
||||||
StartDate: '',
|
StartDate: '',
|
||||||
DaysCount: ''
|
DaysCount: '',
|
||||||
|
Substitute: '',
|
||||||
|
Organization: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Плейсхолдер для поля выбора сотрудника. */
|
/** Плейсхолдер для поля выбора сотрудника. */
|
||||||
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
||||||
|
/** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */
|
||||||
|
export const MIN_NOTICE_DAYS = 14;
|
||||||
|
/** Плейсхолдер для поля выбора замещающего. */
|
||||||
|
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
{ "internalName": "Position", "title": "Должность", "type": "Text" },
|
{ "internalName": "Position", "title": "Должность", "type": "Text" },
|
||||||
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" }
|
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
|
||||||
|
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, PeopleField } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
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 }) => (
|
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => (
|
||||||
<CollapseSection title="Заявка на отпуск" icon="people">
|
<CollapseSection title="Заявка на трудовой отпуск" icon="people">
|
||||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
@@ -16,11 +17,26 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
|||||||
<FormField label="Должность" htmlFor="Position" required>
|
<FormField label="Должность" htmlFor="Position" required>
|
||||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<OrganizationField value={data.Organization} />
|
||||||
<FormField label="Дата начала" htmlFor="StartDate" required>
|
<FormField label="Дата начала" htmlFor="StartDate" required>
|
||||||
<TextInput id="StartDate" type="date" value={data.StartDate} onChange={(v) => set('StartDate', v)} />
|
<TextInput
|
||||||
|
id="StartDate"
|
||||||
|
type="date"
|
||||||
|
value={data.StartDate}
|
||||||
|
onChange={(v) => set('StartDate', v)}
|
||||||
|
min={isoDatePlusDays(MIN_NOTICE_DAYS)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Количество дней" htmlFor="DaysCount" required>
|
<FormField label="Количество дней" htmlFor="DaysCount" required>
|
||||||
<TextInput id="DaysCount" type="number" value={data.DaysCount} onChange={(v) => set('DaysCount', v)} />
|
<TextInput id="DaysCount" type="number" value={data.DaysCount} onChange={(v) => set('DaysCount', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full>
|
||||||
|
<PeopleField
|
||||||
|
context={context}
|
||||||
|
value={data.Substitute}
|
||||||
|
onChange={(v) => set('Substitute', v)}
|
||||||
|
placeholder={SUBSTITUTE_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,8 +29,14 @@ function buildTitle(employeeName: string): string {
|
|||||||
return `Отпуск — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
|
return `Отпуск — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поля элемента списка данных из модели формы (+ Employee по Id, + вычисленная EndDate). */
|
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id, + вычисленная EndDate). */
|
||||||
function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record<string, unknown> {
|
function buildItem(
|
||||||
|
data: IFormData,
|
||||||
|
status: string,
|
||||||
|
employeeId: number,
|
||||||
|
employeeName: string,
|
||||||
|
substituteId: number | undefined
|
||||||
|
): Record<string, unknown> {
|
||||||
const days = Number(data.DaysCount);
|
const days = Number(data.DaysCount);
|
||||||
return {
|
return {
|
||||||
Title: buildTitle(employeeName),
|
Title: buildTitle(employeeName),
|
||||||
@@ -39,7 +45,9 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
|||||||
Position: data.Position || null,
|
Position: data.Position || null,
|
||||||
StartDate: data.StartDate || null,
|
StartDate: data.StartDate || null,
|
||||||
DaysCount: Number.isFinite(days) && days > 0 ? days : null,
|
DaysCount: Number.isFinite(days) && days > 0 ? days : null,
|
||||||
EndDate: computeEndDate(data.StartDate, days) || null
|
EndDate: computeEndDate(data.StartDate, days) || null,
|
||||||
|
SubstituteId: substituteId ?? null,
|
||||||
|
Organization: data.Organization || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +76,9 @@ export async function saveVacationRequest(
|
|||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
const employee = await ensureEmployee(data.Employee);
|
const employee = await ensureEmployee(data.Employee);
|
||||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
|
// Замещающий необязателен: резолвим только если выбран.
|
||||||
|
const substituteId = data.Substitute ? (await ensureEmployee(data.Substitute)).Id : undefined;
|
||||||
|
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
|
||||||
|
|
||||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status);
|
await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
IAttachmentInfo
|
IAttachmentInfo
|
||||||
} from '../../shared/attachments/attachments.service';
|
} from '../../shared/attachments/attachments.service';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import {
|
import {
|
||||||
@@ -111,7 +112,10 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
loadPrefillFromHire(sourceId)
|
loadPrefillFromHire(sourceId)
|
||||||
.then((prefilled) => {
|
.then((prefilled) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setData(prefilled);
|
// Merge, а не replace: организация заявителя (ниже) резолвится отдельным
|
||||||
|
// эффектом и могла успеть записаться раньше — не затираем её пустышкой
|
||||||
|
// из prefilled (в loadPrefillFromHire её нет).
|
||||||
|
setData((prev) => ({ ...prefilled, Organization: prev.Organization || prefilled.Organization }));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -129,6 +133,30 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
// workplaceApprovalService можно звать не опасаясь исключения.
|
// workplaceApprovalService можно звать не опасаясь исключения.
|
||||||
const approval = useMemo(() => workplaceApprovalService(editListId), [editListId]);
|
const approval = useMemo(() => workplaceApprovalService(editListId), [editListId]);
|
||||||
const userEmail = effectiveUserEmail(context);
|
const userEmail = effectiveUserEmail(context);
|
||||||
|
|
||||||
|
// Организации заявителя (Employee/ByEmail) — форма не о самом заявителе, а о
|
||||||
|
// новом сотруднике (FullName — обычный текст, предзаполняется из «Заявки на
|
||||||
|
// прием», и его чаще всего ещё нет в системе кадров), поэтому организация
|
||||||
|
// тянется по ТЕКУЩЕМУ пользователю. На редактировании уже сохранённое
|
||||||
|
// значение не перезатираем.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editId || !userEmail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
getEmployeeOrganizations(context, userEmail)
|
||||||
|
.then((records) => {
|
||||||
|
if (alive) {
|
||||||
|
setData((prev) => ({ ...prev, Organization: prev.Organization || formatEmployeeOrganizations(records) }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [editId, userEmail]);
|
||||||
|
|
||||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { FORM_ID, IFormData, EMPTY_DATA } from './model';
|
|||||||
|
|
||||||
const SCALAR_FIELDS: (keyof IFormData)[] = [
|
const SCALAR_FIELDS: (keyof IFormData)[] = [
|
||||||
'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'DepartmentId', 'WorkKind', 'Location', 'StartDate',
|
'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'DepartmentId', 'WorkKind', 'Location', 'StartDate',
|
||||||
'Software', 'AccessRights'
|
'Software', 'AccessRights', 'Organization'
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ILoadedRequest {
|
export interface ILoadedRequest {
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export interface IFormData {
|
|||||||
WorkKind: string;
|
WorkKind: string;
|
||||||
Location: string;
|
Location: string;
|
||||||
StartDate: string;
|
StartDate: string;
|
||||||
|
/**
|
||||||
|
* Организации заявителя (`\n`-joined «Компания — должность» на строку) —
|
||||||
|
* подтягивается автоматически из Employee/ByEmail по e-mail заявителя (не
|
||||||
|
* нового сотрудника — его ещё нет в системе кадров), read-only (см.
|
||||||
|
* shared/ui/OrganizationField).
|
||||||
|
*/
|
||||||
|
Organization: string;
|
||||||
// Оборудование и доступы (заполняются вручную)
|
// Оборудование и доступы (заполняются вручную)
|
||||||
NeedsEquipment: boolean;
|
NeedsEquipment: boolean;
|
||||||
Software: string;
|
Software: string;
|
||||||
@@ -50,6 +57,7 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
WorkKind: '',
|
WorkKind: '',
|
||||||
Location: '',
|
Location: '',
|
||||||
StartDate: '',
|
StartDate: '',
|
||||||
|
Organization: '',
|
||||||
NeedsEquipment: false,
|
NeedsEquipment: false,
|
||||||
Software: '',
|
Software: '',
|
||||||
AccessRights: ''
|
AccessRights: ''
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
||||||
{ "internalName": "Location", "title": "Местоположение", "type": "Text" },
|
{ "internalName": "Location", "title": "Местоположение", "type": "Text" },
|
||||||
{ "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "Organization", "title": "Организация заявителя", "type": "Note" },
|
||||||
|
|
||||||
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
|
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
|
||||||
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
|
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, PhoneInput } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, PhoneInput, OrganizationField } from '../../../shared/ui';
|
||||||
import { DepartmentPicker } from '../../../shared/dictionary';
|
import { DepartmentPicker } from '../../../shared/dictionary';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
@@ -37,5 +37,6 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context })
|
|||||||
<FormField label="Дата приёма" htmlFor="StartDate">
|
<FormField label="Дата приёма" htmlFor="StartDate">
|
||||||
<TextInput id="StartDate" type="date" value={data.StartDate} onChange={(v) => set('StartDate', v)} />
|
<TextInput id="StartDate" type="date" value={data.StartDate} onChange={(v) => set('StartDate', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<OrganizationField value={data.Organization} label="Организация заявителя" />
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,9 +17,35 @@ async function client(context: WebPartContext): Promise<AadHttpClient> {
|
|||||||
return context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
return context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ретрай-политика на сетевые сбои — тот же принцип и те же по порядку величины
|
||||||
|
// значения, что и у RETRY в shared/pnp/pnpjs.ts (там для PnPjs/SharePoint).
|
||||||
|
const NETWORK_RETRY_ATTEMPTS = 2;
|
||||||
|
const NETWORK_RETRY_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мягкий ретрай на СЕТЕВЫЕ сбои (fetch не долетел вовсе — TypeError «Failed to
|
||||||
|
* fetch»/«NetworkError», не HTTP-статус). Тот же принцип, что и
|
||||||
|
* BrowserFetchWithRetry у PnPjs-клиента (shared/pnp/pnpjs.ts) — там ретрай уже
|
||||||
|
* есть, а этот внешний API его был лишён, из-за чего случайная заминка сети или
|
||||||
|
* холодный старт бэкенда сразу превращались в необратимое «Failed to fetch» в
|
||||||
|
* UI матрицы. HTTP-ошибки (404/401/500 — throw Error ниже по коду) НЕ ретраим:
|
||||||
|
* повтор того же запроса их не лечит, только тянет время до сообщения об ошибке.
|
||||||
|
*/
|
||||||
|
async function withNetworkRetry<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
for (let attempt = 0; ; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
if (attempt >= NETWORK_RETRY_ATTEMPTS || !(e instanceof TypeError)) throw e;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, NETWORK_RETRY_INTERVAL_MS * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/request-types — список типов заявок (id + имя). Пригодится, чтобы
|
// GET /api/request-types — список типов заявок (id + имя). Пригодится, чтобы
|
||||||
// сопоставить форму с RequestTypeId, если он неизвестен заранее.
|
// сопоставить форму с RequestTypeId, если он неизвестен заранее.
|
||||||
export async function getRequestTypes(context: WebPartContext): Promise<IRequestType[]> {
|
export async function getRequestTypes(context: WebPartContext): Promise<IRequestType[]> {
|
||||||
|
return withNetworkRetry(async () => {
|
||||||
const c = await client(context);
|
const c = await client(context);
|
||||||
const response: HttpClientResponse = await c.get(
|
const response: HttpClientResponse = await c.get(
|
||||||
`${getApprovalApiBaseUrl()}/api/request-types`,
|
`${getApprovalApiBaseUrl()}/api/request-types`,
|
||||||
@@ -30,6 +56,7 @@ export async function getRequestTypes(context: WebPartContext): Promise<IRequest
|
|||||||
}
|
}
|
||||||
const body: IApiResponse<IRequestType[]> = await response.json();
|
const body: IApiResponse<IRequestType[]> = await response.json();
|
||||||
return body.data ?? [];
|
return body.data ?? [];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +70,7 @@ export async function getRequestTypes(context: WebPartContext): Promise<IRequest
|
|||||||
* Типы без настроенного списка SharePoint в ответ не попадают.
|
* Типы без настроенного списка SharePoint в ответ не попадают.
|
||||||
*/
|
*/
|
||||||
export async function getListPartitions(context: WebPartContext): Promise<IRequestTypeListSeries[]> {
|
export async function getListPartitions(context: WebPartContext): Promise<IRequestTypeListSeries[]> {
|
||||||
|
return withNetworkRetry(async () => {
|
||||||
const c = await client(context);
|
const c = await client(context);
|
||||||
const response: HttpClientResponse = await c.get(
|
const response: HttpClientResponse = await c.get(
|
||||||
`${getApprovalApiBaseUrl()}/api/request-types/lists`,
|
`${getApprovalApiBaseUrl()}/api/request-types/lists`,
|
||||||
@@ -53,6 +81,7 @@ export async function getListPartitions(context: WebPartContext): Promise<IReque
|
|||||||
}
|
}
|
||||||
const body: IApiResponse<IRequestTypeListSeries[]> = await response.json();
|
const body: IApiResponse<IRequestTypeListSeries[]> = await response.json();
|
||||||
return body.data ?? [];
|
return body.data ?? [];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,6 +96,7 @@ export async function getCurrentListPartition(
|
|||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
requestTypeId: number
|
requestTypeId: number
|
||||||
): Promise<ICurrentListPartition> {
|
): Promise<ICurrentListPartition> {
|
||||||
|
return withNetworkRetry(async () => {
|
||||||
const c = await client(context);
|
const c = await client(context);
|
||||||
const response: HttpClientResponse = await c.get(
|
const response: HttpClientResponse = await c.get(
|
||||||
`${getApprovalApiBaseUrl()}/api/request-types/${requestTypeId}/lists/current`,
|
`${getApprovalApiBaseUrl()}/api/request-types/${requestTypeId}/lists/current`,
|
||||||
@@ -77,6 +107,7 @@ export async function getCurrentListPartition(
|
|||||||
}
|
}
|
||||||
const body: IApiResponse<ICurrentListPartition> = await response.json();
|
const body: IApiResponse<ICurrentListPartition> = await response.json();
|
||||||
return body.data;
|
return body.data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет
|
// Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет
|
||||||
@@ -138,6 +169,7 @@ export async function resolveRoute(
|
|||||||
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
|
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
|
||||||
projectId: string | null = null
|
projectId: string | null = null
|
||||||
): Promise<IResolvedRoute> {
|
): Promise<IResolvedRoute> {
|
||||||
|
return withNetworkRetry(async () => {
|
||||||
const c = await client(context);
|
const c = await client(context);
|
||||||
const url = `${getApprovalApiBaseUrl()}/api/template-routes/resolve`;
|
const url = `${getApprovalApiBaseUrl()}/api/template-routes/resolve`;
|
||||||
|
|
||||||
@@ -164,4 +196,5 @@ export async function resolveRoute(
|
|||||||
}
|
}
|
||||||
const body: IApiResponse<IResolvedRoute> = await response.json();
|
const body: IApiResponse<IResolvedRoute> = await response.json();
|
||||||
return body.data;
|
return body.data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
waiting = false,
|
waiting = false,
|
||||||
waitingText = '',
|
waitingText = '',
|
||||||
}) => {
|
}) => {
|
||||||
const { rows, loading, error, editable, blocks } = editor;
|
const { rows, loading, error, editable, blocks, retry } = editor;
|
||||||
|
|
||||||
// Подпись «доп. согласующий»/«доп. исполнитель» — из roleLabel, а не
|
// Подпись «доп. согласующий»/«доп. исполнитель» — из roleLabel, а не
|
||||||
// хардкод: иначе блок «Исполнение» (roleLabel="Исполнитель") показывал бы
|
// хардкод: иначе блок «Исполнение» (roleLabel="Исполнитель") показывал бы
|
||||||
@@ -113,7 +113,14 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<div className={styles.matrixTitle}>{title}</div>
|
<div className={styles.matrixTitle}>{title}</div>
|
||||||
{waiting && <div className={styles.matrixNote}>{waitingText}</div>}
|
{waiting && <div className={styles.matrixNote}>{waitingText}</div>}
|
||||||
{!waiting && loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
|
{!waiting && loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
|
||||||
{!waiting && !loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>}
|
{!waiting && !loading && error && (
|
||||||
|
<div className={styles.matrixNote}>
|
||||||
|
Маршрут недоступен: {error}{' '}
|
||||||
|
<button type="button" className={styles.retryBtn} onClick={retry}>
|
||||||
|
Повторить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!waiting && !loading && !error && rows.length === 0 && (
|
{!waiting && !loading && !error && rows.length === 0 && (
|
||||||
<div className={styles.matrixNote}>{emptyText}</div>
|
<div className={styles.matrixNote}>{emptyText}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -91,6 +91,23 @@
|
|||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Инлайновая ссылка-кнопка повтора запроса рядом с текстом ошибки — тот же
|
||||||
|
// приём, что у .email в InitiatorLine: не отдельная кнопка с рамкой, а
|
||||||
|
// подчёркиваемая ссылка внутри строки заметки.
|
||||||
|
.retryBtn {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: $navy-700;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Обёртка таблицы: на промежуточных ширинах — горизонтальный скролл вместо
|
// Обёртка таблицы: на промежуточных ширинах — горизонтальный скролл вместо
|
||||||
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
|
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
|
||||||
.matrixScroll {
|
.matrixScroll {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface IUseMatrixEditor {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | undefined;
|
error: string | undefined;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
/** Перезапросить маршрут ещё раз (после сетевой ошибки) без смены reloadKey. */
|
||||||
|
retry: () => void;
|
||||||
addApprover: (user: ISiteUser) => void;
|
addApprover: (user: ISiteUser) => void;
|
||||||
/** Переместить ШАГ целиком (вместе со всеми параллельными строками) выше/ниже. */
|
/** Переместить ШАГ целиком (вместе со всеми параллельными строками) выше/ниже. */
|
||||||
moveBlockUp: (index: number) => void;
|
moveBlockUp: (index: number) => void;
|
||||||
@@ -155,6 +157,10 @@ export function useMatrixEditor(
|
|||||||
const [rows, setRows] = useState<IMatrixRow[]>([]);
|
const [rows, setRows] = useState<IMatrixRow[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
// Ручной повтор после ошибки (см. кнопку «Повторить» в ApprovalMatrix) — свой
|
||||||
|
// счётчик, а не reloadKey: тот приходит от вызывающего и не должен знать о
|
||||||
|
// повторных попытках, это внутренняя механика самого хука.
|
||||||
|
const [retrySeq, setRetrySeq] = useState<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
@@ -175,9 +181,11 @@ export function useMatrixEditor(
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
// load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready.
|
// load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready/retrySeq.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [reloadKey, ready]);
|
}, [reloadKey, ready, retrySeq]);
|
||||||
|
|
||||||
|
const retry = (): void => setRetrySeq((n) => n + 1);
|
||||||
|
|
||||||
const appendUser = (user: ISiteUser, position: string): void => {
|
const appendUser = (user: ISiteUser, position: string): void => {
|
||||||
setRows((prev) => {
|
setRows((prev) => {
|
||||||
@@ -235,6 +243,7 @@ export function useMatrixEditor(
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
editable,
|
editable,
|
||||||
|
retry,
|
||||||
addApprover,
|
addApprover,
|
||||||
moveBlockUp,
|
moveBlockUp,
|
||||||
moveBlockDown,
|
moveBlockDown,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
function pad2(n: number): string {
|
||||||
|
return n < 10 ? `0${n}` : `${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сегодня + n дней в формате YYYY-MM-DD (для min/max у input type=date). */
|
||||||
|
export function isoDatePlusDays(days: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
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('-');
|
||||||
|
return y && m && d ? `${d}.${m}.${y}` : iso;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||||
|
import { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||||
|
|
||||||
|
// GET /Employee/ByEmail?Email=... — организации сотрудника (см. IEmployeeOrganization:
|
||||||
|
// один email может вернуть несколько записей). 404 (email не найден в системе
|
||||||
|
// кадров) — не ошибка формы, просто пусто: не у каждого приглашённого/внешнего
|
||||||
|
// пользователя портала есть карточка сотрудника.
|
||||||
|
async function fetchEmployeeOrganizations(context: WebPartContext, email: string): Promise<IEmployeeOrganization[]> {
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
|
const response: HttpClientResponse = await client.get(
|
||||||
|
`${DICTIONARY_API_BASE_URL}/Employee/ByEmail?Email=${encodeURIComponent(email)}`,
|
||||||
|
AadHttpClient.configurations.v1
|
||||||
|
);
|
||||||
|
if (response.status === 404) return [];
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Employee/ByEmail: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as Array<Record<string, unknown>>;
|
||||||
|
return body.map((r) => ({
|
||||||
|
id: (r.Id as string) || '',
|
||||||
|
fio: (r.Fio as string) || '',
|
||||||
|
email: (r.Email as string) || '',
|
||||||
|
company: (r.Company as string) || '',
|
||||||
|
companyId: (r.CompanyId as string) || '',
|
||||||
|
section: (r.Section as string) || '',
|
||||||
|
departmentId: (r.DepartmentId as string) || '',
|
||||||
|
position: (r.Position as string) || '',
|
||||||
|
mobile: (r.Mobile as string) || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кеш по e-mail (не один общий промис, как у companies/departments — там список
|
||||||
|
// один на всех, здесь результат зависит от аргумента). Ошибку не кешируем — см.
|
||||||
|
// companies.service.ts, тот же принцип.
|
||||||
|
const cache = new Map<string, Promise<IEmployeeOrganization[]>>();
|
||||||
|
|
||||||
|
/** Организации сотрудника по e-mail. Пустой email → [] без обращения к API. */
|
||||||
|
export function getEmployeeOrganizations(context: WebPartContext, email: string): Promise<IEmployeeOrganization[]> {
|
||||||
|
if (!email) return Promise.resolve([]);
|
||||||
|
const cached = cache.get(email);
|
||||||
|
if (cached) return cached;
|
||||||
|
const promise = fetchEmployeeOrganizations(context, email).catch((e) => {
|
||||||
|
cache.delete(email);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
cache.set(email, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Компания — должность» по каждой записи, по одной на строку (`\n`) — общий
|
||||||
|
* формат и для отображения (построчно), и для хранения в списке SharePoint
|
||||||
|
* (колонка Organization, Note — поддерживает переносы строк).
|
||||||
|
*/
|
||||||
|
export function formatEmployeeOrganizations(records: IEmployeeOrganization[]): string {
|
||||||
|
return records.map((r) => (r.position ? `${r.company} — ${r.position}` : r.company)).join('\n');
|
||||||
|
}
|
||||||
@@ -10,3 +10,5 @@ export { getCompanies } from './companies.service';
|
|||||||
export type { ICompany } from './models/ICompany';
|
export type { ICompany } from './models/ICompany';
|
||||||
export { CompanyPickerMulti } from './ui/CompanyPickerMulti';
|
export { CompanyPickerMulti } from './ui/CompanyPickerMulti';
|
||||||
export type { ICompanyPickerMultiProps } from './ui/CompanyPickerMulti';
|
export type { ICompanyPickerMultiProps } from './ui/CompanyPickerMulti';
|
||||||
|
export { getEmployeeOrganizations, formatEmployeeOrganizations } from './employeeOrganizations.service';
|
||||||
|
export type { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Одна запись из GET /Employee/ByEmail — сотрудник МОЖЕТ состоять сразу в
|
||||||
|
* нескольких организациях (совместительство/аффилированные ТОО), тогда на один
|
||||||
|
* и тот же e-mail эндпоинт возвращает НЕСКОЛЬКО записей с разными Company/
|
||||||
|
* CompanyId/Section/DepartmentId/Position.
|
||||||
|
*/
|
||||||
|
export interface IEmployeeOrganization {
|
||||||
|
id: string;
|
||||||
|
fio: string;
|
||||||
|
email: string;
|
||||||
|
company: string;
|
||||||
|
companyId: string;
|
||||||
|
section: string;
|
||||||
|
departmentId: string;
|
||||||
|
position: string;
|
||||||
|
mobile: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only «карточка» на организацию — тот же радиус/паддинг, что у TextInput
|
||||||
|
// ($r-sm, 11px/13px из @tnsu/ui-kit), но приглушённый фон/рамка (как в
|
||||||
|
// readonly-field) — сигнал «подставлено автоматически, не редактируется».
|
||||||
|
.line {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $gray-50;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './OrganizationField.module.scss';
|
||||||
|
import { FormField } from '@tnsu/ui-kit';
|
||||||
|
|
||||||
|
interface IOrganizationFieldProps {
|
||||||
|
/** `\n`-joined «Компания — должность» на организацию (см. formatEmployeeOrganizations). */
|
||||||
|
value: string;
|
||||||
|
/**
|
||||||
|
* Подпись поля. По умолчанию «Организация» — переопределяют формы, где
|
||||||
|
* секция описывает ДРУГОГО человека (напр. нового сотрудника), чтобы не
|
||||||
|
* читалось как «организация того, о ком заявка».
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Организации сотрудника — read-only, подтягивается автоматически по e-mail
|
||||||
|
* (см. shared/dictionary/employeeOrganizations.service.ts), руками не
|
||||||
|
* редактируется. Сотрудник может состоять сразу в НЕСКОЛЬКИХ организациях —
|
||||||
|
* тогда строк несколько. Пока не резолвилось (сотрудник не выбран/не найден) —
|
||||||
|
* ничего не рендерим, а не пустой блок.
|
||||||
|
*/
|
||||||
|
export const OrganizationField: React.FC<IOrganizationFieldProps> = ({ value, label = 'Организация' }) => {
|
||||||
|
const lines = value ? value.split('\n') : [];
|
||||||
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField label={label} full>
|
||||||
|
<div className={styles.list}>
|
||||||
|
{lines.map((line) => (
|
||||||
|
<div key={line} className={styles.line}>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
||||||
|
import { peoplePickerStyles } from './peoplePickerStyles';
|
||||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
|
||||||
interface IPeopleFieldProps {
|
interface IPeopleFieldProps {
|
||||||
@@ -34,6 +35,7 @@ export const PeopleField: React.FC<IPeopleFieldProps> = ({ context, value, onCha
|
|||||||
disabled={effectiveReadOnly}
|
disabled={effectiveReadOnly}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
styles={peoplePickerStyles}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
||||||
|
import { peoplePickerStyles } from './peoplePickerStyles';
|
||||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
|
||||||
interface IPeopleFieldMultiProps {
|
interface IPeopleFieldMultiProps {
|
||||||
@@ -43,6 +44,7 @@ export const PeopleFieldMulti: React.FC<IPeopleFieldMultiProps> = ({
|
|||||||
disabled={effectiveReadOnly}
|
disabled={effectiveReadOnly}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
styles={peoplePickerStyles}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { IBasePickerStyles } from '@fluentui/react/lib/Pickers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PeoplePicker — сторонний Fluent-контрол (@pnp/spfx-controls-react), а не наш
|
||||||
|
* @tnsu/ui-kit: у него нет своего CSS-модуля и без этих стилей он выглядит
|
||||||
|
* дефолтным Fluent-полем — другой border-radius, другие паддинги, другой цвет
|
||||||
|
* рамки, не в тон соседним полям формы (TextInput и т.п.). Стилизуется через
|
||||||
|
* fluent-проп `styles` (IBasePickerStyles), а не через className/CSS-модуль,
|
||||||
|
* поэтому значения ниже — те же токены, что в styles/_design-tokens.scss
|
||||||
|
* ($gray-300, $gold-500, $r-sm, TextInput.module.scss), но продублированы как
|
||||||
|
* значения: Fluent styles-проп не умеет импортировать SCSS-переменные.
|
||||||
|
*/
|
||||||
|
export const peoplePickerStyles: Partial<IBasePickerStyles> = {
|
||||||
|
text: {
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
padding: '9px 12px',
|
||||||
|
border: '1px solid #D1D5DB', // $gray-300
|
||||||
|
borderRadius: 6, // $r-sm
|
||||||
|
background: '#fff',
|
||||||
|
fontFamily: "'Inter', -apple-system, 'Segoe UI', sans-serif",
|
||||||
|
fontSize: 13.5,
|
||||||
|
transition: '.18s',
|
||||||
|
selectors: {
|
||||||
|
// Фокус — на самом input внутри .text, подсветку показываем на всей рамке.
|
||||||
|
':focus-within': {
|
||||||
|
borderColor: '#D4A01E', // $gold-500
|
||||||
|
boxShadow: '0 0 0 3px rgba(212,160,30,.12)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
padding: 0,
|
||||||
|
fontFamily: "'Inter', -apple-system, 'Segoe UI', sans-serif",
|
||||||
|
fontSize: 13.5,
|
||||||
|
color: '#111827', // $gray-900
|
||||||
|
selectors: {
|
||||||
|
'::placeholder': { color: '#9CA3AF' } // $gray-400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -38,6 +38,7 @@ export { EMPTY_LINE_ITEM } from './ItemsTable/ILineItem';
|
|||||||
export { parseExcelRows } from './ItemsTable/parseExcelRows';
|
export { parseExcelRows } from './ItemsTable/parseExcelRows';
|
||||||
export { PeopleField } from './PeopleField/PeopleField';
|
export { PeopleField } from './PeopleField/PeopleField';
|
||||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||||
|
export { OrganizationField } from './OrganizationField/OrganizationField';
|
||||||
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
|
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
|
||||||
export { SubmitActions } from './SubmitActions/SubmitActions';
|
export { SubmitActions } from './SubmitActions/SubmitActions';
|
||||||
export { InitiatorLine } from './InitiatorLine/InitiatorLine';
|
export { InitiatorLine } from './InitiatorLine/InitiatorLine';
|
||||||
|
|||||||
Reference in New Issue
Block a user