diff --git a/provisioning/Provision-Lists.ps1 b/provisioning/Provision-Lists.ps1 index 2d40de8..b60d1f0 100644 --- a/provisioning/Provision-Lists.ps1 +++ b/provisioning/Provision-Lists.ps1 @@ -62,7 +62,20 @@ function Add-Column { param([string]$List, $Col) if (Test-FieldExists -List $List -InternalName $Col.internalName) { - Write-Host " [=] колонка есть: $($Col.internalName)" -ForegroundColor DarkGray + # Колонка уже есть. Для Choice — досетапливаем варианты (могли добавиться новые + # статусы): существующие значения сохраняются, добавляются недостающие. + if ($Col.type -eq 'Choice' -and $Col.choices) { + try { + Set-PnPField -List $List -Identity $Col.internalName -Values @{ Choices = [string[]]$Col.choices } | Out-Null + Write-Host " [~] варианты обновлены: $($Col.internalName)" -ForegroundColor DarkGray + } + catch { + Write-Warning " [!] $($Col.internalName): не удалось обновить варианты — $($_.Exception.Message)" + } + } + else { + Write-Host " [=] колонка есть: $($Col.internalName)" -ForegroundColor DarkGray + } return } diff --git a/src/webparts/requestPortal/components/Drawer/Drawer.module.scss b/src/webparts/requestPortal/components/Drawer/Drawer.module.scss index 828ed4a..aeea9fa 100644 --- a/src/webparts/requestPortal/components/Drawer/Drawer.module.scss +++ b/src/webparts/requestPortal/components/Drawer/Drawer.module.scss @@ -72,6 +72,9 @@ padding: $sp-6; } +// Печать заявки идёт в отдельном окне (см. shared/ui/PrintButton), поэтому +// print-обходов для панели тут не нужно. + @keyframes slide { from { transform: translateX(24px); opacity: .6; } to { transform: translateX(0); opacity: 1; } diff --git a/src/webparts/requestPortal/components/RequestPortal.module.scss b/src/webparts/requestPortal/components/RequestPortal.module.scss index d624a9e..660e246 100644 --- a/src/webparts/requestPortal/components/RequestPortal.module.scss +++ b/src/webparts/requestPortal/components/RequestPortal.module.scss @@ -8,6 +8,21 @@ .CanvasComponent.LCS .CanvasZone { max-width: 100% !important; } + + // ---- Печать ---- + // Печать идёт в ОТДЕЛЬНОМ чистом окне (см. shared/ui/PrintButton): документ + // .rp-print-root переносится туда со стилями страницы, поэтому скролл-контейнеры + // и высота SharePoint печать не режут. Здесь — только вид полей на печати + // (read-only без серой заливки), эти правила применяются и в окне печати. + @media print { + .rp-print-root input, + .rp-print-root textarea, + .rp-print-root select { + background: transparent !important; + color: #000 !important; + border-color: #ccc !important; + } + } } .requestPortal { diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 095a405..7051a97 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -189,7 +189,9 @@ async function indexApprovalsForType( .top(2000)) { allRows.push(...(p as Array>)); } - stepRows = allRows.filter((r) => r.Status === REQUEST_STATUS.onApproval); + stepRows = allRows.filter( + (r) => r.Status === REQUEST_STATUS.onApproval || r.Status === REQUEST_STATUS.onExecution + ); console.log( `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn, строк по ApproverId: ${allRows.length}, после фильтра по статусу: ${stepRows.length}` ); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index a1c0547..6ff29c2 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -1,9 +1,16 @@ import * as React from 'react'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './RecruitingHireView.module.scss'; -import { Alert, Button, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList, Icon } from '../../shared/ui'; -import { ApproverService, DecisionPanel, StatusBadge } from '../../shared/approval'; +import { Alert, Button, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList, Icon, PrintButton } from '../../shared/ui'; +import { + ApproverService, + DecisionPanel, + StatusBadge, + ApprovalMatrix, + useMatrixEditor, + APPROVAL_PHASE +} from '../../shared/approval'; import { navigate } from '../../shared/routing'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { IFormData, EMPTY_DATA, SetField } from './model'; @@ -42,6 +49,29 @@ const RecruitingHireView: React.FC = ({ context, itemI [] ); + // Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам. + const approval = useMemo(() => hireApprovalService(), []); + const loadApprovers = useCallback( + () => + approval + .getMatrixForRequest(itemId) + .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)), + [approval, itemId] + ); + const approvers = useMatrixEditor(loadApprovers, `a-${itemId}`, false, true, (l) => + approval.getJobTitle(l) + ); + const loadExecutors = useCallback( + () => + approval + .getMatrixForRequest(itemId) + .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)), + [approval, itemId] + ); + const executors = useMatrixEditor(loadExecutors, `e-${itemId}`, false, true, (l) => + approval.getJobTitle(l) + ); + useEffect(() => { let alive = true; setLoading(true); @@ -81,7 +111,7 @@ const RecruitingHireView: React.FC = ({ context, itemI return (
-
+
+
- : undefined} - > - - - - - - +
+ : undefined} + > + + + + + + - - - - - - +
+ + + + + +
+ - {/* Панель решения — ВНЕ FormModeProvider, чтобы комментарий был редактируемым. */} - + approval.searchUsers(q)} /> + + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение" + roleLabel="Исполнитель" + emptyText="Исполнители не назначены." + /> + )} +
+ + {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} +
+ +
); }; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json index 207b2dd..b5dcae7 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json @@ -2,7 +2,7 @@ "dataListTitle": "Прием на работу — согласование", "columns": [ { "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, - { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, + { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "На исполнении", "Выполнено", "Выполнение", "Закрыта"] }, { "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }, { "internalName": "Date", "title": "Дата", "type": "DateTime" }, { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.module.scss b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.module.scss index d921f54..6a27763 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.module.scss +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.module.scss @@ -6,3 +6,9 @@ font-size: $fs-sm; text-align: center; } + +.toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: $sp-4; +} diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx index ffb9179..db40ca3 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx @@ -1,9 +1,16 @@ import * as React from 'react'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './WorkplaceSetupView.module.scss'; -import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList } from '../../shared/ui'; -import { ApproverService, DecisionPanel, StatusBadge } from '../../shared/approval'; +import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui'; +import { + ApproverService, + DecisionPanel, + StatusBadge, + ApprovalMatrix, + useMatrixEditor, + APPROVAL_PHASE +} from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { loadWorkplaceRequest } from './load'; @@ -31,6 +38,29 @@ const WorkplaceSetupView: React.FC = ({ context, itemI [] ); + // Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам. + const approval = useMemo(() => workplaceApprovalService(), []); + const loadApprovers = useCallback( + () => + approval + .getMatrixForRequest(itemId) + .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)), + [approval, itemId] + ); + const approvers = useMatrixEditor(loadApprovers, `a-${itemId}`, false, true, (l) => + approval.getJobTitle(l) + ); + const loadExecutors = useCallback( + () => + approval + .getMatrixForRequest(itemId) + .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)), + [approval, itemId] + ); + const executors = useMatrixEditor(loadExecutors, `e-${itemId}`, false, true, (l) => + approval.getJobTitle(l) + ); + useEffect(() => { let alive = true; setLoading(true); @@ -70,24 +100,46 @@ const WorkplaceSetupView: React.FC = ({ context, itemI return (
- : undefined} - > - - - - +
+ +
- - - - - -
+
+ : undefined} + > + + + + - +
+ + + + + +
+
+ + approval.searchUsers(q)} /> + + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение" + roleLabel="Исполнитель" + emptyText="Исполнители не назначены." + /> + )} +
+ +
+ +
); }; diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json index 70dd5ce..e9334ff 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json @@ -2,7 +2,7 @@ "dataListTitle": "Организация рабочего места — согласование", "columns": [ { "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, - { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, + { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "На исполнении", "Выполнено", "Выполнение", "Закрыта"] }, { "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" }, { "internalName": "Date", "title": "Дата", "type": "DateTime" }, { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index e63845f..509a206 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -48,14 +48,10 @@ export class ApprovalListService { // задан, иначе orderStart + позиция в массиве (фазы идут подряд в одном // списке: сначала согласующие 1..N, затем исполнители N+1..N+M). // - // Статус строки решается здесь, а не читается заново при каждом открытии - // заявки: строки с НАИМЕНЬШИМ order в этой партии получают переданный status - // (обычно «На согласовании» — их очередь СЕЙЧАС; если несколько согласующих - // делят один и тот же order — это параллельный шаг, активны все сразу), - // остальные — «Ждёт очереди». Так «чья сейчас очередь» — это просто значение - // колонки Status, без пересчёта порядка на клиенте (см. - // approverDecision.service.ts/decide, который двигает очередь дальше при - // каждом решении — той же логикой «наименьший order среди нерешённых»). + // Статус пишется переданным значением ОДИНАКОВО для всех строк партии — кто + // сейчас «на очереди», а кто «ждёт», решает отдельно activateNextQueued + // (вызывается сразу после createSteps в approvalSubmission.service.ts/writeRoute + // и в approverDecision.service.ts/decide). public async createSteps( requestId: number, approvers: IApproverToWrite[], @@ -66,15 +62,13 @@ export class ApprovalListService { const withOrder = approvers .map((a, i) => ({ a, order: a.order ?? orderStart + i })) .filter((x) => !!x.a.email); - const minOrder = withOrder.length > 0 ? Math.min(...withOrder.map((x) => x.order)) : 0; for (const { a, order } of withOrder) { const approverId = await this.resolveUserId(a.email); - const rowStatus = order === minOrder ? status : REQUEST_STATUS.waitingInQueue; await this.list().items.add({ Title: a.position || a.email, [`${FIELDS.approver}Id`]: approverId, - [FIELDS.status]: rowStatus, + [FIELDS.status]: status, [`${FIELDS.ticket}Id`]: requestId, [FIELDS.order]: order, [FIELDS.allowReassign]: a.allowReassign, @@ -83,6 +77,47 @@ export class ApprovalListService { } } + // Продвигает очередь: среди ещё не активных строк со статусом «Ждёт очереди» + // в текущей фазе активирует те, что делят наименьший StepOrder (обычно одна + // строка; несколько — параллельный шаг). Фаза исполнения активируется, только + // когда фаза согласования пройдена целиком (без Отклонено/Возвращена) — иначе + // строки исполнения остаются «Ждёт очереди». Если фаза согласования уже + // остановлена решением, ничего не активирует. + public async activateNextQueued(requestId: number): Promise { + 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 => { + 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 { + await this.list().items.getById(rowId).update({ [FIELDS.status]: status }); + } + // Добавляет одну строку согласования с заданным Order (для делегата). public async addApproverRow( requestId: number, @@ -206,12 +241,6 @@ export class ApprovalListService { }); } - // Только статус строки, без даты/комментария — продвижение очереди - // («На согласовании» ⇄ «Ждёт очереди»), а не решение согласующего. - public async setStatus(rowId: number, status: string): Promise { - await this.list().items.getById(rowId).update({ [FIELDS.status]: status }); - } - // Удаляет все строки согласования по заявке (при возврате/повторной отправке). public async deleteForRequest(requestId: number): Promise { const rows = await this.getForRequest(requestId); diff --git a/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts b/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts index ac45415..a74e3d9 100644 --- a/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts @@ -43,7 +43,12 @@ export async function writeApprovers( status: string ): Promise { await approval.deleteForRequest(requestId); - await approval.createSteps(requestId, approvers, status, APPROVAL_PHASE.approval, 1); + const submitted = status === REQUEST_STATUS.onApproval; + const rowStatus = submitted ? REQUEST_STATUS.queued : status; + await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1); + if (submitted) { + await approval.activateNextQueued(requestId); + } } // Записать ОБЕ фазы маршрута заявки: согласующих (Order 1..N) и исполнителей @@ -51,10 +56,11 @@ export async function writeApprovers( // стороне согласующего внешний API уже не нужен, переход в фазу исполнения // делает computeRequestStatus, когда все согласующие одобрили. // -// Исполнители стартуют ВСЕ как «Ждёт очереди» (не передаём им status — фаза -// исполнения ещё не наступила, до неё нет ничьей текущей очереди). Первого -// исполнителя переключит на «На согласовании» approverDecision.service.ts/decide, -// когда фаза согласования будет пройдена целиком. +// Исполнители стартуют ВСЕ как «Ждёт очереди» (rowStatus, та же логика, что и +// у согласующих) — до прохождения фазы согласования у исполнителей нет текущей +// очереди. Первого исполнителя переключит на «На исполнении» +// approval.activateNextQueued, когда фаза согласования будет пройдена целиком +// (см. approverDecision.service.ts/decide). export async function writeRoute( approval: ApprovalListService, requestId: number, @@ -63,12 +69,23 @@ export async function writeRoute( status: string ): Promise { await approval.deleteForRequest(requestId); - await approval.createSteps(requestId, approvers, status, APPROVAL_PHASE.approval, 1); + + // При ОТПРАВКЕ все шаги пишем «Ждёт очереди», затем активируем первый — + // так активен ровно текущий шаг, остальные ждут. В черновике — статус заявки + // (Черновик), без активации. + const submitted = status === REQUEST_STATUS.onApproval; + const rowStatus = submitted ? REQUEST_STATUS.queued : status; + + await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1); await approval.createSteps( requestId, executors, - REQUEST_STATUS.waitingInQueue, + rowStatus, APPROVAL_PHASE.execution, approvers.length + 1 ); + + if (submitted) { + await approval.activateNextQueued(requestId); + } } diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index af8c85f..207dd0e 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -8,17 +8,17 @@ import { IApprovalRow } from './models/IApprovalRow'; import { ISiteUser } from './models/ISiteUser'; import { emitRequestsChanged } from '../requestEvents'; -export type ApproverDecision = 'approve' | 'return' | 'reject'; +// approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено». +export type ApproverDecision = 'approve' | 'return' | 'reject' | 'execute'; -// Решение согласующего → статус его строки в списке согласования. +// Решение → статус его строки в списке согласования. const ROW_STATUS: Record = { approve: REQUEST_STATUS.approved, return: REQUEST_STATUS.returned, reject: REQUEST_STATUS.rejected, + execute: REQUEST_STATUS.executed, }; -const TERMINAL_STATUSES: string[] = [REQUEST_STATUS.approved, REQUEST_STATUS.rejected, REQUEST_STATUS.returned]; - // Действия согласующего: определение его строки, применение решения, // делегирование и пересчёт статуса заявки в контентном списке. // @@ -38,7 +38,11 @@ export class ApproverService { public async getPendingRowForCurrentUser(requestId: number): Promise { const rows = await this.approval.getForRequest(requestId); const userId = await this.approval.getCurrentUserId(); - const current = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0]; + // Активна первая незакрытая строка по очереди: согласующий («На согласовании») + // или, когда согласование пройдено, исполнитель («На исполнении»). + const current = rows.filter( + (r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution + )[0]; if (current && current.approverId === userId) return current; return undefined; } @@ -58,16 +62,17 @@ export class ApproverService { const newOrder = (prevOrder + current.order) / 2; const position = await this.approval.getJobTitle(user.loginName); // Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение). - // Пишем его сразу как «На согласовании» — advanceQueue ниже сам переведёт - // строку currentRowId (у неё теперь больше не наименьший Order) в «Ждёт очереди». + // Обе строки — делегат и прежний согласующий — уходят в «Ждёт очереди», + // activateNextQueued затем активирует делегата (у него меньший order). await this.approval.addApproverRow( requestId, { email: user.email, position, allowReassign: false }, newOrder, - REQUEST_STATUS.onApproval, + REQUEST_STATUS.queued, current.phase ); - await this.advanceQueue(requestId); + await this.approval.setStatus(current.id, REQUEST_STATUS.queued); + await this.approval.activateNextQueued(requestId); emitRequestsChanged(); } @@ -82,57 +87,19 @@ export class ApproverService { comment: string ): Promise { await this.approval.setDecision(rowId, ROW_STATUS[decision], comment); - const rows = await this.advanceQueue(requestId); + // Положительное решение двигает очередь — активируем следующий ждущий шаг + // (в т.ч. переход из согласования в исполнение). При возврате/отклонении + // очередь не двигаем: заявка уходит на доработку. + if (decision === 'approve' || decision === 'execute') { + await this.approval.activateNextQueued(requestId); + } + const rows = await this.approval.getForRequest(requestId); const requestStatus = this.computeRequestStatus(rows); await this.updateRequestStatus(requestId, requestStatus); emitRequestsChanged(); return requestStatus; } - // Пересчитывает и записывает «чья сейчас очередь» по обеим фазам маршрута: - // ровно у одной ещё не решённой строки на фазу становится «На согласовании» - // (наименьший StepOrder среди нерешённых), у остальных — «Ждёт очереди». - // Фаза исполнения активна, только когда фаза согласования пройдена целиком - // (без Отклонено/Возвращена) — иначе там тоже сплошное «Ждёт очереди». - // Возвращает строки МАРШРУТА в том виде, в каком они были ДО применения этих - // обновлений (для computeRequestStatus — ему важны только терминальные - // статусы approved/rejected/returned, на которые эта функция не влияет). - private async advanceQueue(requestId: number): Promise { - 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(); - const applyPhase = (phaseRows: IApprovalRow[], eligible: boolean): void => { - const undecided = phaseRows.filter((r) => TERMINAL_STATUSES.indexOf(r.status) === -1); - if (undecided.length === 0) return; - if (!eligible) { - undecided.forEach((r) => { - if (r.status !== REQUEST_STATUS.waitingInQueue) updates.set(r.id, REQUEST_STATUS.waitingInQueue); - }); - return; - } - const minOrder = Math.min(...undecided.map((r) => r.order)); - undecided.forEach((r) => { - const desired = r.order === minOrder ? REQUEST_STATUS.onApproval : REQUEST_STATUS.waitingInQueue; - if (r.status !== desired) updates.set(r.id, desired); - }); - }; - - applyPhase(approvalRows, !approvalStopped); - applyPhase(execRows, approvalDone); - - await Promise.all(Array.from(updates.entries()).map(([id, status]) => this.approval.setStatus(id, status))); - return rows; - } - // Статус заявки из строк маршрута с учётом фазы: // 1) фаза согласования: отклонение/возврат → соответствующий статус; // не все согласовали → «На согласовании». @@ -153,7 +120,7 @@ export class ApproverService { if (!approvalDone) return REQUEST_STATUS.onApproval; if (execRows.length === 0) return REQUEST_STATUS.approved; - if (execRows.every((r) => r.status === REQUEST_STATUS.approved)) return REQUEST_STATUS.closed; + if (execRows.every((r) => r.status === REQUEST_STATUS.executed)) return REQUEST_STATUS.closed; return REQUEST_STATUS.execution; } diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.ts b/src/webparts/requestPortal/shared/approval/models/statuses.ts index dbafdc1..b4461f2 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.ts @@ -3,22 +3,30 @@ // В слое формы кодом ставятся Draft (черновик) и OnApproval (отправка на согласование); // Approved/Rejected/Returned выставляются при решениях согласующих; Execution/Closed — // зарезервированы под будущие этапы. +// Статусы СТРОК списка «— согласование» (не заявки в контентном списке): +// draft/rejected/returned — общие с заявкой; остальные — только для строк +// маршрута, по фазам: +// Согласование: queued («Ждёт очереди») → onApproval («На согласовании») → approved/rejected/returned. +// Исполнение: queued → onExecution («На исполнении») → executed («Выполнено»). +// Активация — только через ApprovalListService.activateNextQueued (вызывается +// при отправке заявки и после каждого положительного решения, см. +// approvalSubmission.service.ts/writeRoute, approverDecision.service.ts/decide): +// среди строк со статусом queued в текущей фазе находит наименьший StepOrder +// и переводит её (или несколько, если делят один order — параллельный шаг) в +// onApproval/onExecution. Так «чья сейчас очередь» — просто значение колонки +// Status, без пересчёта порядка на клиенте. export const REQUEST_STATUS = { draft: 'Черновик', onApproval: 'На согласовании', + queued: 'Ждёт очереди', + onExecution: 'На исполнении', + executed: 'Выполнено', approved: 'Согласовано', rejected: 'Отклонено', returned: 'Возвращена на доработку', + // Статус ЗАЯВКИ (не строки) на этапе исполнения. execution: 'Выполнение', closed: 'Закрыта', - // Строка маршрута, до которой очередь ещё не дошла. Применяется ТОЛЬКО к - // строкам списка «— согласование» (не к заявке в контентном списке): - // при создании маршрута и при каждом решении согласующего (см. - // approvalList.service.ts/createSteps, approverDecision.service.ts/decide) - // ровно у одной строки на фазу выставляется onApproval («её очередь сейчас»), - // у всех остальных недорешённых строк — waitingInQueue. Это позволяет читать - // «чья сейчас очередь» прямым фильтром по Status, без пересчёта на клиенте. - waitingInQueue: 'Ждёт очереди', } as const; // Статусы, при которых заявку ещё можно править (форма редактируема). @@ -35,14 +43,16 @@ export function isEditableStatus(status: string | undefined): boolean { return !status || EDITABLE_STATUSES.indexOf(status) !== -1; } -// Полный набор значений для Choice-колонки при провижининге. +// Полный набор значений для Choice-колонки Status при провижининге. export const REQUEST_STATUS_CHOICES: string[] = [ REQUEST_STATUS.draft, REQUEST_STATUS.onApproval, - REQUEST_STATUS.waitingInQueue, + REQUEST_STATUS.queued, REQUEST_STATUS.approved, REQUEST_STATUS.rejected, REQUEST_STATUS.returned, + REQUEST_STATUS.onExecution, + REQUEST_STATUS.executed, REQUEST_STATUS.execution, REQUEST_STATUS.closed, ]; diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx index 344beb0..1cd1f44 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx @@ -3,6 +3,7 @@ import styles from './approval.module.scss'; import { Icon } from '../../ui'; import { IUseMatrixEditor } from '../useMatrixEditor'; import { ISiteUser } from '../models/ISiteUser'; +import { StatusBadge } from './StatusBadge'; import { UserPicker } from './UserPicker'; export interface IApprovalMatrixProps { @@ -53,7 +54,7 @@ export const ApprovalMatrix: React.FC = ({ {r.who} {r.position} - {r.status} + {r.status && r.status !== '—' ? : '—'} {r.date} {editable && ( diff --git a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx index 050e9fb..765d724 100644 --- a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx @@ -74,8 +74,10 @@ export const DecisionPanel: React.FC = ({ service, requestI const isExecution = phase === APPROVAL_PHASE.execution; - if (checking) { - return
Проверяем вашу очередь…
; + // Пока проверяем очередь или если действий нет (не твоя очередь / заявка + // обработана / тебя нет в маршруте) — панель не показываем вовсе. + if (checking || rowId === undefined) { + return null; } if (resultStatus) { @@ -89,14 +91,6 @@ export const DecisionPanel: React.FC = ({ service, requestI ); } - if (rowId === undefined) { - return ( -
- Сейчас не ваша очередь, либо заявка уже обработана. -
- ); - } - // Фаза исполнения: одно действие «Выполнено» (без обязательного комментария), // строка помечается как «Согласовано» → computeRequestStatus переведёт заявку в // «Выполнение»/«Закрыта». @@ -114,7 +108,7 @@ export const DecisionPanel: React.FC = ({ service, requestI />
- diff --git a/src/webparts/requestPortal/shared/approval/ui/StatusBadge.module.scss b/src/webparts/requestPortal/shared/approval/ui/StatusBadge.module.scss index cb87155..a02d2e9 100644 --- a/src/webparts/requestPortal/shared/approval/ui/StatusBadge.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/StatusBadge.module.scss @@ -17,6 +17,11 @@ color: $gray-700; } +.queued { + background: $gray-100; + color: $gray-500; +} + .onApproval { background: $blue-50; color: $blue-600; diff --git a/src/webparts/requestPortal/shared/approval/ui/StatusBadge.tsx b/src/webparts/requestPortal/shared/approval/ui/StatusBadge.tsx index bd874aa..ff2096f 100644 --- a/src/webparts/requestPortal/shared/approval/ui/StatusBadge.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/StatusBadge.tsx @@ -5,8 +5,11 @@ import { REQUEST_STATUS } from '../models/statuses'; // Цвет бейджа по статусу заявки. Неизвестный статус → нейтральный (как черновик). const VARIANT: Record = { [REQUEST_STATUS.draft]: styles.draft, + [REQUEST_STATUS.queued]: styles.queued, [REQUEST_STATUS.onApproval]: styles.onApproval, + [REQUEST_STATUS.onExecution]: styles.onApproval, // активный шаг — как «на согласовании» [REQUEST_STATUS.approved]: styles.approved, + [REQUEST_STATUS.executed]: styles.approved, // выполнено — как «согласовано» (зелёный) [REQUEST_STATUS.rejected]: styles.rejected, [REQUEST_STATUS.returned]: styles.returned, [REQUEST_STATUS.execution]: styles.execution, diff --git a/src/webparts/requestPortal/shared/ui/PrintButton/PrintButton.tsx b/src/webparts/requestPortal/shared/ui/PrintButton/PrintButton.tsx new file mode 100644 index 0000000..f309fca --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/PrintButton/PrintButton.tsx @@ -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-модули инжектятся как ${clone.outerHTML}` + ); + 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 ( + + ); +}; diff --git a/src/webparts/requestPortal/shared/ui/index.ts b/src/webparts/requestPortal/shared/ui/index.ts index 57e1d45..2c43849 100644 --- a/src/webparts/requestPortal/shared/ui/index.ts +++ b/src/webparts/requestPortal/shared/ui/index.ts @@ -21,5 +21,6 @@ export { FormModeProvider, useFormReadOnly } from './FormMode'; export { Icon } from './Icon/Icon'; export { SubmitActions } from './SubmitActions/SubmitActions'; export { RequestNumber } from './RequestNumber/RequestNumber'; +export { PrintButton } from './PrintButton/PrintButton'; export { useRequestSubmit } from './useRequestSubmit'; export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';