Merge remote-tracking branch 'origin/main' into fired-request
Tests / frontend-test (pull_request) Successful in 1m15s

# Conflicts:
#	src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx
#	src/webparts/requestPortal/forms/Clearance/submit.ts
#	src/webparts/requestPortal/forms/UnpaidLeave/sections/RequestSection.tsx
#	src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx
This commit is contained in:
gorbenko.pavel
2026-08-26 16:09:07 +05:00
72 changed files with 747 additions and 100 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 {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
}
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
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 {
ApproverService,
DecisionPanel,
@@ -62,6 +62,9 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
// ввода (набрал сумму → data стал непустым → поле сразу стало бы readOnly).
const [lockedWriteOffs, setLockedWriteOffs] = useState<Set<string>>(new Set());
const [saveState, setSaveState] = useState<SaveState>('idle');
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
@@ -238,7 +241,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
context,
hrRecordsEditable,
parkingEditable,
writeOffEditableKeys: editableWriteOffKeys
writeOffEditableKeys: editableWriteOffKeys,
printOrgIndex
};
const notify = (): void => {
@@ -256,6 +260,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton />
</div>
@@ -275,6 +280,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
)}
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Обходной лист"
subtitle={`№${code || itemId}`}
@@ -36,6 +36,8 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
'ParkingCard',
'AccessCard',
'Organization',
'Project',
'ProjectId',
'DismissalUrl',
'WriteOffAssetsAmount',
'WriteOffPpeAmount',
@@ -65,6 +67,8 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
// IFormData.TrainingAmount.
TrainingAmount: numberField(item.WriteOffTrainingAmount),
Organization: (item.Organization as string) || '',
Project: (item.Project as string) || '',
ProjectId: (item.ProjectId as string) || '',
// Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес.
DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || '',
WriteOffAssetsAmount: numberField(item.WriteOffAssetsAmount),
@@ -41,6 +41,10 @@ export interface IFormData {
* read-only (см. shared/ui/OrganizationField).
*/
Organization: string;
/** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — необязательное, справочное. */
Project: string;
/** oid проекта ('' — если введён вручную без выбора из списка). */
ProjectId: string;
/**
* Ссылка на заявление на увольнение, по которому создан этот лист — колонка-
* гиперссылка `DismissalUrl`. Форма её не пишет (заполняет автосоздание, см.
@@ -120,6 +124,8 @@ export interface ISectionProps {
* отдельный обработчик. Если не передан (режим чтения) — правка через set.
*/
onEmployeeChange?: (login: string) => void;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
}
export const EMPTY_DATA: IFormData = {
@@ -131,6 +137,8 @@ export const EMPTY_DATA: IFormData = {
AccessCard: false,
TrainingAmount: '',
Organization: '',
Project: '',
ProjectId: '',
DismissalUrl: '',
WriteOffAssetsAmount: '',
WriteOffPpeAmount: '',
@@ -13,6 +13,8 @@
{ "internalName": "AccessCard", "title": "Карта СКУД", "type": "Boolean" },
{ "internalName": "TrainingAmount", "title": "Обучение: сумма", "type": "Number", "decimals": 2 },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Project", "title": "Проект", "type": "Text" },
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
{ "internalName": "DismissalUrl", "title": "Ссылка на заявление на увольнение", "type": "Url" },
@@ -1,5 +1,6 @@
import * as React from 'react';
import { FormGrid, CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField, LinkField } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, WRITE_OFF_CATEGORIES } from '../model';
// Подразделение подтягивается автоматически при выборе сотрудника (см.
@@ -30,6 +31,7 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
onEmployeeChange,
hrRecordsEditable,
parkingEditable,
printOrgIndex,
writeOffEditableKeys
}) => {
return (
@@ -65,7 +67,19 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
placeholder="Подставится автоматически по выбранному сотруднику"
/>
</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>
<TextInput
id="TerminationDate"
@@ -111,7 +111,9 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
ParkingCard: data.ParkingCard,
AccessCard: data.AccessCard,
WriteOffTrainingAmount: 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,
notifyActiveStep,
useRequestTypeId,
APPROVAL_PHASE
APPROVAL_PHASE,
isApprovedStatus
} from '../../shared/approval';
import { navigate } from '../../shared/routing';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
@@ -169,7 +170,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<Icon name="copy" size="sm" />
Организация рабочего места на основе этой
</Button>
<PrintButton />
{isApprovedStatus(status) && <PrintButton />}
</div>
<div className="rp-print-root">
@@ -18,7 +18,7 @@ function parseJsonArray<T>(raw: unknown): T[] {
const SCALAR_FIELDS: (keyof IFormData)[] = [
'Phone', 'HireType', 'ReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode',
'CandidateName', 'HireDate', 'Position', 'WorkKind',
'SiteName', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod',
'Department', 'DepartmentId', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod',
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
'AgencyName', 'AgencyTerms',
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
@@ -34,7 +34,16 @@ export interface IFormData {
HireDate: string;
Position: string;
WorkKind: string;
SiteName: string;
/**
* Подразделение по справочнику (Dictionary/Departments, см.
* shared/dictionary/DepartmentPicker) — как в WorkplaceSetup/RecruitingSelection:
* ручной выбор, чисто информационное, на маршрут согласования НЕ влияет.
* Заменило прежнее свободнотекстовое поле «Отдел / участок» (колонка SiteName
* в SP осталась в схеме нетронутой — просто больше не используется формой).
*/
Department: string;
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
DepartmentId: string;
SiteLocation: string;
// СИЗ
PpeHeight: string;
@@ -77,7 +86,8 @@ export const EMPTY_DATA: IFormData = {
HireDate: '',
Position: '',
WorkKind: '',
SiteName: '',
Department: '',
DepartmentId: '',
SiteLocation: '',
PpeHeight: '',
PpeClothingSize: '',
@@ -22,7 +22,9 @@
{ "internalName": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
{ "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": "PpeHeight", "title": "СИЗ: рост", "type": "Number" },
@@ -1,8 +1,9 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { DepartmentPicker } from '../../../shared/dictionary';
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">
<FormField label="Ф.И.О. кандидата" htmlFor="CandidateName" required full>
<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">
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
</FormField>
<FormField label="Отдел / участок" htmlFor="SiteName">
<TextInput id="SiteName" value={data.SiteName} onChange={(v) => set('SiteName', v)} />
<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="SiteLocation">
<TextInput id="SiteLocation" value={data.SiteLocation} onChange={(v) => set('SiteLocation', v)} />
@@ -35,6 +35,7 @@ import {
IAttachmentInfo
} from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation';
import { getEmployeeOrganizations, ICompany, IEmployeeOrganization } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { IRequestFormProps } from '../../models/types';
import {
@@ -98,6 +99,57 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 инициатора (текущего пользователя).
// У заявки на редактировании маршрут читается из уже записанных строк, чтобы не
// затереть правки инициатора при первом сохранении.
@@ -242,7 +294,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
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 sectionProps = { data, set, context };
const sectionProps = { data, set, context, onResponsibleChange: handleResponsibleChange };
return (
<div>
@@ -10,7 +10,8 @@ import {
ApprovalMatrix,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId
useRequestTypeId,
isApprovedStatus
} from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -118,7 +119,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
<PrintButton />
{isApprovedStatus(status) && <PrintButton />}
</div>
<div className="rp-print-root">
@@ -25,7 +25,16 @@ export interface IFormData {
ObjectProjectId: string;
ObjectProjectCode: string;
// Отдел
/** Подразделение ОТВЕТСТВЕННОГО за подачу (Employee/ByEmail.Section при выборе Responsible). */
DepartmentName: string;
/**
* Подразделение по справочнику (Dictionary/Departments, см.
* shared/dictionary/DepartmentPicker) — как в WorkplaceSetup: ручной выбор,
* чисто информационное, на маршрут согласования НЕ влияет.
*/
Department: string;
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
DepartmentId: string;
OpenVacanciesCount: string;
// Вакансия
Position: string;
@@ -71,6 +80,12 @@ export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/**
* Выбор ответственного подтягивает его подразделение (Employee/ByEmail →
* Section) — поэтому у поля отдельный обработчик. Если не передан (режим
* чтения) — правка через set.
*/
onResponsibleChange?: (login: string) => void;
}
export const EMPTY_DATA: IFormData = {
@@ -84,6 +99,8 @@ export const EMPTY_DATA: IFormData = {
ObjectProjectId: '',
ObjectProjectCode: '',
DepartmentName: '',
Department: '',
DepartmentId: '',
OpenVacanciesCount: '',
Position: '',
EmploymentType: '',
@@ -13,7 +13,9 @@
{ "internalName": "ObjectProjectCode", "title": "Объект/Проект — код", "type": "Text" },
{ "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": "Position", "title": "Должность", "type": "Text", "required": true },
@@ -10,7 +10,7 @@ import {
OPEN_REASON_NEW
} 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 showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW;
const reasonDetailsLabel =
@@ -22,14 +22,22 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
<PeopleField
context={context}
value={data.Responsible}
onChange={(v) => set('Responsible', v)}
onChange={onResponsibleChange ?? ((v) => set('Responsible', v))}
placeholder={PERSON_PLACEHOLDER}
/>
</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)} />
</FormField>
<FormField label="1.3 Крайний срок закрытия" htmlFor="ClosingDeadline">
<FormField label="1.4 Крайний срок закрытия" htmlFor="ClosingDeadline">
<TextInput
id="ClosingDeadline"
type="date"
@@ -37,7 +45,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
onChange={(v) => set('ClosingDeadline', v)}
/>
</FormField>
<FormField label="1.4 Причина открытия вакансии" htmlFor="OpenReason">
<FormField label="1.5 Причина открытия вакансии" htmlFor="OpenReason">
<Select
id="OpenReason"
value={data.OpenReason}
@@ -58,7 +66,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
/>
</FormField>
)}
<FormField label="1.5 Организация" htmlFor="Companies" full>
<FormField label="1.6 Организация" htmlFor="Companies" full>
<CompanyPickerMulti
context={context}
id="Companies"
@@ -67,7 +75,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
placeholder="Начните вводить название организации…"
/>
</FormField>
<FormField label="1.6 Объект/Проект" htmlFor="ObjectProject" full>
<FormField label="1.7 Объект/Проект" htmlFor="ObjectProject" full>
<ProjectPicker
context={context}
id="ObjectProject"
@@ -1,11 +1,20 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { DepartmentPicker } from '../../../shared/dictionary';
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">
<FormField label="2.1 Название отдела/департамента" htmlFor="DepartmentName" required full>
<TextInput id="DepartmentName" value={data.DepartmentName} onChange={(v) => set('DepartmentName', v)} />
<FormField label="2.1 Подразделение" htmlFor="Department" full>
<DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField>
<FormField label="2.2 Количество открытых вакансий" htmlFor="OpenVacanciesCount">
<TextInput
@@ -9,6 +9,9 @@
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
}
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
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 {
ApproverService,
DecisionPanel,
@@ -11,7 +11,8 @@ import {
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
APPROVAL_PHASE
APPROVAL_PHASE,
isApprovedStatus
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
@@ -50,6 +51,9 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Какая из организаций рекомендателя (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// 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 => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
@@ -138,10 +142,16 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
<PrintButton />
{isApprovedStatus(status) && (
<>
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton />
</>
)}
</div>
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Реферальная рекомендация"
subtitle={`№${code || itemId}`}
@@ -1,7 +1,19 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ICompany } from '../../shared/dictionary';
import { dataListTitle } from '../../shared/listTitles';
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 {
data: IFormData;
/** Ф.И.О. рекомендателя из поля Referrer (для заголовка режима чтения). */
@@ -35,7 +47,9 @@ export async function loadReferralRequest(itemId: number, listId?: string): Prom
'CandidateEmail',
'CandidateCity',
'VacancyTitle',
'VacancyDepartment',
'VacancyProject',
'VacancyProjectId',
'VacancyCompaniesJson',
'CurrentEmployer',
'CurrentPosition',
'ExperienceYears',
@@ -69,7 +83,9 @@ export async function loadReferralRequest(itemId: number, listId?: string): Prom
CandidateEmail: (item.CandidateEmail as string) || '',
CandidateCity: (item.CandidateCity 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) || '',
CurrentPosition: (item.CurrentPosition as string) || '',
ExperienceYears: (item.ExperienceYears as string) || '',
@@ -1,6 +1,7 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types';
import { ISelectOption } from '../../shared/ui';
import { ICompany } from '../../shared/dictionary';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -55,7 +56,18 @@ export interface IFormData {
CandidateCity: string;
VacancyTitle: string;
VacancyDepartment: string;
/** Название проекта (справочник /Project, см. shared/dictionary/ProjectPicker). */
VacancyProject: string;
/** oid проекта ('' — если введён вручную без выбора из списка). */
VacancyProjectId: string;
/**
* Организация(и), в которую рекомендуют кандидата (справочник, мультивыбор,
* см. shared/dictionary/CompanyPickerMulti) — хранится VacancyCompaniesJson.
* Необязательное, чисто информативное поле: на маршрут согласования НЕ влияет
* (в отличие от Companies в «Заявке на прием»). Не путать с Organization выше —
* то поле про самого рекомендателя, это — про вакансию.
*/
VacancyCompanies: ICompany[];
CurrentEmployer: string;
CurrentPosition: string;
@@ -85,6 +97,8 @@ export interface ISectionProps {
* обработчик. Если не передан (режим чтения) — правка через set.
*/
onReferrerChange?: (login: string) => void;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
}
export const EMPTY_DATA: IFormData = {
@@ -99,7 +113,9 @@ export const EMPTY_DATA: IFormData = {
CandidateEmail: '',
CandidateCity: '',
VacancyTitle: '',
VacancyDepartment: '',
VacancyProject: '',
VacancyProjectId: '',
VacancyCompanies: [],
CurrentEmployer: '',
CurrentPosition: '',
ExperienceYears: '',
@@ -17,7 +17,9 @@
{ "internalName": "CandidateCity", "title": "Город проживания кандидата", "type": "Text" },
{ "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": "CurrentPosition", "title": "Текущая должность кандидата", "type": "Text" },
@@ -3,7 +3,7 @@ import { CollapseSection, FormField, TextInput, PhoneInput, PeopleField, Organiz
import { ISectionProps, REFERRER_PLACEHOLDER } from '../model';
/** Секция 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">
<FormField label="Дата подачи" htmlFor="SubmitDate" required>
<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="Подставится автоматически по выбранному сотруднику"
/>
</FormField>
<OrganizationField value={data.Organization} />
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Контактный телефон" htmlFor="ReferrerPhone" required>
<PhoneInput id="ReferrerPhone" value={data.ReferrerPhone} onChange={(v) => set('ReferrerPhone', v)} />
</FormField>
@@ -1,18 +1,33 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { CompanyPickerMulti, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model';
/** Секция 3: на какую вакансию рекомендуют. */
export const VacancySection: React.FC<ISectionProps> = ({ data, set }) => (
export const VacancySection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="На какую вакансию рекомендуете" icon="search">
<FormField label="Название вакансии" htmlFor="VacancyTitle" required full>
<TextInput id="VacancyTitle" value={data.VacancyTitle} onChange={(v) => set('VacancyTitle', v)} />
</FormField>
<FormField label="Подразделение / Объект" htmlFor="VacancyDepartment" full>
<TextInput
id="VacancyDepartment"
value={data.VacancyDepartment}
onChange={(v) => set('VacancyDepartment', v)}
<FormField label="Организация" htmlFor="VacancyCompanies" full>
<CompanyPickerMulti
id="VacancyCompanies"
context={context}
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>
</CollapseSection>
@@ -39,7 +39,10 @@ function buildItem(data: IFormData, status: string, referrerId: number): Record<
CandidateEmail: data.CandidateEmail || null,
CandidateCity: data.CandidateCity || 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,
CurrentPosition: data.CurrentPosition || null,
ExperienceYears: data.ExperienceYears || null,
@@ -102,6 +102,26 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
};
}, [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(() => {
if (!editId) {
@@ -131,7 +151,9 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// tmcTransferApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => tmcTransferApprovalService(editListId), [editListId]);
const userEmail = effectiveUserEmail(context);
// Маршрут резолвится по e-mail ВЫБРАННОГО заявителя (не обязательно текущего
// пользователя) — так работает подача за другого, как в Vacation.
const userEmail = data.RequesterEmail;
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
const requestTypeIdState = useRequestTypeId(context, requestType.id);
@@ -142,13 +164,13 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
// «руководитель проекта-источника». Меняется проект → перепланируем маршрут.
const projectId = data.SourceProjectId || null;
const targetProjectId = data.TargetProjectId || null;
// Маршрут согласования зависит от проекта-источника («Откуда»): пока он не
// выбран, resolve вернёт 404 — не грузим, показываем заглушку. У открытой на
// редактирование заявки маршрут уже записан, источник не нужен.
const matrixReady = !!editId || (!!projectId && !!requestTypeId);
// Маршрут согласования зависит от заявителя и проекта-источника («Откуда»):
// пока не выбраны, resolve вернёт 404 — не грузим, показываем заглушку. У
// открытой на редактирование заявки маршрут уже записан, ни то ни другое не нужно.
const matrixReady = !!editId || (!!userEmail && !!projectId && !!requestTypeId);
// Исполнитель определяется проектом-получателем («Куда»): показываем блок с
// заглушкой, пока «Куда» не выбрано. Источник тоже нужен, иначе resolve 404.
const executorsReady = !!editId || (!!projectId && !!targetProjectId && !!requestTypeId);
// заглушкой, пока «Куда» не выбрано. Источник и заявитель тоже нужны, иначе resolve 404.
const executorsReady = !!editId || (!!userEmail && !!projectId && !!targetProjectId && !!requestTypeId);
const loadMatrix = useCallback(
() =>
editId
@@ -182,10 +204,13 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
const hasItems = data.Items.some((i) => i.name.trim().length > 0);
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 => {
if (!isValid) {
return 'Заполните обязательные поля: проект-источник, проект-получатель и хотя бы одну позицию ТМЦ.';
return 'Заполните обязательные поля: заявитель, проект-источник, проект-получатель и хотя бы одну позицию ТМЦ.';
}
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
@@ -278,7 +303,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
const handleSubmit = (): Promise<void> =>
submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
const sectionProps = { data, set, context };
const sectionProps = { data, set, context, onRequesterChange: handleRequesterChange };
return (
<div>
@@ -294,6 +319,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
fallback={{ name: data.AuthorName, email: data.RequesterEmail }}
/>
}
headerRight={
@@ -334,7 +360,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
editor={matrix}
search={(q) => approval.searchUsers(q)}
waiting={!matrixReady}
waitingText="Выберите проект-источник («Откуда»), чтобы построить маршрут согласования."
waitingText="Укажите заявителя и проект-источник («Откуда»), чтобы построить маршрут согласования."
/>
{/* Блок исполнителя показываем, только когда по маршруту реально есть
@@ -9,6 +9,8 @@
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
@@ -12,6 +12,8 @@ import {
FormField,
AttachmentList,
PrintButton,
PrintOrgSelect,
OrgLetterhead,
Icon
} from '../../shared/ui';
import { navigate } from '../../shared/routing';
@@ -57,6 +59,9 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Какая из организаций заявителя (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
// В просмотре форма 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 => {
if (!requestTypeId) return;
@@ -151,10 +156,12 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
Редактировать
</Button>
)}
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton />
</div>
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Служебная записка на перемещение ТМЦ"
subtitle={`№${code || itemId}`}
@@ -39,6 +39,8 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P
'RequesterEmail',
'AuthorName',
'Organization',
'Department',
'DepartmentId',
'Subject',
'RequestDate',
'SourceProject',
@@ -62,6 +64,8 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P
RequesterEmail: (item.RequesterEmail as string) || '',
AuthorName: (item.AuthorName as string) || '',
Organization: (item.Organization as string) || '',
Department: (item.Department as string) || '',
DepartmentId: (item.DepartmentId as string) || '',
Subject: (item.Subject as string) || '',
RequestDate: requestDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
SourceProject: (item.SourceProject as string) || '',
@@ -18,14 +18,25 @@ export interface IFormData {
// Шапка служебной записки
Recipient: string; // Кому (по умолч. «Директору Самаевой А.А.»)
// NB: не «Author» — это зарезервированное системное имя колонки SP (Created By).
RequesterEmail: string; // От — email заявителя (текущий пользователь)
AuthorName: string; // От — Ф.И.О. заявителя (для печати)
// Выбирается через PeopleField (по умолчанию — текущий пользователь, но можно
// подать за другого — см. handleRequesterChange в TmcTransferForm.tsx). Именно
// это поле идёт в getPlannedMatrix: маршрут резолвится на ВЫБРАННОГО заявителя.
RequesterEmail: string; // От — email заявителя
AuthorName: string; // От — Ф.И.О. заявителя (для печати; подтягивается из Employee/ByEmail при выборе)
/**
* Организации заявителя (`\n`-joined «Компания — должность» на строку) —
* подтягивается автоматически из Employee/ByEmail, read-only (см.
* shared/ui/OrganizationField).
*/
Organization: string;
/**
* Подразделение (справочник Dictionary/Departments, см.
* shared/dictionary/DepartmentPicker) — необязательное, справочное, на
* маршрут согласования НЕ влияет.
*/
Department: string;
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
DepartmentId: string;
Subject: string; // Тема (по умолч. «Перемещение ТМЦ»)
RequestDate: string; // Дата (YYYY-MM-DD)
@@ -55,16 +66,27 @@ export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
/**
* Выбор заявителя подтягивает его ФИО и организации — поэтому у поля отдельный
* обработчик. Если не передан (режим чтения) — правка через set.
*/
onRequesterChange?: (login: string) => void;
}
export const DEFAULT_RECIPIENT = 'Директору Самаевой А.А.';
export const DEFAULT_SUBJECT = 'Перемещение ТМЦ';
/** Плейсхолдер для поля выбора заявителя. */
export const REQUESTER_PLACEHOLDER = 'Начните вводить Ф.И.О. заявителя…';
export const EMPTY_DATA: IFormData = {
Recipient: DEFAULT_RECIPIENT,
RequesterEmail: '',
AuthorName: '',
Organization: '',
Department: '',
DepartmentId: '',
Subject: DEFAULT_SUBJECT,
RequestDate: '',
SourceProject: '',
@@ -8,6 +8,8 @@
{ "internalName": "RequesterEmail", "title": "От (email заявителя)", "type": "Text" },
{ "internalName": "AuthorName", "title": "От (Ф.И.О.)", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
{ "internalName": "Subject", "title": "Тема", "type": "Text" },
{ "internalName": "RequestDate", "title": "Дата", "type": "DateTime", "dateOnly": true },
@@ -1,15 +1,34 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, TextArea, OrganizationField } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model';
import { FormGrid, FormField, TextInput, TextArea, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
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>
<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>
<ProjectPicker
id="SourceProject"
@@ -9,6 +9,9 @@
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
}
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
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 {
ApproverService,
DecisionPanel,
@@ -45,6 +45,9 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// 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 => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
@@ -115,10 +118,12 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton />
</div>
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Заявка на отпуск без содержания"
subtitle={`№${code || itemId}`}
@@ -30,6 +30,10 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
'EndDate',
'Reason',
'Organization',
'Department',
'DepartmentId',
'Project',
'ProjectId',
'RequestStatus',
'RequestUniqueCode',
'Employee/Title',
@@ -51,7 +55,11 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
EndDate: end.slice(0, 10),
Reason: (item.Reason as string) || '',
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 {
@@ -33,6 +33,18 @@ export interface IFormData {
* read-only (см. shared/ui/OrganizationField).
*/
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.
*/
onEmployeeChange?: (login: string) => void;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
}
export const EMPTY_DATA: IFormData = {
@@ -57,7 +71,11 @@ export const EMPTY_DATA: IFormData = {
EndDate: '',
Reason: '',
Substitute: '',
Organization: ''
Organization: '',
Department: '',
DepartmentId: '',
Project: '',
ProjectId: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
@@ -11,6 +11,10 @@
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
{ "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" },
{ "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 { FormGrid, 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 { 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 }) => (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
@@ -17,7 +18,30 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</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>
<TextInput
id="StartDate"
@@ -49,7 +49,11 @@ function buildItem(
DaysCount: days ?? null,
Reason: data.Reason || 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 {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
}
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
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 {
ApproverService,
DecisionPanel,
@@ -45,6 +45,9 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// 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 => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
@@ -115,10 +118,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton />
</div>
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Заявка на трудовой отпуск"
subtitle={`№${code || itemId}`}
@@ -29,6 +29,10 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom
'StartDate',
'DaysCount',
'Organization',
'Department',
'DepartmentId',
'Project',
'ProjectId',
'RequestStatus',
'RequestUniqueCode',
'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
DaysCount: days === null || days === undefined ? '' : String(days),
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 {
@@ -32,6 +32,18 @@ export interface IFormData {
* сразу в нескольких организациях — тогда строк несколько.
*/
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.
*/
onEmployeeChange?: (login: string) => void;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
}
export const EMPTY_DATA: IFormData = {
@@ -55,7 +69,11 @@ export const EMPTY_DATA: IFormData = {
StartDate: '',
DaysCount: '',
Substitute: '',
Organization: ''
Organization: '',
Department: '',
DepartmentId: '',
Project: '',
ProjectId: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
@@ -10,6 +10,10 @@
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
{ "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 { FormGrid, 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 { 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 }) => (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
@@ -17,7 +18,30 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</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>
<TextInput
id="StartDate"
@@ -47,7 +47,11 @@ function buildItem(
DaysCount: Number.isFinite(days) && days > 0 ? days : null,
EndDate: computeEndDate(data.StartDate, days) || 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 {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
}
@@ -12,6 +12,8 @@ import {
FormField,
AttachmentList,
PrintButton,
PrintOrgSelect,
OrgLetterhead,
Icon
} from '../../shared/ui';
import { navigate } from '../../shared/routing';
@@ -63,6 +65,9 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
const [error, setError] = useState<string | null>(null);
const [canEditFields, setCanEditFields] = useState<boolean>(false);
const [saveState, setSaveState] = useState<SaveState>('idle');
// Какая из организаций заявителя (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintOrgSelect, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
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 => {
if (!requestTypeId) return;
@@ -205,6 +210,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
Редактировать
</Button>
)}
<PrintOrgSelect value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
<PrintButton />
</div>
@@ -222,6 +228,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
)}
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Организация рабочего места"
subtitle={`№${code || itemId}`}
@@ -3,7 +3,7 @@ import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
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'
];
@@ -21,6 +21,14 @@ export interface IFormData {
Department: string;
/** Id отдела из справочника (Dictionary/Departments); '' — если введён вручную без выбора из списка. */
DepartmentId: string;
/**
* Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — отдельно
* от Department: то влияет на маршрут согласования (getPlannedMatrix в
* WorkplaceSetupForm.tsx), это поле чисто информативное.
*/
Project: string;
/** oid проекта ('' — если введён вручную без выбора из списка). */
ProjectId: string;
WorkKind: string;
Location: string;
StartDate: string;
@@ -45,6 +53,8 @@ export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/** Индекс организации для печати (см. shared/ui/PrintOrgSelect и OrganizationField.printIndex). */
printOrgIndex?: number;
}
export const EMPTY_DATA: IFormData = {
@@ -54,6 +64,8 @@ export const EMPTY_DATA: IFormData = {
Position: '',
Department: '',
DepartmentId: '',
Project: '',
ProjectId: '',
WorkKind: '',
Location: '',
StartDate: '',
@@ -12,7 +12,7 @@ export async function loadPrefillFromHire(hireId: number): Promise<IFormData> {
FullName: hire.CandidateName,
Phone: hire.Phone,
Position: hire.Position,
Department: hire.SiteName,
Department: hire.Department,
WorkKind: hire.WorkKind,
Location: hire.SiteLocation,
StartDate: hire.HireDate
@@ -8,8 +8,10 @@
{ "internalName": "NameLatin", "title": "Фамилия и имя (латиница)", "type": "Text" },
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
{ "internalName": "Department", "title": "Отдел / участок", "type": "Text" },
{ "internalName": "Department", "title": "Подразделение", "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": "Location", "title": "Местоположение", "type": "Text" },
{ "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
@@ -1,9 +1,9 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker } from '../../../shared/dictionary';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
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">
<FormField label="Ф.И.О." htmlFor="FullName" required full>
<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>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<FormField label="Отдел / участок" htmlFor="Department">
<FormField label="Подразделение" htmlFor="Department">
<DepartmentPicker
id="Department"
context={context}
@@ -28,6 +28,18 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context })
}}
/>
</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">
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
</FormField>
@@ -37,6 +49,6 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context })
<FormField label="Дата приёма" htmlFor="StartDate">
<TextInput id="StartDate" type="date" value={data.StartDate} onChange={(v) => set('StartDate', v)} />
</FormField>
<OrganizationField value={data.Organization} label="Организация заявителя" />
<OrganizationField value={data.Organization} label="Организация заявителя" printIndex={printOrgIndex} />
</CollapseSection>
);
@@ -9,6 +9,7 @@ export {
isEditableStatus,
initialSubmitStatus,
isSubmittedStatus,
isApprovedStatus,
} from './models/statuses';
export type { ISiteUser } from './models/ISiteUser';
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 в списке «… — данные». Пишется
+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 {
/** Заголовок списка данных заявки. */
listTitle: string;
/** Id элемента заявки. undefined — заявка ещё не сохранена (ничего не показываем). */
/** Id элемента заявки. undefined — заявка ещё не сохранена (см. fallback). */
itemId?: number;
/**
* Показать вместо Created-By-lookup, пока заявка ещё не сохранена (itemId
* не задан) — напр. в формах без поля «Сотрудник», где заявитель нигде
* больше не виден до первого сохранения (см. TmcTransferForm.tsx).
*/
fallback?: { name?: string; email?: string };
}
/**
* Строка «Инициатор: Ф.И.О. · 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);
React.useEffect(() => {
@@ -32,18 +39,20 @@ export const InitiatorLine: React.FC<IInitiatorLineProps> = ({ listTitle, itemId
};
}, [listTitle, itemId]);
if (!initiator || (!initiator.name && !initiator.email)) {
const shown = itemId ? initiator : fallback;
if (!shown || (!shown.name && !shown.email)) {
return null;
}
return (
<div className={styles.line}>
<span className={styles.label}>Инициатор:</span>
{initiator.name && <span className={styles.name}>{initiator.name}</span>}
{initiator.name && initiator.email && <span className={styles.sep}>·</span>}
{initiator.email && (
<a className={styles.email} href={`mailto:${initiator.email}`}>
{initiator.email}
{shown.name && <span className={styles.name}>{shown.name}</span>}
{shown.name && shown.email && <span className={styles.sep}>·</span>}
{shown.email && (
<a className={styles.email} href={`mailto:${shown.email}`}>
{shown.email}
</a>
)}
</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;
/**
* Индекс строки, которая остаётся в печатной версии (см. 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') : [];
if (lines.length === 0) return null;
return (
<FormField label={label} full>
<div className={styles.list}>
{lines.map((line) => (
<div key={line} className={styles.line}>
{lines.map((line, i) => (
<div
key={i}
className={printIndex !== undefined && i !== printIndex ? `${styles.line} rp-no-print` : styles.line}
>
{line}
</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 { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { OrganizationField } from './OrganizationField/OrganizationField';
export { PrintOrgSelect } from './PrintOrgSelect/PrintOrgSelect';
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
export { LinkField } from './LinkField/LinkField';
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
export { SubmitActions } from './SubmitActions/SubmitActions';