Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/9
This commit was merged in pull request #9.
This commit is contained in:
@@ -20,13 +20,14 @@ import {
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
resolveWriteListId,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
APPROVAL_PHASE,
|
||||
findUnassignedStep
|
||||
unassignedStepMessage
|
||||
} from '../../shared/approval';
|
||||
import {
|
||||
uploadAttachments,
|
||||
@@ -36,7 +37,12 @@ import {
|
||||
} from '../../shared/attachments/attachments.service';
|
||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { saveHireRequest, hireApprovalService } from './submit';
|
||||
import { loadHireRequest } from './load';
|
||||
@@ -45,7 +51,14 @@ import { CandidateSection } from './sections/CandidateSection';
|
||||
import { PpeSection } from './sections/PpeSection';
|
||||
import { ConditionsSection } from './sections/ConditionsSection';
|
||||
|
||||
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
|
||||
const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
requestType,
|
||||
context,
|
||||
onCancel,
|
||||
onSaved,
|
||||
editId,
|
||||
editListId
|
||||
}) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
|
||||
@@ -67,7 +80,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.all([loadHireRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)])
|
||||
Promise.all([loadHireRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
|
||||
.then(([loaded, atts]) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -84,13 +97,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [editId]);
|
||||
}, [editId, editListId]);
|
||||
|
||||
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
||||
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
||||
// редактирование, маршрут читается из уже записанных строк — планирование
|
||||
// затёрло бы правки, сделанные при первом сохранении.
|
||||
const approval = useMemo(() => hireApprovalService(), []);
|
||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||
// hireApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => hireApprovalService(editListId), [editListId]);
|
||||
const userEmail = effectiveUserEmail(context);
|
||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||
@@ -158,7 +174,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
if (requestId === undefined) {
|
||||
return;
|
||||
}
|
||||
deleteAttachment(dataListTitle(FORM_ID), requestId, fileName)
|
||||
deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName)
|
||||
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
@@ -171,13 +187,22 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
if (!rtId) {
|
||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||
}
|
||||
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
||||
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
|
||||
// это единственный запрос, который проверяет порог элементов текущего
|
||||
// списка и ставит в очередь создание следующей партиции при достижении —
|
||||
// без него архивация никогда не сработает. Сбой запроса не должен
|
||||
// блокировать подачу заявки — откатываемся на список по конвенции имён.
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
const { id, code } = await saveHireRequest(
|
||||
requestId,
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
toApprovers(executors.rows),
|
||||
status
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
@@ -185,19 +210,22 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
setRequestCode(code);
|
||||
}
|
||||
if (files.length > 0) {
|
||||
await uploadAttachments(dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
|
||||
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
|
||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); // показать добавленные
|
||||
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); // показать добавленные
|
||||
}
|
||||
if (onSaved) {
|
||||
onSaved();
|
||||
}
|
||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||
// маршрута именно в НЕЙ.
|
||||
if (status === REQUEST_STATUS.onApproval) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: rtId,
|
||||
dataListName: dataListTitle(FORM_ID),
|
||||
approvalListName: approvalListTitle(FORM_ID),
|
||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -209,14 +237,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
const validateSubmit = (): string | undefined => {
|
||||
const formError = validate();
|
||||
if (formError) return formError;
|
||||
const unassigned = findUnassignedStep(matrix.rows);
|
||||
if (unassigned) {
|
||||
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
|
||||
}
|
||||
const unassignedExecutor = findUnassignedStep(executors.rows);
|
||||
if (unassignedExecutor) {
|
||||
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`;
|
||||
}
|
||||
const unassignedApproverError = unassignedStepMessage(
|
||||
matrix.rows,
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedApproverError) return unassignedApproverError;
|
||||
const unassignedExecutorError = unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`
|
||||
);
|
||||
if (unassignedExecutorError) return unassignedExecutorError;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -237,7 +267,12 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
<FormCard
|
||||
title={requestType.title}
|
||||
subtitle="Категория: Отдел рекрутинга"
|
||||
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
|
||||
itemId={requestId}
|
||||
/>
|
||||
}
|
||||
headerRight={
|
||||
requestId || requestStatus ? (
|
||||
<>
|
||||
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
} from '../../shared/approval';
|
||||
import { navigate } from '../../shared/routing';
|
||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadHireRequest } from './load';
|
||||
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
|
||||
@@ -30,6 +35,8 @@ interface IRecruitingHireViewProps {
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
// Ключ формы «Организация рабочего места» в реестре (data/requests) — цель
|
||||
@@ -42,7 +49,7 @@ const NOOP_SET: SetField = () => undefined;
|
||||
// Заявка «на прием» только для просмотра (режим согласующего).
|
||||
// Переиспользует те же секции, что и форма подачи; редактирование выключено
|
||||
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey }) => {
|
||||
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
@@ -52,10 +59,16 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
|
||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||
|
||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||
// hireApprovalService можно звать не опасаясь исключения.
|
||||
//
|
||||
// Сервис решений согласующего: список согласования + список данных этой формы.
|
||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(
|
||||
() => new ApproverService(hireApprovalService(), dataListTitle(FORM_ID)),
|
||||
[]
|
||||
() => new ApproverService(hireApprovalService(listId), dataListRef),
|
||||
[listId, dataListRef]
|
||||
);
|
||||
|
||||
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
|
||||
@@ -66,7 +79,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
// перечитывает матрицу. Иначе на экране остались бы данные, загруженные при
|
||||
// открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании».
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const approval = useMemo(() => hireApprovalService(), []);
|
||||
const approval = useMemo(() => hireApprovalService(listId), [listId]);
|
||||
const loadApprovers = useCallback(
|
||||
() =>
|
||||
approval
|
||||
@@ -91,7 +104,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
Promise.all([loadHireRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)])
|
||||
Promise.all([loadHireRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
|
||||
.then(([loaded, files]) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -111,7 +124,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId]);
|
||||
}, [itemId, listId]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
@@ -130,8 +143,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
requestTypeId,
|
||||
dataListName: dataListTitle(FORM_ID),
|
||||
approvalListName: approvalListTitle(FORM_ID),
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
@@ -160,7 +173,12 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
<FormCard
|
||||
title="Заявка на прием"
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={itemId} />}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
|
||||
itemId={itemId}
|
||||
/>
|
||||
}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
|
||||
@@ -16,11 +16,18 @@ export interface ILoadedRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). */
|
||||
export async function loadHireRequest(itemId: number): Promise<ILoadedRequest> {
|
||||
/**
|
||||
* Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения).
|
||||
*
|
||||
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
|
||||
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
|
||||
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
|
||||
* называется архивный список, и запрос не зависит от реестра партиций.
|
||||
*/
|
||||
export async function loadHireRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||
const sp = getSP();
|
||||
const item = (await sp.web.lists
|
||||
.getByTitle(dataListTitle(FORM_ID))
|
||||
const list = listId ? sp.web.getList(listId) : sp.web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = (await list
|
||||
.items.getById(itemId)
|
||||
.select(...SCALAR_FIELDS, 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { getSP } from '../../shared/pnp/pnpjs';
|
||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||
import { FORM_ID, IFormData } from './model';
|
||||
|
||||
// Числовые поля: пустая строка → null, иначе Number().
|
||||
const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation'];
|
||||
|
||||
/** Экземпляр сервиса списка согласования этой формы. */
|
||||
export function hireApprovalService(): ApprovalListService {
|
||||
return new ApprovalListService(approvalListTitle(FORM_ID));
|
||||
/**
|
||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||
* именем формы для читаемости вызывающего кода.
|
||||
*/
|
||||
export function hireApprovalService(listId?: string): ApprovalListService {
|
||||
return approvalServiceFor(FORM_ID, listId);
|
||||
}
|
||||
|
||||
/** Осмысленный заголовок элемента списка (системная колонка Title). */
|
||||
@@ -33,11 +35,7 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||
return item;
|
||||
}
|
||||
|
||||
export interface ISaveResult {
|
||||
id: number;
|
||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||
code?: string;
|
||||
}
|
||||
export type ISaveResult = ISaveItemResult;
|
||||
|
||||
/**
|
||||
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
|
||||
@@ -45,6 +43,14 @@ export interface ISaveResult {
|
||||
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
|
||||
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
|
||||
* Возвращает Id заявки (+ код при создании).
|
||||
*
|
||||
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||
* ОБНОВЛЯЕМОЙ заявки (см. loadHireRequest), при создании — партиция, полученная
|
||||
* от approval-route API непосредственно перед вызовом (см. getCurrentListPartition
|
||||
* в RecruitingHireForm.tsx) — именно этот вызов, а не кеш из onInit, проверяет
|
||||
* порог элементов и запускает архивацию следующей партиции.
|
||||
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
|
||||
* имён, как до появления партиционирования.
|
||||
*/
|
||||
export async function saveHireRequest(
|
||||
existingId: number | undefined,
|
||||
@@ -52,24 +58,12 @@ export async function saveHireRequest(
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = buildItem(data, status);
|
||||
|
||||
let id: number;
|
||||
let code: string | undefined;
|
||||
if (existingId) {
|
||||
await list.items.getById(existingId).update(item);
|
||||
id = existingId;
|
||||
} else {
|
||||
const added = await list.items.add(item);
|
||||
id = (added as { Id: number }).Id;
|
||||
// Код известен только после создания элемента (нужен его Id).
|
||||
code = buildRequestUniqueCode(requestTypeId, id, new Date());
|
||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||
}
|
||||
|
||||
await writeRoute(hireApprovalService(), id, approvers, executors, status);
|
||||
return { id, code };
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(hireApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user