feat: added status next in que and changed forms state machine
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 {
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import '@pnp/sp/profiles';
|
|||||||
import { IApprovalRow } from './models/IApprovalRow';
|
import { IApprovalRow } from './models/IApprovalRow';
|
||||||
import { IMatrixRow } from './models/IMatrixRow';
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
import { APPROVAL_PHASE } from './models/statuses';
|
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
||||||
|
|
||||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||||
const FIELDS = {
|
const FIELDS = {
|
||||||
@@ -187,6 +187,19 @@ export class ApprovalListService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Активирует следующий шаг маршрута: первую по очереди строку со статусом
|
||||||
|
// «Ждёт очереди» переводит в активный статус её фазы (Согласование → «На
|
||||||
|
// согласовании», Исполнение → «На исполнении»). Если ждущих нет — ничего.
|
||||||
|
// Вызывается при отправке (активировать первый шаг) и после каждого решения.
|
||||||
|
public async activateNextQueued(requestId: number): Promise<void> {
|
||||||
|
const rows = await this.getForRequest(requestId); // отсортированы по StepOrder
|
||||||
|
const next = rows.filter((r) => r.status === REQUEST_STATUS.queued)[0];
|
||||||
|
if (!next) return;
|
||||||
|
const active =
|
||||||
|
next.phase === APPROVAL_PHASE.execution ? REQUEST_STATUS.onExecution : REQUEST_STATUS.onApproval;
|
||||||
|
await this.list().items.getById(next.id).update({ [FIELDS.status]: active });
|
||||||
|
}
|
||||||
|
|
||||||
// Удаляет все строки согласования по заявке (при возврате/повторной отправке).
|
// Удаляет все строки согласования по заявке (при возврате/повторной отправке).
|
||||||
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);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { resolveRoute } from './approvalRoute.service';
|
|||||||
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||||
import { IMatrixRow } from './models/IMatrixRow';
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
import { ApprovalStatus } from './models/IApprovalRouteApi';
|
import { ApprovalStatus } from './models/IApprovalRouteApi';
|
||||||
import { APPROVAL_PHASE } from './models/statuses';
|
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
||||||
|
|
||||||
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
||||||
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
|
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
|
||||||
@@ -58,12 +58,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,
|
||||||
status,
|
rowStatus,
|
||||||
APPROVAL_PHASE.execution,
|
APPROVAL_PHASE.execution,
|
||||||
approvers.length + 1
|
approvers.length + 1
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (submitted) {
|
||||||
|
await approval.activateNextQueued(requestId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Действия согласующего: определение его строки, применение решения,
|
// Действия согласующего: определение его строки, применение решения,
|
||||||
@@ -34,7 +36,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 firstPending = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0];
|
// Активна первая незакрытая строка по очереди: согласующий («На согласовании»)
|
||||||
|
// или, когда согласование пройдено, исполнитель («На исполнении»).
|
||||||
|
const firstPending = rows.filter(
|
||||||
|
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
|
||||||
|
)[0];
|
||||||
if (firstPending && firstPending.approverId === userId) return firstPending;
|
if (firstPending && firstPending.approverId === userId) return firstPending;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -53,12 +59,17 @@ export class ApproverService {
|
|||||||
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
|
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
|
||||||
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);
|
||||||
// Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение).
|
// Делегат наследует фазу той строки, вместо которой действует, и её статус
|
||||||
|
// ожидания (согласование → «На согласовании», исполнение → «На исполнении»).
|
||||||
|
const pendingStatus =
|
||||||
|
current.phase === APPROVAL_PHASE.execution
|
||||||
|
? REQUEST_STATUS.onExecution
|
||||||
|
: REQUEST_STATUS.onApproval;
|
||||||
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,
|
pendingStatus,
|
||||||
current.phase
|
current.phase
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -72,6 +83,12 @@ 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);
|
||||||
|
// Положительное решение двигает очередь — активируем следующий ждущий шаг
|
||||||
|
// (в т.ч. переход из согласования в исполнение). При возврате/отклонении
|
||||||
|
// очередь не двигаем: заявка уходит на доработку.
|
||||||
|
if (decision === 'approve' || decision === 'execute') {
|
||||||
|
await this.approval.activateNextQueued(requestId);
|
||||||
|
}
|
||||||
const rows = await this.approval.getForRequest(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);
|
||||||
@@ -99,7 +116,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export const REQUEST_STATUS = {
|
|||||||
approved: 'Согласовано',
|
approved: 'Согласовано',
|
||||||
rejected: 'Отклонено',
|
rejected: 'Отклонено',
|
||||||
returned: 'Возвращена на доработку',
|
returned: 'Возвращена на доработку',
|
||||||
|
// Статус СТРОКИ маршрута, до которой ещё не дошла очередь (активен только текущий шаг).
|
||||||
|
queued: 'Ждёт очереди',
|
||||||
|
// Статусы СТРОК фазы исполнения: активная очередь исполнителя и выполнено.
|
||||||
|
onExecution: 'На исполнении',
|
||||||
|
executed: 'Выполнено',
|
||||||
|
// Статусы ЗАЯВКИ на этапе исполнения.
|
||||||
execution: 'Выполнение',
|
execution: 'Выполнение',
|
||||||
closed: 'Закрыта',
|
closed: 'Закрыта',
|
||||||
} as const;
|
} as const;
|
||||||
@@ -27,13 +33,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.approved,
|
REQUEST_STATUS.approved,
|
||||||
REQUEST_STATUS.rejected,
|
REQUEST_STATUS.rejected,
|
||||||
REQUEST_STATUS.returned,
|
REQUEST_STATUS.returned,
|
||||||
|
REQUEST_STATUS.queued,
|
||||||
|
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