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/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 3b0092d..f80ee62 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 46810c2..a758a8f 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 78c9b36..ef3dc9b 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -8,7 +8,7 @@ import '@pnp/sp/profiles'; import { IApprovalRow } from './models/IApprovalRow'; import { IMatrixRow } from './models/IMatrixRow'; import { ISiteUser } from './models/ISiteUser'; -import { APPROVAL_PHASE } from './models/statuses'; +import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses'; // Внутренние имена колонок списка согласования (создаются провижинингом). const FIELDS = { @@ -187,6 +187,19 @@ export class ApprovalListService { }); } + // Активирует следующий шаг маршрута: первую по очереди строку со статусом + // «Ждёт очереди» переводит в активный статус её фазы (Согласование → «На + // согласовании», Исполнение → «На исполнении»). Если ждущих нет — ничего. + // Вызывается при отправке (активировать первый шаг) и после каждого решения. + public async activateNextQueued(requestId: number): Promise { + 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 { 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 574eedb..bb4fdb9 100644 --- a/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts @@ -3,7 +3,7 @@ import { resolveRoute } from './approvalRoute.service'; import { ApprovalListService, IApproverToWrite } from './approvalList.service'; import { IMatrixRow } from './models/IMatrixRow'; import { ApprovalStatus } from './models/IApprovalRouteApi'; -import { APPROVAL_PHASE } from './models/statuses'; +import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses'; // Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы. // status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution' @@ -58,12 +58,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, - status, + 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 2f0f22b..a0dbeae 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -8,13 +8,15 @@ 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, }; // Действия согласующего: определение его строки, применение решения, @@ -34,7 +36,11 @@ export class ApproverService { public async getPendingRowForCurrentUser(requestId: number): Promise { const rows = await this.approval.getForRequest(requestId); 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; return undefined; } @@ -53,12 +59,17 @@ export class ApproverService { const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1; const newOrder = (prevOrder + current.order) / 2; 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( requestId, { email: user.email, position, allowReassign: false }, newOrder, - REQUEST_STATUS.onApproval, + pendingStatus, current.phase ); } @@ -72,6 +83,12 @@ export class ApproverService { comment: string ): Promise { 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 requestStatus = this.computeRequestStatus(rows); await this.updateRequestStatus(requestId, requestStatus); @@ -99,7 +116,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 ef2e4cf..d4012b2 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.ts @@ -9,6 +9,12 @@ export const REQUEST_STATUS = { approved: 'Согласовано', rejected: 'Отклонено', returned: 'Возвращена на доработку', + // Статус СТРОКИ маршрута, до которой ещё не дошла очередь (активен только текущий шаг). + queued: 'Ждёт очереди', + // Статусы СТРОК фазы исполнения: активная очередь исполнителя и выполнено. + onExecution: 'На исполнении', + executed: 'Выполнено', + // Статусы ЗАЯВКИ на этапе исполнения. execution: 'Выполнение', closed: 'Закрыта', } as const; @@ -27,13 +33,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.approved, REQUEST_STATUS.rejected, REQUEST_STATUS.returned, + REQUEST_STATUS.queued, + 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';