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
@@ -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,