--no-edit
This commit is contained in:
@@ -63,11 +63,28 @@ export class ApprovalListService {
|
||||
phase: string = APPROVAL_PHASE.approval,
|
||||
orderStart: number = 1
|
||||
): Promise<void> {
|
||||
const withOrder = approvers
|
||||
.map((a, i) => ({ a, order: a.order ?? orderStart + i }))
|
||||
.filter((x) => !!x.a.email);
|
||||
// orderStart — СМЕЩЕНИЕ фазы: у согласующих оно 1, у исполнителей начинается
|
||||
// после последнего согласующего (обе фазы живут в одном списке и делят
|
||||
// сквозную нумерацию). a.order — номер шага ВНУТРИ фазы (1..N), и у
|
||||
// параллельных шагов он совпадает, поэтому нумеровать по позиции в массиве
|
||||
// нельзя. Без a.order падаем на позицию — для вызовов, где параллельности нет.
|
||||
const withOrder = approvers.map((a, i) => ({ a, order: orderStart - 1 + (a.order ?? i + 1) }));
|
||||
|
||||
for (const { a, order } of withOrder) {
|
||||
// Строку без email в SharePoint не записать (Person-поле обязательного
|
||||
// значения) — раньше такие шаги пропадали отсюда молча, и согласующие в
|
||||
// середине маршрута просто не получали заявку (см. findUnassignedStep —
|
||||
// это должно было быть отловлено валидацией ДО вызова createSteps; лог
|
||||
// здесь — на случай, если её обошли, например прямым вызовом сервиса).
|
||||
const missing = withOrder.filter((x) => !x.a.email);
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`[Согласование] createSteps: пропущены шаги без назначенного человека (order ${missing
|
||||
.map((x) => x.order)
|
||||
.join(', ')}) — заявка №${requestId}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const { a, order } of withOrder.filter((x) => !!x.a.email)) {
|
||||
const approverId = await this.resolveUserId(a.email);
|
||||
await this.list().items.add({
|
||||
Title: a.position || a.email,
|
||||
@@ -165,6 +182,12 @@ export class ApprovalListService {
|
||||
}
|
||||
|
||||
// Строки согласования для отображения матрицы (ФИО, должность, статус, дата, комментарий).
|
||||
//
|
||||
// Approver/EMail нужен не только для показа: при редактировании черновика
|
||||
// (см. useMatrixEditor с editable=true в WorkplaceSetupForm/RecruitingHireForm)
|
||||
// это ЕДИНСТВЕННЫЙ источник email для уже существующих строк — без него
|
||||
// findUnassignedStep решает, что назначенного человека нет, хотя он есть
|
||||
// (баг: пропущенный email тут выглядел так же, как реально пустой шаг).
|
||||
public async getMatrixForRequest(requestId: number): Promise<IMatrixRow[]> {
|
||||
const rows = (await this.list()
|
||||
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
||||
@@ -177,17 +200,18 @@ export class ApprovalListService {
|
||||
FIELDS.order,
|
||||
FIELDS.allowReassign,
|
||||
FIELDS.phase,
|
||||
`${FIELDS.approver}/Title`
|
||||
`${FIELDS.approver}/Title`,
|
||||
`${FIELDS.approver}/EMail`
|
||||
)
|
||||
.expand(FIELDS.approver)
|
||||
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||
|
||||
return rows.map((r, i) => {
|
||||
const approver = r[FIELDS.approver] as { Title?: string } | undefined;
|
||||
const approver = r[FIELDS.approver] as { Title?: string; EMail?: string } | undefined;
|
||||
return {
|
||||
order: (r[FIELDS.order] as number) ?? i + 1,
|
||||
who: approver?.Title || '',
|
||||
email: '',
|
||||
email: approver?.EMail || '',
|
||||
position: (r.Title as string) || '',
|
||||
status: (r[FIELDS.status] as string) || '',
|
||||
date: this.formatDate((r[FIELDS.date] as string) || ''),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -26,7 +26,7 @@ export type { ApproverDecision } from './approverDecision.service';
|
||||
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||
export { notifyActiveStep } from './notify.service';
|
||||
export type { INotifyTarget } from './notify.service';
|
||||
export { useMatrixEditor } from './useMatrixEditor';
|
||||
export { useMatrixEditor, findUnassignedStep } from './useMatrixEditor';
|
||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||
export { UserPicker } from './ui/UserPicker';
|
||||
|
||||
@@ -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,23 @@ 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 hasStatuses = rows.some((r) => r.status && r.status !== '—');
|
||||
const hasDates = rows.some((r) => !!r.date);
|
||||
|
||||
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
|
||||
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
|
||||
|
||||
return (
|
||||
<div className={styles.matrixWrap}>
|
||||
@@ -40,68 +56,100 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
)}
|
||||
{!loading && !error && rows.length > 0 && (
|
||||
<div className={styles.matrixScroll}>
|
||||
<table className={styles.matrix}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{roleLabel}</th>
|
||||
<th>Должность</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата</th>
|
||||
{editable && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<tr className={r.comment ? styles.rowNoBorder : undefined}>
|
||||
<td data-label={roleLabel}>{r.who}</td>
|
||||
<td data-label="Должность">{r.position}</td>
|
||||
<td data-label="Статус">
|
||||
{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}
|
||||
</td>
|
||||
<td data-label="Дата">{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>
|
||||
{r.comment && (
|
||||
<tr>
|
||||
<td colSpan={editable ? 5 : 4} className={styles.commentCell}>
|
||||
<span className={styles.commentLabel}>Комментарий:</span> {r.comment}
|
||||
<table className={styles.matrix}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.matrixStepCol}>Шаг</th>
|
||||
<th>{roleLabel}</th>
|
||||
<th>Должность</th>
|
||||
{hasStatuses && <th>Статус</th>}
|
||||
{hasDates && <th>Дата</th>}
|
||||
{editable && <th />}
|
||||
{editable && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 data-label={roleLabel}>
|
||||
{r.who}
|
||||
{/* Комментарий решения — мелким текстом под именем (не отдельной
|
||||
строкой: иначе ломается rowSpan колонки «Шаг» у параллельных). */}
|
||||
{r.comment && (
|
||||
<span className={styles.commentInline}>
|
||||
<span className={styles.commentLabel}>Комментарий:</span> {r.comment}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Должность">{r.position}</td>
|
||||
{hasStatuses && (
|
||||
<td data-label="Статус">
|
||||
{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}
|
||||
</td>
|
||||
)}
|
||||
{hasDates && <td data-label="Дата">{r.date}</td>}
|
||||
{/* Перемещение действует на ШАГ целиком (параллельные строки
|
||||
переезжают вместе): одна пара стрелок на весь шаг. */}
|
||||
{editable && isBlockStart && (
|
||||
<td className={styles.matrixMoveCol} 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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{editable && !loading && (
|
||||
|
||||
@@ -121,15 +121,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Строка, у которой ниже идёт комментарий: убираем нижнюю границу, чтобы строка
|
||||
// и её комментарий читались как единый блок (граница переезжает на .commentCell).
|
||||
.rowNoBorder td {
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.commentCell {
|
||||
padding: 0 8px 8px !important;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
// Комментарий решения — мелким текстом под именем согласующего (в той же ячейке,
|
||||
// а не отдельной строкой: иначе ломается rowSpan колонки «Шаг» у параллельных).
|
||||
.commentInline {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
line-height: 1.45;
|
||||
@@ -141,16 +137,43 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она
|
||||
// перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую
|
||||
// строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap.
|
||||
.matrixActions {
|
||||
display: flex;
|
||||
gap: $sp-1;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
|
||||
// Отступ между любыми соседними элементами: кроме кнопок тут бывают распорки
|
||||
// (см. .iconBtnSpacer), на которые селектор .iconBtn + .iconBtn не сработал бы.
|
||||
> * + * {
|
||||
margin-left: $sp-1;
|
||||
}
|
||||
}
|
||||
|
||||
// Стрелки перемещения шага. Ячейка объединена по вертикали (rowSpan) на все
|
||||
// строки шага, поэтому пара кнопок стоит по центру блока, а не у его первой
|
||||
// строки. Как и .matrixActions — остаётся табличной ячейкой (см. комментарий
|
||||
// там), кнопки держатся в строке через nowrap.
|
||||
.matrixMoveCol {
|
||||
width: 1%; // ужимает колонку по содержимому, ширину забирают текстовые колонки
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
|
||||
> * + * {
|
||||
margin-left: $sp-1;
|
||||
}
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// Инлайновые кнопки иначе выравниваются по baseline и «прыгают» относительно
|
||||
// кнопок соседней ячейки.
|
||||
vertical-align: middle;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
@@ -179,6 +202,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Включённый режим-переключатель (шаг помечен как параллельный) — в отличие от
|
||||
// остальных кнопок это состояние, а не разовое действие, поэтому оно залито.
|
||||
.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;
|
||||
}
|
||||
|
||||
// Строки, входящие в шаг из нескольких параллельных согласующих — лёгкая
|
||||
// заливка, чтобы блок читался как единое целое (границы ячейки «Шаг» его уже
|
||||
// объединяют, но без фона строки визуально распадаются).
|
||||
.parallelRow > td {
|
||||
background: rgba($gold-500, .06);
|
||||
}
|
||||
|
||||
.flipY {
|
||||
transform: scaleY(-1);
|
||||
}
|
||||
@@ -205,13 +263,9 @@
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
// Строка с комментарием: линия — на самом комментарии, не на строке.
|
||||
.rowNoBorder {
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.matrix td {
|
||||
display: flex;
|
||||
flex-wrap: wrap; // чтобы комментарий (100%) переносился на свою строку
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
border: 0 !important;
|
||||
@@ -227,13 +281,14 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.matrixActions {
|
||||
// Комментарий под именем — на всю ширину ячейки (своя строка).
|
||||
.commentInline {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.matrixActions,
|
||||
.matrixMoveCol {
|
||||
justify-content: flex-start;
|
||||
padding-top: $sp-2 !important;
|
||||
}
|
||||
|
||||
.commentCell {
|
||||
display: block;
|
||||
padding: 2px 0 6px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,96 @@ 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 };
|
||||
});
|
||||
}
|
||||
|
||||
// Статус для ТОЛЬКО ЧТО ДОБАВЛЕННОЙ строки. Если у всех уже имеющихся строк
|
||||
// статус одинаковый (маршрут целиком в одном состоянии — например, черновик),
|
||||
// новая получает тот же: при сохранении writeRoute всё равно проставит всем
|
||||
// строкам общий статус, и показывать «—» рядом с «Черновик» было бы враньём.
|
||||
// Если статусы разные (возвращённая заявка, часть шагов уже с решениями) —
|
||||
// честнее оставить «—»: в какое состояние попадёт новый шаг, ещё не решено.
|
||||
function statusForNewRow(rows: IMatrixRow[]): string {
|
||||
if (rows.length === 0) return '—';
|
||||
const first = rows[0].status || '—';
|
||||
return rows.every((r) => (r.status || '—') === first) ? first : '—';
|
||||
}
|
||||
|
||||
// Обратное преобразование для загруженных строк: одинаковый order у соседей
|
||||
// означает параллельный шаг.
|
||||
function markParallel(rows: IMatrixRow[]): IMatrixRow[] {
|
||||
return rows.map((r, i) => ({
|
||||
...r,
|
||||
parallelWithPrev: i > 0 && r.order === rows[i - 1].order,
|
||||
}));
|
||||
}
|
||||
|
||||
// Первый шаг без назначенного человека (email пуст) — обычно шаг маршрута,
|
||||
// который внешний approval-route API вернул как роль без привязанного
|
||||
// исполнителя. Писать такую строку в SharePoint нельзя (Person-поле требует
|
||||
// значение) — approvalList.service.ts/createSteps молча пропускает её при
|
||||
// записи, поэтому перед отправкой это нужно поймать здесь и не дать заявке уйти
|
||||
// на согласование с пропущенным шагом (см. RecruitingHireForm/WorkplaceSetupForm).
|
||||
export function findUnassignedStep(rows: IMatrixRow[]): IMatrixRow | undefined {
|
||||
return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +126,7 @@ export function useMatrixEditor(
|
||||
load()
|
||||
.then((r) => {
|
||||
if (!active) return;
|
||||
setRows(r);
|
||||
setRows(markParallel(r));
|
||||
setError(undefined);
|
||||
setLoading(false);
|
||||
})
|
||||
@@ -65,16 +146,17 @@ 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,
|
||||
status: '—',
|
||||
status: statusForNewRow(prev),
|
||||
date: '',
|
||||
comment: '',
|
||||
allowReassign: false,
|
||||
parallelWithPrev: false,
|
||||
};
|
||||
return [...prev, row];
|
||||
return withOrders([...prev, row]);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -85,10 +167,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import * as React from 'react';
|
||||
// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»),
|
||||
// и на её собственное событие, поэтому реагирует на оба способа смены URL.
|
||||
|
||||
// 'edit' — открыть СВОЮ незавершённую заявку (черновик / возвращённую /
|
||||
// отклонённую) на редактирование в той же выезжающей панели, что и просмотр.
|
||||
export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
|
||||
|
||||
/** Раздел бокового меню. */
|
||||
@@ -29,9 +31,9 @@ export interface IRoute {
|
||||
mode: PortalMode;
|
||||
/** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */
|
||||
nav: NavSection;
|
||||
/** Ключ формы (тип заявки) — для 'approve' и 'create'. */
|
||||
/** Ключ формы (тип заявки) — для 'approve', 'edit' и 'create'. */
|
||||
formKey?: string;
|
||||
/** Id заявки в списке данных — для 'approve'. */
|
||||
/** Id заявки в списке данных — для 'approve' и 'edit'. */
|
||||
itemId?: number;
|
||||
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
|
||||
sourceId?: number;
|
||||
@@ -59,10 +61,9 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
||||
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'approve', nav, formKey, itemId };
|
||||
}
|
||||
// Редактирование существующей заявки (черновик/возвращена/отклонена) — открывает
|
||||
// форму на правку. nav='new', т.к. правка живёт в области подачи.
|
||||
// Редактирование собственной незавершённой заявки — та же панель, но с формой.
|
||||
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'edit', nav: 'new', formKey, itemId };
|
||||
return { mode: 'edit', nav, formKey, itemId };
|
||||
}
|
||||
// Создание производной заявки с предзаполнением из исходной.
|
||||
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||
@@ -83,12 +84,8 @@ function buildQuery(route: IRoute): string {
|
||||
if (route.nav !== 'new') {
|
||||
params.set('rpnav', route.nav);
|
||||
}
|
||||
if (route.mode === 'approve' && route.formKey && route.itemId) {
|
||||
params.set('rpmode', 'approve');
|
||||
params.set('rptype', route.formKey);
|
||||
params.set('rpid', String(route.itemId));
|
||||
} else if (route.mode === 'edit' && route.formKey && route.itemId) {
|
||||
params.set('rpmode', 'edit');
|
||||
if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) {
|
||||
params.set('rpmode', route.mode);
|
||||
params.set('rptype', route.formKey);
|
||||
params.set('rpid', String(route.itemId));
|
||||
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Поверх всего, включая SPFx-хром вебпарта — иначе падение внутри вложенного
|
||||
// блока схлопывает только его, а не видно, что вообще произошло.
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $sp-6;
|
||||
background: rgba($navy-950, .78);
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: $sp-6;
|
||||
border-radius: $r-lg;
|
||||
background: $white;
|
||||
box-shadow: $sh-modal;
|
||||
border-top: 4px solid $red-500;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-form-title;
|
||||
font-weight: 600;
|
||||
color: $navy-950;
|
||||
}
|
||||
|
||||
.source {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.stack {
|
||||
box-sizing: border-box;
|
||||
margin: $sp-4 0 0;
|
||||
padding: $sp-3;
|
||||
overflow: auto;
|
||||
background: $gray-50;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
font-family: 'Consolas', 'Menlo', monospace;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-900;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $sp-2;
|
||||
margin-top: $sp-4;
|
||||
}
|
||||
|
||||
.copyBtn {
|
||||
@include btn-accent;
|
||||
padding: 9px 16px;
|
||||
border-radius: $r-sm;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reloadBtn {
|
||||
padding: 9px 16px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
color: $navy-800;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: $gold-400;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ErrorOverlay.module.scss';
|
||||
|
||||
interface ICaughtError {
|
||||
message: string;
|
||||
stack: string;
|
||||
source: string; // откуда поймана: React-рендер, window.onerror, unhandledrejection
|
||||
}
|
||||
|
||||
interface IErrorOverlayState {
|
||||
error: ICaughtError | undefined;
|
||||
}
|
||||
|
||||
function formatError(err: unknown, source: string): ICaughtError {
|
||||
const e = err instanceof Error ? err : new Error(typeof err === 'string' ? err : JSON.stringify(err));
|
||||
return { message: e.message, stack: e.stack || '(нет стека)', source };
|
||||
}
|
||||
|
||||
// Копия message+stack в буфер обмена, чтобы кинуть разработчику. Ошибка
|
||||
// самого копирования не должна маскировать исходную — просто игнорируем.
|
||||
function copyToClipboard(text: string): void {
|
||||
navigator.clipboard.writeText(text).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ловит падения, которые иначе схлопывают вебпарт в SPFx workbench:
|
||||
* 1) React-рендер (componentDidCatch);
|
||||
* 2) синхронные ошибки вне React (window.onerror);
|
||||
* 3) необработанные rejected promise (unhandledrejection).
|
||||
* Вместо пустого места рисует поверх всего оверлей с текстом ошибки и кнопкой
|
||||
* «Скопировать», чтобы можно было переслать стек трейс.
|
||||
*/
|
||||
export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknown>, IErrorOverlayState> {
|
||||
public state: IErrorOverlayState = { error: undefined };
|
||||
|
||||
private onWindowError = (event: ErrorEvent): void => {
|
||||
this.setState({ error: formatError(event.error || event.message, 'window.onerror') });
|
||||
};
|
||||
|
||||
private onUnhandledRejection = (event: PromiseRejectionEvent): void => {
|
||||
this.setState({ error: formatError(event.reason, 'unhandledrejection') });
|
||||
};
|
||||
|
||||
public componentDidMount(): void {
|
||||
window.addEventListener('error', this.onWindowError);
|
||||
window.addEventListener('unhandledrejection', this.onUnhandledRejection);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
window.removeEventListener('error', this.onWindowError);
|
||||
window.removeEventListener('unhandledrejection', this.onUnhandledRejection);
|
||||
}
|
||||
|
||||
public static getDerivedStateFromError(err: unknown): IErrorOverlayState {
|
||||
return { error: formatError(err, 'React render') };
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
|
||||
const fullText = `[${error.source}] ${error.message}\n\n${error.stack}`;
|
||||
|
||||
return (
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.title}>Необработанное исключение</div>
|
||||
<div className={styles.source}>{error.source}</div>
|
||||
<pre className={styles.stack}>{fullText}</pre>
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.copyBtn} onClick={() => copyToClipboard(fullText)}>
|
||||
Скопировать
|
||||
</button>
|
||||
<button type="button" className={styles.reloadBtn} onClick={() => window.location.reload()}>
|
||||
Перезагрузить страницу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export { PeopleField } from './PeopleField/PeopleField';
|
||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
||||
export { Icon } from './Icon/Icon';
|
||||
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
|
||||
export { SubmitActions } from './SubmitActions/SubmitActions';
|
||||
export { RequestNumber } from './RequestNumber/RequestNumber';
|
||||
export { PrintButton } from './PrintButton/PrintButton';
|
||||
|
||||
@@ -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