основное готово
This commit is contained in:
@@ -73,6 +73,14 @@
|
||||
margin-bottom: $sp-6;
|
||||
}
|
||||
|
||||
// Раздел «Новая заявка», когда открыта другая вкладка. Именно скрытие, а не
|
||||
// размонтирование: заполненная форма должна пережить переключение вкладок
|
||||
// (см. RequestPortal.tsx). display:none выводит содержимое и из потока, и из
|
||||
// последовательности табуляции — скрытая форма не ловит фокус с клавиатуры.
|
||||
.hiddenPane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// На узких экранах рейл уходит наверх.
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { IRequestPortalProps } from './IRequestPortalProps';
|
||||
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
||||
import { navigate, useRoute, NavSection } from '../shared/routing';
|
||||
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
||||
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
|
||||
import { SideNav } from './SideNav/SideNav';
|
||||
import { Drawer } from './Drawer/Drawer';
|
||||
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
|
||||
@@ -68,13 +69,28 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
|
||||
// Переход «создать на основе» (в т.ч. из открытой заявки в панели): переключаем
|
||||
// вкладку категорий на категорию целевой формы, чтобы она совпала с открытой формой.
|
||||
// Форма при этом пересоздаётся (см. key у SubmitFeature), поэтому прежние
|
||||
// несохранённые правки к ней уже не относятся — снимаем флаг.
|
||||
React.useEffect(() => {
|
||||
if (route.mode === 'create' && route.formKey) {
|
||||
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
|
||||
if (found) setActiveCategoryId(found.categoryId);
|
||||
markFormClean();
|
||||
}
|
||||
}, [route.mode, route.formKey]);
|
||||
|
||||
// Сохранение (в том числе черновика) шлёт rp-requests-changed — после него
|
||||
// терять нечего, предупреждать при «создать на основе» больше не о чем.
|
||||
React.useEffect(() => onRequestsChanged(markFormClean), []);
|
||||
|
||||
// Смена категории закрывает открытую форму (SubmitFeature сбрасывает выбор,
|
||||
// когда категория перестаёт совпадать) — значит введённое пропадёт.
|
||||
const changeCategory = (categoryId: string): void => {
|
||||
if (!confirmDiscardFormChanges()) return;
|
||||
markFormClean();
|
||||
setActiveCategoryId(categoryId);
|
||||
};
|
||||
|
||||
const goto = (nav: NavSection): void => {
|
||||
// Клик по УЖЕ активному пункту меню не меняет маршрут, значит эффект
|
||||
// загрузки в таблице не перезапустится сам. Шлём событие обновления —
|
||||
@@ -100,26 +116,46 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
</div>
|
||||
|
||||
<main className={styles.main}>
|
||||
{route.nav === 'new' ? (
|
||||
<>
|
||||
<div className={styles.tabsWrap}>
|
||||
<CategoryTabs
|
||||
categories={CATEGORIES}
|
||||
activeId={activeCategoryId}
|
||||
onChange={setActiveCategoryId}
|
||||
/>
|
||||
</div>
|
||||
<SubmitFeature
|
||||
context={context}
|
||||
activeCategoryId={activeCategoryId}
|
||||
initialForm={
|
||||
route.mode === 'create' && route.formKey && route.sourceId
|
||||
? { formKey: route.formKey, sourceId: route.sourceId }
|
||||
: undefined
|
||||
}
|
||||
{/* Раздел «Новая заявка» держим СМОНТИРОВАННЫМ и просто прячем стилем,
|
||||
когда открыта другая вкладка. Иначе React размонтировал бы форму, и
|
||||
всё введённое пропадало бы при переключении на «Мои заявки» и
|
||||
обратно. Ключ завязан на «создать на основе»: обычные переключения
|
||||
вкладок его не меняют (состояние живёт), а вот открытие новой
|
||||
заявки на основе исходной пересоздаёт форму — SubmitFeature читает
|
||||
initialForm только при монтировании. */}
|
||||
<div
|
||||
className={route.nav === 'new' ? undefined : styles.hiddenPane}
|
||||
// Делегирование: любой пользовательский ввод внутри раздела помечает
|
||||
// форму изменённой. Так не нужно инструментировать каждую форму по
|
||||
// отдельности, и программное заполнение (предзаполнение «на основе»)
|
||||
// сюда не попадает — React выставляет значения без этих событий.
|
||||
onInput={markFormDirty}
|
||||
onChange={markFormDirty}
|
||||
>
|
||||
<div className={styles.tabsWrap}>
|
||||
<CategoryTabs
|
||||
categories={CATEGORIES}
|
||||
activeId={activeCategoryId}
|
||||
onChange={changeCategory}
|
||||
/>
|
||||
</>
|
||||
) : route.nav === 'search' ? (
|
||||
</div>
|
||||
<SubmitFeature
|
||||
key={
|
||||
route.mode === 'create' && route.formKey && route.sourceId
|
||||
? `create-${route.formKey}-${route.sourceId}`
|
||||
: 'blank'
|
||||
}
|
||||
context={context}
|
||||
activeCategoryId={activeCategoryId}
|
||||
initialForm={
|
||||
route.mode === 'create' && route.formKey && route.sourceId
|
||||
? { formKey: route.formKey, sourceId: route.sourceId }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{route.nav === 'search' && (
|
||||
<SearchView
|
||||
context={context}
|
||||
onOpen={(request) =>
|
||||
@@ -131,7 +167,9 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{(route.nav === 'approvals' || route.nav === 'own') && (
|
||||
<RequestsTableView
|
||||
context={context}
|
||||
kind={route.nav === 'approvals' ? 'approvals' : 'own'}
|
||||
|
||||
@@ -19,6 +19,13 @@ import styles from './RequestsTableView.module.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// Автообновление открытых таблиц: раз в 10 минут перечитываем данные, чтобы
|
||||
// вкладка, оставленная открытой надолго, не показывала устаревший список.
|
||||
// Обновление фоновое — строки с экрана не пропадают и перерисовываются, только
|
||||
// если данные реально изменились (см. pageSignature).
|
||||
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||
|
||||
|
||||
export interface IRequestsTableViewProps {
|
||||
context: WebPartContext;
|
||||
/** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */
|
||||
@@ -239,9 +246,15 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
const unsubscribe = onRequestsChanged(() => {
|
||||
load().catch(() => undefined);
|
||||
});
|
||||
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
|
||||
// сработает ближайший тик.
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden) load().catch(() => undefined);
|
||||
}, AUTO_REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [context, types, kind, debouncedQuery, page]);
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ import styles from './SearchView.module.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// Автообновление открытых таблиц: раз в 10 минут перечитываем данные, чтобы
|
||||
// вкладка, оставленная открытой надолго, не показывала устаревший список.
|
||||
// Обновление фоновое — строки с экрана не пропадают и перерисовываются, только
|
||||
// если данные реально изменились (см. pageSignature).
|
||||
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||
|
||||
|
||||
export interface ISearchViewProps {
|
||||
context: WebPartContext;
|
||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||
@@ -169,9 +176,13 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
const unsubscribe = onRequestsChanged(() => {
|
||||
load().catch(() => undefined);
|
||||
});
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden) load().catch(() => undefined);
|
||||
}, AUTO_REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [context, types, debouncedQuery, page]);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getRequestsByCategory, REQUEST_TYPES } from '../../data/requests';
|
||||
import { getFormComponent } from '../../forms/registry';
|
||||
import { IRequestType } from '../../models/types';
|
||||
import { emitRequestsChanged } from '../../shared/requestEvents';
|
||||
import { confirmDiscardFormChanges, markFormClean } from '../../shared/unsavedChanges';
|
||||
|
||||
export interface ISubmitFeatureProps {
|
||||
context: WebPartContext;
|
||||
@@ -43,7 +44,15 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, activeCategoryI
|
||||
|
||||
const requests = getRequestsByCategory(activeCategoryId);
|
||||
|
||||
const handleBack = (): void => setSelectedRequest(null);
|
||||
// Возврат к плиткам закрывает форму и теряет введённое (раздел остаётся
|
||||
// смонтированным, но состояние формы живёт в самом FormComponent). Спрашиваем,
|
||||
// если правки есть и они не сохранены; после согласия флаг снимаем — терять
|
||||
// уже нечего.
|
||||
const handleBack = (): void => {
|
||||
if (!confirmDiscardFormChanges()) return;
|
||||
markFormClean();
|
||||
setSelectedRequest(null);
|
||||
};
|
||||
|
||||
const renderSelected = (request: IRequestType): React.ReactNode => {
|
||||
const FormComponent = getFormComponent(request.id);
|
||||
|
||||
@@ -68,7 +68,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
isValid ? undefined : 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.';
|
||||
|
||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
||||
// order обязателен: параллельные шаги делят один номер, по позиции в
|
||||
// массиве его не восстановить (см. useMatrixEditor/withOrders).
|
||||
rows.map((r) => ({
|
||||
email: r.email,
|
||||
position: r.position,
|
||||
allowReassign: r.allowReassign,
|
||||
order: r.order,
|
||||
}));
|
||||
|
||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||
const id = await saveHireRequest(
|
||||
|
||||
@@ -15,6 +15,7 @@ import { navigate } from '../../shared/routing';
|
||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadHireRequest } from './load';
|
||||
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
|
||||
import { hireApprovalService, DATA_LIST_TITLE } from './submit';
|
||||
import { ApplicationSection } from './sections/ApplicationSection';
|
||||
import { CandidateSection } from './sections/CandidateSection';
|
||||
@@ -121,9 +122,13 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
navigate({ mode: 'create', nav: 'new', formKey: WORKPLACE_FORM_KEY, sourceId: itemId })
|
||||
}
|
||||
onClick={() => {
|
||||
// Раздел «Новая заявка» остаётся смонтированным при переключении
|
||||
// вкладок, но переход «на основе» пересоздаёт форму — если там
|
||||
// остались несохранённые правки, спрашиваем перед их потерей.
|
||||
if (!confirmDiscardFormChanges()) return;
|
||||
navigate({ mode: 'create', nav: 'new', formKey: WORKPLACE_FORM_KEY, sourceId: itemId });
|
||||
}}
|
||||
>
|
||||
<Icon name="copy" size="sm" />
|
||||
Организация рабочего места на основе этой
|
||||
|
||||
@@ -92,7 +92,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
isValid ? undefined : 'Заполните обязательные поля: Ф.И.О., Должность.';
|
||||
|
||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
||||
// order обязателен: параллельные шаги делят один номер, по позиции в
|
||||
// массиве его не восстановить (см. useMatrixEditor/withOrders).
|
||||
rows.map((r) => ({
|
||||
email: r.email,
|
||||
position: r.position,
|
||||
allowReassign: r.allowReassign,
|
||||
order: r.order,
|
||||
}));
|
||||
|
||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||
const id = await saveWorkplaceRequest(
|
||||
|
||||
@@ -63,8 +63,13 @@ export class ApprovalListService {
|
||||
phase: string = APPROVAL_PHASE.approval,
|
||||
orderStart: number = 1
|
||||
): Promise<void> {
|
||||
// orderStart — СМЕЩЕНИЕ фазы: у согласующих оно 1, у исполнителей начинается
|
||||
// после последнего согласующего (обе фазы живут в одном списке и делят
|
||||
// сквозную нумерацию). a.order — номер шага ВНУТРИ фазы (1..N), и у
|
||||
// параллельных шагов он совпадает, поэтому нумеровать по позиции в массиве
|
||||
// нельзя. Без a.order падаем на позицию — для вызовов, где параллельности нет.
|
||||
const withOrder = approvers
|
||||
.map((a, i) => ({ a, order: a.order ?? orderStart + i }))
|
||||
.map((a, i) => ({ a, order: orderStart - 1 + (a.order ?? i + 1) }))
|
||||
.filter((x) => !!x.a.email);
|
||||
|
||||
for (const { a, order } of withOrder) {
|
||||
|
||||
@@ -76,13 +76,24 @@ export async function writeRoute(
|
||||
const submitted = status === REQUEST_STATUS.onApproval;
|
||||
const rowStatus = submitted ? REQUEST_STATUS.queued : status;
|
||||
|
||||
// Смещение фазы исполнения — по МАКСИМАЛЬНОМУ номеру шага согласующих, а не по
|
||||
// их количеству: при параллельных шагах несколько согласующих делят один номер,
|
||||
// поэтому approvers.length больше последнего реального шага (три согласующих,
|
||||
// из которых двое параллельны, — это шаги 1 и 2, а не 1..3). На очередь это не
|
||||
// влияет (activateNextQueued берёт минимальный order среди ждущих, разрывы ему
|
||||
// безразличны) — но нумерация шагов в таблице «Исполнение» шла бы с пропуском.
|
||||
const lastApprovalStep = approvers.reduce(
|
||||
(max, a, i) => Math.max(max, a.order ?? i + 1),
|
||||
0
|
||||
);
|
||||
|
||||
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
|
||||
await approval.createSteps(
|
||||
requestId,
|
||||
executors,
|
||||
rowStatus,
|
||||
APPROVAL_PHASE.execution,
|
||||
approvers.length + 1
|
||||
lastApprovalStep + 1
|
||||
);
|
||||
|
||||
if (submitted) {
|
||||
|
||||
@@ -9,4 +9,10 @@ export interface IMatrixRow {
|
||||
comment: string;
|
||||
allowReassign: boolean; // может ли этот согласующий добавить делеганта
|
||||
phase?: string; // фаза строки: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
|
||||
// Шаг идёт ОДНОВРЕМЕННО с предыдущим (у обоих одинаковый order — так
|
||||
// activateNextQueued активирует их вместе). У первой строки всегда false.
|
||||
// При загрузке выводится из совпадения order с предыдущей строкой, при
|
||||
// редактировании — переключается кнопкой и заново раздаёт order (см.
|
||||
// useMatrixEditor/withOrders).
|
||||
parallelWithPrev?: boolean;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,22 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
emptyText = 'Согласующие не заданы.',
|
||||
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…',
|
||||
}) => {
|
||||
const { rows, loading, error, editable } = editor;
|
||||
const { rows, loading, error, editable, blocks } = editor;
|
||||
|
||||
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
||||
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
||||
const blockByRow: Array<{ index: number; start: number; size: number }> = [];
|
||||
blocks.forEach((b, bi) => {
|
||||
for (let i = b.start; i <= b.end; i += 1) {
|
||||
blockByRow[i] = { index: bi, start: b.start, size: b.end - b.start + 1 };
|
||||
}
|
||||
});
|
||||
|
||||
// Пока по маршруту не принято ни одного решения (заявка не отправлена на
|
||||
// согласование — черновик, доработка), колонки «Статус» и «Дата» пустые у всех
|
||||
// строк: показывать столбец из одних прочерков смысла нет. Как только
|
||||
// появляется первое решение — колонки возвращаются.
|
||||
const hasDecisions = rows.some((r) => (r.status && r.status !== '—') || r.date);
|
||||
|
||||
return (
|
||||
<div className={styles.matrixWrap}>
|
||||
@@ -42,52 +57,87 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
<table className={styles.matrix}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.matrixStepCol}>Шаг</th>
|
||||
<th>{roleLabel}</th>
|
||||
<th>Должность</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата</th>
|
||||
{hasDecisions && <th>Статус</th>}
|
||||
{hasDecisions && <th>Дата</th>}
|
||||
{editable && <th />}
|
||||
{editable && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{r.who}</td>
|
||||
<td>{r.position}</td>
|
||||
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
|
||||
<td>{r.date}</td>
|
||||
{editable && (
|
||||
<td className={styles.matrixActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === 0}
|
||||
onClick={() => editor.moveUp(i)}
|
||||
title="Выше"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => editor.moveDown(i)}
|
||||
title="Ниже"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||
onClick={() => editor.remove(i)}
|
||||
title="Удалить"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{rows.map((r, i) => {
|
||||
const block = blockByRow[i];
|
||||
// Ячейка «Шаг» рисуется один раз на весь шаг и растягивается на его
|
||||
// строки. Кнопки перемещения живут в ней же: двигать параллельные
|
||||
// строки поодиночке бессмысленно — шаг переезжает целиком.
|
||||
const isBlockStart = block.start === i;
|
||||
return (
|
||||
<tr key={i} className={block.size > 1 ? styles.parallelRow : undefined}>
|
||||
{isBlockStart && (
|
||||
<td className={styles.matrixStepCol} rowSpan={block.size}>
|
||||
<span className={styles.stepNo}>{r.order}</span>
|
||||
</td>
|
||||
)}
|
||||
<td>{r.who}</td>
|
||||
<td>{r.position}</td>
|
||||
{hasDecisions && (
|
||||
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
|
||||
)}
|
||||
{hasDecisions && <td>{r.date}</td>}
|
||||
{editable && (
|
||||
<td className={styles.matrixActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
|
||||
disabled={i === 0}
|
||||
onClick={() => editor.toggleParallel(i)}
|
||||
title={
|
||||
r.parallelWithPrev
|
||||
? 'Отцепить: выполнять после предыдущего шага'
|
||||
: 'Объединить: выполнять одновременно с предыдущим шагом'
|
||||
}
|
||||
>
|
||||
<Icon name="user-check" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||
onClick={() => editor.remove(i)}
|
||||
title="Удалить"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
{/* Перемещение — одно на весь шаг: параллельные строки
|
||||
переезжают вместе, поэтому ячейка объединена по rowSpan. */}
|
||||
{editable && isBlockStart && (
|
||||
<td className={styles.matrixBlockMove} rowSpan={block.size}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={block.index === 0}
|
||||
onClick={() => editor.moveBlockUp(i)}
|
||||
title="Шаг выше"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={block.index === blocks.length - 1}
|
||||
onClick={() => editor.moveBlockDown(i)}
|
||||
title="Шаг ниже"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
@@ -151,6 +151,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Включённый режим-переключатель (шаг помечен как параллельный) — в отличие от
|
||||
// остальных кнопок это состояние, а не разовое действие, поэтому оно залито.
|
||||
.iconBtnActive {
|
||||
border-color: $gold-500;
|
||||
background: $gold-500;
|
||||
color: $navy-950;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $gold-600;
|
||||
background: $gold-600;
|
||||
color: $navy-950;
|
||||
}
|
||||
}
|
||||
|
||||
// Колонка с номером шага. Объединяется по вертикали (rowSpan) на все строки
|
||||
// одного шага, поэтому содержимое центрируем — иначе номер и кнопки прижимались
|
||||
// бы к верхней строке блока.
|
||||
.matrixStepCol {
|
||||
width: 48px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.stepNo {
|
||||
font-weight: 600;
|
||||
color: $navy-700;
|
||||
}
|
||||
|
||||
// Перемещение шага целиком — тоже объединённая по вертикали ячейка, поэтому
|
||||
// кнопки центрируем относительно всего блока.
|
||||
.matrixBlockMove {
|
||||
width: 64px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
text-align: right;
|
||||
|
||||
.iconBtn + .iconBtn {
|
||||
margin-left: $sp-1;
|
||||
}
|
||||
}
|
||||
|
||||
// Строки, входящие в шаг из нескольких параллельных согласующих — лёгкая
|
||||
// заливка, чтобы блок читался как единое целое (границы ячейки «Шаг» его уже
|
||||
// объединяют, но без фона строки визуально распадаются).
|
||||
.parallelRow > td {
|
||||
background: rgba($gold-500, .06);
|
||||
}
|
||||
|
||||
.flipY {
|
||||
transform: scaleY(-1);
|
||||
}
|
||||
|
||||
@@ -8,15 +8,74 @@ export interface IUseMatrixEditor {
|
||||
error: string | undefined;
|
||||
editable: boolean;
|
||||
addApprover: (user: ISiteUser) => void;
|
||||
moveUp: (index: number) => void;
|
||||
moveDown: (index: number) => void;
|
||||
/** Переместить ШАГ целиком (вместе со всеми параллельными строками) выше/ниже. */
|
||||
moveBlockUp: (index: number) => void;
|
||||
moveBlockDown: (index: number) => void;
|
||||
remove: (index: number) => void;
|
||||
/** Переключить «идёт параллельно с предыдущим шагом». Для первой строки — no-op. */
|
||||
toggleParallel: (index: number) => void;
|
||||
/** Границы шагов: индексы строк, объединённых общим order. */
|
||||
blocks: IMatrixBlock[];
|
||||
}
|
||||
|
||||
function swap(rows: IMatrixRow[], a: number, b: number): IMatrixRow[] {
|
||||
const next = [...rows];
|
||||
[next[a], next[b]] = [next[b], next[a]];
|
||||
return next;
|
||||
/** Один шаг маршрута: одна строка либо несколько параллельных (общий order). */
|
||||
export interface IMatrixBlock {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
// Разбивает строки на шаги: новый шаг начинается там, где нет флага
|
||||
// parallelWithPrev. Используется и для перемещения (двигаем шаг целиком), и для
|
||||
// отрисовки (одна ячейка «Шаг» на все строки шага, см. ApprovalMatrix/rowSpan).
|
||||
export function blockRanges(rows: IMatrixRow[]): IMatrixBlock[] {
|
||||
const ranges: IMatrixBlock[] = [];
|
||||
rows.forEach((r, i) => {
|
||||
if (i === 0 || !r.parallelWithPrev) ranges.push({ start: i, end: i });
|
||||
else ranges[ranges.length - 1].end = i;
|
||||
});
|
||||
return ranges;
|
||||
}
|
||||
|
||||
// Индекс шага, которому принадлежит строка (-1 — не найдено).
|
||||
function blockIndexOf(ranges: IMatrixBlock[], 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;
|
||||
}
|
||||
|
||||
// Меняет местами два СОСЕДНИХ шага целиком. Флаги parallelWithPrev внутри шага
|
||||
// остаются верными сами собой: у первой строки шага флаг снят, у остальных —
|
||||
// стоит, а порядок строк внутри шага не меняется.
|
||||
function swapBlocks(rows: IMatrixRow[], a: IMatrixBlock, b: IMatrixBlock): IMatrixRow[] {
|
||||
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 по флагу parallelWithPrev: у параллельных шагов order общий с
|
||||
// предыдущим, у последовательных — на единицу больше. Вызывается после КАЖДОГО
|
||||
// изменения состава/порядка, иначе order разъедется с тем, что видно на экране.
|
||||
// Первая строка параллельной быть не может — ей всегда достаётся order 1.
|
||||
function withOrders(rows: IMatrixRow[]): IMatrixRow[] {
|
||||
let order = 0;
|
||||
return rows.map((r, i) => {
|
||||
const parallel = i > 0 && !!r.parallelWithPrev;
|
||||
if (!parallel) order += 1;
|
||||
return { ...r, order, parallelWithPrev: parallel };
|
||||
});
|
||||
}
|
||||
|
||||
// Обратное преобразование для загруженных строк: одинаковый order у соседей
|
||||
// означает параллельный шаг.
|
||||
function markParallel(rows: IMatrixRow[]): IMatrixRow[] {
|
||||
return rows.map((r, i) => ({
|
||||
...r,
|
||||
parallelWithPrev: i > 0 && r.order === rows[i - 1].order,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +104,7 @@ export function useMatrixEditor(
|
||||
load()
|
||||
.then((r) => {
|
||||
if (!active) return;
|
||||
setRows(r);
|
||||
setRows(markParallel(r));
|
||||
setError(undefined);
|
||||
setLoading(false);
|
||||
})
|
||||
@@ -65,7 +124,7 @@ export function useMatrixEditor(
|
||||
setRows((prev) => {
|
||||
if (user.email && prev.some((r) => r.email === user.email)) return prev;
|
||||
const row: IMatrixRow = {
|
||||
order: prev.length + 1,
|
||||
order: 0, // проставит withOrders
|
||||
who: user.name,
|
||||
email: user.email,
|
||||
position,
|
||||
@@ -73,8 +132,9 @@ export function useMatrixEditor(
|
||||
date: '',
|
||||
comment: '',
|
||||
allowReassign: false,
|
||||
parallelWithPrev: false,
|
||||
};
|
||||
return [...prev, row];
|
||||
return withOrders([...prev, row]);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -85,10 +145,42 @@ export function useMatrixEditor(
|
||||
.catch(() => appendUser(user, ''));
|
||||
};
|
||||
|
||||
const moveUp = (i: number): void => setRows((prev) => (i <= 0 ? prev : swap(prev, i, i - 1)));
|
||||
const moveDown = (i: number): void =>
|
||||
setRows((prev) => (i >= prev.length - 1 ? prev : swap(prev, i, i + 1)));
|
||||
const remove = (i: number): void => setRows((prev) => prev.filter((_, idx) => idx !== i));
|
||||
const moveBlockUp = (i: number): void =>
|
||||
setRows((prev) => {
|
||||
const ranges = blockRanges(prev);
|
||||
const bi = blockIndexOf(ranges, i);
|
||||
if (bi <= 0) return prev;
|
||||
return withOrders(swapBlocks(prev, ranges[bi - 1], ranges[bi]));
|
||||
});
|
||||
|
||||
return { rows, loading, error, editable, addApprover, moveUp, moveDown, remove };
|
||||
const moveBlockDown = (i: number): void =>
|
||||
setRows((prev) => {
|
||||
const ranges = blockRanges(prev);
|
||||
const bi = blockIndexOf(ranges, i);
|
||||
if (bi < 0 || bi >= ranges.length - 1) return prev;
|
||||
return withOrders(swapBlocks(prev, ranges[bi], ranges[bi + 1]));
|
||||
});
|
||||
|
||||
const remove = (i: number): void =>
|
||||
setRows((prev) => withOrders(prev.filter((_, idx) => idx !== i)));
|
||||
|
||||
const toggleParallel = (i: number): void =>
|
||||
setRows((prev) =>
|
||||
i <= 0
|
||||
? prev
|
||||
: withOrders(prev.map((r, idx) => (idx === i ? { ...r, parallelWithPrev: !r.parallelWithPrev } : r)))
|
||||
);
|
||||
|
||||
return {
|
||||
rows,
|
||||
loading,
|
||||
error,
|
||||
editable,
|
||||
addApprover,
|
||||
moveBlockUp,
|
||||
moveBlockDown,
|
||||
remove,
|
||||
toggleParallel,
|
||||
blocks: blockRanges(rows),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Есть ли в открытой форме «Новая заявка» правки, которые ещё не сохранены.
|
||||
//
|
||||
// Нужно, потому что раздел «Новая заявка» держится смонтированным при
|
||||
// переключении вкладок (см. RequestPortal.tsx), и единственное, что реально
|
||||
// пересоздаёт форму, — переход «создать на основе». Перед ним и спрашиваем,
|
||||
// но только если терять действительно есть что.
|
||||
//
|
||||
// Флаг ставится по факту пользовательского ввода (событие input/change из
|
||||
// раздела формы — см. RequestPortal), а снимается после сохранения: черновик
|
||||
// тоже считается сохранением, форма зовёт onSaved и в обоих случаях
|
||||
// («Сохранить черновик» и «Отправить»), см. forms/*/Form.tsx.
|
||||
let dirty = false;
|
||||
|
||||
export function markFormDirty(): void {
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
export function markFormClean(): void {
|
||||
dirty = false;
|
||||
}
|
||||
|
||||
export function hasUnsavedFormChanges(): boolean {
|
||||
return dirty;
|
||||
}
|
||||
|
||||
// Спросить подтверждение, если несохранённые правки есть. true — можно
|
||||
// продолжать (правок нет либо пользователь согласился их потерять).
|
||||
export function confirmDiscardFormChanges(): boolean {
|
||||
if (!dirty) return true;
|
||||
return window.confirm(
|
||||
'В открытой форме «Новая заявка» есть несохранённые изменения — они будут потеряны. Продолжить?'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user