куча правок
This commit is contained in:
@@ -6,6 +6,7 @@ import { ApprovalRouteApi } from '../services/api';
|
||||
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
|
||||
import { PortalDictionaryClient } from '../services/portalDictionary';
|
||||
import { SharePointListsClient } from '../services/sharePointLists';
|
||||
import { RequestManageService } from '../services/requestManageService';
|
||||
import { texts } from '../texts';
|
||||
|
||||
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
|
||||
@@ -32,6 +33,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
||||
const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>();
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
const sharePoint = React.useMemo(() => new SharePointListsClient(props.context), [props.context]);
|
||||
const requestManage = React.useMemo(() => new RequestManageService(props.context), [props.context]);
|
||||
|
||||
React.useEffect(() => {
|
||||
ensureFontsLoaded();
|
||||
@@ -83,6 +85,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
||||
api={api}
|
||||
dictionary={dictionary}
|
||||
sharePoint={sharePoint}
|
||||
requestManage={requestManage}
|
||||
currentUserEmail={props.context.pageContext.user.email}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,26 +5,59 @@ import { ScreenKey } from './types';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { SharePointListsClient } from '../../services/sharePointLists';
|
||||
import { RequestManageService } from '../../services/requestManageService';
|
||||
import { RequestType, TemplateRoute } from '../../models';
|
||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||
import { RoutesScreen } from '../screens/RoutesScreen';
|
||||
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||
import { ManageRequestsScreen } from '../screens/ManageRequestsScreen';
|
||||
|
||||
export interface IAdminAppProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
sharePoint: SharePointListsClient;
|
||||
requestManage: RequestManageService;
|
||||
currentUserEmail?: string;
|
||||
}
|
||||
|
||||
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint, currentUserEmail }) => {
|
||||
const [screen, setScreen] = React.useState<ScreenKey>('dashboard');
|
||||
const ACTIVE_TAB_STORAGE_KEY = 'approvalAdmin.activeTab';
|
||||
|
||||
// 'routeSteps' сознательно НЕ входит: этот экран требует selectedRoute — полный
|
||||
// объект маршрута, полученный по клику из RoutesScreen, а не просто ключ. После
|
||||
// перезагрузки страницы такого объекта нет и взять его неоткуда, поэтому эту
|
||||
// вкладку как «последнюю открытую» восстанавливать нельзя — если она вдруг
|
||||
// окажется в localStorage (старое значение, ручная правка), откатываемся на дашборд.
|
||||
const RESTORABLE_SCREENS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests'];
|
||||
|
||||
function readStoredScreen(): ScreenKey {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ACTIVE_TAB_STORAGE_KEY);
|
||||
if (raw && (RESTORABLE_SCREENS as string[]).indexOf(raw) !== -1) return raw as ScreenKey;
|
||||
} catch {
|
||||
// localStorage недоступен (приватный режим и т.п.) — просто не восстанавливаем.
|
||||
}
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint, requestManage, currentUserEmail }) => {
|
||||
const [screen, setScreenState] = React.useState<ScreenKey>(readStoredScreen);
|
||||
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
||||
const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
|
||||
|
||||
// 'routeSteps' в localStorage не пишем — см. RESTORABLE_SCREENS.
|
||||
const setScreen = (key: ScreenKey): void => {
|
||||
setScreenState(key);
|
||||
if (key === 'routeSteps') return;
|
||||
try {
|
||||
window.localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, key);
|
||||
} catch {
|
||||
// не критично — просто не запомнится
|
||||
}
|
||||
};
|
||||
|
||||
const navigate = (key: ScreenKey): void => {
|
||||
setScreen(key);
|
||||
};
|
||||
@@ -58,6 +91,9 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
||||
{screen === 'resolve' && (
|
||||
<ResolveScreen api={api} dictionary={dictionary} sharePoint={sharePoint} currentUserEmail={currentUserEmail} />
|
||||
)}
|
||||
{screen === 'manageRequests' && (
|
||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-right: 28px;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -45,17 +45,20 @@
|
||||
// бы от body и прокрутка уезжала бы не туда.
|
||||
position: relative;
|
||||
|
||||
// Полоса прокрутки на тёмной шапке выглядит инородно, скрываем её —
|
||||
// вкладки остаются доступны свайпом/колесом.
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
// Тонкая полупрозрачная полоса — не спрятана полностью: с добавлением новых
|
||||
// вкладок (сейчас их 6) они не всегда помещаются в узкую панель, и без
|
||||
// видимого скроллбара непонятно, что часть вкладок обрезана, а не пропала.
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, .35) transparent;
|
||||
&::-webkit-scrollbar { height: 4px; }
|
||||
&::-webkit-scrollbar-track { background: transparent; }
|
||||
&::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .35); border-radius: 2px; }
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
padding: 0 16px;
|
||||
padding: 0 13px;
|
||||
border: none;
|
||||
background: 0;
|
||||
color: rgba(255,255,255,.5);
|
||||
|
||||
@@ -12,7 +12,8 @@ const TABS: ITab[] = [
|
||||
{ key: 'dashboard', label: texts.nav.dashboard },
|
||||
{ key: 'requestTypes', label: texts.nav.requestTypes },
|
||||
{ key: 'routes', label: texts.nav.routes },
|
||||
{ key: 'resolve', label: texts.nav.resolve }
|
||||
{ key: 'resolve', label: texts.nav.resolve },
|
||||
{ key: 'manageRequests', label: texts.nav.manageRequests }
|
||||
];
|
||||
|
||||
export interface ITopNavProps {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve';
|
||||
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests';
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import * as React from 'react';
|
||||
import { Employee } from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { Field, FormGrid, FormModal, SearchableSelect } from '../ui';
|
||||
|
||||
export interface IManageRequestsApproverModalProps {
|
||||
title: string;
|
||||
initialEmail?: string;
|
||||
employees: Employee[];
|
||||
employeesLoading: boolean;
|
||||
onSave: (email: string, position: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Переназначение согласующего/исполнителя существующего шага (см.
|
||||
* ManageRequestsScreen). Должность НЕ вводится — берётся из справочника
|
||||
* сотрудников (api.tnsu.kz/Employee/All, см. Employee.position в
|
||||
* models/dictionaries.ts) на момент сохранения, отдельного поля для неё
|
||||
* в этой форме нет. Добавление НОВОГО шага через модалку не идёт — см.
|
||||
* плашку-пикер под таблицей в ManageRequestsPhaseTable.
|
||||
*/
|
||||
export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalProps> = ({
|
||||
title,
|
||||
initialEmail = '',
|
||||
employees,
|
||||
employeesLoading,
|
||||
onSave,
|
||||
onClose
|
||||
}) => {
|
||||
const [email, setEmail] = React.useState(initialEmail);
|
||||
const submit = useAsyncSubmit();
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
const trimmedEmail = email.trim();
|
||||
if (!trimmedEmail) {
|
||||
submit.fail(texts.manageRequests.validationApprover);
|
||||
return;
|
||||
}
|
||||
const position = findEmployeeByEmail(employees, trimmedEmail)?.position || '';
|
||||
submit.run(() => onSave(trimmedEmail, position));
|
||||
};
|
||||
|
||||
return (
|
||||
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose}>
|
||||
<FormGrid>
|
||||
<Field label={texts.manageRequests.approverLabel} required full>
|
||||
<SearchableSelect
|
||||
options={employees.map(toEmployeeOption)}
|
||||
value={email}
|
||||
loading={employeesLoading}
|
||||
placeholder={texts.manageRequests.approverPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={setEmail}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
import * as React from 'react';
|
||||
import tableStyles from '../ui/DataTable.module.scss';
|
||||
import styles from './ManageRequestsScreen.module.scss';
|
||||
import { Employee, ManagedApprovalRow, isEditableRowStatus } from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { toEmployeeOption } from '../../utils/employee';
|
||||
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||
import { ActionsCell, Badge, BadgeVariant, CellNote, IconButton, PrimaryCell, SearchableSelect } from '../ui';
|
||||
|
||||
export interface IManageRequestsPhaseTableProps {
|
||||
title: string;
|
||||
/** Строки ОДНОЙ фазы, уже отсортированные по order (см. ManageRequestsScreen). */
|
||||
rows: ManagedApprovalRow[];
|
||||
/** Заявка целиком только для просмотра (закрыта) — контролов нет вообще. */
|
||||
readOnly: boolean;
|
||||
busy: boolean;
|
||||
statusVariant: (status: string) => BadgeVariant;
|
||||
onReassign: (row: ManagedApprovalRow) => void;
|
||||
onRemove: (row: ManagedApprovalRow) => void;
|
||||
employees: Employee[];
|
||||
employeesLoading: boolean;
|
||||
/** Плейсхолдер плашки-пикера под таблицей — разный для согласования/исполнения. */
|
||||
addPlaceholder: string;
|
||||
/** Должность подставляется из справочника (Employee.position) — здесь не спрашивается. */
|
||||
onAddApprover: (email: string) => void;
|
||||
/** updates — только строки, у которых order реально изменился. */
|
||||
onReorder: (updates: Array<{ id: number; order: number }>) => void;
|
||||
}
|
||||
|
||||
type Row = ManagedApprovalRow & { parallelWithPrev: boolean };
|
||||
|
||||
function diffOrders(original: ManagedApprovalRow[], renumbered: Row[]): Array<{ id: number; order: number }> {
|
||||
const originalOrderById = new Map(original.map(r => [r.id, r.order]));
|
||||
return renumbered
|
||||
.filter(r => originalOrderById.get(r.id) !== r.order)
|
||||
.map(r => ({ id: r.id, order: r.order }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Таблица одной фазы маршрута с поддержкой перестановки шагов и параллельности —
|
||||
* повторяет request-portal/shared/approval/ui/ApprovalMatrix.tsx (та же идея:
|
||||
* колонка «Шаг» объединена по rowSpan на блок, кнопки ↑/↓ двигают блок целиком),
|
||||
* но с одним отличием: часть строк тут может быть УЖЕ РЕШЕНА (approved/executed/
|
||||
* rejected/returned) — такие блоки «заблокированы» и никогда не двигаются и не
|
||||
* участвуют в переключении параллельности. Блокировка распространяется на все
|
||||
* блоки НЕ ПОЗЖЕ последнего заблокированного — по построению маршрута (шаги
|
||||
* решаются по возрастанию order) заблокированные блоки всегда образуют
|
||||
* непрерывный префикс, поэтому «после последнего заблокированного» и есть та
|
||||
* самая граница, ниже которой перестановка запрещена (см. utils/orderBlocks.ts).
|
||||
*
|
||||
* Переназначение/удаление СТРОКИ (не блока) разрешено независимо от соседей —
|
||||
* если у одного из двух параллельных согласующих решение уже принято, а у
|
||||
* другого нет, второго всё ещё можно переназначить.
|
||||
*/
|
||||
export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps> = ({
|
||||
title,
|
||||
rows,
|
||||
readOnly,
|
||||
busy,
|
||||
statusVariant,
|
||||
onReassign,
|
||||
onRemove,
|
||||
employees,
|
||||
employeesLoading,
|
||||
addPlaceholder,
|
||||
onAddApprover,
|
||||
onReorder
|
||||
}) => {
|
||||
const withParallel = React.useMemo(() => markParallel(rows) as Row[], [rows]);
|
||||
const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]);
|
||||
const lockedBlock = React.useMemo(
|
||||
() => blocks.map(b => withParallel.slice(b.start, b.end + 1).some(r => !isEditableRowStatus(r.status))),
|
||||
[blocks, withParallel]
|
||||
);
|
||||
// Индекс последнего заблокированного блока — все блоки ПОСЛЕ него движимы,
|
||||
// все блоки НА НЁМ И РАНЬШЕ — нет (см. комментарий к компоненту).
|
||||
const lastLockedBlockIndex = lockedBlock.reduce((acc, locked, i) => (locked ? i : acc), -1);
|
||||
const firstMovableRowIndex = blocks[lastLockedBlockIndex + 1]?.start ?? -1;
|
||||
|
||||
// Фаза уже целиком решена (например, все согласующие одобрили, и заявка ушла
|
||||
// в статус «Выполнение») — добавлять в неё ещё один шаг бессмысленно, очередь
|
||||
// сюда больше не вернётся. Пустую фазу (rows.length===0) это не блокирует —
|
||||
// так можно завести самый первый шаг фазы, у которой согласующих ещё не было
|
||||
// вовсе.
|
||||
const phaseClosed = rows.length > 0 && rows.every(r => !isEditableRowStatus(r.status));
|
||||
|
||||
const applyReorder = (mutate: (rows: Row[]) => Row[]): void => {
|
||||
const renumbered = withOrdersFrom(mutate(withParallel), 0);
|
||||
const updates = diffOrders(rows, renumbered);
|
||||
if (updates.length > 0) onReorder(updates);
|
||||
};
|
||||
|
||||
const moveBlockUp = (blockIndex: number): void => {
|
||||
if (blockIndex <= lastLockedBlockIndex + 1) return; // упёрлись в заблокированный блок
|
||||
applyReorder(current => {
|
||||
const a = blocks[blockIndex - 1];
|
||||
const b = blocks[blockIndex];
|
||||
return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)];
|
||||
});
|
||||
};
|
||||
|
||||
const moveBlockDown = (blockIndex: number): void => {
|
||||
if (blockIndex >= blocks.length - 1) return;
|
||||
applyReorder(current => {
|
||||
const a = blocks[blockIndex];
|
||||
const b = blocks[blockIndex + 1];
|
||||
return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)];
|
||||
});
|
||||
};
|
||||
|
||||
const toggleParallel = (rowIndex: number): void => {
|
||||
if (rowIndex <= 0 || rowIndex === firstMovableRowIndex) return;
|
||||
applyReorder(current => current.map((r, i) => (i === rowIndex ? { ...r, parallelWithPrev: !r.parallelWithPrev } : r)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.phase}>
|
||||
<div className={styles.phaseHeader}>
|
||||
<span className={styles.phaseTitle}>{title}</span>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 && <div className={tableStyles.wrap}><div className={tableStyles.empty}>{texts.manageRequests.emptyPhase}</div></div>}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className={tableStyles.wrap}>
|
||||
<div className={tableStyles.scroll}>
|
||||
<table className={tableStyles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.stepCol}>{texts.manageRequests.columnOrder}</th>
|
||||
<th>{texts.manageRequests.columnApprover}</th>
|
||||
<th>{texts.manageRequests.columnPosition}</th>
|
||||
<th>{texts.manageRequests.columnStatus}</th>
|
||||
<th>{texts.manageRequests.columnDate}</th>
|
||||
{!readOnly && <th />}
|
||||
{!readOnly && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{withParallel.map((r, i) => {
|
||||
const blockIndex = blockIndexOf(blocks, i);
|
||||
const block = blocks[blockIndex];
|
||||
const isBlockStart = block.start === i;
|
||||
const movable = blockIndex > lastLockedBlockIndex;
|
||||
const rowEditable = isEditableRowStatus(r.status);
|
||||
const canToggle = rowEditable && i !== firstMovableRowIndex;
|
||||
// Отрицательный Id — призрачная строка (добавлена на клиенте,
|
||||
// ответ сервера с настоящим Id ещё не пришёл, см.
|
||||
// ManageRequestsScreen/handleAddApprover). Трогать её нельзя:
|
||||
// запрос на переназначение/удаление/перестановку ушёл бы с
|
||||
// несуществующим для SharePoint Id.
|
||||
const isGhost = r.id < 0;
|
||||
|
||||
return (
|
||||
<tr key={r.id} className={block.end > block.start ? styles.parallelRow : undefined}>
|
||||
{isBlockStart && (
|
||||
<td className={styles.stepCol} rowSpan={block.end - block.start + 1}>
|
||||
{r.parallelWithPrev || block.end > block.start ? (
|
||||
<span className={styles.parallelMark} title={texts.manageRequests.mergeWithPrev}>
|
||||
{r.order} ∥
|
||||
</span>
|
||||
) : (
|
||||
r.order
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<PrimaryCell>{r.approverName || texts.common.empty}</PrimaryCell>
|
||||
{r.approverEmail && <CellNote>{r.approverEmail}</CellNote>}
|
||||
</td>
|
||||
<td>{r.position || texts.common.empty}</td>
|
||||
<td><Badge variant={statusVariant(r.status)}>{r.status || texts.common.empty}</Badge></td>
|
||||
<td>{r.date || texts.common.empty}</td>
|
||||
{/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/
|
||||
переназначить/удалить), одна пара на весь блок (rowSpan): двигают
|
||||
шаг целиком, а не отдельного участника параллельной группы. */}
|
||||
{!readOnly && isBlockStart && (
|
||||
<td rowSpan={block.end - block.start + 1}>
|
||||
<ActionsCell>
|
||||
<IconButton
|
||||
icon="chevron-down"
|
||||
className={`${styles.iconBtnVisible} ${styles.flipY}`}
|
||||
label={texts.manageRequests.moveUp}
|
||||
disabled={busy || isGhost || !movable || blockIndex <= lastLockedBlockIndex + 1}
|
||||
onClick={() => moveBlockUp(blockIndex)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-down"
|
||||
className={styles.iconBtnVisible}
|
||||
label={texts.manageRequests.moveDown}
|
||||
disabled={busy || isGhost || !movable || blockIndex >= blocks.length - 1}
|
||||
onClick={() => moveBlockDown(blockIndex)}
|
||||
/>
|
||||
</ActionsCell>
|
||||
</td>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<td>
|
||||
{isGhost ? (
|
||||
<CellNote>{texts.manageRequests.ghostHint}</CellNote>
|
||||
) : rowEditable ? (
|
||||
<ActionsCell>
|
||||
<IconButton
|
||||
icon="people"
|
||||
className={styles.iconBtnVisible}
|
||||
label={r.parallelWithPrev ? texts.manageRequests.splitFromPrev : texts.manageRequests.mergeWithPrev}
|
||||
disabled={busy || !canToggle}
|
||||
onClick={() => toggleParallel(i)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="edit"
|
||||
className={styles.iconBtnVisible}
|
||||
label={texts.manageRequests.reassign}
|
||||
disabled={busy}
|
||||
onClick={() => onReassign(r)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
className={styles.iconBtnVisible}
|
||||
label={texts.manageRequests.remove}
|
||||
danger
|
||||
disabled={busy}
|
||||
onClick={() => onRemove(r)}
|
||||
/>
|
||||
</ActionsCell>
|
||||
) : (
|
||||
<CellNote>{texts.manageRequests.lockedHint}</CellNote>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Плашка-пикер под таблицей вместо кнопки+модалки — как UserPicker в
|
||||
request-portal/shared/approval/ui/ApprovalMatrix.tsx: выбрал сотрудника →
|
||||
шаг добавлен сразу, значение поля сбрасывается для следующего добавления.
|
||||
Должность отдельно не спрашивается — подставляется из справочника
|
||||
(см. onAddApprover в ManageRequestsScreen). */}
|
||||
{!readOnly && phaseClosed && (
|
||||
<div className={styles.addRow}>
|
||||
<CellNote>{texts.manageRequests.phaseClosedHint}</CellNote>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && !phaseClosed && (
|
||||
<div className={styles.addRow}>
|
||||
<SearchableSelect
|
||||
options={employees.map(toEmployeeOption)}
|
||||
value=""
|
||||
loading={employeesLoading}
|
||||
disabled={busy}
|
||||
placeholder={addPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={onAddApprover}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.matrix {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.phase {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.phase:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.phaseHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.phaseTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.requestHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
// Колонка с номером шага — узкая, содержимое по центру (важно при rowSpan на
|
||||
// параллельный блок: иначе номер прижимался бы к верхней строке блока).
|
||||
.stepCol {
|
||||
width: 56px;
|
||||
white-space: nowrap;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.parallelMark {
|
||||
color: $gold-600;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Строки одного параллельного блока — лёгкая заливка, чтобы объединённая
|
||||
// ячейка «Шаг» читалась как один блок, а не как случайно схлопнутые строки.
|
||||
.parallelRow > td {
|
||||
background: rgba($gold-500, .06);
|
||||
}
|
||||
|
||||
.flipY {
|
||||
svg { transform: scaleY(-1); }
|
||||
}
|
||||
|
||||
// IconButton по умолчанию (Button.module.scss/.iconButton) прозрачная — рамка
|
||||
// появляется только по hover. В этой таблице кнопок много и часть из них не
|
||||
// самоочевидна по смыслу (параллельность, перестановка шага), поэтому здесь
|
||||
// рамка видна всегда — иначе выглядит как текст, а не как элементы управления.
|
||||
// !important — переопределяет .iconButton независимо от порядка подключения
|
||||
// стилей (это два разных CSS-модуля, порядок их правил в бандле не гарантирован).
|
||||
.iconBtnVisible {
|
||||
border-color: $gray-300 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
// Плашка-пикер под таблицей для добавления шага (вместо кнопки+модалки) —
|
||||
// повторяет request-portal/shared/approval/ui/ApprovalMatrix.tsx/.matrixPicker.
|
||||
.addRow {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ManageRequestsScreen.module.scss';
|
||||
import { ManageRequestsApproverModal } from './ManageRequestsApproverModal';
|
||||
import { ManageRequestsPhaseTable } from './ManageRequestsPhaseTable';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { RequestManageService } from '../../services/requestManageService';
|
||||
import {
|
||||
APPROVAL_PHASE,
|
||||
ManagedApprovalRow,
|
||||
ManagedRequestMatrix,
|
||||
ManagedRequestSummary,
|
||||
REQUEST_STATUS,
|
||||
isFullyClosedRequestStatus
|
||||
} from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { findEmployeeByEmail } from '../../utils/employee';
|
||||
import { markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||
import {
|
||||
Alert,
|
||||
AsyncSearchableSelect,
|
||||
Badge,
|
||||
BadgeVariant,
|
||||
Breadcrumbs,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
FormGrid,
|
||||
PageHeader,
|
||||
Panel,
|
||||
PrimaryCell,
|
||||
Select
|
||||
} from '../ui';
|
||||
|
||||
export interface IManageRequestsScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
requestManage: RequestManageService;
|
||||
}
|
||||
|
||||
// Цвет плашки статуса — те же группы смысла, что и в request-portal
|
||||
// (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик.
|
||||
function statusVariant(status: string): BadgeVariant {
|
||||
if (status === 'Согласовано' || status === 'Выполнено' || status === 'Закрыта') return 'ok';
|
||||
if (status === 'Отклонено') return 'red';
|
||||
if (status === 'Возвращена на доработку') return 'gold';
|
||||
if (status === 'Черновик') return 'neutral';
|
||||
return 'blue'; // Ждёт очереди / На согласовании / На исполнении / Выполнение
|
||||
}
|
||||
|
||||
// Новый шаг всегда встаёт ПОСЛЕ всех существующих строк фазы — в т.ч. после уже
|
||||
// решённых. Перестановка (ManageRequestsPhaseTable) двигает существующие шаги,
|
||||
// но НИКОГДА не пускает их раньше последнего уже решённого блока — см.
|
||||
// utils/orderBlocks.ts. Вместе эти два правила и дают гарантию «нельзя
|
||||
// поставить согласующего выше того, кто уже одобрил заявку».
|
||||
function nextOrder(phaseRows: ManagedApprovalRow[]): number {
|
||||
return phaseRows.length > 0 ? Math.max(...phaseRows.map(r => r.order)) + 1 : 1;
|
||||
}
|
||||
|
||||
function patchRow(matrix: ManagedRequestMatrix, rowId: number, patch: Partial<ManagedApprovalRow>): ManagedRequestMatrix {
|
||||
return { ...matrix, rows: matrix.rows.map(r => (r.id === rowId ? { ...r, ...patch } : r)) };
|
||||
}
|
||||
|
||||
// После удаления шага у ОСТАВШИХСЯ шагов той же фазы order пересчитывается
|
||||
// заново по порядку (1..N), а не остаётся с дыркой на месте удалённого — та же
|
||||
// логика перестановки, что и в ManageRequestsPhaseTable (withOrdersFrom),
|
||||
// только вызывается отсюда, т.к. удаление живёт на уровне экрана. Renumber'ит
|
||||
// ВСЮ фазу, включая уже решённые строки: их order на активацию очереди больше
|
||||
// не влияет (activateNextQueued смотрит только на queued/onApproval/
|
||||
// onExecution), так что перенумеровать их безопасно — а без этого дырка от
|
||||
// удалённого решённого шага так и осталась бы видна в колонке «Шаг».
|
||||
function renumberPhase(phaseRows: ManagedApprovalRow[]): Array<{ id: number; order: number }> {
|
||||
const sorted = [...phaseRows].sort((a, b) => a.order - b.order);
|
||||
const renumbered = withOrdersFrom(markParallel(sorted), 0);
|
||||
return renumbered
|
||||
.filter((r, i) => r.order !== sorted[i].order)
|
||||
.map(r => ({ id: r.id, order: r.order }));
|
||||
}
|
||||
|
||||
// Что выбрано в комбобоксах «Тип заявки»/«Заявка» — переживает перезагрузку
|
||||
// страницы (localStorage), чтобы не искать одну и ту же заявку заново после
|
||||
// каждого F5. selectedRequest хранится целиком (не только id), чтобы сразу
|
||||
// показать подпись в AsyncSearchableSelect без похода в API на восстановление.
|
||||
interface IPersistedSelection {
|
||||
requestTypeId: string;
|
||||
selectedRequest: ManagedRequestSummary | undefined;
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION: IPersistedSelection = { requestTypeId: '', selectedRequest: undefined };
|
||||
const SELECTION_STORAGE_KEY = 'approvalAdmin.manageRequestsSelection';
|
||||
|
||||
// Раз в 2 минуты тихо (без «Загрузка…» на весь экран) перечитываем матрицу
|
||||
// открытой заявки — на неё мог повлиять кто-то другой (согласующий принял
|
||||
// решение через request-portal), а вкладка могла быть открыта давно.
|
||||
const AUTO_REFRESH_MS = 2 * 60 * 1000;
|
||||
|
||||
export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api, dictionary, requestManage }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
|
||||
const [selection, setSelection] = useLocalStorageState<IPersistedSelection>(SELECTION_STORAGE_KEY, EMPTY_SELECTION);
|
||||
const { requestTypeId, selectedRequest } = selection;
|
||||
const [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>();
|
||||
const [matrixLoading, setMatrixLoading] = React.useState(false);
|
||||
const [matrixError, setMatrixError] = React.useState<string | undefined>();
|
||||
const [modal, setModal] = React.useState<ManagedApprovalRow | undefined>();
|
||||
const [deleteRow, setDeleteRow] = React.useState<ManagedApprovalRow | undefined>();
|
||||
const [saveError, setSaveError] = React.useState<string | undefined>();
|
||||
// Отрицательные Id — «призрачные» строки: добавлены на клиенте, ответ сервера
|
||||
// (и настоящий Id из SharePoint) ещё не долетел. Реальные Id списка всегда
|
||||
// положительные, поэтому пересечься эти диапазоны не могут.
|
||||
const nextGhostId = React.useRef(-1);
|
||||
// Идёт фоновая запись (перестановка/переключение параллельности) — блокирует
|
||||
// ТОЛЬКО повторную перестановку, чтобы не отправить второй reorder поверх ещё
|
||||
// не завершённого (порядок мог бы разъехаться при гонке двух запросов).
|
||||
// Переназначение/удаление/добавление такой блокировки не требуют: они не
|
||||
// трогают order соседних строк, поэтому гонок между собой не создают.
|
||||
const [reordering, setReordering] = React.useState(false);
|
||||
|
||||
const requestType = requestTypes.items.filter(rt => rt.id === Number(requestTypeId))[0];
|
||||
// Только типы с привязанным списком SharePoint — без list_id/site_url искать
|
||||
// и читать заявки этого типа отсюда просто негде.
|
||||
const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl);
|
||||
|
||||
const selectRequestType = (id: string): void => {
|
||||
// Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит
|
||||
// показывать заявку не того типа, что выбран сейчас.
|
||||
setSelection({ requestTypeId: id, selectedRequest: undefined });
|
||||
setMatrix(undefined);
|
||||
setMatrixError(undefined);
|
||||
};
|
||||
|
||||
const selectRequest = (item: ManagedRequestSummary | undefined): void =>
|
||||
setSelection(prev => ({ ...prev, selectedRequest: item }));
|
||||
|
||||
const searchRequests = React.useCallback(
|
||||
(query: string, page: number) => {
|
||||
if (!requestType) return Promise.resolve({ items: [], hasMore: false });
|
||||
return requestManage.searchRequests(requestType, query, page);
|
||||
},
|
||||
[requestManage, requestType]
|
||||
);
|
||||
|
||||
// silent=true — фоновое обновление (после своего же действия, автообновление
|
||||
// каждые 2 мин): матрица подменяется на новую БЕЗ показа полноэкранного
|
||||
// «Загрузка…», чтобы клик по кнопке не выглядел как перезагрузка страницы.
|
||||
// silent=false — только при выборе ДРУГОЙ заявки/типа: там показывать
|
||||
// загрузку нормально, экран и так пуст.
|
||||
const reloadMatrix = React.useCallback(
|
||||
(silent = false) => {
|
||||
if (!requestType || !selectedRequest) return;
|
||||
if (!silent) setMatrixLoading(true);
|
||||
setMatrixError(undefined);
|
||||
requestManage
|
||||
.getMatrix(requestType, selectedRequest.id)
|
||||
.then(setMatrix)
|
||||
.catch((e: Error) => setMatrixError(e.message))
|
||||
.finally(() => {
|
||||
if (!silent) setMatrixLoading(false);
|
||||
});
|
||||
},
|
||||
[requestManage, requestType, selectedRequest]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMatrix(undefined);
|
||||
reloadMatrix();
|
||||
}, [reloadMatrix]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden) reloadMatrix(true);
|
||||
}, AUTO_REFRESH_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [reloadMatrix]);
|
||||
|
||||
// Удаление — строка убирается из матрицы СРАЗУ по подтверждению, не дожидаясь
|
||||
// ответа сервера (запрос уходит фоном). Если он всё же упадёт — строку
|
||||
// возвращаем обратно и показываем ошибку. useDeleteTarget тут не подходит:
|
||||
// тот хук держит диалог открытым со спиннером до ответа сервера, а нужно
|
||||
// ровно наоборот — закрыть диалог и убрать строку мгновенно.
|
||||
const askDelete = (row: ManagedApprovalRow): void => setDeleteRow(row);
|
||||
const cancelDelete = (): void => setDeleteRow(undefined);
|
||||
|
||||
const confirmDelete = (): void => {
|
||||
if (!matrix || !deleteRow) return;
|
||||
const row = deleteRow;
|
||||
setDeleteRow(undefined);
|
||||
setSaveError(undefined);
|
||||
|
||||
const previous = matrix;
|
||||
const remainingPhaseRows = matrix.rows.filter(r => r.phase === row.phase && r.id !== row.id);
|
||||
const renumberUpdates = renumberPhase(remainingPhaseRows);
|
||||
const orderById = new Map(renumberUpdates.map(u => [u.id, u.order]));
|
||||
|
||||
setMatrix({
|
||||
...matrix,
|
||||
rows: matrix.rows
|
||||
.filter(r => r.id !== row.id)
|
||||
.map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r))
|
||||
});
|
||||
|
||||
requestManage
|
||||
.removeRow(matrix, row.id)
|
||||
.then(() => {
|
||||
if (renumberUpdates.length === 0) return;
|
||||
// Удаление уже прошло — откатывать нечего, даже если это пересохранение
|
||||
// номеров не удастся. Просто сообщаем об ошибке: строка гарантированно
|
||||
// удалена, а нумерация в SharePoint в худшем случае останется с дыркой
|
||||
// (что чинится обычной перестановкой любого шага той же фазы).
|
||||
return requestManage.reorderRows(matrix, renumberUpdates).catch((e: Error) => setSaveError(e.message));
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
setMatrix(previous);
|
||||
setSaveError(e.message);
|
||||
});
|
||||
};
|
||||
|
||||
// Заявка полностью закрыта — маршрут только для просмотра целиком, независимо
|
||||
// от статуса отдельных строк (см. isFullyClosedRequestStatus).
|
||||
const readOnly = matrix ? isFullyClosedRequestStatus(matrix.request.status) : true;
|
||||
|
||||
const approvalRows = matrix ? matrix.rows.filter(r => r.phase !== APPROVAL_PHASE.execution) : [];
|
||||
const executionRows = matrix ? matrix.rows.filter(r => r.phase === APPROVAL_PHASE.execution) : [];
|
||||
|
||||
const openReassign = (row: ManagedApprovalRow): void => setModal(row);
|
||||
const closeModal = (): void => setModal(undefined);
|
||||
|
||||
// Переназначение — тоже оптимистично: patch конкретной строки сразу по ответу
|
||||
// сервера, без похода за всей матрицей заново. approverName берём из уже
|
||||
// загруженного справочника сотрудников (тот же источник, что подставляет
|
||||
// должность, — см. ManageRequestsApproverModal), чтобы не делать лишний
|
||||
// запрос только за отображаемым именем.
|
||||
const saveModal = (email: string, position: string): Promise<void> => {
|
||||
if (!matrix || !modal) return Promise.resolve();
|
||||
setSaveError(undefined);
|
||||
const rowId = modal.id;
|
||||
return requestManage
|
||||
.reassignApprover(matrix, rowId, email, position)
|
||||
.then(() => {
|
||||
const found = findEmployeeByEmail(employees.items, email);
|
||||
setMatrix(prev =>
|
||||
prev ? patchRow(prev, rowId, { approverEmail: email, approverName: found?.fio || email, position }) : prev
|
||||
);
|
||||
closeModal();
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
setSaveError(e.message);
|
||||
throw e;
|
||||
});
|
||||
};
|
||||
|
||||
// Добавление нового шага — плашка-пикер под таблицей (ManageRequestsPhaseTable),
|
||||
// без модалки (как в request-portal). Строка появляется в матрице СРАЗУ, с
|
||||
// временным отрицательным Id («призрачная» — см. nextGhostId и isGhostRow в
|
||||
// ManageRequestsPhaseTable, там ей запрещены переназначение/удаление/
|
||||
// перестановка, пока она не подтверждена сервером). Настоящий Id из
|
||||
// SharePoint долетает следом через тихую фоновую перезагрузку, которая
|
||||
// подменяет призрачную строку настоящей. Если запрос упадёт — просто
|
||||
// убираем призрак и показываем ошибку.
|
||||
const handleAddApprover = (phase: string, email: string): void => {
|
||||
if (!matrix) return;
|
||||
setSaveError(undefined);
|
||||
const phaseRows = phase === APPROVAL_PHASE.execution ? executionRows : approvalRows;
|
||||
const found = findEmployeeByEmail(employees.items, email);
|
||||
const position = found?.position || '';
|
||||
const order = nextOrder(phaseRows);
|
||||
const ghostId = nextGhostId.current--;
|
||||
|
||||
// Новая строка всегда «Ждёт очереди» — request-portal переводит её в «На
|
||||
// согласовании»/«На исполнении» через activateNextQueued при следующем
|
||||
// решении согласующего или повторной отправке формы (см.
|
||||
// request-portal/shared/approval/approvalList.service.ts). Если это ПЕРВЫЙ
|
||||
// шаг фазы (например, добавили исполнителя в заявку, где фазы исполнения
|
||||
// раньше не было вовсе) — activateNextQueued не сработает сам по себе, пока
|
||||
// кто-то не примет решение по заявке; шаг до этого момента просто ждёт.
|
||||
const ghostRow: ManagedApprovalRow = {
|
||||
id: ghostId,
|
||||
ticketId: matrix.request.id,
|
||||
approverId: null,
|
||||
approverName: found?.fio || email,
|
||||
approverEmail: email,
|
||||
position,
|
||||
status: REQUEST_STATUS.queued,
|
||||
date: '',
|
||||
comment: '',
|
||||
order,
|
||||
allowReassign: false,
|
||||
phase
|
||||
};
|
||||
|
||||
const previous = matrix;
|
||||
setMatrix({ ...matrix, rows: [...matrix.rows, ghostRow] });
|
||||
|
||||
requestManage
|
||||
.addRow(matrix, matrix.request.id, phase, order, REQUEST_STATUS.queued, email, position, false)
|
||||
.then(() => reloadMatrix(true))
|
||||
.catch((e: Error) => {
|
||||
setMatrix(previous);
|
||||
setSaveError(e.message);
|
||||
});
|
||||
};
|
||||
|
||||
// Перестановка/параллельность — оптимистично: order из ManageRequestsPhaseTable
|
||||
// применяется к матрице СРАЗУ (клик по стрелке/значку виден мгновенно), запрос
|
||||
// на запись уходит фоном. Если он упадёт — откатываем к состоянию до клика и
|
||||
// показываем ошибку; если пройдёт — тихо перечитываем матрицу (не меняя
|
||||
// видимого состояния, если её никто больше не менял, но подхватит, если менял).
|
||||
const handleReorder = (updates: Array<{ id: number; order: number }>): void => {
|
||||
if (!matrix || reordering) return;
|
||||
setSaveError(undefined);
|
||||
|
||||
const previous = matrix;
|
||||
const orderById = new Map(updates.map(u => [u.id, u.order]));
|
||||
setMatrix({ ...matrix, rows: matrix.rows.map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r)) });
|
||||
|
||||
setReordering(true);
|
||||
requestManage
|
||||
.reorderRows(matrix, updates)
|
||||
.then(() => reloadMatrix(true))
|
||||
.catch((e: Error) => {
|
||||
setMatrix(previous);
|
||||
setSaveError(e.message);
|
||||
})
|
||||
.finally(() => setReordering(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.manageRequests.breadcrumb }]} />
|
||||
<PageHeader icon="clipboard" subtitle={texts.manageRequests.subtitle} title={texts.manageRequests.title} />
|
||||
|
||||
<Panel>
|
||||
<FormGrid>
|
||||
<Field
|
||||
label={texts.manageRequests.requestTypeLabel}
|
||||
required
|
||||
hint={!requestTypes.isLoading && manageableRequestTypes.length === 0 ? texts.manageRequests.requestTypeEmpty : undefined}
|
||||
>
|
||||
<Select
|
||||
options={manageableRequestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder={texts.manageRequests.requestTypePlaceholder}
|
||||
value={requestTypeId}
|
||||
onChange={e => selectRequestType(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.manageRequests.requestLabel} required hint={!requestType ? texts.manageRequests.requestDisabledHint : undefined}>
|
||||
<AsyncSearchableSelect<ManagedRequestSummary>
|
||||
key={requestTypeId || 'none'}
|
||||
value={selectedRequest ? String(selectedRequest.id) : ''}
|
||||
selectedLabel={selectedRequest ? `#${selectedRequest.id} ${selectedRequest.title}` : 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}`}
|
||||
getSublabel={r => r.status}
|
||||
onChange={(_, item) => selectRequest(item)}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</Panel>
|
||||
|
||||
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
||||
{matrixError && <Alert kind="err">{texts.manageRequests.loadError}: {matrixError}</Alert>}
|
||||
{saveError && <Alert kind="err">{texts.manageRequests.saveError}: {saveError}</Alert>}
|
||||
|
||||
{matrixLoading && <Panel>{texts.common.loading}</Panel>}
|
||||
|
||||
{!matrixLoading && matrix && (
|
||||
<Panel>
|
||||
<div className={styles.requestHeader}>
|
||||
<PrimaryCell>{matrix.request.title}</PrimaryCell>
|
||||
<Badge variant={statusVariant(matrix.request.status)}>{matrix.request.status || texts.common.empty}</Badge>
|
||||
</div>
|
||||
|
||||
{readOnly && <Alert kind="warn">{texts.manageRequests.fullyClosedNotice}</Alert>}
|
||||
{!matrix.approvalListTitle && <Alert kind="warn">{texts.manageRequests.noApprovalList}</Alert>}
|
||||
|
||||
{matrix.approvalListTitle && (
|
||||
<div className={styles.matrix}>
|
||||
<ManageRequestsPhaseTable
|
||||
title={texts.manageRequests.approvalPhaseTitle}
|
||||
rows={approvalRows}
|
||||
readOnly={readOnly}
|
||||
busy={reordering}
|
||||
statusVariant={statusVariant}
|
||||
onReassign={openReassign}
|
||||
onRemove={askDelete}
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
addPlaceholder={texts.manageRequests.addApproverPlaceholder}
|
||||
onAddApprover={email => handleAddApprover(APPROVAL_PHASE.approval, email)}
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
<ManageRequestsPhaseTable
|
||||
title={texts.manageRequests.executionPhaseTitle}
|
||||
rows={executionRows}
|
||||
readOnly={readOnly}
|
||||
busy={reordering}
|
||||
statusVariant={statusVariant}
|
||||
onReassign={openReassign}
|
||||
onRemove={askDelete}
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
addPlaceholder={texts.manageRequests.addExecutorPlaceholder}
|
||||
onAddApprover={email => handleAddApprover(APPROVAL_PHASE.execution, email)}
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<ManageRequestsApproverModal
|
||||
title={texts.manageRequests.reassignTitle}
|
||||
initialEmail={modal.approverEmail}
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
onSave={saveModal}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteRow && (
|
||||
<ConfirmDialog
|
||||
title={texts.manageRequests.deleteTitle}
|
||||
text={texts.manageRequests.deleteText(deleteRow.position)}
|
||||
confirmLabel={texts.common.remove}
|
||||
onCancel={cancelDelete}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -28,4 +28,8 @@ export interface Project {
|
||||
export interface Employee {
|
||||
fio: string;
|
||||
email: string;
|
||||
/** Должность из 1С (AD) — '' если не заполнена в источнике. Используется как
|
||||
* подсказка при выборе сотрудника (см. ManageRequestsApproverModal), не для
|
||||
* отображения в списке (сам справочник должностей не показывает). */
|
||||
position: string;
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './requestTypes';
|
||||
export * from './templateRoutes';
|
||||
export * from './templateRouteSteps';
|
||||
export * from './resolve';
|
||||
export * from './requestManage';
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Домен вкладки «Редактирование заявок»: живёт целиком в SharePoint-списках
|
||||
// «<форма> — данные» / «<форма> — согласование», которыми управляет
|
||||
// request-portal (см. request_type.list_id/site_url — ссылки именно на список
|
||||
// данных; список согласования вычисляется по конвенции имени, см. approvalListTitle
|
||||
// в requestManageService.ts). Это НЕ сущности Tnsu.ApprovalRoute.Api — совпадений
|
||||
// с models/templateRoutes.ts нет, здесь другое хранилище.
|
||||
//
|
||||
// Значения статусов — Choice-колонки SharePoint, 1:1 с
|
||||
// request-portal/src/webparts/requestPortal/shared/approval/models/statuses.ts
|
||||
// (тот же самый список, то же самое хранилище — просто другой клиент).
|
||||
export const REQUEST_STATUS = {
|
||||
draft: 'Черновик',
|
||||
onApproval: 'На согласовании',
|
||||
queued: 'Ждёт очереди',
|
||||
onExecution: 'На исполнении',
|
||||
executed: 'Выполнено',
|
||||
approved: 'Согласовано',
|
||||
rejected: 'Отклонено',
|
||||
returned: 'Возвращена на доработку',
|
||||
execution: 'Выполнение',
|
||||
closed: 'Закрыта'
|
||||
} as const;
|
||||
|
||||
export const APPROVAL_PHASE = {
|
||||
approval: 'Согласование',
|
||||
execution: 'Исполнение'
|
||||
} as const;
|
||||
|
||||
// Строка маршрута в одном из этих статусов ЕЩЁ НЕ решена согласующим/исполнителем
|
||||
// ОКОНЧАТЕЛЬНО — её можно менять (переназначить человека, удалить, переставить,
|
||||
// добавить новую рядом). «Возвращена на доработку» сюда включена нарочно: это не
|
||||
// финальное решение (заявка вернётся на новый круг после доработки), поэтому
|
||||
// согласующего на этом шаге всё ещё можно переназначить. approved/executed/
|
||||
// rejected — финальные решения, трогать нельзя ни при каких условиях.
|
||||
const EDITABLE_ROW_STATUSES: string[] = [
|
||||
REQUEST_STATUS.draft,
|
||||
REQUEST_STATUS.queued,
|
||||
REQUEST_STATUS.onApproval,
|
||||
REQUEST_STATUS.onExecution,
|
||||
REQUEST_STATUS.returned
|
||||
];
|
||||
|
||||
export function isEditableRowStatus(status: string): boolean {
|
||||
return EDITABLE_ROW_STATUSES.indexOf(status) !== -1;
|
||||
}
|
||||
|
||||
// Заявка в этом статусе прошла полностью (все фазы завершены) — маршрут можно
|
||||
// только посмотреть, редактирование выключается целиком независимо от статусов
|
||||
// отдельных строк.
|
||||
export function isFullyClosedRequestStatus(status: string): boolean {
|
||||
return status === REQUEST_STATUS.closed;
|
||||
}
|
||||
|
||||
export interface ManagedRequestSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
created: string; // ISO
|
||||
authorName: string;
|
||||
}
|
||||
|
||||
export interface ManagedRequestPage {
|
||||
items: ManagedRequestSummary[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// Строка списка «… — согласование». approverId/approverEmail нужны и для
|
||||
// отображения, и как исходное значение при открытии формы переназначения.
|
||||
export interface ManagedApprovalRow {
|
||||
id: number;
|
||||
ticketId: number;
|
||||
approverId: number | null;
|
||||
approverName: string;
|
||||
approverEmail: string;
|
||||
position: string;
|
||||
status: string;
|
||||
date: string;
|
||||
comment: string;
|
||||
order: number;
|
||||
allowReassign: boolean;
|
||||
phase: string;
|
||||
}
|
||||
|
||||
export interface ManagedRequestMatrix {
|
||||
request: ManagedRequestSummary;
|
||||
/** Абсолютный URL сайта, где лежат списки данных/согласования этого типа заявки. */
|
||||
siteUrl: string;
|
||||
/** Title списка «… — согласование» (вычислен из Title списка данных). */
|
||||
approvalListTitle: string;
|
||||
/** Обе фазы вместе, упорядочены по StepOrder. */
|
||||
rows: ManagedApprovalRow[];
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export interface IProjectSearchResult {
|
||||
interface IPortalEmployee {
|
||||
fio: string;
|
||||
email: string | null;
|
||||
position: string | null;
|
||||
}
|
||||
|
||||
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
|
||||
@@ -107,7 +108,15 @@ export class PortalDictionaryClient {
|
||||
|
||||
const byEmail = new Map<string, Employee>();
|
||||
(raw ?? []).forEach(x => {
|
||||
if (x.email && !byEmail.has(x.email)) byEmail.set(x.email, { fio: x.fio, email: x.email });
|
||||
if (!x.email) return;
|
||||
const existing = byEmail.get(x.email);
|
||||
if (!existing) {
|
||||
byEmail.set(x.email, { fio: x.fio, email: x.email, position: x.position || '' });
|
||||
} else if (!existing.position && x.position) {
|
||||
// Один сотрудник — строка на каждый его отдел; должность могла найтись
|
||||
// не в первой из них.
|
||||
existing.position = x.position;
|
||||
}
|
||||
});
|
||||
return Array.from(byEmail.values());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { spfi, SPFI, SPFx } from '@pnp/sp';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import '@pnp/sp/site-users/web';
|
||||
import '@pnp/sp/search';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import {
|
||||
APPROVAL_PHASE,
|
||||
ManagedApprovalRow,
|
||||
ManagedRequestMatrix,
|
||||
ManagedRequestPage,
|
||||
ManagedRequestSummary,
|
||||
RequestType
|
||||
} from '../models';
|
||||
|
||||
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
|
||||
// (конвенция имён; см. request-portal/src/webparts/requestPortal/forms/*/provisioning.json
|
||||
// и forms/*/approval/provisioning.json — этот сервис читает и пишет ровно те же
|
||||
// списки SharePoint, только с другого клиента).
|
||||
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||
|
||||
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
|
||||
// request-portal/src/webparts/requestPortal/shared/approval/approvalList.service.ts.
|
||||
// Это тот же самый список SharePoint, поэтому имена колонок должны совпадать
|
||||
// буквально — здесь их не выбирают заново, а просто дублируют.
|
||||
const FIELDS = {
|
||||
approver: 'TansuSiteApprover',
|
||||
status: 'RequestStatus',
|
||||
ticket: 'Ticket',
|
||||
date: 'Date',
|
||||
comment: 'Comment',
|
||||
order: 'StepOrder',
|
||||
allowReassign: 'AllowReassignToOtherApprovers',
|
||||
phase: 'Phase'
|
||||
};
|
||||
|
||||
function siteOrigin(siteUrl: string): string {
|
||||
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
|
||||
return match ? match[0] : siteUrl;
|
||||
}
|
||||
|
||||
function listAbsUrl(siteUrl: string, listId: string): string {
|
||||
return `${siteOrigin(siteUrl)}${listId}`;
|
||||
}
|
||||
|
||||
// KQL не переносит большинство спецсимволов в свободном терме — оставляем буквы
|
||||
// (лат./кир.), цифры и пробелы. Та же логика, что в request-portal
|
||||
// (features/myRequests/myRequests.service.ts/sanitizeForKql).
|
||||
function sanitizeForKql(text: string): string {
|
||||
return text.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function freeTextClause(query: string): string | undefined {
|
||||
const q = sanitizeForKql(query);
|
||||
if (!q) return undefined;
|
||||
return `(${q.split(' ').map(w => `${w}*`).join(' AND ')})`;
|
||||
}
|
||||
|
||||
function formatDateIso(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
|
||||
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
|
||||
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
interface IRawDataRow {
|
||||
Id: number;
|
||||
Title?: string;
|
||||
Status?: string;
|
||||
Created?: string;
|
||||
Author?: { Title?: string };
|
||||
}
|
||||
|
||||
function toSummary(r: IRawDataRow): ManagedRequestSummary {
|
||||
return {
|
||||
id: r.Id,
|
||||
title: r.Title || `#${r.Id}`,
|
||||
status: r.Status || '',
|
||||
created: r.Created || '',
|
||||
authorName: r.Author?.Title || ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Чтение/правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint —
|
||||
* в отличие от остального этого веб-парта (который говорит с Tnsu.ApprovalRoute.Api),
|
||||
* данные заявок и строки маршрута живут только в списках SharePoint, которыми
|
||||
* управляет request-portal. У бэкенда этого API нет и не будет: типы заявок
|
||||
* знают только list_id/site_url (см. models/requestTypes.ts), содержимое списков
|
||||
* ему не принадлежит.
|
||||
*/
|
||||
export class RequestManageService {
|
||||
constructor(private readonly context: WebPartContext) {}
|
||||
|
||||
private clientFor(siteUrl: string): SPFI {
|
||||
return spfi(siteUrl).using(SPFx(this.context));
|
||||
}
|
||||
|
||||
// Title списка данных → Title списка согласования. undefined, если Title не
|
||||
// оканчивается на «— данные» (список заведён не по конвенции request-portal,
|
||||
// управлять маршрутом для него отсюда нельзя).
|
||||
private async approvalListTitle(sp: SPFI, listId: string): Promise<string | undefined> {
|
||||
const info = (await sp.web.getList(listId).select('Title')()) as { Title: string };
|
||||
if (!DATA_LIST_SUFFIX.test(info.Title)) return undefined;
|
||||
return `${info.Title.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Заявки типа requestType постранично, для комбобокса выбора заявки. Сначала
|
||||
* Search по названию (свободный терм, пустой query — просто последние без
|
||||
* фильтра), затем дочитывание полей REST'ом по найденным Id — Search не
|
||||
* отдаёт произвольные поля списка надёжно (та же двухшаговая схема, что в
|
||||
* request-portal).
|
||||
*/
|
||||
public async searchRequests(
|
||||
requestType: RequestType,
|
||||
query: string,
|
||||
page: number,
|
||||
pageSize = 20
|
||||
): Promise<ManagedRequestPage> {
|
||||
if (!requestType.listId || !requestType.siteUrl) return { items: [], hasMore: false };
|
||||
|
||||
const sp = this.clientFor(requestType.siteUrl);
|
||||
const absUrl = listAbsUrl(requestType.siteUrl, requestType.listId);
|
||||
const clauses = [`Path:"${absUrl}*"`];
|
||||
const text = freeTextClause(query);
|
||||
if (text) clauses.push(text);
|
||||
|
||||
const result = await sp.search({
|
||||
Querytext: clauses.join(' AND '),
|
||||
SelectProperties: ['ListItemID', 'Path'],
|
||||
RowLimit: pageSize,
|
||||
StartRow: (page - 1) * pageSize,
|
||||
TrimDuplicates: false,
|
||||
SortList: [{ Property: 'Created', Direction: 1 }]
|
||||
});
|
||||
|
||||
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string }>;
|
||||
const ids = rawRows.map(r => Number(r.ListItemID)).filter(id => !isNaN(id) && id > 0);
|
||||
if (ids.length === 0) return { items: [], hasMore: false };
|
||||
|
||||
const list = sp.web.getList(requestType.listId);
|
||||
const dataRows = (await list.items
|
||||
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
|
||||
.select('Id', 'Title', 'Status', 'Created', 'Author/Title')
|
||||
.expand('Author')
|
||||
.top(ids.length)()) as IRawDataRow[];
|
||||
|
||||
const byId = new Map(dataRows.map(r => [r.Id, r]));
|
||||
// Порядок задаёт Search (SortList по дате), не порядок ответа REST.
|
||||
const items = ids
|
||||
.map(id => byId.get(id))
|
||||
.filter((r): r is IRawDataRow => !!r)
|
||||
.map(toSummary);
|
||||
|
||||
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
|
||||
return { items, hasMore: page * pageSize < totalRows };
|
||||
}
|
||||
|
||||
/** Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования. */
|
||||
public async getMatrix(requestType: RequestType, requestId: number): Promise<ManagedRequestMatrix> {
|
||||
if (!requestType.listId || !requestType.siteUrl) {
|
||||
throw new Error('У типа заявки не указан список SharePoint.');
|
||||
}
|
||||
const sp = this.clientFor(requestType.siteUrl);
|
||||
const dataList = sp.web.getList(requestType.listId);
|
||||
|
||||
const [requestRow, approvalListTitle] = await Promise.all([
|
||||
dataList.items
|
||||
.getById(requestId)
|
||||
.select('Id', 'Title', 'Status', 'Created', 'Author/Title')
|
||||
.expand('Author')() as Promise<IRawDataRow>,
|
||||
this.approvalListTitle(sp, requestType.listId)
|
||||
]);
|
||||
|
||||
if (!approvalListTitle) {
|
||||
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, approvalListTitle: '', rows: [] };
|
||||
}
|
||||
|
||||
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
||||
const rawRows = (await approvalList.items
|
||||
.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
||||
.select(
|
||||
'Id',
|
||||
'Title',
|
||||
FIELDS.status,
|
||||
FIELDS.date,
|
||||
FIELDS.comment,
|
||||
FIELDS.order,
|
||||
FIELDS.allowReassign,
|
||||
FIELDS.phase,
|
||||
`${FIELDS.approver}/Id`,
|
||||
`${FIELDS.approver}/Title`,
|
||||
`${FIELDS.approver}/EMail`
|
||||
)
|
||||
.expand(FIELDS.approver)
|
||||
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||
|
||||
const rows: ManagedApprovalRow[] = rawRows.map((r, i) => {
|
||||
const approver = r[FIELDS.approver] as { Id?: number; Title?: string; EMail?: string } | undefined;
|
||||
return {
|
||||
id: r.Id as number,
|
||||
ticketId: requestId,
|
||||
approverId: approver?.Id ?? null,
|
||||
approverName: approver?.Title || '',
|
||||
approverEmail: approver?.EMail || '',
|
||||
position: (r.Title as string) || '',
|
||||
status: (r[FIELDS.status] as string) || '',
|
||||
date: formatDateIso((r[FIELDS.date] as string) || ''),
|
||||
comment: (r[FIELDS.comment] as string) || '',
|
||||
order: (r[FIELDS.order] as number) ?? i + 1,
|
||||
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval
|
||||
};
|
||||
});
|
||||
|
||||
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, approvalListTitle, rows };
|
||||
}
|
||||
|
||||
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
|
||||
private async resolveUserId(sp: SPFI, email: string): Promise<number> {
|
||||
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
|
||||
const user = await sp.web.ensureUser(login);
|
||||
return user.Id;
|
||||
}
|
||||
|
||||
/** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана
|
||||
* проверить isEditableRowStatus(row.status) — сервис это не перепроверяет. */
|
||||
public async reassignApprover(
|
||||
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||
rowId: number,
|
||||
email: string,
|
||||
position: string
|
||||
): Promise<void> {
|
||||
const sp = this.clientFor(matrix.siteUrl);
|
||||
const approverId = await this.resolveUserId(sp, email);
|
||||
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({
|
||||
Title: position,
|
||||
[`${FIELDS.approver}Id`]: approverId
|
||||
});
|
||||
}
|
||||
|
||||
/** Удалить строку маршрута. */
|
||||
public async removeRow(
|
||||
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||
rowId: number
|
||||
): Promise<void> {
|
||||
const sp = this.clientFor(matrix.siteUrl);
|
||||
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавить новую строку в фазу заявки. order считает вызывающая сторона
|
||||
* (см. ManageRequestsScreen) — он обязан быть больше order любой уже решённой
|
||||
* строки той же фазы (иначе новый согласующий встанет впереди того, кто уже
|
||||
* согласовал, а это правило запрещено интерфейсом).
|
||||
*/
|
||||
public async addRow(
|
||||
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||
ticketId: number,
|
||||
phase: string,
|
||||
order: number,
|
||||
status: string,
|
||||
email: string,
|
||||
position: string,
|
||||
allowReassign: boolean
|
||||
): Promise<void> {
|
||||
const sp = this.clientFor(matrix.siteUrl);
|
||||
const approverId = await this.resolveUserId(sp, email);
|
||||
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.add({
|
||||
Title: position || email,
|
||||
[`${FIELDS.approver}Id`]: approverId,
|
||||
[FIELDS.status]: status,
|
||||
[`${FIELDS.ticket}Id`]: ticketId,
|
||||
[FIELDS.order]: order,
|
||||
[FIELDS.allowReassign]: allowReassign,
|
||||
[FIELDS.phase]: phase
|
||||
});
|
||||
}
|
||||
|
||||
/** Переставить StepOrder нескольких строк разом (перестановка шагов местами). */
|
||||
public async reorderRows(
|
||||
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||
updates: Array<{ id: number; order: number }>
|
||||
): Promise<void> {
|
||||
const sp = this.clientFor(matrix.siteUrl);
|
||||
const list = sp.web.lists.getByTitle(matrix.approvalListTitle);
|
||||
await Promise.all(updates.map(u => list.items.getById(u.id).update({ [FIELDS.order]: u.order })));
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,8 @@ export const texts = {
|
||||
dashboard: 'Дашборд',
|
||||
requestTypes: 'Типы заявок',
|
||||
routes: 'Маршруты',
|
||||
resolve: 'Проверка маршрута'
|
||||
resolve: 'Проверка маршрута',
|
||||
manageRequests: 'Редактирование заявок'
|
||||
},
|
||||
|
||||
app: {
|
||||
@@ -269,6 +270,53 @@ export const texts = {
|
||||
emptySteps: 'Маршрут разрешился без шагов для этого статуса.'
|
||||
},
|
||||
|
||||
manageRequests: {
|
||||
breadcrumb: 'Редактирование заявок',
|
||||
subtitle: 'Правка маршрута согласования',
|
||||
title: 'Редактирование заявок',
|
||||
requestTypeLabel: 'Тип заявки',
|
||||
requestTypePlaceholder: 'Выберите тип заявки',
|
||||
requestTypeEmpty: 'Нет типов заявок с привязанным списком SharePoint.',
|
||||
requestLabel: 'Заявка',
|
||||
requestPlaceholder: 'Выберите заявку',
|
||||
requestSearchPlaceholder: 'Поиск по названию...',
|
||||
requestDisabledHint: 'Сначала выберите тип заявки.',
|
||||
noApprovalList: 'У этого типа заявки список согласования не найден (Title списка данных не оканчивается на «— данные»).',
|
||||
requestStatusLabel: 'Статус заявки',
|
||||
fullyClosedNotice: 'Заявка полностью закрыта — маршрут доступен только для просмотра.',
|
||||
approvalPhaseTitle: 'Согласование',
|
||||
executionPhaseTitle: 'Исполнение',
|
||||
columnOrder: 'Шаг',
|
||||
columnApprover: 'Согласующий',
|
||||
columnPosition: 'Должность',
|
||||
columnStatus: 'Статус',
|
||||
columnDate: 'Дата',
|
||||
emptyPhase: 'Шагов нет.',
|
||||
emptyMatrix: 'У этой заявки маршрут ещё не записан.',
|
||||
reassign: 'Переназначить',
|
||||
remove: 'Удалить шаг',
|
||||
addApproverPlaceholder: 'Добавить согласующего: начните вводить ФИО…',
|
||||
addExecutorPlaceholder: 'Добавить исполнителя: начните вводить ФИО…',
|
||||
lockedHint: 'Решение уже принято — недоступно для правки.',
|
||||
ghostHint: 'Сохраняется…',
|
||||
phaseClosedHint: 'Эта фаза уже завершена — добавить в неё новый шаг нельзя.',
|
||||
moveUp: 'Шаг выше',
|
||||
moveDown: 'Шаг ниже',
|
||||
mergeWithPrev: 'Объединить с предыдущим шагом (выполнять параллельно)',
|
||||
splitFromPrev: 'Отделить от предыдущего шага (выполнять после него)',
|
||||
moveBlockedByDecision: 'Нельзя переставить — соседний шаг уже решён.',
|
||||
reassignTitle: 'Переназначить шаг',
|
||||
approverLabel: 'Сотрудник',
|
||||
approverPlaceholder: 'Выберите сотрудника',
|
||||
validationApprover: 'Выберите сотрудника.',
|
||||
deleteTitle: 'Удалить шаг?',
|
||||
deleteText: (position: string): string => `Шаг «${position || 'без должности'}» будет удалён безвозвратно.`,
|
||||
orderFloorHint: (order: number): string =>
|
||||
`Шаг встанет на позицию ${order} — раньше уже принятых решений в этой фазе поставить нельзя.`,
|
||||
loadError: 'Не удалось загрузить заявку',
|
||||
saveError: 'Не удалось сохранить'
|
||||
},
|
||||
|
||||
// Значения по умолчанию для переиспользуемых контролов выбора.
|
||||
select: {
|
||||
placeholder: 'Выберите значение',
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Общая механика «шагов, которые могут идти параллельно» — 1:1 повторяет
|
||||
// request-portal/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts
|
||||
// (blockRanges/swapBlocks/withOrders/markParallel), только обобщена под
|
||||
// generic-тип с полями order/parallelWithPrev, чтобы работать и с
|
||||
// ManagedApprovalRow здесь, и не тащить зависимость на request-portal.
|
||||
//
|
||||
// Параллельность кодируется ЧЕРЕЗ РАВНЫЙ order у соседних строк — отдельного
|
||||
// флага в SharePoint нет (см. FIELDS в requestManageService.ts: там только
|
||||
// StepOrder). parallelWithPrev — это производное поле, вычисляемое на клиенте
|
||||
// из совпадения order с предыдущей строкой (см. markParallel), а не то, что
|
||||
// хранится в списке.
|
||||
export interface IOrderedItem {
|
||||
order: number;
|
||||
parallelWithPrev?: boolean;
|
||||
}
|
||||
|
||||
export interface IBlockRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Разбивает строки (уже отсортированные по order) на шаги — блоки с общим order. */
|
||||
export function blockRanges<T extends IOrderedItem>(rows: T[]): IBlockRange[] {
|
||||
const ranges: IBlockRange[] = [];
|
||||
rows.forEach((r, i) => {
|
||||
if (i === 0 || !r.parallelWithPrev) ranges.push({ start: i, end: i });
|
||||
else ranges[ranges.length - 1].end = i;
|
||||
});
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function blockIndexOf(ranges: IBlockRange[], row: number): number {
|
||||
for (let i = 0; i < ranges.length; i += 1) {
|
||||
if (row >= ranges[i].start && row <= ranges[i].end) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Меняет местами два блока (диапазона индексов), не трогая порядок строк внутри них. */
|
||||
export function swapBlocks<T>(rows: T[], a: IBlockRange, b: IBlockRange): T[] {
|
||||
return [
|
||||
...rows.slice(0, a.start),
|
||||
...rows.slice(b.start, b.end + 1),
|
||||
...rows.slice(a.start, a.end + 1),
|
||||
...rows.slice(b.end + 1)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересчитывает order начиная с floor+1: у параллельных строк (parallelWithPrev)
|
||||
* order общий с предыдущей, у остальных — на единицу больше. Вызывается после
|
||||
* КАЖДОГО перемещения/переключения параллельности, иначе order разъедется с тем,
|
||||
* что показано на экране. floor — order последней ЗАБЛОКИРОВАННОЙ (уже решённой)
|
||||
* строки фазы; строки после неё не могут получить order <= floor — это и есть
|
||||
* гарантия «нельзя поставить согласующего выше того, кто уже одобрил».
|
||||
*/
|
||||
export function withOrdersFrom<T extends IOrderedItem>(rows: T[], floor: number): T[] {
|
||||
let order = floor;
|
||||
return rows.map((r, i) => {
|
||||
const parallel = i > 0 && !!r.parallelWithPrev;
|
||||
if (!parallel) order += 1;
|
||||
return { ...r, order, parallelWithPrev: parallel };
|
||||
});
|
||||
}
|
||||
|
||||
/** Заполняет parallelWithPrev по факту совпадения order с предыдущей строкой (строки уже отсортированы по order). */
|
||||
export function markParallel<T extends IOrderedItem>(rows: T[]): T[] {
|
||||
return rows.map((r, i) => ({ ...r, parallelWithPrev: i > 0 && r.order === rows[i - 1].order }));
|
||||
}
|
||||
Reference in New Issue
Block a user