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:
@@ -16,22 +16,35 @@ import {
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
resolveWriteListId,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
APPROVAL_PHASE,
|
||||
findUnassignedStep
|
||||
unassignedStepMessage
|
||||
} from '../../shared/approval';
|
||||
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 { saveClearanceRequest, clearanceApprovalService } from './submit';
|
||||
import { loadClearanceRequest } from './load';
|
||||
import { RequestSection } from './sections/RequestSection';
|
||||
|
||||
const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
|
||||
const ClearanceForm: React.FC<IRequestFormProps> = ({
|
||||
requestType,
|
||||
context,
|
||||
onCancel,
|
||||
onSaved,
|
||||
editId,
|
||||
editListId
|
||||
}) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||
@@ -41,7 +54,10 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
||||
const readOnly = !isEditableStatus(requestStatus);
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const approval = useMemo(() => clearanceApprovalService(), []);
|
||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||
// clearanceApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => clearanceApprovalService(editListId), [editListId]);
|
||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||
@@ -53,7 +69,7 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
loadClearanceRequest(editId)
|
||||
loadClearanceRequest(editId, editListId)
|
||||
.then((loaded) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -70,7 +86,7 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editId]);
|
||||
}, [editId, editListId]);
|
||||
|
||||
// Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай:
|
||||
// обходной лист оформляют в день увольнения). При открытии на редактирование
|
||||
@@ -174,13 +190,22 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
||||
if (!rtId) {
|
||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||
}
|
||||
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
||||
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
|
||||
// это единственный запрос, который проверяет порог элементов текущего
|
||||
// списка и ставит в очередь создание следующей партиции при достижении —
|
||||
// без него архивация никогда не сработает. Сбой запроса не должен
|
||||
// блокировать подачу заявки — откатываемся на список по конвенции имён.
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
const { id, code } = await saveClearanceRequest(
|
||||
requestId,
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
toApprovers(executors.rows),
|
||||
status
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
@@ -191,11 +216,14 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
||||
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)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -208,14 +236,16 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
||||
if (matrix.error) {
|
||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -234,7 +264,12 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
||||
<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 ? (
|
||||
<>
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
useRequestTypeId,
|
||||
APPROVAL_PHASE
|
||||
} from '../../shared/approval';
|
||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadClearanceRequest } from './load';
|
||||
import {
|
||||
@@ -29,6 +34,8 @@ interface IClearanceViewProps {
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
@@ -36,7 +43,7 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые
|
||||
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
|
||||
// отдел подбора — парковочная карта (см. approverRights.ts).
|
||||
const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey }) => {
|
||||
const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey, listId }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
@@ -48,12 +55,14 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const approval = useMemo(() => clearanceApprovalService(), []);
|
||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||
// clearanceApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => clearanceApprovalService(listId), [listId]);
|
||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||
const approverService = useMemo(
|
||||
() => new ApproverService(approval, dataListTitle(FORM_ID)),
|
||||
[approval]
|
||||
);
|
||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
|
||||
// Матрица маршрута из фактических строк заявки (read-only), раздельно по
|
||||
// фазам: согласование и исполнение — два отдельных блока в UI (см. ниже).
|
||||
@@ -84,7 +93,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
loadClearanceRequest(itemId)
|
||||
loadClearanceRequest(itemId, listId)
|
||||
.then((loaded) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -103,7 +112,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId]);
|
||||
}, [itemId, listId]);
|
||||
|
||||
// Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя.
|
||||
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата
|
||||
@@ -141,8 +150,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
if (parkingEditable) {
|
||||
fields.ParkingCard = data.ParkingCard;
|
||||
}
|
||||
return updateApproverFields(itemId, fields);
|
||||
}, [itemId, hrRecordsEditable, parkingEditable, data.AccessCard, data.TrainingAmount, data.ParkingCard]);
|
||||
return updateApproverFields(itemId, fields, listId);
|
||||
}, [itemId, listId, hrRecordsEditable, parkingEditable, data.AccessCard, data.TrainingAmount, data.ParkingCard]);
|
||||
|
||||
const handleSaveFields = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
@@ -175,8 +184,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -205,7 +214,12 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
<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>
|
||||
|
||||
@@ -11,10 +11,17 @@ export interface ILoadedRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Прочитать обходной лист по id: модель формы + статус (правка / просмотр). */
|
||||
export async function loadClearanceRequest(itemId: number): Promise<ILoadedRequest> {
|
||||
const item = (await getSP()
|
||||
.web.lists.getByTitle(dataListTitle(FORM_ID))
|
||||
/**
|
||||
* Прочитать обходной лист по id: модель формы + статус (правка / просмотр).
|
||||
*
|
||||
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
|
||||
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
|
||||
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
|
||||
* называется архивный список, и запрос не зависит от реестра партиций.
|
||||
*/
|
||||
export async function loadClearanceRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = (await list
|
||||
.items.getById(itemId)
|
||||
.select(
|
||||
'Id',
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||
import { ensureEmployee } from '../../shared/employee';
|
||||
import { FORM_ID, IFormData } from './model';
|
||||
|
||||
/** Экземпляр сервиса списка согласования этой формы. */
|
||||
export function clearanceApprovalService(): ApprovalListService {
|
||||
return new ApprovalListService(approvalListTitle(FORM_ID));
|
||||
/**
|
||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||
* именем формы для читаемости вызывающего кода.
|
||||
*/
|
||||
export function clearanceApprovalService(listId?: string): ApprovalListService {
|
||||
return approvalServiceFor(FORM_ID, listId);
|
||||
}
|
||||
|
||||
/** Отметки, которые проставляет не инициатор, а профильный согласующий (см. approverRights.ts). */
|
||||
@@ -20,10 +23,14 @@ export interface IApproverEditableFields {
|
||||
* Обновить только те отметки, что заполняет согласующий на своём шаге.
|
||||
* Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние
|
||||
* ключи затёрли бы чужие отметки значениями из формы этого согласующего.
|
||||
*
|
||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest); не задан —
|
||||
* текущая партиция, как раньше.
|
||||
*/
|
||||
export async function updateApproverFields(
|
||||
id: number,
|
||||
fields: Partial<IApproverEditableFields>
|
||||
fields: Partial<IApproverEditableFields>,
|
||||
listId?: string
|
||||
): Promise<void> {
|
||||
const item: Record<string, unknown> = {};
|
||||
if (fields.AccessCard !== undefined) item.AccessCard = fields.AccessCard;
|
||||
@@ -34,13 +41,8 @@ export async function updateApproverFields(
|
||||
Number.isFinite(amount) && fields.TrainingAmount.trim() !== '' ? amount : null;
|
||||
}
|
||||
if (Object.keys(item).length === 0) return;
|
||||
await getSP().web.lists.getByTitle(dataListTitle(FORM_ID)).items.getById(id).update(item);
|
||||
}
|
||||
|
||||
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
|
||||
async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> {
|
||||
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
|
||||
return getSP().web.ensureUser(full);
|
||||
const list = resolveDataList(FORM_ID, listId);
|
||||
await list.items.getById(id).update(item);
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
@@ -69,16 +71,20 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
||||
};
|
||||
}
|
||||
|
||||
export interface ISaveResult {
|
||||
id: number;
|
||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||
code?: string;
|
||||
}
|
||||
export type ISaveResult = ISaveItemResult;
|
||||
|
||||
/**
|
||||
* Сохранить обходной лист: содержимое + маршрут (согласование + исполнение).
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Возвращает Id заявки (+ код при создании).
|
||||
*
|
||||
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||
* ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest), при создании — партиция,
|
||||
* полученная от approval-route API непосредственно перед вызовом (см.
|
||||
* getCurrentListPartition в ClearanceForm.tsx) — именно этот вызов, а не кеш из
|
||||
* onInit, проверяет порог элементов и запускает архивацию следующей партиции.
|
||||
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
|
||||
* имён, как до появления партиционирования.
|
||||
*/
|
||||
export async function saveClearanceRequest(
|
||||
existingId: number | undefined,
|
||||
@@ -86,25 +92,13 @@ export async function saveClearanceRequest(
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const employee = await ensureEmployee(data.Employee);
|
||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
|
||||
|
||||
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(clearanceApprovalService(), id, approvers, executors, status);
|
||||
return { id, code };
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(clearanceApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -20,12 +20,13 @@ import {
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
resolveWriteListId,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
findUnassignedStep
|
||||
unassignedStepMessage
|
||||
} from '../../shared/approval';
|
||||
import {
|
||||
uploadAttachments,
|
||||
@@ -35,7 +36,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 { saveSelectionRequest, selectionApprovalService } from './submit';
|
||||
import { loadSelectionRequest } from './load';
|
||||
@@ -45,7 +51,14 @@ import { VacancySection } from './sections/VacancySection';
|
||||
import { HealthTripsSection } from './sections/HealthTripsSection';
|
||||
import { ConditionsSection } from './sections/ConditionsSection';
|
||||
|
||||
const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
|
||||
const RecruitingSelectionForm: 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[]>([]);
|
||||
@@ -64,7 +77,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.all([loadSelectionRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)])
|
||||
Promise.all([loadSelectionRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
|
||||
.then(([loaded, atts]) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -82,12 +95,15 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editId]);
|
||||
}, [editId, editListId]);
|
||||
|
||||
// Маршрут согласования резолвится по email инициатора (текущего пользователя).
|
||||
// У заявки на редактировании маршрут читается из уже записанных строк, чтобы не
|
||||
// затереть правки инициатора при первом сохранении.
|
||||
const approval = useMemo(() => selectionApprovalService(), []);
|
||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||
// selectionApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => selectionApprovalService(editListId), [editListId]);
|
||||
const userEmail = effectiveUserEmail(context);
|
||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||
@@ -132,7 +148,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
|
||||
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);
|
||||
};
|
||||
@@ -145,26 +161,44 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
|
||||
if (!rtId) {
|
||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||
}
|
||||
const { id, code } = await saveSelectionRequest(requestId, rtId, data, toApprovers(matrix.rows), status);
|
||||
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
||||
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
|
||||
// это единственный запрос, который проверяет порог элементов текущего
|
||||
// списка и ставит в очередь создание следующей партиции при достижении —
|
||||
// без него архивация никогда не сработает. Сбой запроса не должен
|
||||
// блокировать подачу заявки — откатываемся на список по конвенции имён.
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
const { id, code } = await saveSelectionRequest(
|
||||
requestId,
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
if (code) {
|
||||
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)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -177,10 +211,11 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
|
||||
if (matrix.error) {
|
||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||
}
|
||||
const unassigned = findUnassignedStep(matrix.rows);
|
||||
if (unassigned) {
|
||||
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
|
||||
}
|
||||
const unassignedError = unassignedStepMessage(
|
||||
matrix.rows,
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -199,7 +234,12 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
|
||||
<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 ? (
|
||||
<>
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
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 { loadSelectionRequest } from './load';
|
||||
import { selectionApprovalService } from './submit';
|
||||
@@ -28,6 +33,8 @@ interface IRecruitingSelectionViewProps {
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
@@ -35,7 +42,7 @@ const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
||||
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>.
|
||||
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey }) => {
|
||||
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
@@ -43,9 +50,14 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const approval = useMemo(() => selectionApprovalService(), []);
|
||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||
// selectionApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => selectionApprovalService(listId), [listId]);
|
||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListTitle(FORM_ID)), [approval]);
|
||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
|
||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||
@@ -58,7 +70,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
Promise.all([loadSelectionRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)])
|
||||
Promise.all([loadSelectionRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
|
||||
.then(([loaded, files]) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -78,7 +90,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId]);
|
||||
}, [itemId, listId]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
@@ -97,8 +109,8 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -112,7 +124,12 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
<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>
|
||||
|
||||
@@ -34,8 +34,15 @@ export interface ILoadedRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Прочитать заявку «на подбор» по id: модель формы + статус (правка / просмотр). */
|
||||
export async function loadSelectionRequest(itemId: number): Promise<ILoadedRequest> {
|
||||
/**
|
||||
* Прочитать заявку «на подбор» по id: модель формы + статус (правка / просмотр).
|
||||
*
|
||||
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
|
||||
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
|
||||
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
|
||||
* называется архивный список, и запрос не зависит от реестра партиций.
|
||||
*/
|
||||
export async function loadSelectionRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||
const keys = Object.keys(EMPTY_DATA) as (keyof IFormData)[];
|
||||
const isPerson = (k: keyof IFormData): boolean =>
|
||||
PERSON_FIELDS.indexOf(k) !== -1 || MULTI_PERSON_FIELDS.indexOf(k) !== -1;
|
||||
@@ -47,8 +54,8 @@ export async function loadSelectionRequest(itemId: number): Promise<ILoadedReque
|
||||
const personSelects = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS].map((k) => `${k}/EMail`);
|
||||
const expands = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS];
|
||||
|
||||
const item = (await getSP()
|
||||
.web.lists.getByTitle(dataListTitle(FORM_ID))
|
||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = (await list
|
||||
.items.getById(itemId)
|
||||
.select(
|
||||
...scalarSelects,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
|
||||
@@ -11,9 +10,13 @@ const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
|
||||
// Числовые поля: пустая строка → null, иначе Number().
|
||||
const NUMBER_FIELDS: (keyof IFormData)[] = ['OpenVacanciesCount', 'RateProbation', 'RateAfterProbation'];
|
||||
|
||||
/** Экземпляр сервиса списка согласования этой формы. */
|
||||
export function selectionApprovalService(): ApprovalListService {
|
||||
return new ApprovalListService(approvalListTitle(FORM_ID));
|
||||
/**
|
||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||
* именем формы для читаемости вызывающего кода.
|
||||
*/
|
||||
export function selectionApprovalService(listId?: string): ApprovalListService {
|
||||
return approvalServiceFor(FORM_ID, listId);
|
||||
}
|
||||
|
||||
/** Логин → числовой Id пользователя на текущем сайте. */
|
||||
@@ -77,43 +80,35 @@ async function applyPeople(item: Record<string, unknown>, data: IFormData): Prom
|
||||
}
|
||||
}
|
||||
|
||||
export interface ISaveResult {
|
||||
id: number;
|
||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||
code?: string;
|
||||
}
|
||||
export type ISaveResult = ISaveItemResult;
|
||||
|
||||
/**
|
||||
* Сохранить заявку «на подбор»: содержимое + маршрут согласования.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* status = «Черновик» (сохранение) или «На согласовании» (отправка). Фазы исполнения
|
||||
* нет — writeRoute вызывается с пустым списком исполнителей. Возвращает Id (+ код при создании).
|
||||
*
|
||||
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||
* ОБНОВЛЯЕМОЙ заявки (см. loadSelectionRequest), при создании — партиция,
|
||||
* полученная от approval-route API непосредственно перед вызовом (см.
|
||||
* getCurrentListPartition в RecruitingSelectionForm.tsx) — именно этот вызов,
|
||||
* а не кеш из onInit, проверяет порог элементов и запускает архивацию
|
||||
* следующей партиции. Не задан (сбой запроса за текущей партицией) → откат
|
||||
* на список по конвенции имён, как до появления партиционирования.
|
||||
*/
|
||||
export async function saveSelectionRequest(
|
||||
existingId: number | undefined,
|
||||
requestTypeId: number,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
status: string
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = buildScalarBody(data);
|
||||
item.RequestStatus = status;
|
||||
await applyPeople(item, data);
|
||||
|
||||
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(selectionApprovalService(), id, approvers, [], status);
|
||||
return { id, code };
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(selectionApprovalService(listId), result.id, approvers, [], status);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -20,12 +20,13 @@ import {
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
resolveWriteListId,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
findUnassignedStep
|
||||
unassignedStepMessage
|
||||
} from '../../shared/approval';
|
||||
import {
|
||||
uploadAttachments,
|
||||
@@ -35,7 +36,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 { saveTicketRequest, setPlannerTaskId, ticket1cApprovalService } from './submit';
|
||||
import { loadTicketRequest } from './load';
|
||||
@@ -63,7 +69,14 @@ function buildPlannerNotes(data: IFormData, code: string): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
|
||||
const Ticket1CForm: 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[]>([]);
|
||||
@@ -81,7 +94,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.all([loadTicketRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)])
|
||||
Promise.all([loadTicketRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
|
||||
.then(([loaded, atts]) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -99,7 +112,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editId]);
|
||||
}, [editId, editListId]);
|
||||
|
||||
// Предзаполнение контактов заявителя текущим пользователем — только для новой заявки.
|
||||
// Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком.
|
||||
@@ -127,7 +140,10 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editId]);
|
||||
|
||||
const approval = useMemo(() => ticket1cApprovalService(), []);
|
||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||
// ticket1cApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => ticket1cApprovalService(editListId), [editListId]);
|
||||
const userEmail = effectiveUserEmail(context);
|
||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||
@@ -188,7 +204,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
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);
|
||||
};
|
||||
@@ -210,7 +226,22 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
? REQUEST_STATUS.onApproval
|
||||
: REQUEST_STATUS.approved;
|
||||
|
||||
const { id, code } = await saveTicketRequest(requestId, data, toApprovers(matrix.rows), finalStatus);
|
||||
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
||||
// спрашиваем у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit):
|
||||
// это единственный запрос, который проверяет порог элементов текущего
|
||||
// списка и ставит в очередь создание следующей партиции при достижении —
|
||||
// без него архивация никогда не сработает. Сбой запроса не должен
|
||||
// блокировать подачу заявки — откатываемся на список по конвенции имён.
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
|
||||
const { id, code } = await saveTicketRequest(
|
||||
requestId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
finalStatus,
|
||||
writeListId
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(finalStatus);
|
||||
if (code) {
|
||||
@@ -219,9 +250,9 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
|
||||
let attachments = existingAttachments;
|
||||
if (files.length > 0) {
|
||||
await uploadAttachments(dataListTitle(FORM_ID), id, files);
|
||||
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files);
|
||||
setFiles([]);
|
||||
attachments = await getAttachments(dataListTitle(FORM_ID), id);
|
||||
attachments = await getAttachments(writeListId || dataListTitle(FORM_ID), id);
|
||||
setExistingAttachments(attachments);
|
||||
}
|
||||
|
||||
@@ -230,10 +261,13 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
}
|
||||
|
||||
if (finalStatus === REQUEST_STATUS.onApproval) {
|
||||
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||
// маршрута именно в НЕЙ.
|
||||
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)
|
||||
});
|
||||
} else if (finalStatus === REQUEST_STATUS.approved) {
|
||||
// Согласование не требуется для этого вида работ — сразу передаём в отдел 1С.
|
||||
@@ -245,7 +279,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
attachments,
|
||||
webAbsoluteUrl: context.pageContext.web.absoluteUrl
|
||||
});
|
||||
await setPlannerTaskId(id, taskId);
|
||||
await setPlannerTaskId(id, taskId, writeListId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -262,10 +296,11 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
// Матрица нередактируема (маршрут задаётся видом работ, а не заявителем) —
|
||||
// незаполненный шаг заявитель исправить сам не может, только администратор
|
||||
// маршрутов (роль в шаге не разрешилась в конкретного человека).
|
||||
const unassigned = findUnassignedStep(matrix.rows);
|
||||
if (unassigned) {
|
||||
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`;
|
||||
}
|
||||
const unassignedError = unassignedStepMessage(
|
||||
matrix.rows,
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -284,7 +319,12 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
<FormCard
|
||||
title={requestType.title}
|
||||
subtitle="Категория: IT"
|
||||
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
|
||||
itemId={requestId}
|
||||
/>
|
||||
}
|
||||
headerRight={
|
||||
requestId || requestStatus ? (
|
||||
<>
|
||||
|
||||
@@ -27,7 +27,12 @@ import {
|
||||
REQUEST_STATUS
|
||||
} from '../../shared/approval';
|
||||
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 { loadTicketRequest } from './load';
|
||||
import { ticket1cApprovalService, getPlannerTaskId, setPlannerTaskId } from './submit';
|
||||
@@ -41,6 +46,8 @@ interface ITicket1CViewProps {
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
/** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */
|
||||
@@ -72,7 +79,7 @@ function buildPlannerNotes(
|
||||
}
|
||||
|
||||
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
||||
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }) => {
|
||||
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
@@ -83,11 +90,13 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
|
||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const approverService = useMemo(
|
||||
() => new ApproverService(ticket1cApprovalService(), dataListTitle(FORM_ID)),
|
||||
[]
|
||||
);
|
||||
const approval = useMemo(() => ticket1cApprovalService(), []);
|
||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||
// ticket1cApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => ticket1cApprovalService(listId), [listId]);
|
||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
|
||||
// decisionSeq входит в reloadKey: после решения строки маршрута меняются на
|
||||
// сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу.
|
||||
@@ -100,7 +109,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
Promise.all([loadTicketRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)])
|
||||
Promise.all([loadTicketRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
|
||||
.then(([loaded, files]) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -120,14 +129,14 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId]);
|
||||
}, [itemId, listId]);
|
||||
|
||||
// Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита
|
||||
// от повторного создания карточки, если решение применяется из нескольких
|
||||
// вкладок/сессий одновременно (см. submit.ts).
|
||||
const handoffToPlanner = useCallback(
|
||||
async (finalStatus: string, currentData: IFormData, currentCode: string): Promise<void> => {
|
||||
const existingTaskId = await getPlannerTaskId(itemId);
|
||||
const existingTaskId = await getPlannerTaskId(itemId, listId);
|
||||
if (existingTaskId) {
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +152,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
|
||||
|
||||
const bucketName =
|
||||
finalStatus === REQUEST_STATUS.rejected ? PLANNER_REJECTED_BUCKET_NAME : PLANNER_BUCKET_NAME;
|
||||
const atts = await getAttachments(dataListTitle(FORM_ID), itemId);
|
||||
const atts = await getAttachments(listId || dataListTitle(FORM_ID), itemId);
|
||||
const taskId = await new PlannerTicketService(context).createTask({
|
||||
planId: PLANNER_PLAN_ID,
|
||||
bucketName,
|
||||
@@ -152,9 +161,9 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
|
||||
attachments: atts,
|
||||
webAbsoluteUrl: context.pageContext.web.absoluteUrl
|
||||
});
|
||||
await setPlannerTaskId(itemId, taskId);
|
||||
await setPlannerTaskId(itemId, taskId, listId);
|
||||
},
|
||||
[approval, context, itemId]
|
||||
[approval, context, itemId, listId]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@@ -174,8 +183,8 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -185,7 +194,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
|
||||
{isEditableStatus(status) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId })}
|
||||
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Редактировать
|
||||
@@ -198,7 +207,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey }
|
||||
<FormCard
|
||||
title="Заявка в отдел 1С"
|
||||
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>
|
||||
|
||||
@@ -14,10 +14,17 @@ export interface ILoadedRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Прочитать заявку по id: модель формы + статус (для режима чтения). */
|
||||
export async function loadTicketRequest(itemId: number): Promise<ILoadedRequest> {
|
||||
const item = (await getSP()
|
||||
.web.lists.getByTitle(dataListTitle(FORM_ID))
|
||||
/**
|
||||
* Прочитать заявку по id: модель формы + статус (для режима чтения).
|
||||
*
|
||||
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
|
||||
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
|
||||
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
|
||||
* называется архивный список, и запрос не зависит от реестра партиций.
|
||||
*/
|
||||
export async function loadTicketRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = (await list
|
||||
.items.getById(itemId)
|
||||
.select(...SCALAR_FIELDS, 'Title', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||
import { FORM_ID, IFormData } from './model';
|
||||
|
||||
// Префикс уникального кода заявки — буквенная метка «1С» вместо числового id
|
||||
@@ -9,9 +7,13 @@ import { FORM_ID, IFormData } from './model';
|
||||
// больше не хардкодится — см. useRequestTypeId в Form.tsx/View.tsx.
|
||||
export const TICKET1C_CODE_PREFIX = '1С';
|
||||
|
||||
/** Экземпляр сервиса списка согласования этой формы. */
|
||||
export function ticket1cApprovalService(): ApprovalListService {
|
||||
return new ApprovalListService(approvalListTitle(FORM_ID));
|
||||
/**
|
||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||
* именем формы для читаемости вызывающего кода.
|
||||
*/
|
||||
export function ticket1cApprovalService(listId?: string): ApprovalListService {
|
||||
return approvalServiceFor(FORM_ID, listId);
|
||||
}
|
||||
|
||||
/** Поля элемента списка данных из модели формы. TaskName пишется в системную колонку Title. */
|
||||
@@ -27,42 +29,34 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||
return item;
|
||||
}
|
||||
|
||||
export interface ISaveResult {
|
||||
id: number;
|
||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||
code?: string;
|
||||
}
|
||||
export type ISaveResult = ISaveItemResult;
|
||||
|
||||
/**
|
||||
* Сохранить заявку в отдел 1С: содержимое + маршрут согласования.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю
|
||||
* из матрицы. Возвращает Id заявки (+ код при создании).
|
||||
*
|
||||
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||
* ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция,
|
||||
* полученная от approval-route API непосредственно перед вызовом (см.
|
||||
* getCurrentListPartition в Ticket1CForm.tsx) — именно этот вызов, а не кеш из
|
||||
* onInit, проверяет порог элементов и запускает архивацию следующей партиции.
|
||||
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
|
||||
* имён, как до появления партиционирования.
|
||||
*/
|
||||
export async function saveTicketRequest(
|
||||
existingId: number | undefined,
|
||||
data: IFormData,
|
||||
approvers: 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(TICKET1C_CODE_PREFIX, id, new Date());
|
||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||
}
|
||||
|
||||
await writeRoute(ticket1cApprovalService(), id, approvers, [], status);
|
||||
return { id, code };
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, TICKET1C_CODE_PREFIX, item);
|
||||
await writeRoute(ticket1cApprovalService(listId), result.id, approvers, [], status);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,14 +64,13 @@ export async function saveTicketRequest(
|
||||
* после createTask — служит защитой от повторного создания карточки, если
|
||||
* решение согласующего применяется из нескольких вкладок/сессий одновременно.
|
||||
*/
|
||||
export async function getPlannerTaskId(id: number): Promise<string | undefined> {
|
||||
const item = (await getSP()
|
||||
.web.lists.getByTitle(dataListTitle(FORM_ID))
|
||||
.items.getById(id)
|
||||
.select('PlannerTaskId')()) as { PlannerTaskId?: string };
|
||||
export async function getPlannerTaskId(id: number, listId?: string): Promise<string | undefined> {
|
||||
const list = resolveDataList(FORM_ID, listId);
|
||||
const item = (await list.items.getById(id).select('PlannerTaskId')()) as { PlannerTaskId?: string };
|
||||
return item.PlannerTaskId || undefined;
|
||||
}
|
||||
|
||||
export async function setPlannerTaskId(id: number, taskId: string): Promise<void> {
|
||||
await getSP().web.lists.getByTitle(dataListTitle(FORM_ID)).items.getById(id).update({ PlannerTaskId: taskId });
|
||||
export async function setPlannerTaskId(id: number, taskId: string, listId?: string): Promise<void> {
|
||||
const list = resolveDataList(FORM_ID, listId);
|
||||
await list.items.getById(id).update({ PlannerTaskId: taskId });
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
APPROVAL_PHASE,
|
||||
findUnassignedStep
|
||||
unassignedStepMessage,
|
||||
resolveWriteListId
|
||||
} from '../../shared/approval';
|
||||
import {
|
||||
uploadAttachments,
|
||||
@@ -36,14 +37,26 @@ 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 { saveTmcTransferRequest, tmcTransferApprovalService } from './submit';
|
||||
import { loadTmcTransferRequest } from './load';
|
||||
import { HeaderSection } from './sections/HeaderSection';
|
||||
import { ItemsSection } from './sections/ItemsSection';
|
||||
|
||||
const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
|
||||
const TmcTransferForm: 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[]>([]);
|
||||
@@ -80,7 +93,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.all([loadTmcTransferRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)])
|
||||
Promise.all([loadTmcTransferRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
|
||||
.then(([loaded, atts]) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -97,9 +110,12 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [editId]);
|
||||
}, [editId, editListId]);
|
||||
|
||||
const approval = useMemo(() => tmcTransferApprovalService(), []);
|
||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||
// tmcTransferApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => tmcTransferApprovalService(editListId), [editListId]);
|
||||
const userEmail = effectiveUserEmail(context);
|
||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||
@@ -174,7 +190,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
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);
|
||||
};
|
||||
@@ -187,13 +203,18 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
if (!rtId) {
|
||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||
}
|
||||
// Новая заявка — резолвим ТЕКУЩУЮ партицию через approval-route (заодно
|
||||
// выполняется проверка порога и создание следующей архивной партиции при
|
||||
// необходимости); при редактировании остаёмся в партиции самой заявки.
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
const { id, code } = await saveTmcTransferRequest(
|
||||
requestId,
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
toApprovers(executors.rows),
|
||||
status
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
@@ -201,18 +222,22 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
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),
|
||||
// а не editListId: для новой заявки это может быть только что созданная
|
||||
// текущая партиция, отличная от editListId (который тут undefined).
|
||||
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)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -220,14 +245,16 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -248,7 +275,12 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
<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 ? (
|
||||
<>
|
||||
|
||||
@@ -27,7 +27,12 @@ import {
|
||||
APPROVAL_PHASE
|
||||
} from '../../shared/approval';
|
||||
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 { loadTmcTransferRequest } from './load';
|
||||
import { tmcTransferApprovalService } from './submit';
|
||||
@@ -39,10 +44,12 @@ interface ITmcTransferViewProps {
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
|
||||
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey }) => {
|
||||
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
@@ -54,13 +61,15 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
||||
// В просмотре форма read-only, но секции ждут set — даём no-op.
|
||||
const set: SetField = () => undefined;
|
||||
|
||||
const approverService = useMemo(
|
||||
() => new ApproverService(tmcTransferApprovalService(), dataListTitle(FORM_ID)),
|
||||
[]
|
||||
);
|
||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||
// tmcTransferApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => tmcTransferApprovalService(listId), [listId]);
|
||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const approval = useMemo(() => tmcTransferApprovalService(), []);
|
||||
const loadApprovers = useCallback(
|
||||
() =>
|
||||
approval
|
||||
@@ -85,7 +94,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
Promise.all([loadTmcTransferRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)])
|
||||
Promise.all([loadTmcTransferRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
|
||||
.then(([loaded, files]) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -105,7 +114,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId]);
|
||||
}, [itemId, listId]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
@@ -124,8 +133,8 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -135,7 +144,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
||||
{isEditableStatus(status) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId })}
|
||||
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Редактировать
|
||||
@@ -148,7 +157,12 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
||||
<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>
|
||||
|
||||
@@ -21,10 +21,17 @@ function parseItems(raw: unknown): ILineItem[] {
|
||||
}
|
||||
}
|
||||
|
||||
/** Прочитать заявку «СЗ на перемещение ТМЦ» по id: модель формы + статус + код. */
|
||||
export async function loadTmcTransferRequest(itemId: number): Promise<ILoadedRequest> {
|
||||
const item = (await getSP()
|
||||
.web.lists.getByTitle(dataListTitle(FORM_ID))
|
||||
/**
|
||||
* Прочитать заявку «СЗ на перемещение ТМЦ» по id: модель формы + статус + код.
|
||||
*
|
||||
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
|
||||
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
|
||||
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
|
||||
* называется архивный список, и запрос не зависит от реестра партиций.
|
||||
*/
|
||||
export async function loadTmcTransferRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = (await list
|
||||
.items.getById(itemId)
|
||||
.select(
|
||||
'Id',
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { getSP } from '../../shared/pnp/pnpjs';
|
||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||
import { ILineItem } from '../../shared/ui';
|
||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData } from './model';
|
||||
|
||||
/** Экземпляр сервиса списка согласования этой формы. */
|
||||
export function tmcTransferApprovalService(): ApprovalListService {
|
||||
return new ApprovalListService(approvalListTitle(FORM_ID));
|
||||
/**
|
||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||
* именем формы для читаемости вызывающего кода.
|
||||
*/
|
||||
export function tmcTransferApprovalService(listId?: string): ApprovalListService {
|
||||
return approvalServiceFor(FORM_ID, listId);
|
||||
}
|
||||
|
||||
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||
@@ -34,16 +36,18 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||
return item;
|
||||
}
|
||||
|
||||
export interface ISaveResult {
|
||||
id: number;
|
||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||
code?: string;
|
||||
}
|
||||
export type ISaveResult = ISaveItemResult;
|
||||
|
||||
/**
|
||||
* Сохранить заявку «СЗ на перемещение ТМЦ»: контент + маршрут (согласующие + исполнители).
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Возвращает Id заявки (+ уникальный код при создании).
|
||||
*
|
||||
* listId — партиция, в которую пишем: для обновления существующей заявки —
|
||||
* партиция ЭТОЙ заявки (см. loadTmcTransferRequest), для новой — ТЕКУЩАЯ
|
||||
* партиция (см. getCurrentListPartition), которую вызывающая сторона резолвит
|
||||
* заранее и передаёт сюда, а не текущая-по-умолчанию: именно на этом резолве
|
||||
* держится проверка порога и создание следующей архивной партиции.
|
||||
*/
|
||||
export async function saveTmcTransferRequest(
|
||||
existingId: number | undefined,
|
||||
@@ -51,24 +55,12 @@ export async function saveTmcTransferRequest(
|
||||
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(tmcTransferApprovalService(), id, approvers, executors, status);
|
||||
return { id, code };
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(tmcTransferApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -21,16 +21,29 @@ import {
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
findUnassignedStep
|
||||
unassignedStepMessage,
|
||||
resolveWriteListId
|
||||
} from '../../shared/approval';
|
||||
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 { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
||||
import { loadUnpaidLeaveRequest } from './load';
|
||||
import { RequestSection } from './sections/RequestSection';
|
||||
|
||||
const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
|
||||
const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
requestType,
|
||||
context,
|
||||
onCancel,
|
||||
onSaved,
|
||||
editId,
|
||||
editListId
|
||||
}) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||
@@ -40,7 +53,10 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
const readOnly = !isEditableStatus(requestStatus);
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const approval = useMemo(() => unpaidLeaveApprovalService(), []);
|
||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||
// unpaidLeaveApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => unpaidLeaveApprovalService(editListId), [editListId]);
|
||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||
@@ -52,7 +68,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
loadUnpaidLeaveRequest(editId)
|
||||
loadUnpaidLeaveRequest(editId, editListId)
|
||||
.then((loaded) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -69,7 +85,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editId]);
|
||||
}, [editId, editListId]);
|
||||
|
||||
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность.
|
||||
useEffect(() => {
|
||||
@@ -161,7 +177,18 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
if (!rtId) {
|
||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||
}
|
||||
const { id, code } = await saveUnpaidLeaveRequest(requestId, rtId, data, toApprovers(matrix.rows), status);
|
||||
// Новая заявка пишется в ТЕКУЩУЮ партицию — запрашиваем её у approval-route
|
||||
// API непосредственно перед записью (не кеш из onInit): именно этот вызов
|
||||
// проверяет порог элементов и запускает архивацию следующей партиции.
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
const { id, code } = await saveUnpaidLeaveRequest(
|
||||
requestId,
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
if (code) {
|
||||
@@ -171,11 +198,13 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
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)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -188,10 +217,11 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
if (matrix.error) {
|
||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||
}
|
||||
const unassigned = findUnassignedStep(matrix.rows);
|
||||
if (unassigned) {
|
||||
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
|
||||
}
|
||||
const unassignedError = unassignedStepMessage(
|
||||
matrix.rows,
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -210,7 +240,12 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
|
||||
<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 ? (
|
||||
<>
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadUnpaidLeaveRequest } from './load';
|
||||
import { unpaidLeaveApprovalService } from './submit';
|
||||
@@ -23,6 +28,8 @@ interface IUnpaidLeaveViewProps {
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
@@ -31,16 +38,21 @@ const NOOP_SET: SetField = () => undefined;
|
||||
// Заявка без содержания только для просмотра (режим согласующего). Переиспользует
|
||||
// ту же секцию, что и форма подачи; редактирование выключено через
|
||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||
const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, formKey }) => {
|
||||
const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, formKey, listId }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const approval = useMemo(() => unpaidLeaveApprovalService(), []);
|
||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||
// unpaidLeaveApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => unpaidLeaveApprovalService(listId), [listId]);
|
||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListTitle(FORM_ID)), [approval]);
|
||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
|
||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||
@@ -53,7 +65,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
loadUnpaidLeaveRequest(itemId)
|
||||
loadUnpaidLeaveRequest(itemId, listId)
|
||||
.then((loaded) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -72,7 +84,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId]);
|
||||
}, [itemId, listId]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
@@ -94,8 +106,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -109,7 +121,12 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
||||
<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>
|
||||
|
||||
@@ -11,10 +11,17 @@ export interface ILoadedRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Прочитать заявку без содержания по id: модель формы + статус (правка / просмотр). */
|
||||
export async function loadUnpaidLeaveRequest(itemId: number): Promise<ILoadedRequest> {
|
||||
const item = (await getSP()
|
||||
.web.lists.getByTitle(dataListTitle(FORM_ID))
|
||||
/**
|
||||
* Прочитать заявку без содержания по id: модель формы + статус (правка / просмотр).
|
||||
*
|
||||
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
|
||||
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
|
||||
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
|
||||
* называется архивный список, и запрос не зависит от реестра партиций.
|
||||
*/
|
||||
export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = (await list
|
||||
.items.getById(itemId)
|
||||
.select(
|
||||
'Id',
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
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 { ensureEmployee } from '../../shared/employee';
|
||||
import { FORM_ID, IFormData } from './model';
|
||||
|
||||
/** Экземпляр сервиса списка согласования этой формы. */
|
||||
export function unpaidLeaveApprovalService(): ApprovalListService {
|
||||
return new ApprovalListService(approvalListTitle(FORM_ID));
|
||||
}
|
||||
|
||||
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
|
||||
async function ensureUser(login: string): Promise<{ Id: number; Title: string }> {
|
||||
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
|
||||
return getSP().web.ensureUser(full);
|
||||
/**
|
||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||
* именем формы для читаемости вызывающего кода.
|
||||
*/
|
||||
export function unpaidLeaveApprovalService(listId?: string): ApprovalListService {
|
||||
return approvalServiceFor(FORM_ID, listId);
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
@@ -55,44 +52,33 @@ function buildItem(
|
||||
};
|
||||
}
|
||||
|
||||
export interface ISaveResult {
|
||||
id: number;
|
||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||
code?: string;
|
||||
}
|
||||
export type ISaveResult = ISaveItemResult;
|
||||
|
||||
/**
|
||||
* Сохранить заявку без содержания: содержимое + маршрут согласования.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
|
||||
* Возвращает Id заявки (+ код при создании).
|
||||
*
|
||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadUnpaidLeaveRequest). Имеет смысл
|
||||
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
|
||||
* писать новые заявки в архив незачем и нельзя (approval-route переключает
|
||||
* текущую партицию сам, по своему порогу элементов).
|
||||
*/
|
||||
export async function saveUnpaidLeaveRequest(
|
||||
existingId: number | undefined,
|
||||
requestTypeId: number,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
status: string
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const employee = await ensureUser(data.Employee);
|
||||
const employee = await ensureEmployee(data.Employee);
|
||||
// Замещающий необязателен: резолвим только если выбран.
|
||||
const substituteId = data.Substitute ? (await ensureUser(data.Substitute)).Id : undefined;
|
||||
const substituteId = data.Substitute ? (await ensureEmployee(data.Substitute)).Id : undefined;
|
||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
|
||||
|
||||
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(unpaidLeaveApprovalService(), id, approvers, [], status);
|
||||
return { id, code };
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, [], status);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -16,21 +16,34 @@ import {
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
resolveWriteListId,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
findUnassignedStep
|
||||
unassignedStepMessage
|
||||
} from '../../shared/approval';
|
||||
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 { saveVacationRequest, vacationApprovalService } from './submit';
|
||||
import { loadVacationRequest } from './load';
|
||||
import { RequestSection } from './sections/RequestSection';
|
||||
|
||||
const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
|
||||
const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
requestType,
|
||||
context,
|
||||
onCancel,
|
||||
onSaved,
|
||||
editId,
|
||||
editListId
|
||||
}) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||
@@ -40,7 +53,10 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
const readOnly = !isEditableStatus(requestStatus);
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const approval = useMemo(() => vacationApprovalService(), []);
|
||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||
// vacationApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => vacationApprovalService(editListId), [editListId]);
|
||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||
@@ -52,7 +68,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
loadVacationRequest(editId)
|
||||
loadVacationRequest(editId, editListId)
|
||||
.then((loaded) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -69,7 +85,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editId]);
|
||||
}, [editId, editListId]);
|
||||
|
||||
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность.
|
||||
useEffect(() => {
|
||||
@@ -158,7 +174,18 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
if (!rtId) {
|
||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||
}
|
||||
const { id, code } = await saveVacationRequest(requestId, rtId, data, toApprovers(matrix.rows), status);
|
||||
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||
// партицию (editListId); иначе резолвится ТЕКУЩАЯ (см. resolveWriteListId —
|
||||
// именно этот запрос проверяет порог элементов и запускает архивацию).
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
const { id, code } = await saveVacationRequest(
|
||||
requestId,
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
if (code) {
|
||||
@@ -168,11 +195,14 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
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)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -185,10 +215,11 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
if (matrix.error) {
|
||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||
}
|
||||
const unassigned = findUnassignedStep(matrix.rows);
|
||||
if (unassigned) {
|
||||
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
|
||||
}
|
||||
const unassignedError = unassignedStepMessage(
|
||||
matrix.rows,
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -207,7 +238,12 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
||||
<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 ? (
|
||||
<>
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadVacationRequest } from './load';
|
||||
import { vacationApprovalService } from './submit';
|
||||
@@ -23,6 +28,8 @@ interface IVacationViewProps {
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
@@ -31,16 +38,21 @@ const NOOP_SET: SetField = () => undefined;
|
||||
// Заявка на отпуск только для просмотра (режим согласующего). Переиспользует ту
|
||||
// же секцию, что и форма подачи; редактирование выключено через
|
||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||
const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }) => {
|
||||
const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey, listId }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const approval = useMemo(() => vacationApprovalService(), []);
|
||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||
// vacationApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => vacationApprovalService(listId), [listId]);
|
||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListTitle(FORM_ID)), [approval]);
|
||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
|
||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||
@@ -53,7 +65,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
loadVacationRequest(itemId)
|
||||
loadVacationRequest(itemId, listId)
|
||||
.then((loaded) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -72,7 +84,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId]);
|
||||
}, [itemId, listId]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
@@ -94,8 +106,8 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -109,7 +121,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey }
|
||||
<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>
|
||||
|
||||
@@ -11,10 +11,17 @@ export interface ILoadedRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Прочитать заявку на отпуск по id: модель формы + статус (правка / просмотр). */
|
||||
export async function loadVacationRequest(itemId: number): Promise<ILoadedRequest> {
|
||||
const item = (await getSP()
|
||||
.web.lists.getByTitle(dataListTitle(FORM_ID))
|
||||
/**
|
||||
* Прочитать заявку на отпуск по id: модель формы + статус (правка / просмотр).
|
||||
*
|
||||
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
|
||||
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
|
||||
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
|
||||
* называется архивный список, и запрос не зависит от реестра партиций.
|
||||
*/
|
||||
export async function loadVacationRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = (await list
|
||||
.items.getById(itemId)
|
||||
.select(
|
||||
'Id',
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
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 { ensureEmployee } from '../../shared/employee';
|
||||
import { FORM_ID, IFormData } from './model';
|
||||
|
||||
/** Экземпляр сервиса списка согласования этой формы. */
|
||||
export function vacationApprovalService(): ApprovalListService {
|
||||
return new ApprovalListService(approvalListTitle(FORM_ID));
|
||||
}
|
||||
|
||||
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
|
||||
async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> {
|
||||
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
|
||||
return getSP().web.ensureUser(full);
|
||||
/**
|
||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||
* именем формы для читаемости вызывающего кода.
|
||||
*/
|
||||
export function vacationApprovalService(listId?: string): ApprovalListService {
|
||||
return approvalServiceFor(FORM_ID, listId);
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
@@ -46,42 +43,34 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
||||
};
|
||||
}
|
||||
|
||||
export interface ISaveResult {
|
||||
id: number;
|
||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||
code?: string;
|
||||
}
|
||||
export type ISaveResult = ISaveItemResult;
|
||||
|
||||
/**
|
||||
* Сохранить заявку на отпуск: содержимое + маршрут согласования.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей.
|
||||
* Возвращает Id заявки (+ код при создании).
|
||||
*
|
||||
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||
* ОБНОВЛЯЕМОЙ заявки (см. loadVacationRequest), при создании — партиция,
|
||||
* полученная от approval-route API непосредственно перед вызовом (см.
|
||||
* getCurrentListPartition в VacationForm.tsx) — именно этот вызов, а не кеш из
|
||||
* onInit, проверяет порог элементов и запускает архивацию следующей партиции.
|
||||
* Не задан (сбой запроса за текущей партицией) → откат на список по конвенции
|
||||
* имён, как до появления партиционирования.
|
||||
*/
|
||||
export async function saveVacationRequest(
|
||||
existingId: number | undefined,
|
||||
requestTypeId: number,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
status: string
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const employee = await ensureEmployee(data.Employee);
|
||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
|
||||
|
||||
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(vacationApprovalService(), id, approvers, [], status);
|
||||
return { id, code };
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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 { saveWorkplaceRequest, workplaceApprovalService } from './submit';
|
||||
import { loadPrefillFromHire } from './prefill';
|
||||
@@ -44,7 +50,15 @@ import { loadWorkplaceRequest } from './load';
|
||||
import { EmployeeSection } from './sections/EmployeeSection';
|
||||
import { EquipmentSection } from './sections/EquipmentSection';
|
||||
|
||||
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, sourceId, editId }) => {
|
||||
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
||||
requestType,
|
||||
context,
|
||||
onCancel,
|
||||
onSaved,
|
||||
sourceId,
|
||||
editId,
|
||||
editListId
|
||||
}) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
|
||||
@@ -68,7 +82,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.all([loadWorkplaceRequest(editId), getAttachments(dataListTitle(FORM_ID), editId)])
|
||||
Promise.all([loadWorkplaceRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
|
||||
.then(([loaded, atts]) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -85,7 +99,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [editId]);
|
||||
}, [editId, editListId]);
|
||||
|
||||
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
||||
useEffect(() => {
|
||||
@@ -109,7 +123,10 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
};
|
||||
}, [sourceId]);
|
||||
|
||||
const approval = useMemo(() => workplaceApprovalService(), []);
|
||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||
// workplaceApprovalService можно звать не опасаясь исключения.
|
||||
const approval = useMemo(() => workplaceApprovalService(editListId), [editListId]);
|
||||
const userEmail = effectiveUserEmail(context);
|
||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||
@@ -181,7 +198,7 @@ const WorkplaceSetupForm: 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);
|
||||
};
|
||||
@@ -194,6 +211,14 @@ const WorkplaceSetupForm: 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 saveWorkplaceRequest(
|
||||
requestId,
|
||||
rtId,
|
||||
@@ -201,7 +226,8 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
toApprovers(matrix.rows),
|
||||
toApprovers(executors.rows),
|
||||
status,
|
||||
sourceId
|
||||
sourceId,
|
||||
writeListId
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
@@ -209,19 +235,22 @@ const WorkplaceSetupForm: 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -233,14 +262,16 @@ const WorkplaceSetupForm: 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;
|
||||
};
|
||||
|
||||
@@ -261,7 +292,12 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
<FormCard
|
||||
title={requestType.title}
|
||||
subtitle="Категория: IT"
|
||||
belowSubtitle={<InitiatorLine listTitle={dataListTitle(FORM_ID)} itemId={requestId} />}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
|
||||
itemId={requestId}
|
||||
/>
|
||||
}
|
||||
headerRight={
|
||||
requestId || requestStatus ? (
|
||||
<>
|
||||
|
||||
@@ -27,7 +27,12 @@ import {
|
||||
APPROVAL_PHASE
|
||||
} from '../../shared/approval';
|
||||
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 { loadWorkplaceRequest } from './load';
|
||||
import { workplaceApprovalService, updateReviewFields } from './submit';
|
||||
@@ -39,6 +44,8 @@ interface IWorkplaceSetupViewProps {
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
@@ -46,7 +53,7 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
||||
// Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель)
|
||||
// на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает).
|
||||
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey }) => {
|
||||
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
@@ -59,9 +66,14 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||
// workplaceApprovalService можно звать не опасаясь исключения.
|
||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(
|
||||
() => new ApproverService(workplaceApprovalService(), dataListTitle(FORM_ID)),
|
||||
[]
|
||||
() => new ApproverService(workplaceApprovalService(listId), dataListRef),
|
||||
[listId, dataListRef]
|
||||
);
|
||||
|
||||
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
|
||||
@@ -72,7 +84,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
// перечитывает матрицу. Иначе на экране остались бы данные, загруженные при
|
||||
// открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании».
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const approval = useMemo(() => workplaceApprovalService(), []);
|
||||
const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
|
||||
const loadApprovers = useCallback(
|
||||
() =>
|
||||
approval
|
||||
@@ -97,7 +109,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
Promise.all([loadWorkplaceRequest(itemId), getAttachments(dataListTitle(FORM_ID), itemId)])
|
||||
Promise.all([loadWorkplaceRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
|
||||
.then(([loaded, files]) => {
|
||||
if (alive) {
|
||||
setData(loaded.data);
|
||||
@@ -117,7 +129,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId]);
|
||||
}, [itemId, listId]);
|
||||
|
||||
// Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования
|
||||
// (по маршруту — непосредственного руководителя) и это текущий пользователь.
|
||||
@@ -144,8 +156,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
}, [itemId, approval, approverService]);
|
||||
|
||||
const saveReviewFields = useCallback(
|
||||
() => updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }),
|
||||
[itemId, data.Software, data.AccessRights]
|
||||
() => updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId),
|
||||
[itemId, data.Software, data.AccessRights, listId]
|
||||
);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
@@ -175,8 +187,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ 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);
|
||||
};
|
||||
|
||||
@@ -186,7 +198,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
{isEditableStatus(status) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId })}
|
||||
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Редактировать
|
||||
@@ -212,7 +224,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ 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>
|
||||
|
||||
@@ -14,10 +14,17 @@ export interface ILoadedRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Прочитать заявку по id: модель формы + статус (для режима чтения). */
|
||||
export async function loadWorkplaceRequest(itemId: number): Promise<ILoadedRequest> {
|
||||
const item = (await getSP()
|
||||
.web.lists.getByTitle(dataListTitle(FORM_ID))
|
||||
/**
|
||||
* Прочитать заявку по id: модель формы + статус (для режима чтения).
|
||||
*
|
||||
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
|
||||
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан —
|
||||
* идём напрямую по url списка, а не по имени: так не нужно резолвить, как
|
||||
* называется архивный список, и запрос не зависит от реестра партиций.
|
||||
*/
|
||||
export async function loadWorkplaceRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = (await list
|
||||
.items.getById(itemId)
|
||||
.select(...SCALAR_FIELDS, 'NeedsEquipment', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
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, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||
import { FORM_ID, IFormData } from './model';
|
||||
|
||||
/** Экземпляр сервиса списка согласования этой формы. */
|
||||
export function workplaceApprovalService(): ApprovalListService {
|
||||
return new ApprovalListService(approvalListTitle(FORM_ID));
|
||||
/**
|
||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts), под
|
||||
* именем формы для читаемости вызывающего кода.
|
||||
*/
|
||||
export function workplaceApprovalService(listId?: string): ApprovalListService {
|
||||
return approvalServiceFor(FORM_ID, listId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить поля, которые заполняет согласующий-руководитель на этапе согласования
|
||||
* (инициатор часто не знает нужное ПО и доступы). Пишет только эти два поля.
|
||||
*
|
||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest): элемент уже
|
||||
* существует, поэтому читаем/пишем напрямую по url его списка, если он задан.
|
||||
*/
|
||||
export async function updateReviewFields(
|
||||
id: number,
|
||||
fields: { Software: string; AccessRights: string }
|
||||
fields: { Software: string; AccessRights: string },
|
||||
listId?: string
|
||||
): Promise<void> {
|
||||
await getSP().web.lists.getByTitle(dataListTitle(FORM_ID)).items.getById(id).update({
|
||||
const list = resolveDataList(FORM_ID, listId);
|
||||
await list.items.getById(id).update({
|
||||
Software: fields.Software === '' ? null : fields.Software,
|
||||
AccessRights: fields.AccessRights === '' ? null : fields.AccessRights,
|
||||
});
|
||||
@@ -48,17 +55,21 @@ function buildItem(data: IFormData, status: string, sourceHireId?: number): Reco
|
||||
return item;
|
||||
}
|
||||
|
||||
export interface ISaveResult {
|
||||
id: number;
|
||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||
code?: string;
|
||||
}
|
||||
export type ISaveResult = ISaveItemResult;
|
||||
|
||||
/**
|
||||
* Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители).
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
|
||||
* sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки (+ код при создании).
|
||||
*
|
||||
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||
* ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest), при создании — партиция,
|
||||
* полученная от approval-route API непосредственно перед вызовом (см.
|
||||
* getCurrentListPartition в WorkplaceSetupForm.tsx) — именно этот вызов, а не
|
||||
* кеш из onInit, проверяет порог элементов и запускает архивацию следующей
|
||||
* партиции. Не задан (сбой запроса за текущей партицией) → откат на список по
|
||||
* конвенции имён, как до появления партиционирования.
|
||||
*/
|
||||
export async function saveWorkplaceRequest(
|
||||
existingId: number | undefined,
|
||||
@@ -67,24 +78,12 @@ export async function saveWorkplaceRequest(
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string,
|
||||
sourceHireId?: number
|
||||
sourceHireId?: number,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = buildItem(data, status, sourceHireId);
|
||||
|
||||
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(workplaceApprovalService(), id, approvers, executors, status);
|
||||
return { id, code };
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(workplaceApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ export interface IApprovalViewProps {
|
||||
* подачи получает готовый requestType.id, а View — только itemId/context).
|
||||
*/
|
||||
formKey: string;
|
||||
/**
|
||||
* Партиция (серверный url списка), где лежит именно ЭТА заявка — см.
|
||||
* shared/approval/listPartitions.ts. Не задан — просмотр откатывается на
|
||||
* текущую партицию типа заявки (старые ссылки без параметра).
|
||||
*/
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
type ViewComponent = React.ComponentType<IApprovalViewProps>;
|
||||
|
||||
Reference in New Issue
Block a user