feat: added status next in que and changed forms state machine

This commit is contained in:
Тимур Городецкий
2026-08-05 13:53:26 +05:00
parent 5bc80df11c
commit 571614c031
18 changed files with 373 additions and 68 deletions
+13
View File
@@ -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';