feat: feat: брендированная печать, поле «Подразделение» и подача ТМЦ за другого

- Печать доступна только после согласования (Отдел рекрутинга); выбор
  организации и логотип-шапка для форм с несколькими юрлицами
- «СЗ на перемещение ТМЦ»: заявителя можно выбрать (подача за другого),
  маршрут резолвится на выбранного
- «Реферальная рекомендация»: организация вакансии (мультивыбор) и
  подразделение/проект вакансии
- Поле «Подразделение» (справочник) — Vacation, UnpaidLeave, Clearance,
  WorkplaceSetup, RecruitingHire, RecruitingSelection, TmcTransfer
- Поле «Проект» — Vacation, UnpaidLeave, Clearance
This commit is contained in:
Тимур Городецкий
2026-08-26 15:49:25 +05:00
parent f751f03060
commit a4bc54def2
71 changed files with 749 additions and 103 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -9,7 +9,10 @@
.toolbar { .toolbar {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4; margin-bottom: $sp-4;
} }
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ClearanceView.module.scss'; import styles from './ClearanceView.module.scss';
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui'; import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintButton, PrintOrgSelect, OrgLetterhead } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -53,6 +53,9 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false); const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false);
const [parkingEditable, setParkingEditable] = useState<boolean>(false); const [parkingEditable, setParkingEditable] = useState<boolean>(false);
const [saveState, setSaveState] = useState<SaveState>('idle'); const [saveState, setSaveState] = useState<SaveState>('idle');
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
@@ -176,7 +179,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
} }
const canEditAnything = hrRecordsEditable || parkingEditable; const canEditAnything = hrRecordsEditable || parkingEditable;
const sectionProps = { data, set, context, hrRecordsEditable, parkingEditable }; const sectionProps = { data, set, context, hrRecordsEditable, parkingEditable, printOrgIndex };
const notify = (): void => { const notify = (): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка // requestTypeId ещё не резолвился — решение уже применилось (строка списка
@@ -193,6 +196,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton /> <PrintButton />
</div> </div>
@@ -212,6 +216,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
)} )}
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Обходной лист" title="Обходной лист"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
@@ -32,6 +32,8 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
'AccessCard', 'AccessCard',
'TrainingAmount', 'TrainingAmount',
'Organization', 'Organization',
'Project',
'ProjectId',
'RequestStatus', 'RequestStatus',
'RequestUniqueCode', 'RequestUniqueCode',
'Employee/Title', 'Employee/Title',
@@ -52,7 +54,9 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
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) || '' Organization: (item.Organization as string) || '',
Project: (item.Project as string) || '',
ProjectId: (item.ProjectId as string) || ''
}; };
return { return {
@@ -31,6 +31,10 @@ export interface IFormData {
* read-only (см. shared/ui/OrganizationField). * read-only (см. shared/ui/OrganizationField).
*/ */
Organization: string; Organization: string;
/** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — необязательное, справочное. */
Project: string;
/** oid проекта ('' — если введён вручную без выбора из списка). */
ProjectId: string;
} }
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
@@ -46,6 +50,8 @@ export interface ISectionProps {
* отдельный обработчик. Если не передан (режим чтения) — правка через set. * отдельный обработчик. Если не передан (режим чтения) — правка через set.
*/ */
onEmployeeChange?: (login: string) => void; onEmployeeChange?: (login: string) => void;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
} }
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
@@ -56,7 +62,9 @@ export const EMPTY_DATA: IFormData = {
ParkingCard: false, ParkingCard: false,
AccessCard: false, AccessCard: false,
TrainingAmount: '', TrainingAmount: '',
Organization: '' Organization: '',
Project: '',
ProjectId: ''
}; };
/** Плейсхолдер для поля выбора сотрудника. */ /** Плейсхолдер для поля выбора сотрудника. */
@@ -12,6 +12,8 @@
{ "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" } { "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Project", "title": "Проект", "type": "Text" },
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" }
] ]
} }
@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
// Подразделение подтягивается автоматически при выборе сотрудника (см. // Подразделение подтягивается автоматически при выборе сотрудника (см.
@@ -21,7 +22,8 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
context, context,
onEmployeeChange, onEmployeeChange,
hrRecordsEditable, hrRecordsEditable,
parkingEditable parkingEditable,
printOrgIndex
}) => ( }) => (
<CollapseSection title="Обходной лист" icon="people"> <CollapseSection title="Обходной лист" icon="people">
<FormField label="Сотрудник" htmlFor="Employee" required full> <FormField label="Сотрудник" htmlFor="Employee" required full>
@@ -54,7 +56,19 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
placeholder="Подставится автоматически по выбранному сотруднику" placeholder="Подставится автоматически по выбранному сотруднику"
/> />
</FormField> </FormField>
<OrganizationField value={data.Organization} /> <OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Проект" htmlFor="Project">
<ProjectPicker
id="Project"
context={context}
value={data.Project}
onChange={(name, id) => {
set('Project', name);
set('ProjectId', id);
}}
placeholder="Начните вводить название или код проекта…"
/>
</FormField>
<FormField label="Дата расторжения договора" htmlFor="TerminationDate" required> <FormField label="Дата расторжения договора" htmlFor="TerminationDate" required>
<TextInput <TextInput
id="TerminationDate" id="TerminationDate"
@@ -68,7 +68,9 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
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 Organization: data.Organization || null,
Project: data.Project || null,
ProjectId: data.ProjectId || null
}; };
} }
@@ -11,7 +11,8 @@ import {
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
APPROVAL_PHASE APPROVAL_PHASE,
isApprovedStatus
} from '../../shared/approval'; } from '../../shared/approval';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
@@ -169,7 +170,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<Icon name="copy" size="sm" /> <Icon name="copy" size="sm" />
Организация рабочего места на основе этой Организация рабочего места на основе этой
</Button> </Button>
<PrintButton /> {isApprovedStatus(status) && <PrintButton />}
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
@@ -18,7 +18,7 @@ function parseJsonArray<T>(raw: unknown): T[] {
const SCALAR_FIELDS: (keyof IFormData)[] = [ const SCALAR_FIELDS: (keyof IFormData)[] = [
'Phone', 'HireType', 'ReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode', 'Phone', 'HireType', 'ReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode',
'CandidateName', 'HireDate', 'Position', 'WorkKind', 'CandidateName', 'HireDate', 'Position', 'WorkKind',
'SiteName', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'Department', 'DepartmentId', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod',
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther', 'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
'AgencyName', 'AgencyTerms', 'AgencyName', 'AgencyTerms',
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode' 'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
@@ -34,7 +34,16 @@ export interface IFormData {
HireDate: string; HireDate: string;
Position: string; Position: string;
WorkKind: string; WorkKind: string;
SiteName: string; /**
* Подразделение по справочнику (Dictionary/Departments, см.
* shared/dictionary/DepartmentPicker) — как в WorkplaceSetup/RecruitingSelection:
* ручной выбор, чисто информационное, на маршрут согласования НЕ влияет.
* Заменило прежнее свободнотекстовое поле «Отдел / участок» (колонка SiteName
* в SP осталась в схеме нетронутой — просто больше не используется формой).
*/
Department: string;
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
DepartmentId: string;
SiteLocation: string; SiteLocation: string;
// СИЗ // СИЗ
PpeHeight: string; PpeHeight: string;
@@ -77,7 +86,8 @@ export const EMPTY_DATA: IFormData = {
HireDate: '', HireDate: '',
Position: '', Position: '',
WorkKind: '', WorkKind: '',
SiteName: '', Department: '',
DepartmentId: '',
SiteLocation: '', SiteLocation: '',
PpeHeight: '', PpeHeight: '',
PpeClothingSize: '', PpeClothingSize: '',
@@ -22,7 +22,9 @@
{ "internalName": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true }, { "internalName": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true }, { "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" }, { "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
{ "internalName": "SiteName", "title": "Отдел / участок", "type": "Text" }, { "internalName": "SiteName", "title": "Отдел / участок (устарело)", "type": "Text" },
{ "internalName": "Department", "title": "Подразделение (справочник)", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
{ "internalName": "SiteLocation", "title": "Местоположение", "type": "Text" }, { "internalName": "SiteLocation", "title": "Местоположение", "type": "Text" },
{ "internalName": "PpeHeight", "title": "СИЗ: рост", "type": "Number" }, { "internalName": "PpeHeight", "title": "СИЗ: рост", "type": "Number" },
@@ -1,8 +1,9 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { DepartmentPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model'; import { ISectionProps } from '../model';
export const CandidateSection: React.FC<ISectionProps> = ({ data, set }) => ( export const CandidateSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="Кандидат" icon="people"> <CollapseSection title="Кандидат" icon="people">
<FormField label="Ф.И.О. кандидата" htmlFor="CandidateName" required full> <FormField label="Ф.И.О. кандидата" htmlFor="CandidateName" required full>
<TextInput id="CandidateName" value={data.CandidateName} onChange={(v) => set('CandidateName', v)} /> <TextInput id="CandidateName" value={data.CandidateName} onChange={(v) => set('CandidateName', v)} />
@@ -16,8 +17,16 @@ export const CandidateSection: React.FC<ISectionProps> = ({ data, set }) => (
<FormField label="Вид работ" htmlFor="WorkKind"> <FormField label="Вид работ" htmlFor="WorkKind">
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} /> <TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
</FormField> </FormField>
<FormField label="Отдел / участок" htmlFor="SiteName"> <FormField label="Подразделение" htmlFor="Department">
<TextInput id="SiteName" value={data.SiteName} onChange={(v) => set('SiteName', v)} /> <DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField> </FormField>
<FormField label="Местоположение" htmlFor="SiteLocation"> <FormField label="Местоположение" htmlFor="SiteLocation">
<TextInput id="SiteLocation" value={data.SiteLocation} onChange={(v) => set('SiteLocation', v)} /> <TextInput id="SiteLocation" value={data.SiteLocation} onChange={(v) => set('SiteLocation', v)} />
@@ -35,6 +35,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, ICompany, IEmployeeOrganization } 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 {
@@ -98,6 +99,57 @@ const RecruitingSelectionForm: 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.company/companyId) → предзаполнение
// «1.6 Организация» (Companies/CompanyPickerMulti). companyId из Employee/ByEmail
// совпадает с Id из Dictionary/Companies (тот же источник) — доп. запрос не нужен.
// Поле остаётся редактируемым: значения только ПРЕДЛАГАЮТСЯ, как и DepartmentName.
const toCompanies = (records: IEmployeeOrganization[]): ICompany[] =>
records.filter((r) => r.companyId && r.company).map((r) => ({ Id: r.companyId, Name: r.company }));
// Новая заявка — по умолчанию ответственный: сам подающий, подразделение и
// организация — из Employee/ByEmail (Section/company), как в остальных формах
// с авто-«Организацией» (см. shared/dictionary/employeeOrganizations.service.ts).
useEffect(() => {
if (editId) {
return;
}
let alive = true;
const user = context.pageContext.user;
setData((prev) => ({ ...prev, Responsible: prev.Responsible || user.email }));
getEmployeeOrganizations(context, user.email)
.then((records) => {
if (alive) {
setData((prev) => ({
...prev,
DepartmentName: prev.DepartmentName || records[0]?.section || prev.DepartmentName,
Companies: prev.Companies.length > 0 ? prev.Companies : toCompanies(records)
}));
}
})
.catch(() => undefined);
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]);
// Выбор другого ответственного подтягивает его подразделение и организацию.
const handleResponsibleChange = (login: string): void => {
setData((prev) => ({ ...prev, Responsible: login }));
if (!login) {
return;
}
getEmployeeOrganizations(context, login)
.then((records) =>
setData((prev) => ({
...prev,
DepartmentName: records[0]?.section || prev.DepartmentName,
Companies: toCompanies(records)
}))
)
.catch(() => undefined);
};
// Маршрут согласования резолвится по email инициатора (текущего пользователя). // Маршрут согласования резолвится по email инициатора (текущего пользователя).
// У заявки на редактировании маршрут читается из уже записанных строк, чтобы не // У заявки на редактировании маршрут читается из уже записанных строк, чтобы не
// затереть правки инициатора при первом сохранении. // затереть правки инициатора при первом сохранении.
@@ -242,7 +294,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate); const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
const sectionProps = { data, set, context }; const sectionProps = { data, set, context, onResponsibleChange: handleResponsibleChange };
return ( return (
<div> <div>
@@ -10,7 +10,8 @@ import {
ApprovalMatrix, ApprovalMatrix,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId useRequestTypeId,
isApprovedStatus
} from '../../shared/approval'; } from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -118,7 +119,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintButton /> {isApprovedStatus(status) && <PrintButton />}
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
@@ -25,7 +25,16 @@ export interface IFormData {
ObjectProjectId: string; ObjectProjectId: string;
ObjectProjectCode: string; ObjectProjectCode: string;
// Отдел // Отдел
/** Подразделение ОТВЕТСТВЕННОГО за подачу (Employee/ByEmail.Section при выборе Responsible). */
DepartmentName: string; DepartmentName: string;
/**
* Подразделение по справочнику (Dictionary/Departments, см.
* shared/dictionary/DepartmentPicker) — как в WorkplaceSetup: ручной выбор,
* чисто информационное, на маршрут согласования НЕ влияет.
*/
Department: string;
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
DepartmentId: string;
OpenVacanciesCount: string; OpenVacanciesCount: string;
// Вакансия // Вакансия
Position: string; Position: string;
@@ -71,6 +80,12 @@ export interface ISectionProps {
data: IFormData; data: IFormData;
set: SetField; set: SetField;
context: WebPartContext; context: WebPartContext;
/**
* Выбор ответственного подтягивает его подразделение (Employee/ByEmail →
* Section) — поэтому у поля отдельный обработчик. Если не передан (режим
* чтения) — правка через set.
*/
onResponsibleChange?: (login: string) => void;
} }
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
@@ -84,6 +99,8 @@ export const EMPTY_DATA: IFormData = {
ObjectProjectId: '', ObjectProjectId: '',
ObjectProjectCode: '', ObjectProjectCode: '',
DepartmentName: '', DepartmentName: '',
Department: '',
DepartmentId: '',
OpenVacanciesCount: '', OpenVacanciesCount: '',
Position: '', Position: '',
EmploymentType: '', EmploymentType: '',
@@ -13,7 +13,9 @@
{ "internalName": "ObjectProjectCode", "title": "Объект/Проект — код", "type": "Text" }, { "internalName": "ObjectProjectCode", "title": "Объект/Проект — код", "type": "Text" },
{ "internalName": "ClosingDeadline", "title": "Крайний срок закрытия", "type": "DateTime", "dateOnly": true }, { "internalName": "ClosingDeadline", "title": "Крайний срок закрытия", "type": "DateTime", "dateOnly": true },
{ "internalName": "DepartmentName", "title": "Название отдела/департамента", "type": "Text", "required": true }, { "internalName": "DepartmentName", "title": "Подразделение ответственного за подачу", "type": "Text", "required": true },
{ "internalName": "Department", "title": "Подразделение (справочник)", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
{ "internalName": "OpenVacanciesCount", "title": "Количество открытых вакансий", "type": "Number" }, { "internalName": "OpenVacanciesCount", "title": "Количество открытых вакансий", "type": "Number" },
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true }, { "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
@@ -10,7 +10,7 @@ import {
OPEN_REASON_NEW OPEN_REASON_NEW
} from '../model'; } from '../model';
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context }) => { export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context, onResponsibleChange }) => {
const reason = data.OpenReason; const reason = data.OpenReason;
const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW; const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW;
const reasonDetailsLabel = const reasonDetailsLabel =
@@ -22,14 +22,22 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
<PeopleField <PeopleField
context={context} context={context}
value={data.Responsible} value={data.Responsible}
onChange={(v) => set('Responsible', v)} onChange={onResponsibleChange ?? ((v) => set('Responsible', v))}
placeholder={PERSON_PLACEHOLDER} placeholder={PERSON_PLACEHOLDER}
/> />
</FormField> </FormField>
<FormField label="1.2 Дата подачи заявки" htmlFor="SubmitDate"> <FormField label="1.2 Подразделение ответственного за подачу" htmlFor="DepartmentName" required full>
<TextInput
id="DepartmentName"
value={data.DepartmentName}
onChange={(v) => set('DepartmentName', v)}
placeholder="Подставится автоматически по выбранному ответственному"
/>
</FormField>
<FormField label="1.3 Дата подачи заявки" htmlFor="SubmitDate">
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} /> <TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} />
</FormField> </FormField>
<FormField label="1.3 Крайний срок закрытия" htmlFor="ClosingDeadline"> <FormField label="1.4 Крайний срок закрытия" htmlFor="ClosingDeadline">
<TextInput <TextInput
id="ClosingDeadline" id="ClosingDeadline"
type="date" type="date"
@@ -37,7 +45,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
onChange={(v) => set('ClosingDeadline', v)} onChange={(v) => set('ClosingDeadline', v)}
/> />
</FormField> </FormField>
<FormField label="1.4 Причина открытия вакансии" htmlFor="OpenReason"> <FormField label="1.5 Причина открытия вакансии" htmlFor="OpenReason">
<Select <Select
id="OpenReason" id="OpenReason"
value={data.OpenReason} value={data.OpenReason}
@@ -58,7 +66,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
/> />
</FormField> </FormField>
)} )}
<FormField label="1.5 Организация" htmlFor="Companies" full> <FormField label="1.6 Организация" htmlFor="Companies" full>
<CompanyPickerMulti <CompanyPickerMulti
context={context} context={context}
id="Companies" id="Companies"
@@ -67,7 +75,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
placeholder="Начните вводить название организации…" placeholder="Начните вводить название организации…"
/> />
</FormField> </FormField>
<FormField label="1.6 Объект/Проект" htmlFor="ObjectProject" full> <FormField label="1.7 Объект/Проект" htmlFor="ObjectProject" full>
<ProjectPicker <ProjectPicker
context={context} context={context}
id="ObjectProject" id="ObjectProject"
@@ -1,11 +1,20 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { DepartmentPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model'; import { ISectionProps } from '../model';
export const DepartmentSection: React.FC<ISectionProps> = ({ data, set }) => ( export const DepartmentSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="2. Отдел / Подразделение" icon="building"> <CollapseSection title="2. Отдел / Подразделение" icon="building">
<FormField label="2.1 Название отдела/департамента" htmlFor="DepartmentName" required full> <FormField label="2.1 Подразделение" htmlFor="Department" full>
<TextInput id="DepartmentName" value={data.DepartmentName} onChange={(v) => set('DepartmentName', v)} /> <DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField> </FormField>
<FormField label="2.2 Количество открытых вакансий" htmlFor="OpenVacanciesCount"> <FormField label="2.2 Количество открытых вакансий" htmlFor="OpenVacanciesCount">
<TextInput <TextInput
@@ -9,6 +9,9 @@
.toolbar { .toolbar {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4; margin-bottom: $sp-4;
} }
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ReferralView.module.scss'; import styles from './ReferralView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui'; import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, PrintOrgSelect, OrgLetterhead } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -11,7 +11,8 @@ import {
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
APPROVAL_PHASE APPROVAL_PHASE,
isApprovedStatus
} from '../../shared/approval'; } from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
import { import {
@@ -50,6 +51,9 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Какая из организаций рекомендателя (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// referralApprovalService можно звать не опасаясь исключения. // referralApprovalService можно звать не опасаясь исключения.
@@ -121,7 +125,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
); );
} }
const sectionProps = { data, set: NOOP_SET, context }; const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
const notify = (): void => { const notify = (): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка // requestTypeId ещё не резолвился — решение уже применилось (строка списка
@@ -138,10 +142,16 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintButton /> {isApprovedStatus(status) && (
<>
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton />
</>
)}
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Реферальная рекомендация" title="Реферальная рекомендация"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
@@ -1,7 +1,19 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { getSP } from '../../shared/pnp/pnpjs';
import { ICompany } from '../../shared/dictionary';
import { dataListTitle } from '../../shared/listTitles'; import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model'; import { FORM_ID, IFormData, EMPTY_DATA } from './model';
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
function parseJsonArray<T>(raw: unknown): T[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch {
return [];
}
}
export interface ILoadedRequest { export interface ILoadedRequest {
data: IFormData; data: IFormData;
/** Ф.И.О. рекомендателя из поля Referrer (для заголовка режима чтения). */ /** Ф.И.О. рекомендателя из поля Referrer (для заголовка режима чтения). */
@@ -35,7 +47,9 @@ export async function loadReferralRequest(itemId: number, listId?: string): Prom
'CandidateEmail', 'CandidateEmail',
'CandidateCity', 'CandidateCity',
'VacancyTitle', 'VacancyTitle',
'VacancyDepartment', 'VacancyProject',
'VacancyProjectId',
'VacancyCompaniesJson',
'CurrentEmployer', 'CurrentEmployer',
'CurrentPosition', 'CurrentPosition',
'ExperienceYears', 'ExperienceYears',
@@ -69,7 +83,9 @@ export async function loadReferralRequest(itemId: number, listId?: string): Prom
CandidateEmail: (item.CandidateEmail as string) || '', CandidateEmail: (item.CandidateEmail as string) || '',
CandidateCity: (item.CandidateCity as string) || '', CandidateCity: (item.CandidateCity as string) || '',
VacancyTitle: (item.VacancyTitle as string) || '', VacancyTitle: (item.VacancyTitle as string) || '',
VacancyDepartment: (item.VacancyDepartment as string) || '', VacancyProject: (item.VacancyProject as string) || '',
VacancyProjectId: (item.VacancyProjectId as string) || '',
VacancyCompanies: parseJsonArray<ICompany>(item.VacancyCompaniesJson),
CurrentEmployer: (item.CurrentEmployer as string) || '', CurrentEmployer: (item.CurrentEmployer as string) || '',
CurrentPosition: (item.CurrentPosition as string) || '', CurrentPosition: (item.CurrentPosition as string) || '',
ExperienceYears: (item.ExperienceYears as string) || '', ExperienceYears: (item.ExperienceYears as string) || '',
@@ -1,6 +1,7 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types'; import { RequestTypeId } from '../../models/types';
import { ISelectOption } from '../../shared/ui'; import { ISelectOption } from '../../shared/ui';
import { ICompany } from '../../shared/dictionary';
/** /**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков * Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -55,7 +56,18 @@ export interface IFormData {
CandidateCity: string; CandidateCity: string;
VacancyTitle: string; VacancyTitle: string;
VacancyDepartment: string; /** Название проекта (справочник /Project, см. shared/dictionary/ProjectPicker). */
VacancyProject: string;
/** oid проекта ('' — если введён вручную без выбора из списка). */
VacancyProjectId: string;
/**
* Организация(и), в которую рекомендуют кандидата (справочник, мультивыбор,
* см. shared/dictionary/CompanyPickerMulti) — хранится VacancyCompaniesJson.
* Необязательное, чисто информативное поле: на маршрут согласования НЕ влияет
* (в отличие от Companies в «Заявке на прием»). Не путать с Organization выше —
* то поле про самого рекомендателя, это — про вакансию.
*/
VacancyCompanies: ICompany[];
CurrentEmployer: string; CurrentEmployer: string;
CurrentPosition: string; CurrentPosition: string;
@@ -85,6 +97,8 @@ export interface ISectionProps {
* обработчик. Если не передан (режим чтения) — правка через set. * обработчик. Если не передан (режим чтения) — правка через set.
*/ */
onReferrerChange?: (login: string) => void; onReferrerChange?: (login: string) => void;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
} }
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
@@ -99,7 +113,9 @@ export const EMPTY_DATA: IFormData = {
CandidateEmail: '', CandidateEmail: '',
CandidateCity: '', CandidateCity: '',
VacancyTitle: '', VacancyTitle: '',
VacancyDepartment: '', VacancyProject: '',
VacancyProjectId: '',
VacancyCompanies: [],
CurrentEmployer: '', CurrentEmployer: '',
CurrentPosition: '', CurrentPosition: '',
ExperienceYears: '', ExperienceYears: '',
@@ -17,7 +17,9 @@
{ "internalName": "CandidateCity", "title": "Город проживания кандидата", "type": "Text" }, { "internalName": "CandidateCity", "title": "Город проживания кандидата", "type": "Text" },
{ "internalName": "VacancyTitle", "title": "Название вакансии", "type": "Text", "required": true }, { "internalName": "VacancyTitle", "title": "Название вакансии", "type": "Text", "required": true },
{ "internalName": "VacancyDepartment", "title": "Подразделение / Объект вакансии", "type": "Text" }, { "internalName": "VacancyCompaniesJson", "title": "Организация вакансии (JSON)", "type": "Note" },
{ "internalName": "VacancyProject", "title": "Подразделение / Проект вакансии", "type": "Text" },
{ "internalName": "VacancyProjectId", "title": "Id проекта вакансии", "type": "Text" },
{ "internalName": "CurrentEmployer", "title": "Текущее место работы кандидата", "type": "Text" }, { "internalName": "CurrentEmployer", "title": "Текущее место работы кандидата", "type": "Text" },
{ "internalName": "CurrentPosition", "title": "Текущая должность кандидата", "type": "Text" }, { "internalName": "CurrentPosition", "title": "Текущая должность кандидата", "type": "Text" },
@@ -3,7 +3,7 @@ import { CollapseSection, FormField, TextInput, PhoneInput, PeopleField, Organiz
import { ISectionProps, REFERRER_PLACEHOLDER } from '../model'; import { ISectionProps, REFERRER_PLACEHOLDER } from '../model';
/** Секция 1: кто рекомендует — дата подачи, сам рекомендатель и его контакты. */ /** Секция 1: кто рекомендует — дата подачи, сам рекомендатель и его контакты. */
export const ReferrerSection: React.FC<ISectionProps> = ({ data, set, context, onReferrerChange }) => ( export const ReferrerSection: React.FC<ISectionProps> = ({ data, set, context, onReferrerChange, printOrgIndex }) => (
<CollapseSection title="Рекомендатель" icon="people"> <CollapseSection title="Рекомендатель" icon="people">
<FormField label="Дата подачи" htmlFor="SubmitDate" required> <FormField label="Дата подачи" htmlFor="SubmitDate" required>
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} /> <TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} />
@@ -31,7 +31,7 @@ export const ReferrerSection: React.FC<ISectionProps> = ({ data, set, context, o
placeholder="Подставится автоматически по выбранному сотруднику" placeholder="Подставится автоматически по выбранному сотруднику"
/> />
</FormField> </FormField>
<OrganizationField value={data.Organization} /> <OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<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>
@@ -1,18 +1,33 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { CompanyPickerMulti, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model'; import { ISectionProps } from '../model';
/** Секция 3: на какую вакансию рекомендуют. */ /** Секция 3: на какую вакансию рекомендуют. */
export const VacancySection: React.FC<ISectionProps> = ({ data, set }) => ( export const VacancySection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="На какую вакансию рекомендуете" icon="search"> <CollapseSection title="На какую вакансию рекомендуете" icon="search">
<FormField label="Название вакансии" htmlFor="VacancyTitle" required full> <FormField label="Название вакансии" htmlFor="VacancyTitle" required full>
<TextInput id="VacancyTitle" value={data.VacancyTitle} onChange={(v) => set('VacancyTitle', v)} /> <TextInput id="VacancyTitle" value={data.VacancyTitle} onChange={(v) => set('VacancyTitle', v)} />
</FormField> </FormField>
<FormField label="Подразделение / Объект" htmlFor="VacancyDepartment" full> <FormField label="Организация" htmlFor="VacancyCompanies" full>
<TextInput <CompanyPickerMulti
id="VacancyDepartment" id="VacancyCompanies"
value={data.VacancyDepartment} context={context}
onChange={(v) => set('VacancyDepartment', v)} value={data.VacancyCompanies}
onChange={(companies) => set('VacancyCompanies', companies)}
placeholder="Начните вводить название организации…"
/>
</FormField>
<FormField label="Подразделение / Проект" htmlFor="VacancyProject" full>
<ProjectPicker
id="VacancyProject"
context={context}
value={data.VacancyProject}
onChange={(name, id) => {
set('VacancyProject', name);
set('VacancyProjectId', id);
}}
placeholder="Начните вводить название или код проекта…"
/> />
</FormField> </FormField>
</CollapseSection> </CollapseSection>
@@ -39,7 +39,10 @@ function buildItem(data: IFormData, status: string, referrerId: number): Record<
CandidateEmail: data.CandidateEmail || null, CandidateEmail: data.CandidateEmail || null,
CandidateCity: data.CandidateCity || null, CandidateCity: data.CandidateCity || null,
VacancyTitle: data.VacancyTitle || null, VacancyTitle: data.VacancyTitle || null,
VacancyDepartment: data.VacancyDepartment || null, VacancyProject: data.VacancyProject || null,
VacancyProjectId: data.VacancyProjectId || null,
// Мультивыбор компаний не разворачивается в колонки — сериализуем в Note (см. Companies в RecruitingHire).
VacancyCompaniesJson: data.VacancyCompanies.length > 0 ? JSON.stringify(data.VacancyCompanies) : null,
CurrentEmployer: data.CurrentEmployer || null, CurrentEmployer: data.CurrentEmployer || null,
CurrentPosition: data.CurrentPosition || null, CurrentPosition: data.CurrentPosition || null,
ExperienceYears: data.ExperienceYears || null, ExperienceYears: data.ExperienceYears || null,
@@ -102,6 +102,26 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
}; };
}, [editId, context]); }, [editId, context]);
// Выбор другого заявителя подтягивает его ФИО (для печати) и организации (как
// в Vacation.handleEmployeeChange, но источник тот же Employee/ByEmail — здесь
// дополнительно берём ФИО, т.к. TMC хранит его отдельным полем AuthorName).
const handleRequesterChange = (login: string): void => {
setData((prev) => ({ ...prev, RequesterEmail: login }));
if (!login) {
setData((prev) => ({ ...prev, Organization: '' }));
return;
}
getEmployeeOrganizations(context, login)
.then((records) =>
setData((prev) => ({
...prev,
AuthorName: records[0]?.fio || prev.AuthorName,
Organization: formatEmployeeOrganizations(records)
}))
)
.catch(() => undefined);
};
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и вложения. // Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и вложения.
useEffect(() => { useEffect(() => {
if (!editId) { if (!editId) {
@@ -131,7 +151,9 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// tmcTransferApprovalService можно звать не опасаясь исключения. // tmcTransferApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => tmcTransferApprovalService(editListId), [editListId]); const approval = useMemo(() => tmcTransferApprovalService(editListId), [editListId]);
const userEmail = effectiveUserEmail(context); // Маршрут резолвится по e-mail ВЫБРАННОГО заявителя (не обязательно текущего
// пользователя) — так работает подача за другого, как в Vacation.
const userEmail = data.RequesterEmail;
// 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);
@@ -142,13 +164,13 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
// «руководитель проекта-источника». Меняется проект → перепланируем маршрут. // «руководитель проекта-источника». Меняется проект → перепланируем маршрут.
const projectId = data.SourceProjectId || null; const projectId = data.SourceProjectId || null;
const targetProjectId = data.TargetProjectId || null; const targetProjectId = data.TargetProjectId || null;
// Маршрут согласования зависит от проекта-источника («Откуда»): пока он не // Маршрут согласования зависит от заявителя и проекта-источника («Откуда»):
// выбран, resolve вернёт 404 — не грузим, показываем заглушку. У открытой на // пока не выбраны, resolve вернёт 404 — не грузим, показываем заглушку. У
// редактирование заявки маршрут уже записан, источник не нужен. // открытой на редактирование заявки маршрут уже записан, ни то ни другое не нужно.
const matrixReady = !!editId || (!!projectId && !!requestTypeId); const matrixReady = !!editId || (!!userEmail && !!projectId && !!requestTypeId);
// Исполнитель определяется проектом-получателем («Куда»): показываем блок с // Исполнитель определяется проектом-получателем («Куда»): показываем блок с
// заглушкой, пока «Куда» не выбрано. Источник тоже нужен, иначе resolve 404. // заглушкой, пока «Куда» не выбрано. Источник и заявитель тоже нужны, иначе resolve 404.
const executorsReady = !!editId || (!!projectId && !!targetProjectId && !!requestTypeId); const executorsReady = !!editId || (!!userEmail && !!projectId && !!targetProjectId && !!requestTypeId);
const loadMatrix = useCallback( const loadMatrix = useCallback(
() => () =>
editId editId
@@ -182,10 +204,13 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
const hasItems = data.Items.some((i) => i.name.trim().length > 0); const hasItems = data.Items.some((i) => i.name.trim().length > 0);
const isValid = const isValid =
data.SourceProject.trim().length > 0 && data.TargetProject.trim().length > 0 && hasItems; !!data.RequesterEmail &&
data.SourceProject.trim().length > 0 &&
data.TargetProject.trim().length > 0 &&
hasItems;
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (!isValid) { if (!isValid) {
return 'Заполните обязательные поля: проект-источник, проект-получатель и хотя бы одну позицию ТМЦ.'; return 'Заполните обязательные поля: заявитель, проект-источник, проект-получатель и хотя бы одну позицию ТМЦ.';
} }
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
@@ -278,7 +303,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
const handleSubmit = (): Promise<void> => const handleSubmit = (): Promise<void> =>
submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
const sectionProps = { data, set, context }; const sectionProps = { data, set, context, onRequesterChange: handleRequesterChange };
return ( return (
<div> <div>
@@ -294,6 +319,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
<InitiatorLine <InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)} listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId} itemId={requestId}
fallback={{ name: data.AuthorName, email: data.RequesterEmail }}
/> />
} }
headerRight={ headerRight={
@@ -334,7 +360,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
editor={matrix} editor={matrix}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
waiting={!matrixReady} waiting={!matrixReady}
waitingText="Выберите проект-источник («Откуда»), чтобы построить маршрут согласования." waitingText="Укажите заявителя и проект-источник («Откуда»), чтобы построить маршрут согласования."
/> />
{/* Блок исполнителя показываем, только когда по маршруту реально есть {/* Блок исполнителя показываем, только когда по маршруту реально есть
@@ -9,6 +9,8 @@
.toolbar { .toolbar {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: $sp-3; gap: $sp-3;
margin-bottom: $sp-4; margin-bottom: $sp-4;
@@ -12,6 +12,8 @@ import {
FormField, FormField,
AttachmentList, AttachmentList,
PrintButton, PrintButton,
PrintOrgSelect,
OrgLetterhead,
Icon Icon
} from '../../shared/ui'; } from '../../shared/ui';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
@@ -57,6 +59,9 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]); const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Какая из организаций заявителя (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
const { id: requestTypeId } = useRequestTypeId(context, formKey); const { id: requestTypeId } = useRequestTypeId(context, formKey);
// В просмотре форма read-only, но секции ждут set — даём no-op. // В просмотре форма read-only, но секции ждут set — даём no-op.
@@ -128,7 +133,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
); );
} }
const sectionProps = { data, set, context }; const sectionProps = { data, set, context, printOrgIndex };
const notify = (): void => { const notify = (): void => {
if (!requestTypeId) return; if (!requestTypeId) return;
@@ -151,10 +156,12 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
Редактировать Редактировать
</Button> </Button>
)} )}
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton /> <PrintButton />
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Служебная записка на перемещение ТМЦ" title="Служебная записка на перемещение ТМЦ"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
@@ -39,6 +39,8 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P
'RequesterEmail', 'RequesterEmail',
'AuthorName', 'AuthorName',
'Organization', 'Organization',
'Department',
'DepartmentId',
'Subject', 'Subject',
'RequestDate', 'RequestDate',
'SourceProject', 'SourceProject',
@@ -62,6 +64,8 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P
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) || '', Organization: (item.Organization as string) || '',
Department: (item.Department as string) || '',
DepartmentId: (item.DepartmentId 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) || '',
@@ -18,14 +18,25 @@ export interface IFormData {
// Шапка служебной записки // Шапка служебной записки
Recipient: string; // Кому (по умолч. «Директору Самаевой А.А.») Recipient: string; // Кому (по умолч. «Директору Самаевой А.А.»)
// NB: не «Author» — это зарезервированное системное имя колонки SP (Created By). // NB: не «Author» — это зарезервированное системное имя колонки SP (Created By).
RequesterEmail: string; // От — email заявителя (текущий пользователь) // Выбирается через PeopleField (по умолчанию — текущий пользователь, но можно
AuthorName: string; // От — Ф.И.О. заявителя (для печати) // подать за другого — см. handleRequesterChange в TmcTransferForm.tsx). Именно
// это поле идёт в getPlannedMatrix: маршрут резолвится на ВЫБРАННОГО заявителя.
RequesterEmail: string; // От — email заявителя
AuthorName: string; // От — Ф.И.О. заявителя (для печати; подтягивается из Employee/ByEmail при выборе)
/** /**
* Организации заявителя (`\n`-joined «Компания — должность» на строку) — * Организации заявителя (`\n`-joined «Компания — должность» на строку) —
* подтягивается автоматически из Employee/ByEmail, read-only (см. * подтягивается автоматически из Employee/ByEmail, read-only (см.
* shared/ui/OrganizationField). * shared/ui/OrganizationField).
*/ */
Organization: string; Organization: string;
/**
* Подразделение (справочник Dictionary/Departments, см.
* shared/dictionary/DepartmentPicker) — необязательное, справочное, на
* маршрут согласования НЕ влияет.
*/
Department: string;
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
DepartmentId: string;
Subject: string; // Тема (по умолч. «Перемещение ТМЦ») Subject: string; // Тема (по умолч. «Перемещение ТМЦ»)
RequestDate: string; // Дата (YYYY-MM-DD) RequestDate: string; // Дата (YYYY-MM-DD)
@@ -55,16 +66,27 @@ export interface ISectionProps {
data: IFormData; data: IFormData;
set: SetField; set: SetField;
context: WebPartContext; context: WebPartContext;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
/**
* Выбор заявителя подтягивает его ФИО и организации — поэтому у поля отдельный
* обработчик. Если не передан (режим чтения) — правка через set.
*/
onRequesterChange?: (login: string) => void;
} }
export const DEFAULT_RECIPIENT = 'Директору Самаевой А.А.'; export const DEFAULT_RECIPIENT = 'Директору Самаевой А.А.';
export const DEFAULT_SUBJECT = 'Перемещение ТМЦ'; export const DEFAULT_SUBJECT = 'Перемещение ТМЦ';
/** Плейсхолдер для поля выбора заявителя. */
export const REQUESTER_PLACEHOLDER = 'Начните вводить Ф.И.О. заявителя…';
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
Recipient: DEFAULT_RECIPIENT, Recipient: DEFAULT_RECIPIENT,
RequesterEmail: '', RequesterEmail: '',
AuthorName: '', AuthorName: '',
Organization: '', Organization: '',
Department: '',
DepartmentId: '',
Subject: DEFAULT_SUBJECT, Subject: DEFAULT_SUBJECT,
RequestDate: '', RequestDate: '',
SourceProject: '', SourceProject: '',
@@ -8,6 +8,8 @@
{ "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": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
{ "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,15 +1,34 @@
import * as React from 'react'; import * as React from 'react';
import { FormGrid, FormField, TextInput, TextArea, OrganizationField } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, TextArea, PeopleField, OrganizationField } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary'; import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model'; import { ISectionProps, REQUESTER_PLACEHOLDER } from '../model';
/** /**
* Шапка служебной записки: заявитель, проекты «откуда → куда», дата, обоснование. * Шапка служебной записки: заявитель, проекты «откуда → куда», дата, обоснование.
* «Кому» и «Тема» — константы служебки (пишутся из модели по умолчанию), в форме не показываются. * «Кому» и «Тема» — константы служебки (пишутся из модели по умолчанию), в форме не показываются.
*/ */
export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context }) => ( export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex, onRequesterChange }) => (
<FormGrid> <FormGrid>
<OrganizationField value={data.Organization} /> <FormField label="Заявитель" htmlFor="RequesterEmail" required full>
<PeopleField
context={context}
value={data.RequesterEmail}
onChange={onRequesterChange ?? ((v) => set('RequesterEmail', v))}
placeholder={REQUESTER_PLACEHOLDER}
/>
</FormField>
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Подразделение" htmlFor="Department">
<DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField>
<FormField label="Откуда (проект-источник)" htmlFor="SourceProject" required> <FormField label="Откуда (проект-источник)" htmlFor="SourceProject" required>
<ProjectPicker <ProjectPicker
id="SourceProject" id="SourceProject"
@@ -9,6 +9,9 @@
.toolbar { .toolbar {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4; margin-bottom: $sp-4;
} }
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './UnpaidLeaveView.module.scss'; import styles from './UnpaidLeaveView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui'; import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, PrintOrgSelect, OrgLetterhead } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -45,6 +45,9 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// unpaidLeaveApprovalService можно звать не опасаясь исключения. // unpaidLeaveApprovalService можно звать не опасаясь исключения.
@@ -98,7 +101,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
); );
} }
const sectionProps = { data, set: NOOP_SET, context }; const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
const notify = (): void => { const notify = (): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка // requestTypeId ещё не резолвился — решение уже применилось (строка списка
@@ -115,10 +118,12 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton /> <PrintButton />
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Заявка на отпуск без содержания" title="Заявка на отпуск без содержания"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
@@ -30,6 +30,10 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
'EndDate', 'EndDate',
'Reason', 'Reason',
'Organization', 'Organization',
'Department',
'DepartmentId',
'Project',
'ProjectId',
'RequestStatus', 'RequestStatus',
'RequestUniqueCode', 'RequestUniqueCode',
'Employee/Title', 'Employee/Title',
@@ -51,7 +55,11 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
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) || '' Organization: (item.Organization as string) || '',
Department: (item.Department as string) || '',
DepartmentId: (item.DepartmentId as string) || '',
Project: (item.Project as string) || '',
ProjectId: (item.ProjectId as string) || ''
}; };
return { return {
@@ -33,6 +33,18 @@ export interface IFormData {
* read-only (см. shared/ui/OrganizationField). * read-only (см. shared/ui/OrganizationField).
*/ */
Organization: string; Organization: string;
/**
* Подразделение (справочник Dictionary/Departments, см.
* shared/dictionary/DepartmentPicker) — необязательное, справочное, на
* маршрут согласования НЕ влияет.
*/
Department: string;
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
DepartmentId: string;
/** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — необязательное, справочное. */
Project: string;
/** oid проекта ('' — если введён вручную без выбора из списка). */
ProjectId: string;
} }
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
@@ -48,6 +60,8 @@ export interface ISectionProps {
* отдельный обработчик. Если не передан (режим чтения) — правка через set. * отдельный обработчик. Если не передан (режим чтения) — правка через set.
*/ */
onEmployeeChange?: (login: string) => void; onEmployeeChange?: (login: string) => void;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
} }
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
@@ -57,7 +71,11 @@ export const EMPTY_DATA: IFormData = {
EndDate: '', EndDate: '',
Reason: '', Reason: '',
Substitute: '', Substitute: '',
Organization: '' Organization: '',
Department: '',
DepartmentId: '',
Project: '',
ProjectId: ''
}; };
/** Плейсхолдер для поля выбора сотрудника. */ /** Плейсхолдер для поля выбора сотрудника. */
@@ -11,6 +11,10 @@
{ "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" } { "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
{ "internalName": "Project", "title": "Проект", "type": "Text" },
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" }
] ]
} }
@@ -1,10 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, PeopleField, OrganizationField } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, TextArea, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils'; import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Отпуск без содержания»: сотрудник, должность, период, основание, замещающий. */ /** Единственная секция формы «Отпуск без содержания»: сотрудник, должность, период, основание, замещающий. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => ( export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
<CollapseSection title="Заявка на отпуск без содержания" icon="people"> <CollapseSection title="Заявка на отпуск без содержания" icon="people">
<FormField label="Сотрудник" htmlFor="Employee" required full> <FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField <PeopleField
@@ -17,7 +18,30 @@ 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} /> <OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Подразделение" htmlFor="Department">
<DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField>
<FormField label="Проект" htmlFor="Project">
<ProjectPicker
id="Project"
context={context}
value={data.Project}
onChange={(name, id) => {
set('Project', name);
set('ProjectId', id);
}}
placeholder="Начните вводить название или код проекта…"
/>
</FormField>
<FormField label="Дата начала" htmlFor="StartDate" required> <FormField label="Дата начала" htmlFor="StartDate" required>
<TextInput <TextInput
id="StartDate" id="StartDate"
@@ -49,7 +49,11 @@ function buildItem(
DaysCount: days ?? null, DaysCount: days ?? null,
Reason: data.Reason || null, Reason: data.Reason || null,
SubstituteId: substituteId ?? null, SubstituteId: substituteId ?? null,
Organization: data.Organization || null Organization: data.Organization || null,
Department: data.Department || null,
DepartmentId: data.DepartmentId || null,
Project: data.Project || null,
ProjectId: data.ProjectId || null
}; };
} }
@@ -9,6 +9,9 @@
.toolbar { .toolbar {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4; margin-bottom: $sp-4;
} }
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './VacationView.module.scss'; import styles from './VacationView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui'; import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, PrintOrgSelect, OrgLetterhead } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -45,6 +45,9 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// vacationApprovalService можно звать не опасаясь исключения. // vacationApprovalService можно звать не опасаясь исключения.
@@ -98,7 +101,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
); );
} }
const sectionProps = { data, set: NOOP_SET, context }; const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
const notify = (): void => { const notify = (): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка // requestTypeId ещё не резолвился — решение уже применилось (строка списка
@@ -115,10 +118,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton /> <PrintButton />
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Заявка на трудовой отпуск" title="Заявка на трудовой отпуск"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
@@ -29,6 +29,10 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom
'StartDate', 'StartDate',
'DaysCount', 'DaysCount',
'Organization', 'Organization',
'Department',
'DepartmentId',
'Project',
'ProjectId',
'RequestStatus', 'RequestStatus',
'RequestUniqueCode', 'RequestUniqueCode',
'Employee/Title', 'Employee/Title',
@@ -49,7 +53,11 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom
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 || '', Substitute: substitute?.EMail || '',
Organization: (item.Organization as string) || '' Organization: (item.Organization as string) || '',
Department: (item.Department as string) || '',
DepartmentId: (item.DepartmentId as string) || '',
Project: (item.Project as string) || '',
ProjectId: (item.ProjectId as string) || ''
}; };
return { return {
@@ -32,6 +32,18 @@ export interface IFormData {
* сразу в нескольких организациях — тогда строк несколько. * сразу в нескольких организациях — тогда строк несколько.
*/ */
Organization: string; Organization: string;
/**
* Подразделение (справочник Dictionary/Departments, см.
* shared/dictionary/DepartmentPicker) — необязательное, справочное, на
* маршрут согласования НЕ влияет.
*/
Department: string;
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
DepartmentId: string;
/** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — необязательное, справочное. */
Project: string;
/** oid проекта ('' — если введён вручную без выбора из списка). */
ProjectId: string;
} }
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
@@ -47,6 +59,8 @@ export interface ISectionProps {
* отдельный обработчик. Если не передан (режим чтения) — правка через set. * отдельный обработчик. Если не передан (режим чтения) — правка через set.
*/ */
onEmployeeChange?: (login: string) => void; onEmployeeChange?: (login: string) => void;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
} }
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
@@ -55,7 +69,11 @@ export const EMPTY_DATA: IFormData = {
StartDate: '', StartDate: '',
DaysCount: '', DaysCount: '',
Substitute: '', Substitute: '',
Organization: '' Organization: '',
Department: '',
DepartmentId: '',
Project: '',
ProjectId: ''
}; };
/** Плейсхолдер для поля выбора сотрудника. */ /** Плейсхолдер для поля выбора сотрудника. */
@@ -10,6 +10,10 @@
{ "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": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" } { "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
{ "internalName": "Project", "title": "Проект", "type": "Text" },
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" }
] ]
} }
@@ -1,10 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils'; import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */ /** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => ( export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
<CollapseSection title="Заявка на трудовой отпуск" icon="people"> <CollapseSection title="Заявка на трудовой отпуск" icon="people">
<FormField label="Сотрудник" htmlFor="Employee" required full> <FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField <PeopleField
@@ -17,7 +18,30 @@ 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} /> <OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Подразделение" htmlFor="Department">
<DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField>
<FormField label="Проект" htmlFor="Project">
<ProjectPicker
id="Project"
context={context}
value={data.Project}
onChange={(name, id) => {
set('Project', name);
set('ProjectId', id);
}}
placeholder="Начните вводить название или код проекта…"
/>
</FormField>
<FormField label="Дата начала" htmlFor="StartDate" required> <FormField label="Дата начала" htmlFor="StartDate" required>
<TextInput <TextInput
id="StartDate" id="StartDate"
@@ -47,7 +47,11 @@ function buildItem(
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, SubstituteId: substituteId ?? null,
Organization: data.Organization || null Organization: data.Organization || null,
Department: data.Department || null,
DepartmentId: data.DepartmentId || null,
Project: data.Project || null,
ProjectId: data.ProjectId || null
}; };
} }
@@ -9,7 +9,10 @@
.toolbar { .toolbar {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4; margin-bottom: $sp-4;
} }
@@ -12,6 +12,8 @@ import {
FormField, FormField,
AttachmentList, AttachmentList,
PrintButton, PrintButton,
PrintOrgSelect,
OrgLetterhead,
Icon Icon
} from '../../shared/ui'; } from '../../shared/ui';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
@@ -63,6 +65,9 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [canEditFields, setCanEditFields] = useState<boolean>(false); const [canEditFields, setCanEditFields] = useState<boolean>(false);
const [saveState, setSaveState] = useState<SaveState>('idle'); const [saveState, setSaveState] = useState<SaveState>('idle');
// Какая из организаций заявителя (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
const { id: requestTypeId } = useRequestTypeId(context, formKey); const { id: requestTypeId } = useRequestTypeId(context, formKey);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
@@ -182,7 +187,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
); );
} }
const sectionProps = { data, set, context }; const sectionProps = { data, set, context, printOrgIndex };
const notify = (): void => { const notify = (): void => {
if (!requestTypeId) return; if (!requestTypeId) return;
@@ -205,6 +210,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
Редактировать Редактировать
</Button> </Button>
)} )}
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton /> <PrintButton />
</div> </div>
@@ -222,6 +228,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
)} )}
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Организация рабочего места" title="Организация рабочего места"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
@@ -3,7 +3,7 @@ import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model'; 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', 'Project', 'ProjectId', 'WorkKind', 'Location', 'StartDate',
'Software', 'AccessRights', 'Organization' 'Software', 'AccessRights', 'Organization'
]; ];
@@ -21,6 +21,14 @@ export interface IFormData {
Department: string; Department: string;
/** Id отдела из справочника (Dictionary/Departments); '' — если введён вручную без выбора из списка. */ /** Id отдела из справочника (Dictionary/Departments); '' — если введён вручную без выбора из списка. */
DepartmentId: string; DepartmentId: string;
/**
* Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — отдельно
* от Department: то влияет на маршрут согласования (getPlannedMatrix в
* WorkplaceSetupForm.tsx), это поле чисто информативное.
*/
Project: string;
/** oid проекта ('' — если введён вручную без выбора из списка). */
ProjectId: string;
WorkKind: string; WorkKind: string;
Location: string; Location: string;
StartDate: string; StartDate: string;
@@ -45,6 +53,8 @@ export interface ISectionProps {
data: IFormData; data: IFormData;
set: SetField; set: SetField;
context: WebPartContext; context: WebPartContext;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
} }
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
@@ -54,6 +64,8 @@ export const EMPTY_DATA: IFormData = {
Position: '', Position: '',
Department: '', Department: '',
DepartmentId: '', DepartmentId: '',
Project: '',
ProjectId: '',
WorkKind: '', WorkKind: '',
Location: '', Location: '',
StartDate: '', StartDate: '',
@@ -8,8 +8,10 @@
{ "internalName": "NameLatin", "title": "Фамилия и имя (латиница)", "type": "Text" }, { "internalName": "NameLatin", "title": "Фамилия и имя (латиница)", "type": "Text" },
{ "internalName": "Phone", "title": "Телефон", "type": "Text" }, { "internalName": "Phone", "title": "Телефон", "type": "Text" },
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true }, { "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
{ "internalName": "Department", "title": "Отдел / участок", "type": "Text" }, { "internalName": "Department", "title": "Подразделение", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id отдела (справочник)", "type": "Text" }, { "internalName": "DepartmentId", "title": "Id отдела (справочник)", "type": "Text" },
{ "internalName": "Project", "title": "Проект", "type": "Text" },
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
{ "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 },
@@ -1,9 +1,9 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput, OrganizationField } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, PhoneInput, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker } from '../../../shared/dictionary'; import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model'; import { ISectionProps } from '../model';
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context }) => ( export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex }) => (
<CollapseSection title="Сотрудник" icon="people"> <CollapseSection title="Сотрудник" icon="people">
<FormField label="Ф.И.О." htmlFor="FullName" required full> <FormField label="Ф.И.О." htmlFor="FullName" required full>
<TextInput id="FullName" value={data.FullName} onChange={(v) => set('FullName', v)} /> <TextInput id="FullName" value={data.FullName} onChange={(v) => set('FullName', v)} />
@@ -17,7 +17,7 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context })
<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>
<FormField label="Отдел / участок" htmlFor="Department"> <FormField label="Подразделение" htmlFor="Department">
<DepartmentPicker <DepartmentPicker
id="Department" id="Department"
context={context} context={context}
@@ -28,6 +28,18 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context })
}} }}
/> />
</FormField> </FormField>
<FormField label="Проект" htmlFor="Project">
<ProjectPicker
id="Project"
context={context}
value={data.Project}
onChange={(name, id) => {
set('Project', name);
set('ProjectId', id);
}}
placeholder="Начните вводить название или код проекта…"
/>
</FormField>
<FormField label="Вид работ" htmlFor="WorkKind"> <FormField label="Вид работ" htmlFor="WorkKind">
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} /> <TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
</FormField> </FormField>
@@ -37,6 +49,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="Организация заявителя" /> <OrganizationField value={data.Organization} label="Организация заявителя" printIndex={printOrgIndex} />
</CollapseSection> </CollapseSection>
); );
@@ -9,6 +9,7 @@ export {
isEditableStatus, isEditableStatus,
initialSubmitStatus, initialSubmitStatus,
isSubmittedStatus, isSubmittedStatus,
isApprovedStatus,
} from './models/statuses'; } from './models/statuses';
export type { ISiteUser } from './models/ISiteUser'; export type { ISiteUser } from './models/ISiteUser';
export type { IMatrixRow } from './models/IMatrixRow'; export type { IMatrixRow } from './models/IMatrixRow';
@@ -82,6 +82,23 @@ export function isSubmittedStatus(status: string): boolean {
); );
} }
/**
* Заявка прошла согласование (кнопка «Печать» открывается только тут).
*
* Формы без этапа исполнения (напр. «Заявка на подбор») получают финальный
* статус «Согласовано» сразу. Формы с исполнителями (Hire, Referral) минуют
* «Согласовано» — из computeRequestStatus в approverDecision.service.ts статус
* сразу становится «Выполнение», а по завершении — «Закрыта». Поэтому здесь
* нужны все три статуса, иначе печать для таких форм не появится никогда.
*/
export function isApprovedStatus(status: string): boolean {
return (
status === REQUEST_STATUS.approved ||
status === REQUEST_STATUS.execution ||
status === REQUEST_STATUS.closed
);
}
// Наборов значений ДВА, и они не совпадают — это разные сущности: // Наборов значений ДВА, и они не совпадают — это разные сущности:
// //
// 1) статус ЗАЯВКИ — колонка RequestStatus в списке «… — данные». Пишется // 1) статус ЗАЯВКИ — колонка RequestStatus в списке «… — данные». Пишется
+24
View File
@@ -0,0 +1,24 @@
// Позволяет TypeScript импортировать растровые/векторные изображения как модуль,
// отдающий URL строкой (см. shared/orgLogos.ts) — саму подстановку URL при сборке
// делает webpack (см. config/typescript.json → staticAssetsToCopy), тайпчекеру
// нужна только сигнатура.
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
@@ -0,0 +1,23 @@
import tansuConstruction from '../assets/logos/tansu-construction.png';
import astelK from '../assets/logos/astel-k.png';
import ctGlobal from '../assets/logos/ctglobal.png';
import kazpromService from '../assets/logos/kazprom-service.png';
import qazgeomax from '../assets/logos/qazgeomax.png';
/**
* Логотипы организаций для брендированной шапки печати (см. shared/ui/OrgLetterhead).
* Ключ — ТОЧНОЕ название компании, как отдаёт GET /Dictionary/Companies (см.
* shared/dictionary/companies.service.ts) и GET /Employee/ByEmail (IEmployeeOrganization.company) —
* оба идут из одного источника (кадровый API), названия совпадают дословно.
*
* Не для каждой компании из справочника есть логотип — тогда OrgLetterhead просто
* не рендерится (lookup возвращает undefined). Новая компания → положить PNG в
* ../assets/logos и добавить сюда запись.
*/
export const ORG_LOGOS: Record<string, string> = {
'ТОО TANSU Construction': tansuConstruction,
'ТОО АСТЕЛ-К': astelK,
'ТОО СТ Global': ctGlobal,
'ТОО KazPromService': kazpromService,
'ТОО QAZGEOMAX': qazgeomax
};
@@ -5,15 +5,22 @@ import { getCreatedBy, IInitiator } from '../../createdBy';
export interface IInitiatorLineProps { export interface IInitiatorLineProps {
/** Заголовок списка данных заявки. */ /** Заголовок списка данных заявки. */
listTitle: string; listTitle: string;
/** Id элемента заявки. undefined — заявка ещё не сохранена (ничего не показываем). */ /** Id элемента заявки. undefined — заявка ещё не сохранена (см. fallback). */
itemId?: number; itemId?: number;
/**
* Показать вместо Created-By-lookup, пока заявка ещё не сохранена (itemId
* не задан) — напр. в формах без поля «Сотрудник», где заявитель нигде
* больше не виден до первого сохранения (см. TmcTransferForm.tsx).
*/
fallback?: { name?: string; email?: string };
} }
/** /**
* Строка «Инициатор: Ф.И.О. · email» под номером заявки. Сама подгружает * Строка «Инициатор: Ф.И.О. · email» под номером заявки. Сама подгружает
* создателя (Created By) по listTitle+itemId. Пока не сохранено / нет данных — не рендерится. * создателя (Created By) по listTitle+itemId. Пока не сохранено — либо не
* рендерится, либо (если передан fallback) показывает его вместо lookup'а.
*/ */
export const InitiatorLine: React.FC<IInitiatorLineProps> = ({ listTitle, itemId }) => { export const InitiatorLine: React.FC<IInitiatorLineProps> = ({ listTitle, itemId, fallback }) => {
const [initiator, setInitiator] = React.useState<IInitiator | undefined>(undefined); const [initiator, setInitiator] = React.useState<IInitiator | undefined>(undefined);
React.useEffect(() => { React.useEffect(() => {
@@ -32,18 +39,20 @@ export const InitiatorLine: React.FC<IInitiatorLineProps> = ({ listTitle, itemId
}; };
}, [listTitle, itemId]); }, [listTitle, itemId]);
if (!initiator || (!initiator.name && !initiator.email)) { const shown = itemId ? initiator : fallback;
if (!shown || (!shown.name && !shown.email)) {
return null; return null;
} }
return ( return (
<div className={styles.line}> <div className={styles.line}>
<span className={styles.label}>Инициатор:</span> <span className={styles.label}>Инициатор:</span>
{initiator.name && <span className={styles.name}>{initiator.name}</span>} {shown.name && <span className={styles.name}>{shown.name}</span>}
{initiator.name && initiator.email && <span className={styles.sep}>·</span>} {shown.name && shown.email && <span className={styles.sep}>·</span>}
{initiator.email && ( {shown.email && (
<a className={styles.email} href={`mailto:${initiator.email}`}> <a className={styles.email} href={`mailto:${shown.email}`}>
{initiator.email} {shown.email}
</a> </a>
)} )}
</div> </div>
@@ -0,0 +1,17 @@
@import '../../../../../styles/design-tokens';
.letterhead {
display: flex;
justify-content: flex-start;
padding-left: $sp-3;
margin-bottom: $sp-4;
img {
display: block;
max-width: 100%;
max-height: 96px;
width: auto;
height: auto;
object-fit: contain;
}
}
@@ -0,0 +1,31 @@
import * as React from 'react';
import styles from './OrgLetterhead.module.scss';
import { ORG_LOGOS } from '../../orgLogos';
interface IOrgLetterheadProps {
/** `\n`-joined «Компания — должность», как в data.Organization. */
value: string;
selectedIndex: number;
}
/**
* Брендированная плашка над FormCard — логотип организации, выбранной в
* PrintOrgSelect (видна и на экране, и в печати). Название компании — часть
* строки до « — должность» (см. formatEmployeeOrganizations). Если логотипа
* для этой компании нет (shared/orgLogos.ts) — ничего не рендерим.
*/
export const OrgLetterhead: React.FC<IOrgLetterheadProps> = ({ value, selectedIndex }) => {
const lines = value ? value.split('\n') : [];
const line = lines[selectedIndex];
if (!line) return null;
const company = line.split(' — ')[0].trim();
const logo = ORG_LOGOS[company];
if (!logo) return null;
return (
<div className={styles.letterhead}>
<img src={logo} alt={company} />
</div>
);
};
@@ -11,6 +11,14 @@ interface IOrganizationFieldProps {
* читалось как «организация того, о ком заявка». * читалось как «организация того, о ком заявка».
*/ */
label?: string; label?: string;
/**
* Индекс строки, которая остаётся в печатной версии (см. PrintOrgSelect в
* тулбаре *View.tsx) — остальные строки помечаются `rp-no-print` и PrintButton
* вырезает их из клона перед печатью (shared/ui/PrintButton живёт в
* @tnsu/ui-kit). На экране видны ВСЕ строки независимо от выбора — это только
* для печати. Не передан → печатаются все организации как раньше.
*/
printIndex?: number;
} }
/** /**
@@ -20,15 +28,18 @@ interface IOrganizationFieldProps {
* тогда строк несколько. Пока не резолвилось (сотрудник не выбран/не найден) — * тогда строк несколько. Пока не резолвилось (сотрудник не выбран/не найден) —
* ничего не рендерим, а не пустой блок. * ничего не рендерим, а не пустой блок.
*/ */
export const OrganizationField: React.FC<IOrganizationFieldProps> = ({ value, label = 'Организация' }) => { export const OrganizationField: React.FC<IOrganizationFieldProps> = ({ value, label = 'Организация', printIndex }) => {
const lines = value ? value.split('\n') : []; const lines = value ? value.split('\n') : [];
if (lines.length === 0) return null; if (lines.length === 0) return null;
return ( return (
<FormField label={label} full> <FormField label={label} full>
<div className={styles.list}> <div className={styles.list}>
{lines.map((line) => ( {lines.map((line, i) => (
<div key={line} className={styles.line}> <div
key={i}
className={printIndex !== undefined && i !== printIndex ? `${styles.line} rp-no-print` : styles.line}
>
{line} {line}
</div> </div>
))} ))}
@@ -0,0 +1,16 @@
@import '../../../../../styles/design-tokens';
.wrap {
display: flex;
align-items: center;
gap: $sp-2;
min-width: 0;
}
.label {
flex: none;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-500;
white-space: nowrap;
}
@@ -0,0 +1,31 @@
import * as React from 'react';
import styles from './PrintOrgSelect.module.scss';
import { Select, ISelectOption } from '@tnsu/ui-kit';
interface IPrintOrgSelectProps {
/** `\n`-joined «Компания — должность», как в data.Organization. */
value: string;
selectedIndex: number;
onChange: (index: number) => void;
}
/**
* Выбор организации для печати — сотрудник может состоять сразу в нескольких
* (см. shared/ui/OrganizationField), а печатный документ должен ссылаться на
* одну конкретную. Живёт в тулбаре рядом с PrintButton, помечен `rp-no-print`
* самим тулбаром — сам в печать не идёт. Рендерится, только если организаций
* больше одной, иначе выбирать не из чего.
*/
export const PrintOrgSelect: React.FC<IPrintOrgSelectProps> = ({ value, selectedIndex, onChange }) => {
const lines = value ? value.split('\n') : [];
if (lines.length <= 1) return null;
const options: ISelectOption[] = lines.map((line, i) => ({ value: String(i), label: line }));
return (
<div className={styles.wrap}>
<span className={styles.label}>Организация для печати</span>
<Select value={String(selectedIndex)} onChange={(v) => onChange(Number(v))} options={options} />
</div>
);
};
@@ -39,6 +39,8 @@ 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 { OrganizationField } from './OrganizationField/OrganizationField';
export { PrintOrgSelect } from './PrintOrgSelect/PrintOrgSelect';
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
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';