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,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 { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
export { SubmitActions } from './SubmitActions/SubmitActions';
export { InitiatorLine } from './InitiatorLine/InitiatorLine';