Редактирование заявок у конкретного пользователя (#14)
Tests / frontend-test (push) Successful in 45s
Tests / backend-test (push) Successful in 7m18s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
2026-08-19 12:58:07 +00:00
parent 82512eb1db
commit 1ee1b71e3e
36 changed files with 1862 additions and 333 deletions
@@ -15,6 +15,7 @@ import {
} from '../../models';
import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeListPartitions } from '../../hooks/useRequestTypeListPartitions';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { findEmployeeByEmail } from '../../utils/employee';
@@ -47,6 +48,14 @@ export interface IManageRequestsScreenProps {
route?: IManageRequestRouteParams;
}
// Подпись заявки в комбобоксе — уникальный код (RequestUniqueCode, тот же, что
// видит заявитель на портале), а не служебный числовой Id списка SharePoint.
// Запасной вариант на #id — для заявок без заполненного кода (старые заявки,
// либо список данных этой колонки не заводит).
function requestLabel(r: Pick<ManagedRequestSummary, 'id' | 'title' | 'uniqueCode'>): string {
return `${r.uniqueCode || `#${r.id}`} ${r.title}`;
}
// Цвет плашки статуса — те же группы смысла, что и в request-portal
// (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик.
function statusVariant(status: string): BadgeVariant {
@@ -158,7 +167,19 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
if (!route) return;
setSelection({
requestTypeId: route.requestTypeId,
selectedRequest: { id: Number(route.requestId), title: '', status: '', created: '', authorName: '' }
// listId из ссылки — какая партиция, если ссылку скопировали уже после
// этого фикса; пусто у старых ссылок — тогда reloadMatrix ниже
// подставит текущую партицию (тот же эффект, что был раньше, но теперь
// явный, а не единственно возможный).
selectedRequest: {
id: Number(route.requestId),
title: '',
status: '',
created: '',
authorName: '',
uniqueCode: null,
listId: route.listId
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -167,11 +188,15 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка
// состояния текущего экрана, а не переход на новую страницу с отдельным
// шагом в истории браузера. title/status заявки в URL не пишем — их выводит
// сам requestId, хранить производные данные в ссылке незачем.
// сам requestId, хранить производные данные в ссылке незачем. listId —
// пишем: без него по ссылке нельзя однозначно понять, в какой партиции
// искать requestId (см. IManageRequestRouteParams).
React.useEffect(() => {
replaceRoute({
tab: 'manageRequests',
manage: selectedRequest ? { requestTypeId, requestId: String(selectedRequest.id) } : undefined
manage: selectedRequest
? { requestTypeId, requestId: String(selectedRequest.id), listId: selectedRequest.listId }
: undefined
});
}, [requestTypeId, selectedRequest]);
const [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>();
@@ -192,6 +217,11 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// и читать заявки этого типа отсюда просто негде.
const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl);
// Вся серия партиций выбранного типа (архив + текущая) — заявки этого типа
// физически лежат в любой из них (см. ТЗ по архивации), искать и открывать
// нужно по всем сразу, не только по текущей.
const partitions = useRequestTypeListPartitions(api, requestType?.id);
const selectRequestType = (id: string): void => {
// Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит
// показывать заявку не того типа, что выбран сейчас.
@@ -205,10 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const searchRequests = React.useCallback(
(query: string, page: number) => {
if (!requestType) return Promise.resolve({ items: [], hasMore: false });
return requestManage.searchRequests(requestType, query, page);
if (!requestType || !requestType.siteUrl || partitions.items.length === 0) {
return Promise.resolve({ items: [], hasMore: false });
}
return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page);
},
[requestManage, requestType]
[requestManage, requestType, partitions.items]
);
// silent=true — фоновое обновление (после своего же действия, автообновление
@@ -219,18 +251,38 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const reloadMatrix = React.useCallback(
(silent = false) => {
if (!requestType || !selectedRequest) return;
// Партиции ещё не подтянулись (первый рендер после смены типа/захода по
// ссылке) — без них нельзя даже подставить запасной вариант (текущая
// партиция) для ссылок без listId, ждём следующего рендера.
if (partitions.isLoading) return;
if (!requestType.siteUrl) {
setMatrixError('У типа заявки не указан список SharePoint.');
return;
}
// listId заявки уже известен (выбрана из комбобокса, либо ссылка после
// этого фикса) — используем его. Иначе (старая ссылка без listId) —
// единственный разумный запасной вариант: текущая партиция, тот же
// список, что искался бы и раньше.
const listId = selectedRequest.listId || partitions.items.filter(p => p.isCurrent)[0]?.listId;
if (!listId) {
setMatrixError('Для типа заявки не найдено ни одной партиции списка SharePoint.');
return;
}
if (!silent) setMatrixLoading(true);
setMatrixError(undefined);
requestManage
.getMatrix(requestType, selectedRequest.id)
.getMatrix(requestType.siteUrl, listId, selectedRequest.id)
.then(m => {
setMatrix(m);
// title/status в комбобоксе (а значит, и в localStorage) держим
// title/status/listId в комбобоксе (а значит, и в localStorage) держим
// синхронными с сервером — особенно важно после захода по глубокой
// ссылке, где изначально они пустые (см. эффект-затравку выше).
// ссылке, где изначально они пустые (см. эффект-затравку выше), и
// после подстановки запасной (текущей) партиции выше: как только
// реальная партиция заявки известна, оседает в выборе — следующий
// reloadMatrix пойдёт уже по ней напрямую.
//
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если title/status не
// отличаются — m.request это каждый раз НОВЫЙ объект (свежий парсинг
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если ничего не
// отличается — m.request это каждый раз НОВЫЙ объект (свежий парсинг
// ответа), и если писать его в состояние безусловно, selectedRequest
// получает новую ссылку на КАЖДОЙ загрузке, даже когда содержимое не
// поменялось. Это меняет зависимости reloadMatrix → пересоздаёт саму
@@ -240,7 +292,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
setSelection(prev => {
const sel = prev.selectedRequest;
if (!sel || sel.id !== m.request.id) return prev;
const same = sel.title === m.request.title && sel.status === m.request.status;
const same = sel.title === m.request.title && sel.status === m.request.status && sel.listId === m.request.listId;
return same ? prev : { ...prev, selectedRequest: m.request };
});
})
@@ -249,7 +301,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
if (!silent) setMatrixLoading(false);
});
},
[requestManage, requestType, selectedRequest]
[requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading]
);
React.useEffect(() => {
@@ -533,14 +585,14 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
<AsyncSearchableSelect<ManagedRequestSummary>
key={requestTypeId || 'none'}
value={selectedRequest ? String(selectedRequest.id) : ''}
selectedLabel={selectedRequest ? `#${selectedRequest.id} ${selectedRequest.title}` : undefined}
selectedLabel={selectedRequest ? requestLabel(selectedRequest) : undefined}
selectedSublabel={selectedRequest?.status}
disabled={!requestType}
placeholder={texts.manageRequests.requestPlaceholder}
searchPlaceholder={texts.manageRequests.requestSearchPlaceholder}
search={searchRequests}
getValue={r => String(r.id)}
getLabel={r => `#${r.id} ${r.title}`}
getLabel={requestLabel}
getSublabel={r => r.status}
onChange={(_, item) => selectRequest(item)}
/>