мердж
This commit is contained in:
@@ -62,7 +62,20 @@ function Add-Column {
|
||||
param([string]$List, $Col)
|
||||
|
||||
if (Test-FieldExists -List $List -InternalName $Col.internalName) {
|
||||
Write-Host " [=] колонка есть: $($Col.internalName)" -ForegroundColor DarkGray
|
||||
# Колонка уже есть. Для Choice — досетапливаем варианты (могли добавиться новые
|
||||
# статусы): существующие значения сохраняются, добавляются недостающие.
|
||||
if ($Col.type -eq 'Choice' -and $Col.choices) {
|
||||
try {
|
||||
Set-PnPField -List $List -Identity $Col.internalName -Values @{ Choices = [string[]]$Col.choices } | Out-Null
|
||||
Write-Host " [~] варианты обновлены: $($Col.internalName)" -ForegroundColor DarkGray
|
||||
}
|
||||
catch {
|
||||
Write-Warning " [!] $($Col.internalName): не удалось обновить варианты — $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " [=] колонка есть: $($Col.internalName)" -ForegroundColor DarkGray
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@
|
||||
padding: $sp-6;
|
||||
}
|
||||
|
||||
// Печать заявки идёт в отдельном окне (см. shared/ui/PrintButton), поэтому
|
||||
// print-обходов для панели тут не нужно.
|
||||
|
||||
@keyframes slide {
|
||||
from { transform: translateX(24px); opacity: .6; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
|
||||
@@ -8,6 +8,21 @@
|
||||
.CanvasComponent.LCS .CanvasZone {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
// ---- Печать ----
|
||||
// Печать идёт в ОТДЕЛЬНОМ чистом окне (см. shared/ui/PrintButton): документ
|
||||
// .rp-print-root переносится туда со стилями страницы, поэтому скролл-контейнеры
|
||||
// и высота SharePoint печать не режут. Здесь — только вид полей на печати
|
||||
// (read-only без серой заливки), эти правила применяются и в окне печати.
|
||||
@media print {
|
||||
.rp-print-root input,
|
||||
.rp-print-root textarea,
|
||||
.rp-print-root select {
|
||||
background: transparent !important;
|
||||
color: #000 !important;
|
||||
border-color: #ccc !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.requestPortal {
|
||||
|
||||
@@ -189,7 +189,9 @@ async function indexApprovalsForType(
|
||||
.top(2000)) {
|
||||
allRows.push(...(p as Array<Record<string, unknown>>));
|
||||
}
|
||||
stepRows = allRows.filter((r) => r.Status === REQUEST_STATUS.onApproval);
|
||||
stepRows = allRows.filter(
|
||||
(r) => r.Status === REQUEST_STATUS.onApproval || r.Status === REQUEST_STATUS.onExecution
|
||||
);
|
||||
console.log(
|
||||
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn, строк по ApproverId: ${allRows.length}, после фильтра по статусу: ${stepRows.length}`
|
||||
);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './RecruitingHireView.module.scss';
|
||||
import { Alert, Button, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList, Icon } from '../../shared/ui';
|
||||
import { ApproverService, DecisionPanel, StatusBadge } from '../../shared/approval';
|
||||
import { Alert, Button, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList, Icon, PrintButton } from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
useMatrixEditor,
|
||||
APPROVAL_PHASE
|
||||
} from '../../shared/approval';
|
||||
import { navigate } from '../../shared/routing';
|
||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||
@@ -42,6 +49,29 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
[]
|
||||
);
|
||||
|
||||
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
|
||||
const approval = useMemo(() => hireApprovalService(), []);
|
||||
const loadApprovers = useCallback(
|
||||
() =>
|
||||
approval
|
||||
.getMatrixForRequest(itemId)
|
||||
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
|
||||
[approval, itemId]
|
||||
);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
const loadExecutors = useCallback(
|
||||
() =>
|
||||
approval
|
||||
.getMatrixForRequest(itemId)
|
||||
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
|
||||
[approval, itemId]
|
||||
);
|
||||
const executors = useMatrixEditor(loadExecutors, `e-${itemId}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
@@ -81,7 +111,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
@@ -91,29 +121,48 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
<Icon name="copy" size="sm" />
|
||||
Организация рабочего места на основе этой
|
||||
</Button>
|
||||
<PrintButton />
|
||||
</div>
|
||||
|
||||
<FormCard
|
||||
title="Заявка на прием"
|
||||
subtitle={`№${itemId} · режим согласования`}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
<ApplicationSection {...sectionProps} />
|
||||
<CandidateSection {...sectionProps} />
|
||||
<PpeSection {...sectionProps} />
|
||||
<ConditionsSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
<div className="rp-print-root">
|
||||
<FormCard
|
||||
title="Заявка на прием"
|
||||
subtitle={`№${itemId}`}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
<ApplicationSection {...sectionProps} />
|
||||
<CandidateSection {...sectionProps} />
|
||||
<PpeSection {...sectionProps} />
|
||||
<ConditionsSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
<FormField label="Файлы" full>
|
||||
<AttachmentList items={attachments} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
</FormCard>
|
||||
<div className="rp-no-print">
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
<FormField label="Файлы" full>
|
||||
<AttachmentList items={attachments} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
</div>
|
||||
</FormCard>
|
||||
|
||||
{/* Панель решения — ВНЕ FormModeProvider, чтобы комментарий был редактируемым. */}
|
||||
<DecisionPanel service={approverService} requestId={itemId} />
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не назначены."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel service={approverService} requestId={itemId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"dataListTitle": "Прием на работу — согласование",
|
||||
"columns": [
|
||||
{ "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "На исполнении", "Выполнено", "Выполнение", "Закрыта"] },
|
||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" },
|
||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||
|
||||
@@ -6,3 +6,9 @@
|
||||
font-size: $fs-sm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './WorkplaceSetupView.module.scss';
|
||||
import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList } from '../../shared/ui';
|
||||
import { ApproverService, DecisionPanel, StatusBadge } from '../../shared/approval';
|
||||
import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
useMatrixEditor,
|
||||
APPROVAL_PHASE
|
||||
} from '../../shared/approval';
|
||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadWorkplaceRequest } from './load';
|
||||
@@ -31,6 +38,29 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
[]
|
||||
);
|
||||
|
||||
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
|
||||
const approval = useMemo(() => workplaceApprovalService(), []);
|
||||
const loadApprovers = useCallback(
|
||||
() =>
|
||||
approval
|
||||
.getMatrixForRequest(itemId)
|
||||
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
|
||||
[approval, itemId]
|
||||
);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
const loadExecutors = useCallback(
|
||||
() =>
|
||||
approval
|
||||
.getMatrixForRequest(itemId)
|
||||
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
|
||||
[approval, itemId]
|
||||
);
|
||||
const executors = useMatrixEditor(loadExecutors, `e-${itemId}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
@@ -70,24 +100,46 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormCard
|
||||
title="Организация рабочего места"
|
||||
subtitle={`№${itemId} · режим согласования`}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
<EmployeeSection {...sectionProps} />
|
||||
<EquipmentSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintButton />
|
||||
</div>
|
||||
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
<FormField label="Файлы" full>
|
||||
<AttachmentList items={attachments} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
</FormCard>
|
||||
<div className="rp-print-root">
|
||||
<FormCard
|
||||
title="Организация рабочего места"
|
||||
subtitle={`№${itemId}`}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
<EmployeeSection {...sectionProps} />
|
||||
<EquipmentSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
|
||||
<DecisionPanel service={approverService} requestId={itemId} />
|
||||
<div className="rp-no-print">
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
<FormField label="Файлы" full>
|
||||
<AttachmentList items={attachments} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
</div>
|
||||
</FormCard>
|
||||
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не назначены."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel service={approverService} requestId={itemId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"dataListTitle": "Организация рабочего места — согласование",
|
||||
"columns": [
|
||||
{ "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "На исполнении", "Выполнено", "Выполнение", "Закрыта"] },
|
||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" },
|
||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||
|
||||
@@ -48,14 +48,10 @@ export class ApprovalListService {
|
||||
// задан, иначе orderStart + позиция в массиве (фазы идут подряд в одном
|
||||
// списке: сначала согласующие 1..N, затем исполнители N+1..N+M).
|
||||
//
|
||||
// Статус строки решается здесь, а не читается заново при каждом открытии
|
||||
// заявки: строки с НАИМЕНЬШИМ order в этой партии получают переданный status
|
||||
// (обычно «На согласовании» — их очередь СЕЙЧАС; если несколько согласующих
|
||||
// делят один и тот же order — это параллельный шаг, активны все сразу),
|
||||
// остальные — «Ждёт очереди». Так «чья сейчас очередь» — это просто значение
|
||||
// колонки Status, без пересчёта порядка на клиенте (см.
|
||||
// approverDecision.service.ts/decide, который двигает очередь дальше при
|
||||
// каждом решении — той же логикой «наименьший order среди нерешённых»).
|
||||
// Статус пишется переданным значением ОДИНАКОВО для всех строк партии — кто
|
||||
// сейчас «на очереди», а кто «ждёт», решает отдельно activateNextQueued
|
||||
// (вызывается сразу после createSteps в approvalSubmission.service.ts/writeRoute
|
||||
// и в approverDecision.service.ts/decide).
|
||||
public async createSteps(
|
||||
requestId: number,
|
||||
approvers: IApproverToWrite[],
|
||||
@@ -66,15 +62,13 @@ export class ApprovalListService {
|
||||
const withOrder = approvers
|
||||
.map((a, i) => ({ a, order: a.order ?? orderStart + i }))
|
||||
.filter((x) => !!x.a.email);
|
||||
const minOrder = withOrder.length > 0 ? Math.min(...withOrder.map((x) => x.order)) : 0;
|
||||
|
||||
for (const { a, order } of withOrder) {
|
||||
const approverId = await this.resolveUserId(a.email);
|
||||
const rowStatus = order === minOrder ? status : REQUEST_STATUS.waitingInQueue;
|
||||
await this.list().items.add({
|
||||
Title: a.position || a.email,
|
||||
[`${FIELDS.approver}Id`]: approverId,
|
||||
[FIELDS.status]: rowStatus,
|
||||
[FIELDS.status]: status,
|
||||
[`${FIELDS.ticket}Id`]: requestId,
|
||||
[FIELDS.order]: order,
|
||||
[FIELDS.allowReassign]: a.allowReassign,
|
||||
@@ -83,6 +77,47 @@ export class ApprovalListService {
|
||||
}
|
||||
}
|
||||
|
||||
// Продвигает очередь: среди ещё не активных строк со статусом «Ждёт очереди»
|
||||
// в текущей фазе активирует те, что делят наименьший StepOrder (обычно одна
|
||||
// строка; несколько — параллельный шаг). Фаза исполнения активируется, только
|
||||
// когда фаза согласования пройдена целиком (без Отклонено/Возвращена) — иначе
|
||||
// строки исполнения остаются «Ждёт очереди». Если фаза согласования уже
|
||||
// остановлена решением, ничего не активирует.
|
||||
public async activateNextQueued(requestId: number): Promise<void> {
|
||||
const rows = await this.getForRequest(requestId);
|
||||
const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
|
||||
const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
|
||||
|
||||
const approvalStopped = approvalRows.some(
|
||||
(r) => r.status === REQUEST_STATUS.rejected || r.status === REQUEST_STATUS.returned
|
||||
);
|
||||
if (approvalStopped) return;
|
||||
|
||||
const approvalDone =
|
||||
approvalRows.length === 0 || approvalRows.every((r) => r.status === REQUEST_STATUS.approved);
|
||||
|
||||
const activate = async (phaseRows: IApprovalRow[], activeStatus: string): Promise<void> => {
|
||||
if (phaseRows.some((r) => r.status === activeStatus)) return;
|
||||
const queuedRows = phaseRows.filter((r) => r.status === REQUEST_STATUS.queued);
|
||||
if (queuedRows.length === 0) return;
|
||||
const minOrder = Math.min(...queuedRows.map((r) => r.order));
|
||||
await Promise.all(
|
||||
queuedRows.filter((r) => r.order === minOrder).map((r) => this.setStatus(r.id, activeStatus))
|
||||
);
|
||||
};
|
||||
|
||||
if (!approvalDone) {
|
||||
await activate(approvalRows, REQUEST_STATUS.onApproval);
|
||||
} else {
|
||||
await activate(execRows, REQUEST_STATUS.onExecution);
|
||||
}
|
||||
}
|
||||
|
||||
// Точечное обновление статуса одной строки маршрута.
|
||||
public async setStatus(rowId: number, status: string): Promise<void> {
|
||||
await this.list().items.getById(rowId).update({ [FIELDS.status]: status });
|
||||
}
|
||||
|
||||
// Добавляет одну строку согласования с заданным Order (для делегата).
|
||||
public async addApproverRow(
|
||||
requestId: number,
|
||||
@@ -206,12 +241,6 @@ export class ApprovalListService {
|
||||
});
|
||||
}
|
||||
|
||||
// Только статус строки, без даты/комментария — продвижение очереди
|
||||
// («На согласовании» ⇄ «Ждёт очереди»), а не решение согласующего.
|
||||
public async setStatus(rowId: number, status: string): Promise<void> {
|
||||
await this.list().items.getById(rowId).update({ [FIELDS.status]: status });
|
||||
}
|
||||
|
||||
// Удаляет все строки согласования по заявке (при возврате/повторной отправке).
|
||||
public async deleteForRequest(requestId: number): Promise<void> {
|
||||
const rows = await this.getForRequest(requestId);
|
||||
|
||||
@@ -43,7 +43,12 @@ export async function writeApprovers(
|
||||
status: string
|
||||
): Promise<void> {
|
||||
await approval.deleteForRequest(requestId);
|
||||
await approval.createSteps(requestId, approvers, status, APPROVAL_PHASE.approval, 1);
|
||||
const submitted = status === REQUEST_STATUS.onApproval;
|
||||
const rowStatus = submitted ? REQUEST_STATUS.queued : status;
|
||||
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
|
||||
if (submitted) {
|
||||
await approval.activateNextQueued(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
// Записать ОБЕ фазы маршрута заявки: согласующих (Order 1..N) и исполнителей
|
||||
@@ -51,10 +56,11 @@ export async function writeApprovers(
|
||||
// стороне согласующего внешний API уже не нужен, переход в фазу исполнения
|
||||
// делает computeRequestStatus, когда все согласующие одобрили.
|
||||
//
|
||||
// Исполнители стартуют ВСЕ как «Ждёт очереди» (не передаём им status — фаза
|
||||
// исполнения ещё не наступила, до неё нет ничьей текущей очереди). Первого
|
||||
// исполнителя переключит на «На согласовании» approverDecision.service.ts/decide,
|
||||
// когда фаза согласования будет пройдена целиком.
|
||||
// Исполнители стартуют ВСЕ как «Ждёт очереди» (rowStatus, та же логика, что и
|
||||
// у согласующих) — до прохождения фазы согласования у исполнителей нет текущей
|
||||
// очереди. Первого исполнителя переключит на «На исполнении»
|
||||
// approval.activateNextQueued, когда фаза согласования будет пройдена целиком
|
||||
// (см. approverDecision.service.ts/decide).
|
||||
export async function writeRoute(
|
||||
approval: ApprovalListService,
|
||||
requestId: number,
|
||||
@@ -63,12 +69,23 @@ export async function writeRoute(
|
||||
status: string
|
||||
): Promise<void> {
|
||||
await approval.deleteForRequest(requestId);
|
||||
await approval.createSteps(requestId, approvers, status, APPROVAL_PHASE.approval, 1);
|
||||
|
||||
// При ОТПРАВКЕ все шаги пишем «Ждёт очереди», затем активируем первый —
|
||||
// так активен ровно текущий шаг, остальные ждут. В черновике — статус заявки
|
||||
// (Черновик), без активации.
|
||||
const submitted = status === REQUEST_STATUS.onApproval;
|
||||
const rowStatus = submitted ? REQUEST_STATUS.queued : status;
|
||||
|
||||
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
|
||||
await approval.createSteps(
|
||||
requestId,
|
||||
executors,
|
||||
REQUEST_STATUS.waitingInQueue,
|
||||
rowStatus,
|
||||
APPROVAL_PHASE.execution,
|
||||
approvers.length + 1
|
||||
);
|
||||
|
||||
if (submitted) {
|
||||
await approval.activateNextQueued(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,17 @@ import { IApprovalRow } from './models/IApprovalRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
import { emitRequestsChanged } from '../requestEvents';
|
||||
|
||||
export type ApproverDecision = 'approve' | 'return' | 'reject';
|
||||
// approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено».
|
||||
export type ApproverDecision = 'approve' | 'return' | 'reject' | 'execute';
|
||||
|
||||
// Решение согласующего → статус его строки в списке согласования.
|
||||
// Решение → статус его строки в списке согласования.
|
||||
const ROW_STATUS: Record<ApproverDecision, string> = {
|
||||
approve: REQUEST_STATUS.approved,
|
||||
return: REQUEST_STATUS.returned,
|
||||
reject: REQUEST_STATUS.rejected,
|
||||
execute: REQUEST_STATUS.executed,
|
||||
};
|
||||
|
||||
const TERMINAL_STATUSES: string[] = [REQUEST_STATUS.approved, REQUEST_STATUS.rejected, REQUEST_STATUS.returned];
|
||||
|
||||
// Действия согласующего: определение его строки, применение решения,
|
||||
// делегирование и пересчёт статуса заявки в контентном списке.
|
||||
//
|
||||
@@ -38,7 +38,11 @@ export class ApproverService {
|
||||
public async getPendingRowForCurrentUser(requestId: number): Promise<IApprovalRow | undefined> {
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const userId = await this.approval.getCurrentUserId();
|
||||
const current = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0];
|
||||
// Активна первая незакрытая строка по очереди: согласующий («На согласовании»)
|
||||
// или, когда согласование пройдено, исполнитель («На исполнении»).
|
||||
const current = rows.filter(
|
||||
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
|
||||
)[0];
|
||||
if (current && current.approverId === userId) return current;
|
||||
return undefined;
|
||||
}
|
||||
@@ -58,16 +62,17 @@ export class ApproverService {
|
||||
const newOrder = (prevOrder + current.order) / 2;
|
||||
const position = await this.approval.getJobTitle(user.loginName);
|
||||
// Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение).
|
||||
// Пишем его сразу как «На согласовании» — advanceQueue ниже сам переведёт
|
||||
// строку currentRowId (у неё теперь больше не наименьший Order) в «Ждёт очереди».
|
||||
// Обе строки — делегат и прежний согласующий — уходят в «Ждёт очереди»,
|
||||
// activateNextQueued затем активирует делегата (у него меньший order).
|
||||
await this.approval.addApproverRow(
|
||||
requestId,
|
||||
{ email: user.email, position, allowReassign: false },
|
||||
newOrder,
|
||||
REQUEST_STATUS.onApproval,
|
||||
REQUEST_STATUS.queued,
|
||||
current.phase
|
||||
);
|
||||
await this.advanceQueue(requestId);
|
||||
await this.approval.setStatus(current.id, REQUEST_STATUS.queued);
|
||||
await this.approval.activateNextQueued(requestId);
|
||||
emitRequestsChanged();
|
||||
}
|
||||
|
||||
@@ -82,57 +87,19 @@ export class ApproverService {
|
||||
comment: string
|
||||
): Promise<string> {
|
||||
await this.approval.setDecision(rowId, ROW_STATUS[decision], comment);
|
||||
const rows = await this.advanceQueue(requestId);
|
||||
// Положительное решение двигает очередь — активируем следующий ждущий шаг
|
||||
// (в т.ч. переход из согласования в исполнение). При возврате/отклонении
|
||||
// очередь не двигаем: заявка уходит на доработку.
|
||||
if (decision === 'approve' || decision === 'execute') {
|
||||
await this.approval.activateNextQueued(requestId);
|
||||
}
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const requestStatus = this.computeRequestStatus(rows);
|
||||
await this.updateRequestStatus(requestId, requestStatus);
|
||||
emitRequestsChanged();
|
||||
return requestStatus;
|
||||
}
|
||||
|
||||
// Пересчитывает и записывает «чья сейчас очередь» по обеим фазам маршрута:
|
||||
// ровно у одной ещё не решённой строки на фазу становится «На согласовании»
|
||||
// (наименьший StepOrder среди нерешённых), у остальных — «Ждёт очереди».
|
||||
// Фаза исполнения активна, только когда фаза согласования пройдена целиком
|
||||
// (без Отклонено/Возвращена) — иначе там тоже сплошное «Ждёт очереди».
|
||||
// Возвращает строки МАРШРУТА в том виде, в каком они были ДО применения этих
|
||||
// обновлений (для computeRequestStatus — ему важны только терминальные
|
||||
// статусы approved/rejected/returned, на которые эта функция не влияет).
|
||||
private async advanceQueue(requestId: number): Promise<IApprovalRow[]> {
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
|
||||
const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
|
||||
|
||||
const approvalStopped = approvalRows.some(
|
||||
(r) => r.status === REQUEST_STATUS.rejected || r.status === REQUEST_STATUS.returned
|
||||
);
|
||||
const approvalDone =
|
||||
!approvalStopped &&
|
||||
(approvalRows.length === 0 || approvalRows.every((r) => r.status === REQUEST_STATUS.approved));
|
||||
|
||||
const updates = new Map<number, string>();
|
||||
const applyPhase = (phaseRows: IApprovalRow[], eligible: boolean): void => {
|
||||
const undecided = phaseRows.filter((r) => TERMINAL_STATUSES.indexOf(r.status) === -1);
|
||||
if (undecided.length === 0) return;
|
||||
if (!eligible) {
|
||||
undecided.forEach((r) => {
|
||||
if (r.status !== REQUEST_STATUS.waitingInQueue) updates.set(r.id, REQUEST_STATUS.waitingInQueue);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const minOrder = Math.min(...undecided.map((r) => r.order));
|
||||
undecided.forEach((r) => {
|
||||
const desired = r.order === minOrder ? REQUEST_STATUS.onApproval : REQUEST_STATUS.waitingInQueue;
|
||||
if (r.status !== desired) updates.set(r.id, desired);
|
||||
});
|
||||
};
|
||||
|
||||
applyPhase(approvalRows, !approvalStopped);
|
||||
applyPhase(execRows, approvalDone);
|
||||
|
||||
await Promise.all(Array.from(updates.entries()).map(([id, status]) => this.approval.setStatus(id, status)));
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Статус заявки из строк маршрута с учётом фазы:
|
||||
// 1) фаза согласования: отклонение/возврат → соответствующий статус;
|
||||
// не все согласовали → «На согласовании».
|
||||
@@ -153,7 +120,7 @@ export class ApproverService {
|
||||
if (!approvalDone) return REQUEST_STATUS.onApproval;
|
||||
|
||||
if (execRows.length === 0) return REQUEST_STATUS.approved;
|
||||
if (execRows.every((r) => r.status === REQUEST_STATUS.approved)) return REQUEST_STATUS.closed;
|
||||
if (execRows.every((r) => r.status === REQUEST_STATUS.executed)) return REQUEST_STATUS.closed;
|
||||
return REQUEST_STATUS.execution;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,22 +3,30 @@
|
||||
// В слое формы кодом ставятся Draft (черновик) и OnApproval (отправка на согласование);
|
||||
// Approved/Rejected/Returned выставляются при решениях согласующих; Execution/Closed —
|
||||
// зарезервированы под будущие этапы.
|
||||
// Статусы СТРОК списка «— согласование» (не заявки в контентном списке):
|
||||
// draft/rejected/returned — общие с заявкой; остальные — только для строк
|
||||
// маршрута, по фазам:
|
||||
// Согласование: queued («Ждёт очереди») → onApproval («На согласовании») → approved/rejected/returned.
|
||||
// Исполнение: queued → onExecution («На исполнении») → executed («Выполнено»).
|
||||
// Активация — только через ApprovalListService.activateNextQueued (вызывается
|
||||
// при отправке заявки и после каждого положительного решения, см.
|
||||
// approvalSubmission.service.ts/writeRoute, approverDecision.service.ts/decide):
|
||||
// среди строк со статусом queued в текущей фазе находит наименьший StepOrder
|
||||
// и переводит её (или несколько, если делят один order — параллельный шаг) в
|
||||
// onApproval/onExecution. Так «чья сейчас очередь» — просто значение колонки
|
||||
// Status, без пересчёта порядка на клиенте.
|
||||
export const REQUEST_STATUS = {
|
||||
draft: 'Черновик',
|
||||
onApproval: 'На согласовании',
|
||||
queued: 'Ждёт очереди',
|
||||
onExecution: 'На исполнении',
|
||||
executed: 'Выполнено',
|
||||
approved: 'Согласовано',
|
||||
rejected: 'Отклонено',
|
||||
returned: 'Возвращена на доработку',
|
||||
// Статус ЗАЯВКИ (не строки) на этапе исполнения.
|
||||
execution: 'Выполнение',
|
||||
closed: 'Закрыта',
|
||||
// Строка маршрута, до которой очередь ещё не дошла. Применяется ТОЛЬКО к
|
||||
// строкам списка «— согласование» (не к заявке в контентном списке):
|
||||
// при создании маршрута и при каждом решении согласующего (см.
|
||||
// approvalList.service.ts/createSteps, approverDecision.service.ts/decide)
|
||||
// ровно у одной строки на фазу выставляется onApproval («её очередь сейчас»),
|
||||
// у всех остальных недорешённых строк — waitingInQueue. Это позволяет читать
|
||||
// «чья сейчас очередь» прямым фильтром по Status, без пересчёта на клиенте.
|
||||
waitingInQueue: 'Ждёт очереди',
|
||||
} as const;
|
||||
|
||||
// Статусы, при которых заявку ещё можно править (форма редактируема).
|
||||
@@ -35,14 +43,16 @@ export function isEditableStatus(status: string | undefined): boolean {
|
||||
return !status || EDITABLE_STATUSES.indexOf(status) !== -1;
|
||||
}
|
||||
|
||||
// Полный набор значений для Choice-колонки при провижининге.
|
||||
// Полный набор значений для Choice-колонки Status при провижининге.
|
||||
export const REQUEST_STATUS_CHOICES: string[] = [
|
||||
REQUEST_STATUS.draft,
|
||||
REQUEST_STATUS.onApproval,
|
||||
REQUEST_STATUS.waitingInQueue,
|
||||
REQUEST_STATUS.queued,
|
||||
REQUEST_STATUS.approved,
|
||||
REQUEST_STATUS.rejected,
|
||||
REQUEST_STATUS.returned,
|
||||
REQUEST_STATUS.onExecution,
|
||||
REQUEST_STATUS.executed,
|
||||
REQUEST_STATUS.execution,
|
||||
REQUEST_STATUS.closed,
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@ import styles from './approval.module.scss';
|
||||
import { Icon } from '../../ui';
|
||||
import { IUseMatrixEditor } from '../useMatrixEditor';
|
||||
import { ISiteUser } from '../models/ISiteUser';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { UserPicker } from './UserPicker';
|
||||
|
||||
export interface IApprovalMatrixProps {
|
||||
@@ -53,7 +54,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
<tr key={i}>
|
||||
<td>{r.who}</td>
|
||||
<td>{r.position}</td>
|
||||
<td>{r.status}</td>
|
||||
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
|
||||
<td>{r.date}</td>
|
||||
{editable && (
|
||||
<td className={styles.matrixActions}>
|
||||
|
||||
@@ -74,8 +74,10 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
|
||||
|
||||
const isExecution = phase === APPROVAL_PHASE.execution;
|
||||
|
||||
if (checking) {
|
||||
return <div className={styles.state}>Проверяем вашу очередь…</div>;
|
||||
// Пока проверяем очередь или если действий нет (не твоя очередь / заявка
|
||||
// обработана / тебя нет в маршруте) — панель не показываем вовсе.
|
||||
if (checking || rowId === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (resultStatus) {
|
||||
@@ -89,14 +91,6 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
|
||||
);
|
||||
}
|
||||
|
||||
if (rowId === undefined) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<Alert variant="warn">Сейчас не ваша очередь, либо заявка уже обработана.</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Фаза исполнения: одно действие «Выполнено» (без обязательного комментария),
|
||||
// строка помечается как «Согласовано» → computeRequestStatus переведёт заявку в
|
||||
// «Выполнение»/«Закрыта».
|
||||
@@ -114,7 +108,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
|
||||
/>
|
||||
</FormField>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="accent" disabled={submitting} onClick={() => decide('approve')}>
|
||||
<Button variant="accent" disabled={submitting} onClick={() => decide('execute')}>
|
||||
<Icon name="check" size="sm" />
|
||||
Выполнено
|
||||
</Button>
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.queued {
|
||||
background: $gray-100;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.onApproval {
|
||||
background: $blue-50;
|
||||
color: $blue-600;
|
||||
|
||||
@@ -5,8 +5,11 @@ import { REQUEST_STATUS } from '../models/statuses';
|
||||
// Цвет бейджа по статусу заявки. Неизвестный статус → нейтральный (как черновик).
|
||||
const VARIANT: Record<string, string> = {
|
||||
[REQUEST_STATUS.draft]: styles.draft,
|
||||
[REQUEST_STATUS.queued]: styles.queued,
|
||||
[REQUEST_STATUS.onApproval]: styles.onApproval,
|
||||
[REQUEST_STATUS.onExecution]: styles.onApproval, // активный шаг — как «на согласовании»
|
||||
[REQUEST_STATUS.approved]: styles.approved,
|
||||
[REQUEST_STATUS.executed]: styles.approved, // выполнено — как «согласовано» (зелёный)
|
||||
[REQUEST_STATUS.rejected]: styles.rejected,
|
||||
[REQUEST_STATUS.returned]: styles.returned,
|
||||
[REQUEST_STATUS.execution]: styles.execution,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '../Button/Button';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
// Значение поля для печати. Дату (input type=date, ISO yyyy-mm-dd) — в дд.мм.гггг.
|
||||
function formatValue(el: HTMLInputElement): string {
|
||||
if (el.type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(el.value)) {
|
||||
const [y, m, d] = el.value.split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
return el.value;
|
||||
}
|
||||
|
||||
// Печатаем как ДАННЫЕ, а не как форму: каждое поле ввода в клоне заменяем на
|
||||
// обычный текст его значения (значения берём из живого DOM — React держит их как
|
||||
// свойства, в клонированном HTML их нет). Так textarea/поля не занимают лишнего
|
||||
// места и лист выглядит как справка, а не как незаполненный бланк.
|
||||
function renderValuesAsText(source: Element, clone: Element): void {
|
||||
const put = (cloneEl: Element | undefined, text: string): void => {
|
||||
if (!cloneEl) return;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'rp-print-value';
|
||||
div.textContent = text && text.length > 0 ? text : '—';
|
||||
(cloneEl.closest('label') ?? cloneEl).replaceWith(div);
|
||||
};
|
||||
|
||||
const cInputs = clone.querySelectorAll('input');
|
||||
source.querySelectorAll('input').forEach((el, i) => {
|
||||
if (el.type === 'checkbox' || el.type === 'radio') {
|
||||
put(cInputs[i], el.checked ? 'Да' : 'Нет');
|
||||
} else {
|
||||
put(cInputs[i], formatValue(el));
|
||||
}
|
||||
});
|
||||
|
||||
const cAreas = clone.querySelectorAll('textarea');
|
||||
source.querySelectorAll('textarea').forEach((el, i) => put(cAreas[i], el.value));
|
||||
|
||||
const cSelects = clone.querySelectorAll('select');
|
||||
source.querySelectorAll('select').forEach((el, i) => {
|
||||
const opt = el.options[el.selectedIndex];
|
||||
put(cSelects[i], opt && opt.value ? opt.text : '');
|
||||
});
|
||||
}
|
||||
|
||||
// Стили печатного окна: чистый лист (без «коробки» карточки), без иконок,
|
||||
// значения — обычным текстом с переносами.
|
||||
const PRINT_CSS =
|
||||
// height:auto перебивает глобальный CSS SharePoint (html/body 100vh) — иначе
|
||||
// body раздувается выше листа A4 и в печать идёт лишняя пустая страница.
|
||||
'html,body{margin:0;background:#fff;height:auto!important;min-height:0!important}' +
|
||||
'body{padding:24px;font-size:13px}' +
|
||||
'svg{display:none!important}' +
|
||||
'.rp-print-root>*{border:0!important;box-shadow:none!important;padding:0!important}' +
|
||||
'.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}';
|
||||
|
||||
/**
|
||||
* Кнопка «Печать»: переносит документ (`.rp-print-root`) в отдельное чистое окно,
|
||||
* заменяя поля ввода на текст значений, и печатает его. Не зависит от скролл-
|
||||
* контейнеров/высоты SharePoint, корректно разбивается на страницы. Общая для форм.
|
||||
*/
|
||||
export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => {
|
||||
const onPrint = (): void => {
|
||||
const source = document.querySelector('.rp-print-root');
|
||||
if (!source) {
|
||||
window.print();
|
||||
return;
|
||||
}
|
||||
const win = window.open('', '_blank', 'width=980,height=1200');
|
||||
if (!win) {
|
||||
window.print(); // всплывающее окно заблокировано — печатаем как есть
|
||||
return;
|
||||
}
|
||||
|
||||
const clone = source.cloneNode(true) as Element;
|
||||
// Убираем из печати всё помеченное rp-no-print (напр. блок «Вложения»).
|
||||
clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove());
|
||||
renderValuesAsText(source, clone);
|
||||
|
||||
// Переносим стили страницы (CSS-модули инжектятся как <style>, шрифты — <link>).
|
||||
const head = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
|
||||
.map((n) => n.outerHTML)
|
||||
.join('\n');
|
||||
|
||||
win.document.open();
|
||||
win.document.write(
|
||||
`<!doctype html><html><head><meta charset="utf-8">${head}` +
|
||||
`<style>${PRINT_CSS}</style></head><body>${clone.outerHTML}</body></html>`
|
||||
);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
|
||||
// Даём стилям/шрифтам примениться, затем печатаем и закрываем окно.
|
||||
const done = (): void => {
|
||||
win.print();
|
||||
win.close();
|
||||
};
|
||||
if (win.document.readyState === 'complete') {
|
||||
window.setTimeout(done, 350);
|
||||
} else {
|
||||
win.onload = (): void => {
|
||||
window.setTimeout(done, 350);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="secondary" onClick={onPrint}>
|
||||
<Icon name="document" size="sm" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -21,5 +21,6 @@ export { FormModeProvider, useFormReadOnly } from './FormMode';
|
||||
export { Icon } from './Icon/Icon';
|
||||
export { SubmitActions } from './SubmitActions/SubmitActions';
|
||||
export { RequestNumber } from './RequestNumber/RequestNumber';
|
||||
export { PrintButton } from './PrintButton/PrintButton';
|
||||
export { useRequestSubmit } from './useRequestSubmit';
|
||||
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';
|
||||
|
||||
Reference in New Issue
Block a user