мердж
This commit is contained in:
@@ -62,7 +62,20 @@ function Add-Column {
|
|||||||
param([string]$List, $Col)
|
param([string]$List, $Col)
|
||||||
|
|
||||||
if (Test-FieldExists -List $List -InternalName $Col.internalName) {
|
if (Test-FieldExists -List $List -InternalName $Col.internalName) {
|
||||||
|
# Колонка уже есть. Для 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
|
Write-Host " [=] колонка есть: $($Col.internalName)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,9 @@
|
|||||||
padding: $sp-6;
|
padding: $sp-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Печать заявки идёт в отдельном окне (см. shared/ui/PrintButton), поэтому
|
||||||
|
// print-обходов для панели тут не нужно.
|
||||||
|
|
||||||
@keyframes slide {
|
@keyframes slide {
|
||||||
from { transform: translateX(24px); opacity: .6; }
|
from { transform: translateX(24px); opacity: .6; }
|
||||||
to { transform: translateX(0); opacity: 1; }
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
|||||||
@@ -8,6 +8,21 @@
|
|||||||
.CanvasComponent.LCS .CanvasZone {
|
.CanvasComponent.LCS .CanvasZone {
|
||||||
max-width: 100% !important;
|
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 {
|
.requestPortal {
|
||||||
|
|||||||
@@ -189,7 +189,9 @@ async function indexApprovalsForType(
|
|||||||
.top(2000)) {
|
.top(2000)) {
|
||||||
allRows.push(...(p as Array<Record<string, unknown>>));
|
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(
|
console.log(
|
||||||
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn, строк по ApproverId: ${allRows.length}, после фильтра по статусу: ${stepRows.length}`
|
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn, строк по ApproverId: ${allRows.length}, после фильтра по статусу: ${stepRows.length}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import * as React from 'react';
|
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 type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './RecruitingHireView.module.scss';
|
import styles from './RecruitingHireView.module.scss';
|
||||||
import { Alert, Button, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList, Icon } from '../../shared/ui';
|
import { Alert, Button, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList, Icon, PrintButton } from '../../shared/ui';
|
||||||
import { ApproverService, DecisionPanel, StatusBadge } from '../../shared/approval';
|
import {
|
||||||
|
ApproverService,
|
||||||
|
DecisionPanel,
|
||||||
|
StatusBadge,
|
||||||
|
ApprovalMatrix,
|
||||||
|
useMatrixEditor,
|
||||||
|
APPROVAL_PHASE
|
||||||
|
} from '../../shared/approval';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
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(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -81,7 +111,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.toolbar}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -91,11 +121,13 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
<Icon name="copy" size="sm" />
|
<Icon name="copy" size="sm" />
|
||||||
Организация рабочего места на основе этой
|
Организация рабочего места на основе этой
|
||||||
</Button>
|
</Button>
|
||||||
|
<PrintButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на прием"
|
title="Заявка на прием"
|
||||||
subtitle={`№${itemId} · режим согласования`}
|
subtitle={`№${itemId}`}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
@@ -105,16 +137,33 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
<ConditionsSection {...sectionProps} />
|
<ConditionsSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|
||||||
|
<div className="rp-no-print">
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
<FormField label="Файлы" full>
|
<FormField label="Файлы" full>
|
||||||
<AttachmentList items={attachments} />
|
<AttachmentList items={attachments} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
|
</div>
|
||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ FormModeProvider, чтобы комментарий был редактируемым. */}
|
<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} />
|
<DecisionPanel service={approverService} requestId={itemId} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"dataListTitle": "Прием на работу — согласование",
|
"dataListTitle": "Прием на работу — согласование",
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "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": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" },
|
||||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
|
|||||||
@@ -6,3 +6,9 @@
|
|||||||
font-size: $fs-sm;
|
font-size: $fs-sm;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import * as React from 'react';
|
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 type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './WorkplaceSetupView.module.scss';
|
import styles from './WorkplaceSetupView.module.scss';
|
||||||
import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList } from '../../shared/ui';
|
import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui';
|
||||||
import { ApproverService, DecisionPanel, StatusBadge } from '../../shared/approval';
|
import {
|
||||||
|
ApproverService,
|
||||||
|
DecisionPanel,
|
||||||
|
StatusBadge,
|
||||||
|
ApprovalMatrix,
|
||||||
|
useMatrixEditor,
|
||||||
|
APPROVAL_PHASE
|
||||||
|
} from '../../shared/approval';
|
||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { loadWorkplaceRequest } from './load';
|
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(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -70,9 +100,14 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
|
<PrintButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Организация рабочего места"
|
title="Организация рабочего места"
|
||||||
subtitle={`№${itemId} · режим согласования`}
|
subtitle={`№${itemId}`}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
@@ -80,15 +115,32 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
<EquipmentSection {...sectionProps} />
|
<EquipmentSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|
||||||
|
<div className="rp-no-print">
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
<FormField label="Файлы" full>
|
<FormField label="Файлы" full>
|
||||||
<AttachmentList items={attachments} />
|
<AttachmentList items={attachments} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
|
</div>
|
||||||
</FormCard>
|
</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} />
|
<DecisionPanel service={approverService} requestId={itemId} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"dataListTitle": "Организация рабочего места — согласование",
|
"dataListTitle": "Организация рабочего места — согласование",
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "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": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" },
|
||||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
|
|||||||
@@ -48,14 +48,10 @@ export class ApprovalListService {
|
|||||||
// задан, иначе orderStart + позиция в массиве (фазы идут подряд в одном
|
// задан, иначе orderStart + позиция в массиве (фазы идут подряд в одном
|
||||||
// списке: сначала согласующие 1..N, затем исполнители N+1..N+M).
|
// списке: сначала согласующие 1..N, затем исполнители N+1..N+M).
|
||||||
//
|
//
|
||||||
// Статус строки решается здесь, а не читается заново при каждом открытии
|
// Статус пишется переданным значением ОДИНАКОВО для всех строк партии — кто
|
||||||
// заявки: строки с НАИМЕНЬШИМ order в этой партии получают переданный status
|
// сейчас «на очереди», а кто «ждёт», решает отдельно activateNextQueued
|
||||||
// (обычно «На согласовании» — их очередь СЕЙЧАС; если несколько согласующих
|
// (вызывается сразу после createSteps в approvalSubmission.service.ts/writeRoute
|
||||||
// делят один и тот же order — это параллельный шаг, активны все сразу),
|
// и в approverDecision.service.ts/decide).
|
||||||
// остальные — «Ждёт очереди». Так «чья сейчас очередь» — это просто значение
|
|
||||||
// колонки Status, без пересчёта порядка на клиенте (см.
|
|
||||||
// approverDecision.service.ts/decide, который двигает очередь дальше при
|
|
||||||
// каждом решении — той же логикой «наименьший order среди нерешённых»).
|
|
||||||
public async createSteps(
|
public async createSteps(
|
||||||
requestId: number,
|
requestId: number,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
@@ -66,15 +62,13 @@ export class ApprovalListService {
|
|||||||
const withOrder = approvers
|
const withOrder = approvers
|
||||||
.map((a, i) => ({ a, order: a.order ?? orderStart + i }))
|
.map((a, i) => ({ a, order: a.order ?? orderStart + i }))
|
||||||
.filter((x) => !!x.a.email);
|
.filter((x) => !!x.a.email);
|
||||||
const minOrder = withOrder.length > 0 ? Math.min(...withOrder.map((x) => x.order)) : 0;
|
|
||||||
|
|
||||||
for (const { a, order } of withOrder) {
|
for (const { a, order } of withOrder) {
|
||||||
const approverId = await this.resolveUserId(a.email);
|
const approverId = await this.resolveUserId(a.email);
|
||||||
const rowStatus = order === minOrder ? status : REQUEST_STATUS.waitingInQueue;
|
|
||||||
await this.list().items.add({
|
await this.list().items.add({
|
||||||
Title: a.position || a.email,
|
Title: a.position || a.email,
|
||||||
[`${FIELDS.approver}Id`]: approverId,
|
[`${FIELDS.approver}Id`]: approverId,
|
||||||
[FIELDS.status]: rowStatus,
|
[FIELDS.status]: status,
|
||||||
[`${FIELDS.ticket}Id`]: requestId,
|
[`${FIELDS.ticket}Id`]: requestId,
|
||||||
[FIELDS.order]: order,
|
[FIELDS.order]: order,
|
||||||
[FIELDS.allowReassign]: a.allowReassign,
|
[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 (для делегата).
|
// Добавляет одну строку согласования с заданным Order (для делегата).
|
||||||
public async addApproverRow(
|
public async addApproverRow(
|
||||||
requestId: number,
|
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> {
|
public async deleteForRequest(requestId: number): Promise<void> {
|
||||||
const rows = await this.getForRequest(requestId);
|
const rows = await this.getForRequest(requestId);
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ export async function writeApprovers(
|
|||||||
status: string
|
status: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await approval.deleteForRequest(requestId);
|
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) и исполнителей
|
// Записать ОБЕ фазы маршрута заявки: согласующих (Order 1..N) и исполнителей
|
||||||
@@ -51,10 +56,11 @@ export async function writeApprovers(
|
|||||||
// стороне согласующего внешний API уже не нужен, переход в фазу исполнения
|
// стороне согласующего внешний API уже не нужен, переход в фазу исполнения
|
||||||
// делает computeRequestStatus, когда все согласующие одобрили.
|
// делает computeRequestStatus, когда все согласующие одобрили.
|
||||||
//
|
//
|
||||||
// Исполнители стартуют ВСЕ как «Ждёт очереди» (не передаём им status — фаза
|
// Исполнители стартуют ВСЕ как «Ждёт очереди» (rowStatus, та же логика, что и
|
||||||
// исполнения ещё не наступила, до неё нет ничьей текущей очереди). Первого
|
// у согласующих) — до прохождения фазы согласования у исполнителей нет текущей
|
||||||
// исполнителя переключит на «На согласовании» approverDecision.service.ts/decide,
|
// очереди. Первого исполнителя переключит на «На исполнении»
|
||||||
// когда фаза согласования будет пройдена целиком.
|
// approval.activateNextQueued, когда фаза согласования будет пройдена целиком
|
||||||
|
// (см. approverDecision.service.ts/decide).
|
||||||
export async function writeRoute(
|
export async function writeRoute(
|
||||||
approval: ApprovalListService,
|
approval: ApprovalListService,
|
||||||
requestId: number,
|
requestId: number,
|
||||||
@@ -63,12 +69,23 @@ export async function writeRoute(
|
|||||||
status: string
|
status: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await approval.deleteForRequest(requestId);
|
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(
|
await approval.createSteps(
|
||||||
requestId,
|
requestId,
|
||||||
executors,
|
executors,
|
||||||
REQUEST_STATUS.waitingInQueue,
|
rowStatus,
|
||||||
APPROVAL_PHASE.execution,
|
APPROVAL_PHASE.execution,
|
||||||
approvers.length + 1
|
approvers.length + 1
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (submitted) {
|
||||||
|
await approval.activateNextQueued(requestId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ import { IApprovalRow } from './models/IApprovalRow';
|
|||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
import { emitRequestsChanged } from '../requestEvents';
|
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> = {
|
const ROW_STATUS: Record<ApproverDecision, string> = {
|
||||||
approve: REQUEST_STATUS.approved,
|
approve: REQUEST_STATUS.approved,
|
||||||
return: REQUEST_STATUS.returned,
|
return: REQUEST_STATUS.returned,
|
||||||
reject: REQUEST_STATUS.rejected,
|
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> {
|
public async getPendingRowForCurrentUser(requestId: number): Promise<IApprovalRow | undefined> {
|
||||||
const rows = await this.approval.getForRequest(requestId);
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
const userId = await this.approval.getCurrentUserId();
|
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;
|
if (current && current.approverId === userId) return current;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -58,16 +62,17 @@ export class ApproverService {
|
|||||||
const newOrder = (prevOrder + current.order) / 2;
|
const newOrder = (prevOrder + current.order) / 2;
|
||||||
const position = await this.approval.getJobTitle(user.loginName);
|
const position = await this.approval.getJobTitle(user.loginName);
|
||||||
// Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение).
|
// Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение).
|
||||||
// Пишем его сразу как «На согласовании» — advanceQueue ниже сам переведёт
|
// Обе строки — делегат и прежний согласующий — уходят в «Ждёт очереди»,
|
||||||
// строку currentRowId (у неё теперь больше не наименьший Order) в «Ждёт очереди».
|
// activateNextQueued затем активирует делегата (у него меньший order).
|
||||||
await this.approval.addApproverRow(
|
await this.approval.addApproverRow(
|
||||||
requestId,
|
requestId,
|
||||||
{ email: user.email, position, allowReassign: false },
|
{ email: user.email, position, allowReassign: false },
|
||||||
newOrder,
|
newOrder,
|
||||||
REQUEST_STATUS.onApproval,
|
REQUEST_STATUS.queued,
|
||||||
current.phase
|
current.phase
|
||||||
);
|
);
|
||||||
await this.advanceQueue(requestId);
|
await this.approval.setStatus(current.id, REQUEST_STATUS.queued);
|
||||||
|
await this.approval.activateNextQueued(requestId);
|
||||||
emitRequestsChanged();
|
emitRequestsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,57 +87,19 @@ export class ApproverService {
|
|||||||
comment: string
|
comment: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
await this.approval.setDecision(rowId, ROW_STATUS[decision], comment);
|
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);
|
const requestStatus = this.computeRequestStatus(rows);
|
||||||
await this.updateRequestStatus(requestId, requestStatus);
|
await this.updateRequestStatus(requestId, requestStatus);
|
||||||
emitRequestsChanged();
|
emitRequestsChanged();
|
||||||
return requestStatus;
|
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) фаза согласования: отклонение/возврат → соответствующий статус;
|
// 1) фаза согласования: отклонение/возврат → соответствующий статус;
|
||||||
// не все согласовали → «На согласовании».
|
// не все согласовали → «На согласовании».
|
||||||
@@ -153,7 +120,7 @@ export class ApproverService {
|
|||||||
if (!approvalDone) return REQUEST_STATUS.onApproval;
|
if (!approvalDone) return REQUEST_STATUS.onApproval;
|
||||||
|
|
||||||
if (execRows.length === 0) return REQUEST_STATUS.approved;
|
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;
|
return REQUEST_STATUS.execution;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,30 @@
|
|||||||
// В слое формы кодом ставятся Draft (черновик) и OnApproval (отправка на согласование);
|
// В слое формы кодом ставятся Draft (черновик) и OnApproval (отправка на согласование);
|
||||||
// Approved/Rejected/Returned выставляются при решениях согласующих; Execution/Closed —
|
// 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 = {
|
export const REQUEST_STATUS = {
|
||||||
draft: 'Черновик',
|
draft: 'Черновик',
|
||||||
onApproval: 'На согласовании',
|
onApproval: 'На согласовании',
|
||||||
|
queued: 'Ждёт очереди',
|
||||||
|
onExecution: 'На исполнении',
|
||||||
|
executed: 'Выполнено',
|
||||||
approved: 'Согласовано',
|
approved: 'Согласовано',
|
||||||
rejected: 'Отклонено',
|
rejected: 'Отклонено',
|
||||||
returned: 'Возвращена на доработку',
|
returned: 'Возвращена на доработку',
|
||||||
|
// Статус ЗАЯВКИ (не строки) на этапе исполнения.
|
||||||
execution: 'Выполнение',
|
execution: 'Выполнение',
|
||||||
closed: 'Закрыта',
|
closed: 'Закрыта',
|
||||||
// Строка маршрута, до которой очередь ещё не дошла. Применяется ТОЛЬКО к
|
|
||||||
// строкам списка «— согласование» (не к заявке в контентном списке):
|
|
||||||
// при создании маршрута и при каждом решении согласующего (см.
|
|
||||||
// approvalList.service.ts/createSteps, approverDecision.service.ts/decide)
|
|
||||||
// ровно у одной строки на фазу выставляется onApproval («её очередь сейчас»),
|
|
||||||
// у всех остальных недорешённых строк — waitingInQueue. Это позволяет читать
|
|
||||||
// «чья сейчас очередь» прямым фильтром по Status, без пересчёта на клиенте.
|
|
||||||
waitingInQueue: 'Ждёт очереди',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Статусы, при которых заявку ещё можно править (форма редактируема).
|
// Статусы, при которых заявку ещё можно править (форма редактируема).
|
||||||
@@ -35,14 +43,16 @@ export function isEditableStatus(status: string | undefined): boolean {
|
|||||||
return !status || EDITABLE_STATUSES.indexOf(status) !== -1;
|
return !status || EDITABLE_STATUSES.indexOf(status) !== -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Полный набор значений для Choice-колонки при провижининге.
|
// Полный набор значений для Choice-колонки Status при провижининге.
|
||||||
export const REQUEST_STATUS_CHOICES: string[] = [
|
export const REQUEST_STATUS_CHOICES: string[] = [
|
||||||
REQUEST_STATUS.draft,
|
REQUEST_STATUS.draft,
|
||||||
REQUEST_STATUS.onApproval,
|
REQUEST_STATUS.onApproval,
|
||||||
REQUEST_STATUS.waitingInQueue,
|
REQUEST_STATUS.queued,
|
||||||
REQUEST_STATUS.approved,
|
REQUEST_STATUS.approved,
|
||||||
REQUEST_STATUS.rejected,
|
REQUEST_STATUS.rejected,
|
||||||
REQUEST_STATUS.returned,
|
REQUEST_STATUS.returned,
|
||||||
|
REQUEST_STATUS.onExecution,
|
||||||
|
REQUEST_STATUS.executed,
|
||||||
REQUEST_STATUS.execution,
|
REQUEST_STATUS.execution,
|
||||||
REQUEST_STATUS.closed,
|
REQUEST_STATUS.closed,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import styles from './approval.module.scss';
|
|||||||
import { Icon } from '../../ui';
|
import { Icon } from '../../ui';
|
||||||
import { IUseMatrixEditor } from '../useMatrixEditor';
|
import { IUseMatrixEditor } from '../useMatrixEditor';
|
||||||
import { ISiteUser } from '../models/ISiteUser';
|
import { ISiteUser } from '../models/ISiteUser';
|
||||||
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { UserPicker } from './UserPicker';
|
import { UserPicker } from './UserPicker';
|
||||||
|
|
||||||
export interface IApprovalMatrixProps {
|
export interface IApprovalMatrixProps {
|
||||||
@@ -53,7 +54,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td>{r.who}</td>
|
<td>{r.who}</td>
|
||||||
<td>{r.position}</td>
|
<td>{r.position}</td>
|
||||||
<td>{r.status}</td>
|
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
|
||||||
<td>{r.date}</td>
|
<td>{r.date}</td>
|
||||||
{editable && (
|
{editable && (
|
||||||
<td className={styles.matrixActions}>
|
<td className={styles.matrixActions}>
|
||||||
|
|||||||
@@ -74,8 +74,10 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
|
|||||||
|
|
||||||
const isExecution = phase === APPROVAL_PHASE.execution;
|
const isExecution = phase === APPROVAL_PHASE.execution;
|
||||||
|
|
||||||
if (checking) {
|
// Пока проверяем очередь или если действий нет (не твоя очередь / заявка
|
||||||
return <div className={styles.state}>Проверяем вашу очередь…</div>;
|
// обработана / тебя нет в маршруте) — панель не показываем вовсе.
|
||||||
|
if (checking || rowId === undefined) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resultStatus) {
|
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 переведёт заявку в
|
// строка помечается как «Согласовано» → computeRequestStatus переведёт заявку в
|
||||||
// «Выполнение»/«Закрыта».
|
// «Выполнение»/«Закрыта».
|
||||||
@@ -114,7 +108,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className={styles.actions}>
|
<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" />
|
<Icon name="check" size="sm" />
|
||||||
Выполнено
|
Выполнено
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
color: $gray-700;
|
color: $gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queued {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
.onApproval {
|
.onApproval {
|
||||||
background: $blue-50;
|
background: $blue-50;
|
||||||
color: $blue-600;
|
color: $blue-600;
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import { REQUEST_STATUS } from '../models/statuses';
|
|||||||
// Цвет бейджа по статусу заявки. Неизвестный статус → нейтральный (как черновик).
|
// Цвет бейджа по статусу заявки. Неизвестный статус → нейтральный (как черновик).
|
||||||
const VARIANT: Record<string, string> = {
|
const VARIANT: Record<string, string> = {
|
||||||
[REQUEST_STATUS.draft]: styles.draft,
|
[REQUEST_STATUS.draft]: styles.draft,
|
||||||
|
[REQUEST_STATUS.queued]: styles.queued,
|
||||||
[REQUEST_STATUS.onApproval]: styles.onApproval,
|
[REQUEST_STATUS.onApproval]: styles.onApproval,
|
||||||
|
[REQUEST_STATUS.onExecution]: styles.onApproval, // активный шаг — как «на согласовании»
|
||||||
[REQUEST_STATUS.approved]: styles.approved,
|
[REQUEST_STATUS.approved]: styles.approved,
|
||||||
|
[REQUEST_STATUS.executed]: styles.approved, // выполнено — как «согласовано» (зелёный)
|
||||||
[REQUEST_STATUS.rejected]: styles.rejected,
|
[REQUEST_STATUS.rejected]: styles.rejected,
|
||||||
[REQUEST_STATUS.returned]: styles.returned,
|
[REQUEST_STATUS.returned]: styles.returned,
|
||||||
[REQUEST_STATUS.execution]: styles.execution,
|
[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 { Icon } from './Icon/Icon';
|
||||||
export { SubmitActions } from './SubmitActions/SubmitActions';
|
export { SubmitActions } from './SubmitActions/SubmitActions';
|
||||||
export { RequestNumber } from './RequestNumber/RequestNumber';
|
export { RequestNumber } from './RequestNumber/RequestNumber';
|
||||||
|
export { PrintButton } from './PrintButton/PrintButton';
|
||||||
export { useRequestSubmit } from './useRequestSubmit';
|
export { useRequestSubmit } from './useRequestSubmit';
|
||||||
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';
|
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';
|
||||||
|
|||||||
Reference in New Issue
Block a user