редактирование архивных заявок

This commit is contained in:
gorbenko.pavel
2026-08-18 10:02:42 +05:00
parent 11811d101f
commit c85989230a
43 changed files with 976 additions and 304 deletions
@@ -227,6 +227,11 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
nav: route.nav,
formKey: request.formKey,
itemId: request.id,
// Заявки нумерованные (см. shared/approval/listPartitions.ts) — строка
// таблицы уже знает, в каком СПИСКЕ она реально лежит (см. IMyRequest.listId
// в myRequests.service.ts), и это надо передать дальше, иначе архивная
// заявка откроется так, будто лежит в текущей партиции.
listId: request.listId,
ownTab: route.ownTab,
ownStatus: route.ownStatus,
// Страница и запрос — часть того же списка под панелью: без них ссылка на
@@ -374,7 +379,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
{route.mode === 'approve' && route.formKey && route.itemId && (
<Drawer title={`Заявка №${route.itemId}`} onClose={closeDrawer}>
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} listId={route.listId} />
</Drawer>
)}
@@ -387,6 +392,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
context={context}
formKey={route.formKey}
itemId={route.itemId}
listId={route.listId}
onClose={closeDrawer}
/>
</div>
@@ -2,6 +2,7 @@ import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ApproveFeature.module.scss';
import { getViewComponent } from '../../forms/viewRegistry';
import { partitionForPageUrlAndListId } from '../../shared/approval';
export interface IApproveFeatureProps {
context: WebPartContext;
@@ -9,18 +10,28 @@ export interface IApproveFeatureProps {
formKey: string;
/** RowID заявки из URL. */
itemId: number;
/** Партиция, где лежит заявка (см. IRoute.listId) — не задана у ссылок старого формата. */
listId?: string;
}
// Фича «Согласование»: открывается по ссылке из потока (mode=approve&type=&id=).
// Показывает заявку в режиме чтения; панель решения добавим следующим шагом.
const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, itemId }) => {
const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, itemId, listId }) => {
const ViewComponent = getViewComponent(formKey);
// Партиция указана, но её нет в реестре — см. тот же комментарий в EditFeature.
const partitionError =
listId && !partitionForPageUrlAndListId(formKey, listId)
? `Не удалось открыть заявку: список «${listId}» не найден в реестре партиций. Ссылка могла устареть — попробуйте открыть заявку заново из списка.`
: undefined;
return (
<div className={styles.content}>
{ViewComponent ? (
{partitionError ? (
<div className={styles.state}>{partitionError}</div>
) : ViewComponent ? (
<React.Suspense fallback={<div className={styles.state}>Загрузка…</div>}>
<ViewComponent context={context} itemId={itemId} formKey={formKey} />
<ViewComponent context={context} itemId={itemId} formKey={formKey} listId={listId} />
</React.Suspense>
) : (
<div className={styles.state}>Для формы «{formKey}» режим согласования пока не настроен.</div>
@@ -4,6 +4,7 @@ import styles from '../approve/ApproveFeature.module.scss';
import { getFormComponent } from '../../forms/registry';
import { REQUEST_TYPES } from '../../data/requests';
import { emitRequestsChanged } from '../../shared/requestEvents';
import { partitionForPageUrlAndListId } from '../../shared/approval';
export interface IEditFeatureProps {
context: WebPartContext;
@@ -11,6 +12,8 @@ export interface IEditFeatureProps {
formKey: string;
/** RowID редактируемой заявки из URL. */
itemId: number;
/** Партиция, где лежит заявка (см. IRoute.listId) — не задана у ссылок старого формата. */
listId?: string;
/** Закрыть панель (после сохранения). */
onClose: () => void;
}
@@ -22,7 +25,7 @@ export interface IEditFeatureProps {
//
// Отдельно от features/submit намеренно: там форма живёт в потоке «плитки →
// новая заявка», а тут — точечное открытие конкретной записи по ссылке.
const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, onClose }) => {
const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, listId, onClose }) => {
const FormComponent = getFormComponent(formKey);
const requestType = REQUEST_TYPES.filter((r) => r.id === formKey)[0];
@@ -30,6 +33,20 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, on
return <div className={styles.state}>Для формы «{formKey}» редактирование не настроено.</div>;
}
// Партиция указана (заявки нумерованные, см. shared/approval/listPartitions.ts),
// но её нет в реестре — устаревшая/битая ссылка. Проверяем ЗДЕСЬ, ДО монтирования
// формы: внутри формы имя списка резолвится синхронно (useMemo) и там уже негде
// красиво показать ошибку без риска уронить рендер всей панели — см.
// vacationApprovalService и аналоги в остальных формах.
if (listId && !partitionForPageUrlAndListId(formKey, listId)) {
return (
<div className={styles.state}>
Не удалось открыть заявку: список «{listId}» не найден в реестре партиций. Ссылка могла устареть —
попробуйте открыть заявку заново из списка.
</div>
);
}
return (
<div className={styles.content}>
<React.Suspense fallback={<div className={styles.state}>Загрузка формы…</div>}>
@@ -37,6 +54,7 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, on
requestType={requestType}
context={context}
editId={itemId}
editListId={listId}
onCancel={onClose}
onSaved={() => {
emitRequestsChanged();
@@ -24,13 +24,25 @@ import {
findUnassignedStep
} 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);
@@ -40,7 +52,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);
@@ -52,7 +67,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);
@@ -69,7 +84,7 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]);
}, [editId, editListId]);
// Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай:
// обходной лист оформляют в день увольнения). При открытии на редактирование
@@ -148,7 +163,14 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
}
const { id, code } = await saveClearanceRequest(requestId, rtId, data, toApprovers(matrix.rows), status);
const { id, code } = await saveClearanceRequest(
requestId,
rtId,
data,
toApprovers(matrix.rows),
status,
editListId
);
setRequestId(id);
setRequestStatus(status);
if (code) {
@@ -158,11 +180,13 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
onSaved();
}
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
// поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID)
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
});
}
};
@@ -197,7 +221,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). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
@@ -66,7 +75,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);
@@ -85,7 +94,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
return () => {
alive = false;
};
}, [itemId]);
}, [itemId, listId]);
// Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя.
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата
@@ -123,8 +132,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');
@@ -157,8 +166,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);
};
@@ -187,7 +196,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,21 @@
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 { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы. */
export function clearanceApprovalService(): ApprovalListService {
return new ApprovalListService(approvalListTitle(FORM_ID));
/**
* Экземпляр сервиса списка согласования этой формы.
*
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
* архивной партиции имя приходится резолвить через реестр — если реестр не
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
* а не тихо откатится на текущую партицию.
*/
export function clearanceApprovalService(listId?: string): ApprovalListService {
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
}
/** Отметки, которые проставляет не инициатор, а профильный согласующий (см. approverRights.ts). */
@@ -20,10 +29,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,7 +47,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);
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
await list.items.getById(id).update(item);
}
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
@@ -80,15 +94,22 @@ export interface ISaveResult {
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения у обходного листа нет — writeRoute вызывается с пустым списком исполнителей.
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest). Имеет смысл
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
* писать новые заявки в архив незачем и нельзя (approval-route переключает
* текущую партицию сам, по своему порогу элементов).
*/
export async function saveClearanceRequest(
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 list =
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const employee = await ensureEmployee(data.Employee);
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
@@ -105,6 +126,6 @@ export async function saveClearanceRequest(
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(clearanceApprovalService(), id, approvers, [], status);
await writeRoute(clearanceApprovalService(existingId ? listId : undefined), id, approvers, [], status);
return { id, code };
}
@@ -36,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 { saveHireRequest, hireApprovalService } from './submit';
import { loadHireRequest } from './load';
@@ -45,7 +50,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 +79,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 +96,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 +173,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);
};
@@ -177,7 +192,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
data,
toApprovers(matrix.rows),
toApprovers(executors.rows),
status
status,
editListId
);
setRequestId(id);
setRequestStatus(status);
@@ -185,19 +201,21 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
setRequestCode(code);
}
if (files.length > 0) {
await uploadAttachments(dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
await uploadAttachments(editListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); // показать добавленные
setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id)); // показать добавленные
}
if (onSaved) {
onSaved();
}
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
// поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID),
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID),
});
}
};
@@ -237,7 +255,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,24 @@
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 { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
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));
/**
* Экземпляр сервиса списка согласования этой формы.
*
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
* архивной партиции имя приходится резолвить через реестр — если реестр не
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
* а не тихо откатится на текущую партицию.
*/
export function hireApprovalService(listId?: string): ApprovalListService {
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
}
/** Осмысленный заголовок элемента списка (системная колонка Title). */
@@ -45,6 +54,11 @@ export interface ISaveResult {
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadHireRequest). Имеет смысл
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
* писать новые заявки в архив незачем и нельзя (approval-route переключает
* текущую партицию сам, по своему порогу элементов).
*/
export async function saveHireRequest(
existingId: number | undefined,
@@ -52,9 +66,11 @@ 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 list =
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = buildItem(data, status);
let id: number;
@@ -70,6 +86,6 @@ export async function saveHireRequest(
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(hireApprovalService(), id, approvers, executors, status);
await writeRoute(hireApprovalService(existingId ? listId : undefined), id, approvers, executors, status);
return { id, code };
}
@@ -35,7 +35,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 +50,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 +76,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 +94,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 +147,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 +160,35 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
}
const { id, code } = await saveSelectionRequest(requestId, rtId, data, toApprovers(matrix.rows), status);
const { id, code } = await saveSelectionRequest(
requestId,
rtId,
data,
toApprovers(matrix.rows),
status,
editListId
);
setRequestId(id);
setRequestStatus(status);
if (code) {
setRequestCode(code);
}
if (files.length > 0) {
await uploadAttachments(dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
await uploadAttachments(editListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id));
setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id));
}
if (onSaved) {
onSaved();
}
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
// поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID)
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
});
}
};
@@ -199,7 +223,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>
@@ -17,8 +17,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;
@@ -28,8 +35,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, 'RequestStatus', 'RequestUniqueCode', ...personSelects)
.expand(...expands)()) as Record<string, unknown>;
@@ -1,7 +1,7 @@
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 { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model';
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
@@ -11,9 +11,18 @@ const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
// Числовые поля: пустая строка → null, иначе Number().
const NUMBER_FIELDS: (keyof IFormData)[] = ['DepartmentHeadcount', 'RateProbation', 'RateAfterProbation'];
/** Экземпляр сервиса списка согласования этой формы. */
export function selectionApprovalService(): ApprovalListService {
return new ApprovalListService(approvalListTitle(FORM_ID));
/**
* Экземпляр сервиса списка согласования этой формы.
*
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
* архивной партиции имя приходится резолвить через реестр — если реестр не
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
* а не тихо откатится на текущую партицию.
*/
export function selectionApprovalService(listId?: string): ApprovalListService {
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
}
/** Логин → числовой Id пользователя на текущем сайте. */
@@ -68,15 +77,22 @@ export interface ISaveResult {
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* status = «Черновик» (сохранение) или «На согласовании» (отправка). Фазы исполнения
* нет — writeRoute вызывается с пустым списком исполнителей. Возвращает Id (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadSelectionRequest). Имеет смысл
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
* писать новые заявки в архив незачем и нельзя (approval-route переключает
* текущую партицию сам, по своему порогу элементов).
*/
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 list =
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = buildScalarBody(data);
item.RequestStatus = status;
await applyPeople(item, data);
@@ -94,6 +110,6 @@ export async function saveSelectionRequest(
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(selectionApprovalService(), id, approvers, [], status);
await writeRoute(selectionApprovalService(existingId ? listId : undefined), id, approvers, [], status);
return { id, code };
}
@@ -35,7 +35,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 +68,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 +93,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 +111,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 +139,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 +203,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 +225,13 @@ 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);
const { id, code } = await saveTicketRequest(
requestId,
data,
toApprovers(matrix.rows),
finalStatus,
editListId
);
setRequestId(id);
setRequestStatus(finalStatus);
if (code) {
@@ -219,9 +240,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(editListId || dataListTitle(FORM_ID), id, files);
setFiles([]);
attachments = await getAttachments(dataListTitle(FORM_ID), id);
attachments = await getAttachments(editListId || dataListTitle(FORM_ID), id);
setExistingAttachments(attachments);
}
@@ -230,10 +251,12 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
}
if (finalStatus === REQUEST_STATUS.onApproval) {
// Имена списков — той же партиции, что и сама заявка (editListId), не
// текущей: поток ищет строку маршрута именно в НЕЙ.
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID)
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
});
} else if (finalStatus === REQUEST_STATUS.approved) {
// Согласование не требуется для этого вида работ — сразу передаём в отдел 1С.
@@ -245,7 +268,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
attachments,
webAbsoluteUrl: context.pageContext.web.absoluteUrl
});
await setPlannerTaskId(id, taskId);
await setPlannerTaskId(id, taskId, editListId);
}
};
@@ -284,7 +307,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,7 @@
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 { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model';
// Префикс уникального кода заявки — буквенная метка «1С» вместо числового id
@@ -9,9 +9,18 @@ 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));
/**
* Экземпляр сервиса списка согласования этой формы.
*
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
* архивной партиции имя приходится резолвить через реестр — если реестр не
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
* а не тихо откатится на текущую партицию.
*/
export function ticket1cApprovalService(listId?: string): ApprovalListService {
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
}
/** Поля элемента списка данных из модели формы. TaskName пишется в системную колонку Title. */
@@ -38,14 +47,21 @@ export interface ISaveResult {
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю
* из матрицы. Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest). Имеет смысл
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
* писать новые заявки в архив незачем и нельзя (approval-route переключает
* текущую партицию сам, по своему порогу элементов).
*/
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 list =
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = buildItem(data, status);
let id: number;
@@ -61,7 +77,7 @@ export async function saveTicketRequest(
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(ticket1cApprovalService(), id, approvers, [], status);
await writeRoute(ticket1cApprovalService(existingId ? listId : undefined), id, approvers, [], status);
return { id, code };
}
@@ -70,14 +86,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 = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
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 = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
await list.items.getById(id).update({ PlannerTaskId: taskId });
}
@@ -36,14 +36,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 +92,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 +109,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 +189,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);
};
@@ -193,7 +208,8 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
data,
toApprovers(matrix.rows),
toApprovers(executors.rows),
status
status,
editListId
);
setRequestId(id);
setRequestStatus(status);
@@ -201,18 +217,21 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, on
setRequestCode(code);
}
if (files.length > 0) {
await uploadAttachments(dataListTitle(FORM_ID), id, files);
await uploadAttachments(editListId || dataListTitle(FORM_ID), id, files);
setFiles([]);
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id));
setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id));
}
if (onSaved) {
onSaved();
}
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
// поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID)
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
});
}
};
@@ -248,7 +267,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',
@@ -2,12 +2,21 @@ import { getSP } from '../../shared/pnp/pnpjs';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
import { buildRequestUniqueCode } from '../../shared/requestCode';
import { ILineItem } from '../../shared/ui';
import { approvalListTitle, dataListTitle } from '../../shared/listTitles';
import { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы. */
export function tmcTransferApprovalService(): ApprovalListService {
return new ApprovalListService(approvalListTitle(FORM_ID));
/**
* Экземпляр сервиса списка согласования этой формы.
*
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
* архивной партиции имя приходится резолвить через реестр — если реестр не
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
* а не тихо откатится на текущую партицию.
*/
export function tmcTransferApprovalService(listId?: string): ApprovalListService {
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
}
/** Осмысленный заголовок элемента (системная колонка Title). */
@@ -44,6 +53,11 @@ export interface ISaveResult {
* Сохранить заявку «СЗ на перемещение ТМЦ»: контент + маршрут (согласующие + исполнители).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Возвращает Id заявки (+ уникальный код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadTmcTransferRequest). Имеет смысл
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
* писать новые заявки в архив незачем и нельзя (approval-route переключает
* текущую партицию сам, по своему порогу элементов).
*/
export async function saveTmcTransferRequest(
existingId: number | undefined,
@@ -51,9 +65,11 @@ 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 list =
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = buildItem(data, status);
let id: number;
@@ -69,6 +85,6 @@ export async function saveTmcTransferRequest(
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(tmcTransferApprovalService(), id, approvers, executors, status);
await writeRoute(tmcTransferApprovalService(existingId ? listId : undefined), id, approvers, executors, status);
return { id, code };
}
@@ -24,13 +24,25 @@ import {
findUnassignedStep
} 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 +52,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 +67,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 +84,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 +176,14 @@ 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);
const { id, code } = await saveUnpaidLeaveRequest(
requestId,
rtId,
data,
toApprovers(matrix.rows),
status,
editListId
);
setRequestId(id);
setRequestStatus(status);
if (code) {
@@ -171,11 +193,13 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ requestType, context, on
onSaved();
}
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
// поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID)
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
});
}
};
@@ -210,7 +234,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,12 +1,21 @@
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 { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы. */
export function unpaidLeaveApprovalService(): ApprovalListService {
return new ApprovalListService(approvalListTitle(FORM_ID));
/**
* Экземпляр сервиса списка согласования этой формы.
*
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
* архивной партиции имя приходится резолвить через реестр — если реестр не
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
* а не тихо откатится на текущую партицию.
*/
export function unpaidLeaveApprovalService(listId?: string): ApprovalListService {
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
}
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
@@ -66,15 +75,22 @@ export interface ISaveResult {
* 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 list =
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const employee = await ensureUser(data.Employee);
// Замещающий необязателен: резолвим только если выбран.
const substituteId = data.Substitute ? (await ensureUser(data.Substitute)).Id : undefined;
@@ -93,6 +109,6 @@ export async function saveUnpaidLeaveRequest(
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(unpaidLeaveApprovalService(), id, approvers, [], status);
await writeRoute(unpaidLeaveApprovalService(existingId ? listId : undefined), id, approvers, [], status);
return { id, code };
}
@@ -24,13 +24,25 @@ import {
findUnassignedStep
} 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 +52,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 +67,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 +84,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 +173,7 @@ 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);
const { id, code } = await saveVacationRequest(requestId, rtId, data, toApprovers(matrix.rows), status, editListId);
setRequestId(id);
setRequestStatus(status);
if (code) {
@@ -168,11 +183,13 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
onSaved();
}
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
// поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID)
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID)
});
}
};
@@ -207,7 +224,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,12 +1,21 @@
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 { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы. */
export function vacationApprovalService(): ApprovalListService {
return new ApprovalListService(approvalListTitle(FORM_ID));
/**
* Экземпляр сервиса списка согласования этой формы.
*
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
* архивной партиции имя приходится резолвить через реестр — если реестр не
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
* а не тихо откатится на текущую партицию.
*/
export function vacationApprovalService(listId?: string): ApprovalListService {
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
}
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
@@ -57,15 +66,22 @@ export interface ISaveResult {
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей.
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadVacationRequest). Имеет смысл
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
* писать новые заявки в архив незачем и нельзя (approval-route переключает
* текущую партицию сам, по своему порогу элементов).
*/
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 list =
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const employee = await ensureEmployee(data.Employee);
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
@@ -82,6 +98,6 @@ export async function saveVacationRequest(
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(vacationApprovalService(), id, approvers, [], status);
await writeRoute(vacationApprovalService(existingId ? listId : undefined), id, approvers, [], status);
return { id, code };
}
@@ -36,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 { saveWorkplaceRequest, workplaceApprovalService } from './submit';
import { loadPrefillFromHire } from './prefill';
@@ -44,7 +49,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 +81,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 +98,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
return () => {
alive = false;
};
}, [editId]);
}, [editId, editListId]);
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
useEffect(() => {
@@ -109,7 +122,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 +197,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);
};
@@ -201,7 +217,8 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
toApprovers(matrix.rows),
toApprovers(executors.rows),
status,
sourceId
sourceId,
editListId
);
setRequestId(id);
setRequestStatus(status);
@@ -209,19 +226,21 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
setRequestCode(code);
}
if (files.length > 0) {
await uploadAttachments(dataListTitle(FORM_ID), id, files);
await uploadAttachments(editListId || dataListTitle(FORM_ID), id, files);
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setExistingAttachments(await getAttachments(dataListTitle(FORM_ID), id)); // показать добавленные
setExistingAttachments(await getAttachments(editListId || dataListTitle(FORM_ID), id)); // показать добавленные
}
if (onSaved) {
onSaved();
}
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
// Имена списков — той же партиции, что и сама заявка (editListId), не текущей:
// поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: dataListTitle(FORM_ID),
approvalListName: approvalListTitle(FORM_ID),
dataListName: editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID),
approvalListName: editListId ? approvalListTitleForListId(FORM_ID, editListId) : approvalListTitle(FORM_ID),
});
}
};
@@ -261,7 +280,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,37 @@
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 { approvalListTitle, approvalListTitleForListId, dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы. */
export function workplaceApprovalService(): ApprovalListService {
return new ApprovalListService(approvalListTitle(FORM_ID));
/**
* Экземпляр сервиса списка согласования этой формы.
*
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
* архивной партиции имя приходится резолвить через реестр — если реестр не
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
* а не тихо откатится на текущую партицию.
*/
export function workplaceApprovalService(listId?: string): ApprovalListService {
return new ApprovalListService(listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID));
}
/**
* Обновить поля, которые заполняет согласующий-руководитель на этапе согласования
* (инициатор часто не знает нужное ПО и доступы). Пишет только эти два поля.
*
* 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 = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
await list.items.getById(id).update({
Software: fields.Software === '' ? null : fields.Software,
AccessRights: fields.AccessRights === '' ? null : fields.AccessRights,
});
@@ -59,6 +73,11 @@ export interface ISaveResult {
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
* sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest). Имеет смысл
* только вместе с existingId: новая заявка всегда создаётся в ТЕКУЩЕЙ партиции —
* писать новые заявки в архив незачем и нельзя (approval-route переключает
* текущую партицию сам, по своему порогу элементов).
*/
export async function saveWorkplaceRequest(
existingId: number | undefined,
@@ -67,9 +86,11 @@ 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 list =
existingId && listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = buildItem(data, status, sourceHireId);
let id: number;
@@ -85,6 +106,6 @@ export async function saveWorkplaceRequest(
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
await writeRoute(workplaceApprovalService(existingId ? listId : undefined), id, approvers, executors, status);
return { id, code };
}
@@ -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>;
@@ -44,4 +44,13 @@ export interface IRequestFormProps {
* согласования читается из уже записанных строк, а не планируется заново.
*/
editId?: number;
/**
* Партиция (серверный url списка), где лежит editId — см.
* shared/approval/listPartitions.ts. Заявки нумерованные: без этого поля
* форма при редактировании всегда читала/писала бы в ТЕКУЩУЮ партицию, и
* архивная заявка правилась бы не туда. Не задан — либо editId тоже не
* задан (новая заявка), либо ссылка старого формата без параметра — тогда
* форма откатывается на текущую партицию.
*/
editListId?: string;
}
@@ -1,6 +1,7 @@
import { getSP } from '../pnp/pnpjs';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import { ApprovalListService } from './approvalList.service';
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
@@ -33,9 +34,17 @@ const ROW_STATUS: Record<ApproverDecision, string> = {
// заявке (requestId). Пока в портале нет маршрутизации на форму заявки, этот сервис
// готов, но UI-режим согласующего ещё не подключён (см. план).
export class ApproverService {
/**
* dataListRef — либо Title списка данных (текущая партиция, как раньше), либо
* его серверный относительный url (конкретная, в т.ч. архивная партиция —
* см. shared/listTitles.ts). Различаем по ведущему '/': Title списка в
* SharePoint с него не начинается, url — всегда начинается. Так конструктору
* не нужен третий параметр «что именно передали» — вызывающая сторона просто
* передаёт то, что у неё есть (см. submit.ts каждой формы).
*/
constructor(
private readonly approval: ApprovalListService,
private readonly dataListTitle: string
private readonly dataListRef: string
) {}
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
@@ -162,8 +171,14 @@ export class ApproverService {
return REQUEST_STATUS.execution;
}
private dataList(): IList {
return this.dataListRef.startsWith('/')
? getSP().web.getList(this.dataListRef)
: getSP().web.lists.getByTitle(this.dataListRef);
}
private async updateRequestStatus(id: number, status: string): Promise<void> {
await getSP().web.lists.getByTitle(this.dataListTitle).items.getById(id).update({
await this.dataList().items.getById(id).update({
RequestStatus: status,
});
}
@@ -28,6 +28,7 @@ export {
partitionsForTypeId,
currentPartitionForTypeId,
currentPartitionForPageUrl,
partitionForPageUrlAndListId,
} from './listPartitions';
export { useRequestTypeId } from './useRequestTypeId';
export type { IRequestTypeIdState } from './useRequestTypeId';
@@ -89,3 +89,22 @@ export function currentPartitionForPageUrl(pageUrl: string): IListPartition | un
const id = typeIdByPageUrl.get(pageUrl);
return id === undefined ? undefined : currentPartitionForTypeId(id);
}
/**
* Конкретная партиция по её listId — нужна, чтобы открыть/сохранить АРХИВНУЮ
* заявку: тогда известен не номер, а сам listId (пришёл из строки поиска, см.
* IMyRequest.listId в features/myRequests), и его надо сопоставить с записью в
* реестре, чтобы узнать имя списка (для парного списка согласования — см.
* shared/listTitles.ts, approvalListTitleForListId).
*
* undefined — партиции с таким listId в реестре нет (устарела ссылка, реестр ещё
* не загрузился). Вызывающая сторона в этом случае должна показать ошибку, а не
* молча откатываться на текущую партицию — иначе данные архивной заявки
* читались/писались бы не в тот список.
*/
export function partitionForPageUrlAndListId(pageUrl: string, listId: string): IListPartition | undefined {
const id = typeIdByPageUrl.get(pageUrl);
if (id === undefined) return undefined;
const lower = listId.toLowerCase();
return partitionsForTypeId(id).filter((p) => p.listId.toLowerCase() === lower)[0];
}
@@ -1,4 +1,7 @@
import { getSP } from '../pnp/pnpjs';
import '@pnp/sp/lists';
import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/attachments';
export interface IAttachmentInfo {
@@ -7,6 +10,18 @@ export interface IAttachmentInfo {
url: string;
}
/**
* listRef — либо Title списка данных (текущая партиция), либо его серверный
* относительный url (конкретная, в т.ч. архивная партиция — см. shared/
* listTitles.ts). Различаем по ведущему '/', тем же приёмом, что и
* ApproverService.dataListRef — иначе вложения заявки, лежащей в архивной
* партиции, читались/писались бы в список ТЕКУЩЕЙ (там либо нет элемента с
* таким Id, либо, хуже, есть чужой элемент с тем же Id).
*/
function resolveList(listRef: string): IList {
return listRef.startsWith('/') ? getSP().web.getList(listRef) : getSP().web.lists.getByTitle(listRef);
}
/** Прочитать файл как ArrayBuffer (FileReader — совместимо со всеми браузерами). */
function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise<ArrayBuffer>((resolve, reject) => {
@@ -18,11 +33,8 @@ function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
}
/** Вложения элемента списка (для режима согласующего — ссылки на скачивание). */
export async function getAttachments(listTitle: string, itemId: number): Promise<IAttachmentInfo[]> {
const files = await getSP()
.web.lists.getByTitle(listTitle)
.items.getById(itemId)
.attachmentFiles();
export async function getAttachments(listRef: string, itemId: number): Promise<IAttachmentInfo[]> {
const files = await resolveList(listRef).items.getById(itemId).attachmentFiles();
return files.map((f) => ({ fileName: f.FileName, url: f.ServerRelativeUrl }));
}
@@ -31,14 +43,14 @@ export async function getAttachments(listTitle: string, itemId: number): Promise
* Вызывать ПОСЛЕ создания элемента (нужен его id).
*/
export async function uploadAttachments(
listTitle: string,
listRef: string,
itemId: number,
files: File[]
): Promise<void> {
if (files.length === 0) {
return;
}
const item = getSP().web.lists.getByTitle(listTitle).items.getById(itemId);
const item = resolveList(listRef).items.getById(itemId);
for (let i = 0; i < files.length; i++) {
const file = files[i];
const content = await readAsArrayBuffer(file);
@@ -48,13 +60,9 @@ export async function uploadAttachments(
/** Удалить вложение элемента по имени файла (для правки заявки). */
export async function deleteAttachment(
listTitle: string,
listRef: string,
itemId: number,
fileName: string
): Promise<void> {
await getSP()
.web.lists.getByTitle(listTitle)
.items.getById(itemId)
.attachmentFiles.getByName(fileName)
.delete();
await resolveList(listRef).items.getById(itemId).attachmentFiles.getByName(fileName).delete();
}
@@ -1,5 +1,5 @@
import { RequestTypeId } from '../models/types';
import { currentPartitionForPageUrl } from './approval/listPartitions';
import { currentPartitionForPageUrl, partitionForPageUrlAndListId } from './approval/listPartitions';
/**
* ЕДИНЫЙ источник имён списков SharePoint.
@@ -93,3 +93,39 @@ export function approvalListTitle(id: RequestTypeId): string {
);
return `${listTitleBase(id)} ${FALLBACK_LIST_NUMBER} — согласование`;
}
/**
* Имя списка данных КОНКРЕТНОЙ партиции по её listId — для открытия/сохранения
* заявки, которая не обязательно лежит в текущей партиции (архив).
*
* В отличие от dataListTitle() НИЧЕГО не подставляет по умолчанию: если listId
* не нашёлся в реестре (устаревшая ссылка, партиция удалена из реестра), это
* означает, что мы рискуем прочитать/записать не тот список — правильный ответ
* тут явная ошибка, а не молчаливый откат на текущую партицию.
*/
export function dataListTitleForListId(id: RequestTypeId, listId: string): string {
const partition = partitionForPageUrlAndListId(id, listId);
if (!partition) {
throw new Error(
`Список «${listId}» не найден в реестре партиций типа заявки «${id}» — возможно, ссылка устарела.`
);
}
// Title кешируется в реестре на момент создания партиции — то же ограничение,
// что и у dataListTitle() для текущей партиции (см. её комментарий), поэтому
// фолбэк на конвенцию по номеру здесь абсолютно тот же приём.
return partition.title || `${listTitleBase(id)} ${partition.number} — данные`;
}
/** Имя парного списка согласования конкретной партиции — см. dataListTitleForListId. */
export function approvalListTitleForListId(id: RequestTypeId, listId: string): string {
const dataTitle = dataListTitleForListId(id, listId);
if (DATA_LIST_SUFFIX.test(dataTitle)) {
return `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
}
const partition = partitionForPageUrlAndListId(id, listId);
console.warn(
`[Партиции списков] «${dataTitle}» не оканчивается на «— данные» — имя списка согласования собрано по конвенции`
);
return `${listTitleBase(id)} ${partition?.number ?? FALLBACK_LIST_NUMBER} — согласование`;
}
+16 -2
View File
@@ -52,6 +52,15 @@ export interface IRoute {
formKey?: string;
/** Id заявки в списке данных — для 'approve' и 'edit'. */
itemId?: number;
/**
* Серверный относительный url КОНКРЕТНОЙ партиции, где лежит заявка — для
* 'approve' и 'edit'. Списки заявок нумерованные (см. shared/approval/
* listPartitions.ts): без этого поля заявка всегда открывалась бы так, будто
* лежит в ТЕКУЩЕЙ партиции, и архивная заявка читалась/сохранялась бы не в
* тот список. Не задан — значит партиция неизвестна (старая ссылка без
* параметра), и форма/просмотр откатываются на текущую партицию.
*/
listId?: string;
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
sourceId?: number;
/** Подвкладка «Моих заявок»; задана только при nav='own'. */
@@ -263,6 +272,7 @@ export function parseRoute(search: string = window.location.search): IRoute {
const formKey = params.get('rptype') ?? undefined;
const idRaw = params.get('rpid');
const itemId = idRaw ? Number(idRaw) : undefined;
const listId = params.get('rplist') ?? undefined;
const sourceRaw = params.get('rpsource');
const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
@@ -288,11 +298,11 @@ export function parseRoute(search: string = window.location.search): IRoute {
// страницей: закрыв панель, пользователь должен вернуться ровно в тот же
// список и на то же место в нём.
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus, page, query };
return { mode: 'approve', nav, formKey, itemId, listId, ownTab, ownStatus, page, query };
}
// Редактирование собственной незавершённой заявки — та же панель, но с формой.
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus, page, query };
return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, page, query };
}
// Создание производной заявки с предзаполнением из исходной.
if (formKey && sourceId && !isNaN(sourceId)) {
@@ -308,6 +318,7 @@ function buildQuery(route: IRoute): string {
params.delete('rpnav');
params.delete('rptype');
params.delete('rpid');
params.delete('rplist');
params.delete('rpsource');
params.delete('rptab');
params.delete('rpstatus');
@@ -331,6 +342,9 @@ function buildQuery(route: IRoute): string {
params.set('rpmode', route.mode);
params.set('rptype', route.formKey);
params.set('rpid', String(route.itemId));
// Партиция неизвестна (архивная ссылка старого формата, до этого поля) —
// ссылка всё равно валидна, форма/просмотр просто откатятся на текущую.
if (route.listId) params.set('rplist', route.listId);
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
params.set('rptype', route.formKey);
params.set('rpsource', String(route.sourceId));