diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 19621f1..0eba3a4 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -227,6 +227,11 @@ const RequestPortal: React.FC = ({ 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 = ({ hasTeamsContext, context {route.mode === 'approve' && route.formKey && route.itemId && ( - + )} @@ -387,6 +392,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context context={context} formKey={route.formKey} itemId={route.itemId} + listId={route.listId} onClose={closeDrawer} /> diff --git a/src/webparts/requestPortal/features/approve/ApproveFeature.tsx b/src/webparts/requestPortal/features/approve/ApproveFeature.tsx index 2f674f8..49a5d0a 100644 --- a/src/webparts/requestPortal/features/approve/ApproveFeature.tsx +++ b/src/webparts/requestPortal/features/approve/ApproveFeature.tsx @@ -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 = ({ context, formKey, itemId }) => { +const ApproveFeature: React.FC = ({ context, formKey, itemId, listId }) => { const ViewComponent = getViewComponent(formKey); + // Партиция указана, но её нет в реестре — см. тот же комментарий в EditFeature. + const partitionError = + listId && !partitionForPageUrlAndListId(formKey, listId) + ? `Не удалось открыть заявку: список «${listId}» не найден в реестре партиций. Ссылка могла устареть — попробуйте открыть заявку заново из списка.` + : undefined; + return (
- {ViewComponent ? ( + {partitionError ? ( +
{partitionError}
+ ) : ViewComponent ? ( Загрузка…
}> - + ) : (
Для формы «{formKey}» режим согласования пока не настроен.
diff --git a/src/webparts/requestPortal/features/edit/EditFeature.tsx b/src/webparts/requestPortal/features/edit/EditFeature.tsx index e721636..72a57b4 100644 --- a/src/webparts/requestPortal/features/edit/EditFeature.tsx +++ b/src/webparts/requestPortal/features/edit/EditFeature.tsx @@ -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 = ({ context, formKey, itemId, onClose }) => { +const EditFeature: React.FC = ({ 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 = ({ context, formKey, itemId, on return
Для формы «{formKey}» редактирование не настроено.
; } + // Партиция указана (заявки нумерованные, см. shared/approval/listPartitions.ts), + // но её нет в реестре — устаревшая/битая ссылка. Проверяем ЗДЕСЬ, ДО монтирования + // формы: внутри формы имя списка резолвится синхронно (useMemo) и там уже негде + // красиво показать ошибку без риска уронить рендер всей панели — см. + // vacationApprovalService и аналоги в остальных формах. + if (listId && !partitionForPageUrlAndListId(formKey, listId)) { + return ( +
+ Не удалось открыть заявку: список «{listId}» не найден в реестре партиций. Ссылка могла устареть — + попробуйте открыть заявку заново из списка. +
+ ); + } + return (
Загрузка формы…
}> @@ -37,6 +54,7 @@ const EditFeature: React.FC = ({ context, formKey, itemId, on requestType={requestType} context={context} editId={itemId} + editListId={listId} onCancel={onClose} onSaved={() => { emitRequestsChanged(); diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index 4417739..31b0bce 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -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 = ({ requestType, context, onCancel, onSaved, editId }) => { +const ClearanceForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId +}) => { const [data, setData] = useState(EMPTY_DATA); const [requestId, setRequestId] = useState(editId); const [requestStatus, setRequestStatus] = useState(undefined); @@ -40,7 +52,10 @@ const ClearanceForm: React.FC = ({ 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 = ({ 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 = ({ requestType, context, onCa alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + }, [editId, editListId]); // Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай: // обходной лист оформляют в день увольнения). При открытии на редактирование @@ -148,7 +163,14 @@ const ClearanceForm: React.FC = ({ 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 = ({ 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 = ({ requestType, context, onCa } + belowSubtitle={ + + } headerRight={ requestId || requestStatus ? ( <> diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx index 9771ed7..4c3a59f 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx @@ -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 = ({ context, itemId, formKey }) => { +const ClearanceView: React.FC = ({ context, itemId, formKey, listId }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -48,12 +55,14 @@ const ClearanceView: React.FC = ({ 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 = ({ 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 = ({ context, itemId, formKey return () => { alive = false; }; - }, [itemId]); + }, [itemId, listId]); // Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя. // isDelegator намеренно не даёт прав: делегировавший не решает за делегата @@ -123,8 +132,8 @@ const ClearanceView: React.FC = ({ 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 => { setSaveState('saving'); @@ -157,8 +166,8 @@ const ClearanceView: React.FC = ({ 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 = ({ context, itemId, formKey } + belowSubtitle={ + + } headerRight={status ? : undefined} > diff --git a/src/webparts/requestPortal/forms/Clearance/load.ts b/src/webparts/requestPortal/forms/Clearance/load.ts index 1740103..9496198 100644 --- a/src/webparts/requestPortal/forms/Clearance/load.ts +++ b/src/webparts/requestPortal/forms/Clearance/load.ts @@ -11,10 +11,17 @@ export interface ILoadedRequest { code: string; } -/** Прочитать обходной лист по id: модель формы + статус (правка / просмотр). */ -export async function loadClearanceRequest(itemId: number): Promise { - 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 { + const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); + const item = (await list .items.getById(itemId) .select( 'Id', diff --git a/src/webparts/requestPortal/forms/Clearance/submit.ts b/src/webparts/requestPortal/forms/Clearance/submit.ts index fe5c512..0745a12 100644 --- a/src/webparts/requestPortal/forms/Clearance/submit.ts +++ b/src/webparts/requestPortal/forms/Clearance/submit.ts @@ -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 + fields: Partial, + listId?: string ): Promise { const item: Record = {}; 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 { - 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 }; } diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index a704b0d..9cf78ef 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -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 = ({ requestType, context, onCancel, onSaved, editId }) => { +const RecruitingHireForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId +}) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); const [existingAttachments, setExistingAttachments] = useState([]); @@ -67,7 +79,7 @@ const RecruitingHireForm: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ requestType, context, data, toApprovers(matrix.rows), toApprovers(executors.rows), - status + status, + editListId ); setRequestId(id); setRequestStatus(status); @@ -185,19 +201,21 @@ const RecruitingHireForm: React.FC = ({ 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 = ({ requestType, context, } + belowSubtitle={ + + } headerRight={ requestId || requestStatus ? ( <> diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 43a8251..0ce8804 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -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; // Заявка «на прием» только для просмотра (режим согласующего). // Переиспользует те же секции, что и форма подачи; редактирование выключено // через — поля сами становятся неизменяемыми. -const RecruitingHireView: React.FC = ({ context, itemId, formKey }) => { +const RecruitingHireView: React.FC = ({ context, itemId, formKey, listId }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -52,10 +59,16 @@ const RecruitingHireView: React.FC = ({ 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 = ({ context, itemI // перечитывает матрицу. Иначе на экране остались бы данные, загруженные при // открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании». const [decisionSeq, setDecisionSeq] = useState(0); - const approval = useMemo(() => hireApprovalService(), []); + const approval = useMemo(() => hireApprovalService(listId), [listId]); const loadApprovers = useCallback( () => approval @@ -91,7 +104,7 @@ const RecruitingHireView: React.FC = ({ 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 = ({ context, itemI return () => { alive = false; }; - }, [itemId]); + }, [itemId, listId]); if (loading) { return
Загрузка заявки…
; @@ -130,8 +143,8 @@ const RecruitingHireView: React.FC = ({ 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 = ({ context, itemI } + belowSubtitle={ + + } headerRight={status ? : undefined} > diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts index cb020db..77ad94f 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -16,11 +16,18 @@ export interface ILoadedRequest { code: string; } -/** Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). */ -export async function loadHireRequest(itemId: number): Promise { +/** + * Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). + * + * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/ + * listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан — + * идём напрямую по url списка, а не по имени: так не нужно резолвить, как + * называется архивный список, и запрос не зависит от реестра партиций. + */ +export async function loadHireRequest(itemId: number, listId?: string): Promise { 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; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index 91ab1d4..af3f216 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -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 { - 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 }; } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 38757a4..125622e 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -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 = ({ requestType, context, onCancel, onSaved, editId }) => { +const RecruitingSelectionForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId +}) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); const [existingAttachments, setExistingAttachments] = useState([]); @@ -64,7 +76,7 @@ const RecruitingSelectionForm: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ requestType, con } + belowSubtitle={ + + } headerRight={ requestId || requestStatus ? ( <> diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index ac89b97..2882216 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -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; // Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же // секции, что и форма подачи; редактирование выключено через . -const RecruitingSelectionView: React.FC = ({ context, itemId, formKey }) => { +const RecruitingSelectionView: React.FC = ({ context, itemId, formKey, listId }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -43,9 +50,14 @@ const RecruitingSelectionView: React.FC = ({ cont const [loading, setLoading] = useState(true); const [error, setError] = useState(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 = ({ 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 = ({ cont return () => { alive = false; }; - }, [itemId]); + }, [itemId, listId]); if (loading) { return
Загрузка заявки…
; @@ -97,8 +109,8 @@ const RecruitingSelectionView: React.FC = ({ 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 = ({ cont } + belowSubtitle={ + + } headerRight={status ? : undefined} > diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts index 7769da4..088be1d 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts @@ -17,8 +17,15 @@ export interface ILoadedRequest { code: string; } -/** Прочитать заявку «на подбор» по id: модель формы + статус (правка / просмотр). */ -export async function loadSelectionRequest(itemId: number): Promise { +/** + * Прочитать заявку «на подбор» по id: модель формы + статус (правка / просмотр). + * + * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/ + * listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (как раньше); задан — + * идём напрямую по url списка, а не по имени: так не нужно резолвить, как + * называется архивный список, и запрос не зависит от реестра партиций. + */ +export async function loadSelectionRequest(itemId: number, listId?: string): Promise { 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 `${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; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts index 939c4e3..22c5854 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts @@ -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 в колонку 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 { - 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 }; } diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index b12d540..2095c43 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -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 = ({ requestType, context, onCancel, onSaved, editId }) => { +const Ticket1CForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId +}) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); const [existingAttachments, setExistingAttachments] = useState([]); @@ -81,7 +93,7 @@ const Ticket1CForm: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ requestType, context, onCan } + belowSubtitle={ + + } headerRight={ requestId || requestStatus ? ( <> diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx index cec9b4c..1a7ef75 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx @@ -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 = ({ context, itemId, formKey }) => { +const Ticket1CView: React.FC = ({ context, itemId, formKey, listId }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -83,11 +90,13 @@ const Ticket1CView: React.FC = ({ 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 = ({ 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 = ({ context, itemId, formKey } return () => { alive = false; }; - }, [itemId]); + }, [itemId, listId]); // Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита // от повторного создания карточки, если решение применяется из нескольких // вкладок/сессий одновременно (см. submit.ts). const handoffToPlanner = useCallback( async (finalStatus: string, currentData: IFormData, currentCode: string): Promise => { - const existingTaskId = await getPlannerTaskId(itemId); + const existingTaskId = await getPlannerTaskId(itemId, listId); if (existingTaskId) { return; } @@ -143,7 +152,7 @@ const Ticket1CView: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ context, itemId, formKey } {isEditableStatus(status) && (