From 583f400e26d84613fa2af5ec60490caa4f56a8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Thu, 29 Oct 2026 17:44:03 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D0=BC=D1=8F=20PDF=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D0=B8=20=E2=80=94=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=8B,=20=D0=A4=D0=98=D0=9E=20=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=D0=B5=D1=80=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Окно печати ui-kit было без заголовка, и при «Сохранить как PDF» браузер называл файл как попало. Теперь имя — «<Форма> — <ФИО> №<код>» во всех 15 просмотрах с печатью (у подбора без ФИО — это заявка на вакансию). Пока временно локальная копия printRoot/PrintButton в shared/ui/PrintButton с параметром заголовка; параметр будет перенесён в ui-kit, после чего копия удаляется. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../forms/BusinessTrip/BusinessTripView.tsx | 8 +- .../requestPortal/forms/BusinessTrip/load.ts | 10 +- .../forms/Clearance/ClearanceView.tsx | 25 +++- .../ConcreteRequest/ConcreteRequestView.tsx | 21 ++- .../forms/Dismissal/DismissalView.tsx | 13 +- .../EmployeeTransfer/EmployeeTransferView.tsx | 24 ++- .../RecruitingHire/RecruitingHireView.tsx | 12 +- .../RecruitingSelectionView.tsx | 11 +- .../forms/Referral/ReferralView.tsx | 14 +- .../ServiceRequest/ServiceRequestView.tsx | 14 +- .../SkudExplanation/SkudExplanationView.tsx | 17 ++- .../forms/Ticket1C/Ticket1CView.tsx | 9 +- .../forms/TmcTransfer/TmcTransferView.tsx | 14 +- .../forms/UnpaidLeave/UnpaidLeaveView.tsx | 14 +- .../forms/Vacation/VacationView.tsx | 14 +- .../WorkplaceSetup/WorkplaceSetupView.tsx | 13 +- .../shared/ui/PrintButton/PrintButton.tsx | 20 +++ .../shared/ui/PrintButton/printRoot.ts | 141 ++++++++++++++++++ .../ui/PrintSplitButton/PrintSplitButton.tsx | 15 +- src/webparts/requestPortal/shared/ui/index.ts | 6 +- 20 files changed, 362 insertions(+), 53 deletions(-) create mode 100644 src/webparts/requestPortal/shared/ui/PrintButton/PrintButton.tsx create mode 100644 src/webparts/requestPortal/shared/ui/PrintButton/printRoot.ts diff --git a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripView.tsx b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripView.tsx index d05ba88..c7efa84 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripView.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripView.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useState, useEffect, useMemo } from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './BusinessTripView.module.scss'; -import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui'; +import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, buildPrintTitle } from '../../shared/ui'; import { ApproverService, ApprovalMatrix, @@ -55,6 +55,7 @@ const BusinessTripView: React.FC = ({ context, itemId, f const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); + const [employeeName, setEmployeeName] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -84,6 +85,7 @@ const BusinessTripView: React.FC = ({ context, itemId, f setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); + setEmployeeName(loaded.employeeName); notifyCode(loaded.code); setError(null); setLoading(false); @@ -128,7 +130,9 @@ const BusinessTripView: React.FC = ({ context, itemId, f return (
- +
diff --git a/src/webparts/requestPortal/forms/BusinessTrip/load.ts b/src/webparts/requestPortal/forms/BusinessTrip/load.ts index cd682d8..e85e291 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/load.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/load.ts @@ -11,6 +11,8 @@ export interface ILoadedRequest { status: string; /** Уникальный код заявки; '' — если запись создана до появления поля. */ code: string; + /** ФИО командируемого (Employee/Title) — для имени печатного документа. */ + employeeName: string; } /** @@ -37,11 +39,12 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string): 'TripObligationAccepted', 'RequestStatus', 'RequestUniqueCode', - 'Employee/EMail' + 'Employee/EMail', + 'Employee/Title' ) .expand('Employee')()) as Record; - const employee = item.Employee as { EMail?: string } | undefined; + const employee = item.Employee as { EMail?: string; Title?: string } | undefined; const data: IFormData = { ...EMPTY_DATA, @@ -61,6 +64,7 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string): return { data, status: (item.RequestStatus as string) || '', - code: (item.RequestUniqueCode as string) || '' + code: (item.RequestUniqueCode as string) || '', + employeeName: employee?.Title || '' }; } diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx index 26c7113..e8cb62b 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx @@ -2,7 +2,16 @@ import * as React from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './ClearanceView.module.scss'; -import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui'; +import { + Alert, + Button, + FormCard, + InitiatorLine, + FormModeProvider, + PrintSplitButton, + OrgLetterhead, + buildPrintTitle +} from '../../shared/ui'; import { ApproverService, DecisionPanel, @@ -48,6 +57,8 @@ interface IClearanceViewProps { type SaveState = 'idle' | 'saving' | 'saved' | 'error'; +const FORM_TITLE = 'Обходной лист'; + // Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые // заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения, // отдел подбора — парковочная карта (см. approverRights.ts). @@ -60,6 +71,8 @@ const ClearanceView: React.FC = ({ context, itemId, formKey const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); + // ФИО увольняемого — для имени печатного документа. + const [employeeName, setEmployeeName] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [hrRecordsEditable, setHrRecordsEditable] = useState(false); @@ -123,6 +136,7 @@ const ClearanceView: React.FC = ({ context, itemId, formKey setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); + setEmployeeName(loaded.employeeName); notifyCode(loaded.code); setError(null); setLoading(false); @@ -274,7 +288,12 @@ const ClearanceView: React.FC = ({ context, itemId, formKey return (
- +
{canEditAnything && ( @@ -295,7 +314,7 @@ const ClearanceView: React.FC = ({ context, itemId, formKey
void; } +const FORM_TITLE = 'Заявка на бетон'; + // В режиме чтения поля не редактируются, поэтому обновление — пустышка. const NOOP_SET: SetField = () => undefined; @@ -137,13 +147,18 @@ const ConcreteRequestView: React.FC = ({ context, ite return (
- +
void; } +const FORM_TITLE = 'Заявление на увольнение'; + // Заявление на увольнение только для просмотра (режим согласующего). // Переиспользует ту же секцию, что и форма подачи; редактирование выключено через // — поля сами становятся неизменяемыми. @@ -70,6 +72,8 @@ const DismissalView: React.FC = ({ context, itemId, formKey const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); + // ФИО увольняющегося — для имени печатного документа. + const [employeeName, setEmployeeName] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Итог автосоздания обходного листа после согласования (см. onDecided ниже). @@ -107,6 +111,7 @@ const DismissalView: React.FC = ({ context, itemId, formKey setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); + setEmployeeName(loaded.employeeName); notifyCode(loaded.code); setError(null); setLoading(false); @@ -265,7 +270,9 @@ const DismissalView: React.FC = ({ context, itemId, formKey
{printGate === 'allowed' ? ( - + ) : ( Печать станет доступна после согласования обходного листа @@ -275,7 +282,7 @@ const DismissalView: React.FC = ({ context, itemId, formKey
void; } +const FORM_TITLE = 'Заявка на перемещение сотрудника'; + // В режиме чтения поля не редактируются, поэтому обновление — пустышка. const NOOP_SET: SetField = () => undefined; @@ -56,6 +66,8 @@ const EmployeeTransferView: React.FC = ({ context, i const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); + // ФИО перемещаемого — для имени печатного документа. + const [employeeName, setEmployeeName] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Какая из организаций сотрудника (см. OrganizationField) идёт в печать — @@ -89,6 +101,7 @@ const EmployeeTransferView: React.FC = ({ context, i setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); + setEmployeeName(loaded.employeeName); notifyCode(loaded.code); setError(null); setLoading(false); @@ -136,13 +149,18 @@ const EmployeeTransferView: React.FC = ({ context, i return (
- +
= ({ context, itemI Организация рабочего места на основе этой - {isApprovedStatus(status) && } + {isApprovedStatus(status) && ( + + )}
{hasEditableFields && ( @@ -400,7 +406,7 @@ const RecruitingHireView: React.FC = ({ context, itemI
, @@ -366,7 +368,10 @@ const RecruitingSelectionView: React.FC = ({ cont return (
- {isApprovedStatus(status) && } + {/* Подбор — на вакансию, конкретного человека ещё нет: имя файла без ФИО. */} + {isApprovedStatus(status) && ( + + )}
{fullEditable && ( @@ -384,7 +389,7 @@ const RecruitingSelectionView: React.FC = ({ cont
void; } +const FORM_TITLE = 'Реферальная рекомендация'; + // В режиме чтения поля не редактируются, поэтому обновление — пустышка. const NOOP_SET: SetField = () => undefined; @@ -189,14 +192,19 @@ const ReferralView: React.FC = ({ context, itemId, formKey,
{isApprovedStatus(status) && ( - + )}
void; } +const FORM_TITLE = 'Заявка на услуги'; + // Заявка «Заявка на услуги» только для просмотра (режим согласующего). const ServiceRequestView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { // onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта @@ -166,13 +169,18 @@ const ServiceRequestView: React.FC = ({ context, itemI Редактировать )} - +
= ({ context, ite return (
- +
void; } +const FORM_TITLE = 'Заявка в отдел 1С'; + // Заявка в отдел 1С только для просмотра (режим согласующего). const Ticket1CView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка @@ -185,12 +188,12 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, Редактировать )} - +
void; } +const FORM_TITLE = 'Служебная записка на перемещение ТМЦ'; + // Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего). const TmcTransferView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка @@ -169,13 +172,18 @@ const TmcTransferView: React.FC = ({ context, itemId, for Редактировать )} - +
void; } +const FORM_TITLE = 'Заявление на отпуск без сохранения заработной платы'; + // В режиме чтения поля не редактируются, поэтому обновление — пустышка. const NOOP_SET: SetField = () => undefined; @@ -148,13 +151,18 @@ const UnpaidLeaveView: React.FC = ({ context, itemId, for return (
- +
void; } +const FORM_TITLE = 'Заявление на оплачиваемый ежегодный трудовой отпуск'; + // В режиме чтения поля не редактируются, поэтому обновление — пустышка. const NOOP_SET: SetField = () => undefined; @@ -207,13 +210,18 @@ const VacationView: React.FC = ({ context, itemId, formKey, return (
- +
= ({ context, itemI Редактировать )} - +
{canEditAnything && ( @@ -290,7 +297,7 @@ const WorkplaceSetupView: React.FC = ({ context, itemI
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..796d354 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/PrintButton/PrintButton.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { Button, Icon } from '@tnsu/ui-kit'; +import { printRoot } from './printRoot'; + +interface IPrintButtonProps { + label?: string; + /** Заголовок документа = имя PDF-файла (см. buildPrintTitle). Не задан — браузер назовёт сам. */ + documentTitle?: string; +} + +/** + * ВРЕМЕННО: локальная кнопка «Печать» вместо PrintButton из @tnsu/ui-kit — ради + * documentTitle (см. printRoot.ts). Вид и поведение те же, что у кита. + */ +export const PrintButton: React.FC = ({ label = 'Печать', documentTitle }) => ( + +); diff --git a/src/webparts/requestPortal/shared/ui/PrintButton/printRoot.ts b/src/webparts/requestPortal/shared/ui/PrintButton/printRoot.ts new file mode 100644 index 0000000..5c0cd5f --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/PrintButton/printRoot.ts @@ -0,0 +1,141 @@ +/** + * ВРЕМЕННО (2026-10-29): локальная копия printRoot из @tnsu/ui-kit с одним + * отличием — заголовком окна печати (title). Браузер берёт его как имя файла при + * «Сохранить как PDF»; у кита окно без , и файл назывался как попало. + * План: перенести параметр title в кит (сигнатура уже совместимая: + * printRoot(selector, title)), затем удалить этот файл и вернуть импорт из кита. + */ + +// Значение поля для печати. Дату (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 их нет). +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 = + 'html,body{margin:0;background:#fff;height:auto!important;min-height:0!important}' + + 'body{padding:24px;font-size:13px}' + + 'svg{display:none!important}' + + '.ms-Persona-imageArea,.ms-Persona-image{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}'; + +/** + * Запасной путь — печать самой страницы (нет корня печати или всплывающее окно + * заблокировано). Заголовок страницы подменяем на время диалога, чтобы и тут + * имя файла было осмысленным; window.print() ждёт закрытия диалога. + */ +function printCurrentPage(title?: string): void { + if (!title) { + window.print(); + return; + } + const pageTitle = document.title; + document.title = title; + window.print(); + document.title = pageTitle; +} + +/** + * Переносит документ (по умолчанию `.rp-print-root`) в отдельное чистое окно, + * заменяя поля ввода на текст значений, и печатает его. title — заголовок окна, + * он же имя файла при сохранении в PDF (см. buildPrintTitle). + */ +export function printRoot(selector = '.rp-print-root', title?: string): void { + const source = document.querySelector(selector); + if (!source) { + printCurrentPage(title); + return; + } + const win = window.open('', '_blank', 'width=980,height=1200'); + if (!win) { + printCurrentPage(title); + return; + } + + const clone = source.cloneNode(true) as Element; + clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove()); + renderValuesAsText(source, clone); + + 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(); + // Через свойство, а не тегом в разметке выше: так текст не надо экранировать. + if (title) win.document.title = title; + 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); + }; + } +} + +export interface IPrintTitleParts { + /** Название формы, как в шапке карточки. */ + formTitle: string; + /** ФИО сотрудника (кандидата), о ком заявка; '' — пропускаем. Берётся как есть. */ + personName: string; + /** Уникальный код заявки; '' у старых заявок — тогда номер элемента. */ + code: string; + itemId: number; +} + +/** + * Имя печатного документа (и PDF-файла): + * «<Название формы> — <ФИО> №<код>», напр. «Заявка на отпуск — Иванов Иван №HR-2610-45». + * Номер — как в шапке карточки (код, а у старых заявок — номер элемента). + */ +export function buildPrintTitle({ formTitle, personName, code, itemId }: IPrintTitleParts): string { + const name = personName.trim(); + const head = name ? `${formTitle} — ${name}` : formTitle; + return `${head} №${code || itemId}`; +} diff --git a/src/webparts/requestPortal/shared/ui/PrintSplitButton/PrintSplitButton.tsx b/src/webparts/requestPortal/shared/ui/PrintSplitButton/PrintSplitButton.tsx index a0b3d93..052984f 100644 --- a/src/webparts/requestPortal/shared/ui/PrintSplitButton/PrintSplitButton.tsx +++ b/src/webparts/requestPortal/shared/ui/PrintSplitButton/PrintSplitButton.tsx @@ -1,5 +1,7 @@ import * as React from 'react'; -import { DropdownButton, Icon, PrintButton, printRoot } from '@tnsu/ui-kit'; +import { DropdownButton, Icon } from '@tnsu/ui-kit'; +import { PrintButton } from '../PrintButton/PrintButton'; +import { printRoot } from '../PrintButton/printRoot'; interface IPrintSplitButtonProps { /** `\n`-joined «Компания — должность», как в data.Organization. */ @@ -7,6 +9,8 @@ interface IPrintSplitButtonProps { selectedIndex: number; onChange: (index: number) => void; label?: string; + /** Заголовок документа = имя PDF-файла (см. buildPrintTitle). */ + documentTitle?: string; } /** @@ -19,7 +23,8 @@ export const PrintSplitButton: React.FC<IPrintSplitButtonProps> = ({ value, selectedIndex, onChange, - label = 'Печать' + label = 'Печать', + documentTitle }) => { const [pendingPrint, setPendingPrint] = React.useState(false); const lines = value ? value.split('\n') : []; @@ -31,11 +36,11 @@ export const PrintSplitButton: React.FC<IPrintSplitButtonProps> = ({ React.useEffect(() => { if (!pendingPrint) return; setPendingPrint(false); - printRoot(); - }, [pendingPrint]); + printRoot(undefined, documentTitle); + }, [pendingPrint, documentTitle]); if (lines.length <= 1) { - return <PrintButton label={label} />; + return <PrintButton label={label} documentTitle={documentTitle} />; } const options = lines.map((line, i) => ({ value: String(i), label: line })); diff --git a/src/webparts/requestPortal/shared/ui/index.ts b/src/webparts/requestPortal/shared/ui/index.ts index 69e151e..d463229 100644 --- a/src/webparts/requestPortal/shared/ui/index.ts +++ b/src/webparts/requestPortal/shared/ui/index.ts @@ -24,8 +24,7 @@ export { Icon, ConfirmHost, confirmModal, - RequestNumber, - PrintButton + RequestNumber } from '@tnsu/ui-kit'; export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit'; @@ -48,6 +47,9 @@ export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti'; export { PeopleTextField } from './PeopleField/PeopleTextField'; export { OrganizationField } from './OrganizationField/OrganizationField'; export { OrganizationTable } from './OrganizationField/OrganizationTable'; +// PrintButton — ВРЕМЕННО локальный (заголовок = имя PDF), пока параметр title не перенесён в ui-kit. +export { PrintButton } from './PrintButton/PrintButton'; +export { buildPrintTitle } from './PrintButton/printRoot'; export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton'; export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead'; export { PrintSignature } from './PrintSignature/PrintSignature';