3 Commits

Author SHA1 Message Date
Тимур Городецкий 4f292dde3d feat: печатный вид объяснительной по СКУД с маршрутом согласования
Tests / frontend-test (push) Successful in 1m21s
Вместо линии для подписи под запиской печатается маршрут согласования
и исполнения. Документ больше не вылезает за карточку (box-sizing), а текст
причины «Другое» получает точку, чтобы следующая фраза к нему не прилипала.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 09:56:35 +05:00
Тимур Городецкий a05a336275 fix: пустой квадратик под ФИО на печати
В окне печати у выбранного в PeoplePicker человека оставалась голая кнопка «×»
(иконки скрыты, стилей Fluent там нет). На печати она не нужна — скрываем.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 09:56:35 +05:00
Тимур Городецкий ff7d1f26a7 fix: причины в объяснительной по СКУД
Убрана причина «Забыл(а) пропуск», неисправность турникета/считывателя
обобщена до «Неисправность СКУД» — так просит отдел кадров.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 09:56:35 +05:00
10 changed files with 87 additions and 36 deletions
@@ -22,6 +22,13 @@
color: #000 !important;
border-color: #ccc !important;
}
// У выбранного человека в PeoplePicker (Fluent) есть кнопка «×». В окне печати
// svg-иконки скрыты, а стили Fluent туда не копируются — остаётся пустая
// системная кнопка-квадратик под ФИО. На печати удалять некого — скрываем.
.rp-print-root .ms-PickerItem-removeButton {
display: none !important;
}
}
}
@@ -19,6 +19,15 @@
color: $navy-900;
}
// Только для окна печати (PrintButton печатает с media print — там блок виден).
.printOnly {
margin-top: $sp-6;
@media screen {
display: none;
}
}
.toolbar {
display: flex;
flex-wrap: wrap;
@@ -7,11 +7,11 @@ import {
ApproverService,
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
notifyActiveStep,
useRequestTypeId
} from '../../shared/approval';
import type { ISiteUser } from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
@@ -28,6 +28,7 @@ import { EmployeeSection } from './sections/EmployeeSection';
import { DaysSection } from './sections/DaysSection';
import { ExplanationSection } from './sections/ExplanationSection';
import { NoteDocument } from './components/NoteDocument';
import { SkudRouteTables } from './components/SkudRouteTables';
interface ISkudExplanationViewProps {
context: WebPartContext;
@@ -44,7 +45,8 @@ const NOOP_SET: SetField = () => undefined;
/**
* Объяснительная для согласующего: данные заявки (только чтение) + документ
* записки. На печать идёт только документ (rp-print-root) — как бумажный бланк.
* записки. На печать идёт только rp-print-root: документ как бумажный бланк и
* под ним маршрут согласования/исполнения вместо подписи.
*/
const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
const notifyCode = useStableCallback(onCodeLoaded);
@@ -119,6 +121,7 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
};
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
const searchUsers = (query: string): Promise<ISiteUser[]> => approval.searchUsers(query);
return (
<div>
@@ -150,21 +153,15 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
{/* Created — запасной вариант для заявок, сохранённых до появления «Даты подачи». */}
<NoteDocument data={data} noteDate={data.SubmitDate || created} orgIndex={printOrgIndex} />
{/* На экране маршрут уже есть под карточкой — копия нужна только в окне печати. */}
<div className={styles.printOnly}>
<SkudRouteTables approvers={approvers} executors={executors} search={searchUsers} />
</div>
</div>
</div>
</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="Исполнители не назначены."
/>
)}
<SkudRouteTables approvers={approvers} executors={executors} search={searchUsers} />
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
@@ -4,9 +4,10 @@
// красная строка — как в образце объяснительной отдела кадров.
$doc-font: 'Times New Roman', Times, serif;
$paragraph-indent: 2.5em;
$sign-line-width: 120px;
.document {
// border-box: иначе padding и рамка прибавляются к 100% и документ вылезает за карточку.
box-sizing: border-box;
width: 100%;
padding: $sp-4;
border: 1px solid $gray-200;
@@ -57,15 +58,3 @@ $sign-line-width: 120px;
margin-top: $sp-8;
font-weight: 600;
}
.signature {
display: inline-flex;
align-items: flex-end;
gap: $sp-2;
}
.signLine {
display: inline-block;
width: $sign-line-width;
border-bottom: 1px solid $gray-900;
}
@@ -19,7 +19,7 @@ function companyOf(organization: string, index: number): string {
/**
* Объяснительная записка в виде бумажного документа (как бланк отдела кадров):
* шапка справа, заголовок, текст, дата и подпись. Предпросмотр в форме и
* шапка справа, заголовок, текст, дата и ФИО. Предпросмотр в форме и
* печатная форма в просмотре — один и тот же компонент.
*/
export const NoteDocument: React.FC<INoteDocumentProps> = ({ data, noteDate, orgIndex = 0 }) => {
@@ -42,12 +42,10 @@ export const NoteDocument: React.FC<INoteDocumentProps> = ({ data, noteDate, org
</p>
))}
{/* Без линии для подписи: подписью служит маршрут согласования, который печатается под запиской. */}
<div className={styles.footer}>
<span>{formatShortDate(noteDate)} г.</span>
<span className={styles.signature}>
<span className={styles.signLine} />
{name}
</span>
<span>{name}</span>
</div>
</div>
);
@@ -0,0 +1,33 @@
import * as React from 'react';
import { ApprovalMatrix } from '../../../shared/approval';
import type { ISiteUser, IUseMatrixEditor } from '../../../shared/approval';
interface ISkudRouteTablesProps {
approvers: IUseMatrixEditor;
executors: IUseMatrixEditor;
search: (query: string) => Promise<ISiteUser[]>;
}
/**
* Маршрут согласования + исполнение (если исполнители есть). Рисуется дважды:
* на экране под карточкой и внутри печатного блока под запиской — на печати
* таблица маршрута заменяет подпись сотрудника.
*/
export const SkudRouteTables: React.FC<ISkudRouteTablesProps> = ({ approvers, executors, search }) => {
const hasExecutors = !executors.loading && executors.rows.length > 0;
return (
<>
<ApprovalMatrix editor={approvers} search={search} />
{hasExecutors && (
<ApprovalMatrix
editor={executors}
search={search}
title="Исполнение"
roleLabel="Исполнитель"
emptyText="Исполнители не назначены."
/>
)}
</>
);
};
@@ -85,8 +85,7 @@ export interface ISkudReason {
/** Варианты причины. Меняя value — менять и choices колонки SkudReason в provisioning.json. */
export const SKUD_REASONS: ISkudReason[] = [
{ value: 'Невнимательность', phrase: 'невнимательности' },
{ value: 'Забыл(а) пропуск', phrase: 'того, что я забыл(а) пропуск' },
{ value: 'Неисправность турникета или считывателя', phrase: 'неисправности турникета или считывателя' },
{ value: 'Неисправность СКУД', phrase: 'неисправности СКУД' },
{ value: OTHER_REASON, phrase: '' }
];
@@ -43,7 +43,21 @@ describe('noteText', () => {
ReasonDetails: 'был на объекте',
PresenceConfirmed: false
});
expect(second).toBe('Отсутствие отметки в системе СКУД в указанную дату произошло по следующей причине: был на объекте');
expect(second).toBe('Отсутствие отметки в системе СКУД в указанную дату произошло по следующей причине: был на объекте.');
});
it('«Другое»: свою точку сотрудника не дублирует, фраза о подтверждении не прилипает', () => {
const [, second] = buildNoteParagraphs({
...DATA,
MissedDays: [DATA.MissedDays[0]],
Reason: OTHER_REASON,
ReasonDetails: 'был на объекте.',
PresenceConfirmed: true
});
expect(second).toBe(
'Отсутствие отметки в системе СКУД в указанную дату произошло по следующей причине: был на объекте. ' +
'Факт моего нахождения на рабочем месте и исполнения трудовых обязанностей в течение рабочего дня подтверждаю.'
);
});
it('сводка дней для колонки SP', () => {
@@ -62,9 +62,14 @@ export function formatMissedDaysSummary(days: IMissedDay[]): string {
return days.map((d) => `${formatShortDate(d.date)} (${formatMissedDetails(d)})`).join('; ');
}
/** Текст сотрудника — законченное предложение: без точки следующая фраза записки к нему прилипает. */
function withFinalPunctuation(text: string): string {
return /[.!?…]$/.test(text) ? text : `${text}.`;
}
function reasonSentence(data: IFormData, dateWord: string): string {
if (data.Reason === OTHER_REASON) {
const details = data.ReasonDetails.trim() || BLANK;
const details = withFinalPunctuation(data.ReasonDetails.trim() || BLANK);
return `Отсутствие отметки в системе СКУД в ${dateWord} произошло по следующей причине: ${details}`;
}
const reason = SKUD_REASONS.find((r) => r.value === data.Reason);
@@ -11,7 +11,7 @@
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "SkudMissedDates", "title": "Дни без отметок", "type": "Note" },
{ "internalName": "SkudMissedDaysJson", "title": "Дни без отметок (JSON)", "type": "Note" },
{ "internalName": "SkudReason", "title": "Причина", "type": "Choice", "choices": ["Невнимательность", "Забыл(а) пропуск", "Неисправность турникета или считывателя", "Другое"] },
{ "internalName": "SkudReason", "title": "Причина", "type": "Choice", "choices": ["Невнимательность", "Неисправность СКУД", "Другое"] },
{ "internalName": "SkudReasonDetails", "title": "Причина (подробно)", "type": "Note" },
{ "internalName": "SkudPresenceConfirmed", "title": "Присутствие подтверждено", "type": "Boolean" }
]