feat: added unique request form code to forms
Tests / frontend-test (push) Successful in 1m7s

This commit is contained in:
Тимур Городецкий
2026-08-10 16:59:20 +05:00
parent 9aa8f88d66
commit dcf3f06121
12 changed files with 85 additions and 29 deletions
@@ -55,6 +55,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
// после первого сохранения. // после первого сохранения.
const [requestId, setRequestId] = useState<number | undefined>(editId); const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit(); const submit = useRequestSubmit();
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
@@ -73,6 +74,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
setExistingAttachments(atts); setExistingAttachments(atts);
} }
}) })
@@ -152,7 +154,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
}; };
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => { const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
const id = await saveHireRequest( const { id, code } = await saveHireRequest(
requestId, requestId,
data, data,
toApprovers(matrix.rows), toApprovers(matrix.rows),
@@ -161,6 +163,9 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
); );
setRequestId(id); setRequestId(id);
setRequestStatus(status); setRequestStatus(status);
if (code) {
setRequestCode(code);
}
if (files.length > 0) { if (files.length > 0) {
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
@@ -218,7 +223,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
{requestStatus && <StatusBadge status={requestStatus} />} {requestStatus && <StatusBadge status={requestStatus} />}
{requestId && <RequestNumber id={requestId} />} {requestCode && <RequestNumber code={requestCode} />}
</> </>
) : undefined ) : undefined
} }
@@ -41,6 +41,7 @@ const NOOP_SET: SetField = () => undefined;
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId }) => { const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]); const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -89,6 +90,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code);
setAttachments(files); setAttachments(files);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -140,7 +142,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<div className="rp-print-root"> <div className="rp-print-root">
<FormCard <FormCard
title="Заявка на прием" title="Заявка на прием"
subtitle={`№${itemId}`} subtitle={`№${code || itemId}`}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -13,6 +13,8 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
export interface ILoadedRequest { export interface ILoadedRequest {
data: IFormData; data: IFormData;
status: string; status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
} }
/** Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). */ /** Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). */
@@ -21,7 +23,7 @@ export async function loadHireRequest(itemId: number): Promise<ILoadedRequest> {
const item = (await sp.web.lists const item = (await sp.web.lists
.getByTitle(LIST_TITLE) .getByTitle(LIST_TITLE)
.items.getById(itemId) .items.getById(itemId)
.select(...SCALAR_FIELDS, 'Status')()) as Record<string, unknown>; .select(...SCALAR_FIELDS, 'Status', 'RequestUniqueCode')()) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA }; const data: IFormData = { ...EMPTY_DATA };
const writable = data as unknown as Record<string, string>; const writable = data as unknown as Record<string, string>;
@@ -37,5 +39,5 @@ export async function loadHireRequest(itemId: number): Promise<ILoadedRequest> {
} }
}); });
return { data, status: (item.Status as string) || '' }; return { data, status: (item.Status as string) || '', code: (item.RequestUniqueCode as string) || '' };
} }
@@ -1,5 +1,6 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { getSP } from '../../shared/pnp/pnpjs';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
import { buildRequestUniqueCode } from '../../shared/requestCode';
import { IFormData } from './model'; import { IFormData } from './model';
export const DATA_LIST_TITLE = 'Прием на работу — данные'; export const DATA_LIST_TITLE = 'Прием на работу — данные';
@@ -37,12 +38,18 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
return item; return item;
} }
export interface ISaveResult {
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
/** /**
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом. * Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* status = «Черновик» (сохранение) или «На согласовании» (отправка). * status = «Черновик» (сохранение) или «На согласовании» (отправка).
* executors может быть пустым — тогда после согласования заявка станет «Согласовано». * executors может быть пустым — тогда после согласования заявка станет «Согласовано».
* Возвращает Id заявки. * Возвращает Id заявки (+ код при создании).
*/ */
export async function saveHireRequest( export async function saveHireRequest(
existingId: number | undefined, existingId: number | undefined,
@@ -50,21 +57,23 @@ export async function saveHireRequest(
approvers: IApproverToWrite[], approvers: IApproverToWrite[],
executors: IApproverToWrite[], executors: IApproverToWrite[],
status: string status: string
): Promise<number> { ): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE); const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE);
const item = buildItem(data, status); const item = buildItem(data, status);
let id: number; let id: number;
let code: string | undefined;
if (existingId) { if (existingId) {
await list.items.getById(existingId).update(item); await list.items.getById(existingId).update(item);
id = existingId; id = existingId;
} else { } else {
const added = await list.items.add(item); const added = await list.items.add(item);
id = (added as { Id: number }).Id; id = (added as { Id: number }).Id;
// Уникальный код заявки: пока = id (id известен только после создания). // Код известен только после создания элемента (нужен его Id).
await list.items.getById(id).update({ RequestUniqueCode: String(id) }); code = buildRequestUniqueCode(HIRE_REQUEST_TYPE_ID, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
} }
await writeRoute(hireApprovalService(), id, approvers, executors, status); await writeRoute(hireApprovalService(), id, approvers, executors, status);
return id; return { id, code };
} }
@@ -15,6 +15,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [saving, setSaving] = useState<boolean>(false); const [saving, setSaving] = useState<boolean>(false);
const [saved, setSaved] = useState<boolean>(false); const [saved, setSaved] = useState<boolean>(false);
const [savedCode, setSavedCode] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const set: SetField = (key, value) => { const set: SetField = (key, value) => {
@@ -35,8 +36,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
setError(null); setError(null);
setSaving(true); setSaving(true);
try { try {
await submitRecruitingSelection(data); const code = await submitRecruitingSelection(data);
setSaved(true); setSaved(true);
setSavedCode(code);
if (onSaved) { if (onSaved) {
onSaved(); onSaved();
} }
@@ -57,7 +59,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
</button> </button>
<FormCard title={requestType.title} subtitle="Категория: Отдел рекрутинга"> <FormCard title={requestType.title} subtitle="Категория: Отдел рекрутинга">
{saved && <Alert variant="ok">Заявка сохранена в SharePoint.</Alert>} {saved && (
<Alert variant="ok">Заявка сохранена в SharePoint. Код заявки: №{savedCode}.</Alert>
)}
{error && <Alert variant="err">{error}</Alert>} {error && <Alert variant="err">{error}</Alert>}
<ApplicationSection {...sectionProps} /> <ApplicationSection {...sectionProps} />
@@ -1,7 +1,10 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { getSP } from '../../shared/pnp/pnpjs';
import { buildRequestUniqueCode } from '../../shared/requestCode';
import { IFormData } from './model'; import { IFormData } from './model';
const LIST_TITLE = 'Заявка на подбор — данные'; const LIST_TITLE = 'Заявка на подбор — данные';
// Тип заявки во внешнем approval-route API (GET /api/request-types → id 4).
const RECRUITING_SELECTION_REQUEST_TYPE_ID = 4;
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id. // Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager']; const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager'];
@@ -16,8 +19,8 @@ async function resolveUserId(login: string): Promise<number> {
return ensured.Id; return ensured.Id;
} }
/** Записать заявку «на подбор» в список данных SharePoint. */ /** Записать заявку «на подбор» в список данных SharePoint. Возвращает уникальный код заявки. */
export async function submitRecruitingSelection(data: IFormData): Promise<void> { export async function submitRecruitingSelection(data: IFormData): Promise<string> {
const sp = getSP(); const sp = getSP();
const item: Record<string, unknown> = {}; const item: Record<string, unknown> = {};
@@ -56,6 +59,8 @@ export async function submitRecruitingSelection(data: IFormData): Promise<void>
const added = await sp.web.lists.getByTitle(LIST_TITLE).items.add(item); const added = await sp.web.lists.getByTitle(LIST_TITLE).items.add(item);
const id = (added as { Id: number }).Id; const id = (added as { Id: number }).Id;
// Уникальный код заявки: пока = id (id известен только после создания). // Код известен только после создания элемента (нужен его Id).
await sp.web.lists.getByTitle(LIST_TITLE).items.getById(id).update({ RequestUniqueCode: String(id) }); const code = buildRequestUniqueCode(RECRUITING_SELECTION_REQUEST_TYPE_ID, id, new Date());
await sp.web.lists.getByTitle(LIST_TITLE).items.getById(id).update({ RequestUniqueCode: code });
return code;
} }
@@ -54,6 +54,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
// открытии на редактирование (editId) — сразу из загруженной записи. // открытии на редактирование (editId) — сразу из загруженной записи.
const [requestId, setRequestId] = useState<number | undefined>(editId); const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit(); const submit = useRequestSubmit();
// Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение. // Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение.
@@ -74,6 +75,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
setExistingAttachments(atts); setExistingAttachments(atts);
} }
}) })
@@ -175,7 +177,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
}; };
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => { const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
const id = await saveWorkplaceRequest( const { id, code } = await saveWorkplaceRequest(
requestId, requestId,
data, data,
toApprovers(matrix.rows), toApprovers(matrix.rows),
@@ -185,6 +187,9 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
); );
setRequestId(id); setRequestId(id);
setRequestStatus(status); setRequestStatus(status);
if (code) {
setRequestCode(code);
}
if (files.length > 0) { if (files.length > 0) {
await uploadAttachments(DATA_LIST_TITLE, id, files); await uploadAttachments(DATA_LIST_TITLE, id, files);
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
@@ -242,7 +247,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
{requestStatus && <StatusBadge status={requestStatus} />} {requestStatus && <StatusBadge status={requestStatus} />}
{requestId && <RequestNumber id={requestId} />} {requestCode && <RequestNumber code={requestCode} />}
</> </>
) : undefined ) : undefined
} }
@@ -50,6 +50,7 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId }) => { const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]); const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -101,6 +102,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code);
setAttachments(files); setAttachments(files);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -202,7 +204,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
<div className="rp-print-root"> <div className="rp-print-root">
<FormCard <FormCard
title="Организация рабочего места" title="Организация рабочего места"
subtitle={`№${itemId}`} subtitle={`№${code || itemId}`}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -11,6 +11,8 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
export interface ILoadedRequest { export interface ILoadedRequest {
data: IFormData; data: IFormData;
status: string; status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
} }
/** Прочитать заявку по id: модель формы + статус (для режима чтения). */ /** Прочитать заявку по id: модель формы + статус (для режима чтения). */
@@ -18,7 +20,7 @@ export async function loadWorkplaceRequest(itemId: number): Promise<ILoadedReque
const item = (await getSP() const item = (await getSP()
.web.lists.getByTitle(LIST_TITLE) .web.lists.getByTitle(LIST_TITLE)
.items.getById(itemId) .items.getById(itemId)
.select(...SCALAR_FIELDS, 'NeedsEquipment', 'Status')()) as Record<string, unknown>; .select(...SCALAR_FIELDS, 'NeedsEquipment', 'Status', 'RequestUniqueCode')()) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA }; const data: IFormData = { ...EMPTY_DATA };
const writable = data as unknown as Record<string, string>; const writable = data as unknown as Record<string, string>;
@@ -36,5 +38,5 @@ export async function loadWorkplaceRequest(itemId: number): Promise<ILoadedReque
data.NeedsEquipment = item.NeedsEquipment === true; data.NeedsEquipment = item.NeedsEquipment === true;
return { data, status: (item.Status as string) || '' }; return { data, status: (item.Status as string) || '', code: (item.RequestUniqueCode as string) || '' };
} }
@@ -1,5 +1,6 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { getSP } from '../../shared/pnp/pnpjs';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
import { buildRequestUniqueCode } from '../../shared/requestCode';
import { IFormData } from './model'; import { IFormData } from './model';
export const DATA_LIST_TITLE = 'Организация рабочего места — данные'; export const DATA_LIST_TITLE = 'Организация рабочего места — данные';
@@ -51,11 +52,17 @@ function buildItem(data: IFormData, status: string, sourceHireId?: number): Reco
return item; return item;
} }
export interface ISaveResult {
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
/** /**
* Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители). * Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования. * Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
* sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки. * sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки (+ код при создании).
*/ */
export async function saveWorkplaceRequest( export async function saveWorkplaceRequest(
existingId: number | undefined, existingId: number | undefined,
@@ -64,21 +71,23 @@ export async function saveWorkplaceRequest(
executors: IApproverToWrite[], executors: IApproverToWrite[],
status: string, status: string,
sourceHireId?: number sourceHireId?: number
): Promise<number> { ): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE); const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE);
const item = buildItem(data, status, sourceHireId); const item = buildItem(data, status, sourceHireId);
let id: number; let id: number;
let code: string | undefined;
if (existingId) { if (existingId) {
await list.items.getById(existingId).update(item); await list.items.getById(existingId).update(item);
id = existingId; id = existingId;
} else { } else {
const added = await list.items.add(item); const added = await list.items.add(item);
id = (added as { Id: number }).Id; id = (added as { Id: number }).Id;
// Уникальный код заявки: пока = id (id известен только после создания). // Код известен только после создания элемента (нужен его Id).
await list.items.getById(id).update({ RequestUniqueCode: String(id) }); code = buildRequestUniqueCode(WORKPLACE_REQUEST_TYPE_ID, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
} }
await writeRoute(workplaceApprovalService(), id, approvers, executors, status); await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
return id; return { id, code };
} }
@@ -0,0 +1,11 @@
/**
* Уникальный код заявки: {requestTypeId}-{YY}{MM}-{itemId}.
* requestTypeId — id типа заявки во внешнем approval-route API (GET /api/request-types).
* YY/MM — год и месяц подачи, itemId — Id элемента в списке данных. Пример: 1-2608-12.
*/
export function buildRequestUniqueCode(requestTypeId: number, itemId: number, submittedAt: Date): string {
const yy = String(submittedAt.getFullYear()).slice(-2);
const month = submittedAt.getMonth() + 1;
const mm = month < 10 ? `0${month}` : String(month);
return `${requestTypeId}-${yy}${mm}-${itemId}`;
}
@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import styles from './RequestNumber.module.scss'; import styles from './RequestNumber.module.scss';
/** Номер заявки (Id элемента списка) — плашка в правом углу шапки формы. */ /** Уникальный код заявки ({requestTypeId}-{YY}{MM}-{itemId}) — плашка в правом углу шапки формы. */
export const RequestNumber: React.FC<{ id: number }> = ({ id }) => ( export const RequestNumber: React.FC<{ code: string }> = ({ code }) => (
<span className={styles.badge}>№{id}</span> <span className={styles.badge}>№{code}</span>
); );