feat: форма «Объяснительная по СКУД»
Tests / frontend-test (push) Successful in 1m17s

Сотрудник пишет объяснительную за дни без отметок СКУД, выбирая их кликом
в календаре «Мой СКУД» (перенесён из portal-main-page: те же цвета и
правила, проходы Hikvision текущего пользователя, праздники из
«Производственного календаря»). Текст записки собирается автоматически,
в просмотре печатается бланк объяснительной.

- выбираются прошедшие дни < 8 ч, с одной записью или без отметок и
  сегодня без отметок; только текущий и прошлый месяц;
- дни сохраняются снимком (JSON), т.к. согласующий чужой СКУД не видит;
- защита от повторов: дни из уже поданных объяснительных помечены ✓ и
  не выбираются, перед отправкой список перечитывается;
- маршрут в две фазы: согласование + исполнение (кадры);
- дата подачи ставится автоматически.

Плитка пока только для тестировщиков (comingSoon): списки SP
провизионированы, тип и маршрут заведены, осталась проверка e2e.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-29 17:27:46 +05:00
parent 1dda76caae
commit 7e9848e83d
42 changed files with 2696 additions and 0 deletions
@@ -17,6 +17,14 @@ export const REQUEST_TYPES: IRequestType[] = [
// Отдел кадров
{ id: 'hr-vacation', categoryId: 'hr', title: 'Заявка на трудовой отпуск', icon: 'calendar' },
{ id: 'hr-unpaid-leave', categoryId: 'hr', title: 'Заявка на отпуск без содержания', icon: 'document' },
// Пока только тестировщикам: тип заявки и маршрут ещё не заведены в админке approval-route.
{
id: 'hr-skud-explanation',
categoryId: 'hr',
title: 'Объяснительная по СКУД',
icon: 'user-check',
comingSoon: true
},
{ id: 'hr-dismissal', categoryId: 'hr', title: 'Заявление на увольнение', icon: 'document', comingSoon: true },
{ id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard', comingSoon: true },
{
@@ -0,0 +1,61 @@
# Объяснительная об отсутствии отметок в СКУД (hr-skud-explanation)
**Назначение:** объяснительная сотрудника за дни, когда в СКУД нет отметки прихода/ухода
(одна запись) или нет отметок вовсе.
**Категория:** Отдел кадров (плитка «Объяснительная по СКУД»)
**Списки SP:** «Объяснительная по СКУД — данные» / «Объяснительная по СКУД — согласование»
**RequestTypeId (внешний API маршрутов):** _плейсхолдер_ — получит числовой id при заведении
типа в админке approval-route с `pageUrl` «hr-skud-explanation».
## Маршрут
- Фазы: согласование (напр. непосредственный руководитель) + исполнение (напр. кадры) —
обе фазы грузятся отдельными запросами `getPlannedMatrix(..., 'Approval' | 'Execution')`
в `useSkudRoute` (паттерн Referral). Без запроса 'Execution' шаг исполнения пропадает.
- Планируемый маршрут резолвится по e-mail сотрудника; согласующих заявитель может
править (новая / черновик / возврат), исполнителей — нет (только чтение).
- Нет согласующих вовсе — `initialSubmitStatus` сразу ставит «Выполнение».
## Особое поведение
- **Только «на себя».** Проходы берутся из `GET api.tnsu.kz/Hikvision/Events/CurrentUser` —
API определяет сотрудника по токену, за другого их не получить. Поле «Сотрудник» не
редактируется. 404 от API = учётная запись не связана с 1С → вместо календаря сообщение.
- **Календарь «Мой СКУД»** (перенесён из portal-main-page, те же цвета и правила):
зелёный ≥ 8 ч, жёлтый < 8 ч (рабочее время = последний проход − первый − 1 ч обеда),
красный — 1 запись, штриховка — прошедший будний день без проходов, индиго — праздник
(SP-список «Производственный Календарь» на корневом сайте, `HOLIDAYS_SITE_URL`).
- **Какие дни выбираются** (`toMissedKind`): прошедшие < 8 ч (часто пропущен приход/уход),
с 1 записью и без отметок; сегодня — только если нет ни одной отметки (одна запись утром —
норма, сотрудник ещё не ушёл). ≥ 8 ч, выходные, праздники — нет.
- **Лимит — текущий и прошлый месяц** (`SELECTABLE_MONTHS_BACK = 1`): дальше календарь не
листается, а при отправке черновика с устаревшими днями валидация просит их убрать.
- **Защита от повторов** (`submittedDays.ts` / `.service.ts`): день, уже вошедший в свою
объяснительную со статусом На согласовании / Согласовано / Выполнение / Закрыта /
Возвращена на доработку, в календаре помечен ✓ и не выбирается (подсказка — номер и
статус той заявки). «Отклонено» и «Черновик» не блокируют. Ищем по всем партициям,
фильтр `EmployeeId` + `Created ≥ начала доступного периода`; открытая заявка себя не
блокирует. Перед отправкой список перечитывается (вторая вкладка). Ошибка чтения
форму не блокирует — календарь без пометок. Индекс на `Employee` провижинер не ставит;
понадобится, только если партиция перерастёт 5000 элементов.
- **Дни сохраняются снимком** (`SkudMissedDaysJson`: дата, вид, первая/последняя запись),
т.к. согласующий чужой СКУД открыть не может. В режиме чтения календаря нет — только
список дней. `SkudMissedDates` — та же информация читаемой строкой для представлений SP.
- **Ф.И.О. полностью** — из `Employee/ByEmail` (`SkudEmployeeFio`), а не из профиля SP
(там «Имя Фамилия»): в тексте записки нужно «Фамилия И. О.».
- **Текст записки собирается автоматически** (`noteText.ts`) из дат, причины и
подтверждения; показывается только в просмотре (документ для печати) — в форме подачи
предпросмотра нет (убран по просьбе Тимура).
- **Печать** — только документ (бланк: шапка «Директору {организация}», должность, Ф.И.О.,
заголовок, текст, дата подачи, подпись), без данных формы и маршрута.
- **Дата подачи** (`SubmitDate`, общий столбец сайта «только дата») — не редактируется,
ставится сегодняшней у новой заявки и при открытии черновика/доработки; у отправленной
остаётся прежней. Печатается внизу бланка (для старых записей без неё — `Created`).
- **Заголовок элемента** (`Title`): `Объяснительная по СКУД — {ФИО} — дд.мм.гггг`.
## Секции формы
EmployeeSection (сотрудник · должность · организация) → DaysSection (календарь + выбранные
дни) → ExplanationSection (причина · «другое» · подтверждение).
## Точки входа
- rptype: `hr-skud-explanation`
- Режим согласующего (просмотр): есть (`SkudExplanationView`)
- «Создать на основе»: нет
@@ -0,0 +1,20 @@
@import '../../../../styles/design-tokens';
.back {
display: inline-flex;
align-items: center;
gap: $sp-2;
margin-bottom: $sp-4;
padding: 0;
border: 0;
background: transparent;
color: $navy-700;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: color .18s;
&:hover {
color: $gold-600;
}
}
@@ -0,0 +1,309 @@
import * as React from 'react';
import { useState, useMemo, useEffect } from 'react';
import styles from './SkudExplanationForm.module.scss';
import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
Icon,
RequestNumber,
SubmitActions,
getDraftDeleteHandler,
useRequestSubmit
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
isEditableStatus,
initialSubmitStatus,
IApproverToWrite,
unassignedStepMessage,
resolveWriteListId
} from '../../shared/approval';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { useStableCallback } from '../../shared/useStableCallback';
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
import { validateSkudExplanation } from './validation';
import { saveSkudExplanationRequest, skudExplanationApprovalService } from './submit';
import { loadSkudExplanationRequest } from './load';
import { submittedConflictMessage } from './submittedDays';
import { getSubmittedDays } from './submittedDays.service';
import { useSubmittedDays } from './useSubmittedDays';
import { useSkudRoute } from './useSkudRoute';
import { todayDateKey } from './skud/skudDays';
import { EmployeeSection } from './sections/EmployeeSection';
import { DaysSection } from './sections/DaysSection';
import { ExplanationSection } from './sections/ExplanationSection';
const SkudExplanationForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId,
onCodeLoaded
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit();
// Стабильная обёртка: ссылка onCodeLoaded у родителя меняется на каждом
// рендере, а в зависимостях эффекта загрузки она перезапрашивала бы заявку.
const notifyCode = useStableCallback(onCodeLoaded);
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Отправленную заявку уже не правят — пустой e-mail не делает запрос.
const submittedDays = useSubmittedDays(readOnly ? '' : data.Employee, requestId);
const approval = useMemo(() => skudExplanationApprovalService(editListId), [editListId]);
const requestTypeIdState = useRequestTypeId(context, requestType.id);
const requestTypeId = requestTypeIdState.id;
// Открыта своя заявка на редактирование (черновик/возврат).
useEffect(() => {
if (!editId) return;
let alive = true;
loadSkudExplanationRequest(editId, editListId)
.then((loaded) => {
if (!alive) return;
// Черновик/доработку подают заново — дата подачи становится сегодняшней;
// у отправленной заявки остаётся та, с которой её подали.
const submitDate = isEditableStatus(loaded.status) ? todayDateKey() : loaded.data.SubmitDate;
setData({ ...loaded.data, SubmitDate: submitDate });
setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
if (loaded.code) notifyCode(loaded.code);
})
.catch((e) => {
if (alive) setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
});
return () => {
alive = false;
};
}, [editId, editListId, notifyCode, setSubmitError]);
// Новая заявка — всегда на себя: ФИО полностью, должность и организации из
// Employee/ByEmail (ФИО профиля SharePoint — «Имя Фамилия», для записки не годится).
useEffect(() => {
if (editId) return;
let alive = true;
const user = context.pageContext.user;
setData((prev) => ({
...prev,
SubmitDate: todayDateKey(),
Employee: user.email,
EmployeeFio: prev.EmployeeFio || user.displayName
}));
getEmployeeOrganizations(context, user.email)
.then((records) => {
if (!alive) return;
setData((prev) => ({
...prev,
EmployeeFio: records[0]?.fio || prev.EmployeeFio,
Position: prev.Position || records[0]?.position || '',
Organization: formatEmployeeOrganizations(records)
}));
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [editId, context]);
// Маршрут по сотруднику; у открытой заявки — из уже записанных строк, чтобы не затереть правки.
const { approvers: matrix, executors } = useSkudRoute({
context,
approval,
requestId: editId,
employeeEmail: data.Employee,
requestTypeId,
editable: !readOnly
});
const validate = (): string | undefined => {
const formError = validateSkudExplanation(data);
if (formError) return formError;
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
};
// На отправку маршрут должен быть укомплектован — шаг без человека молча пропал бы при записи.
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
const repeatError = submittedConflictMessage(data.MissedDays, submittedDays);
if (repeatError) return repeatError;
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
}
const unassignedError = unassignedStepMessage(
matrix.rows,
(r) =>
`Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
return unassignedStepMessage(
executors.rows,
(r) =>
`Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
);
};
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
order: r.order,
slaHours: r.slaHours,
askOverdueReason: r.askOverdueReason,
autoApprove: r.autoApprove
}));
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
// validate() уже гарантировал requestTypeId, но TS не видит этого через замыкание.
const rtId = requestTypeId;
if (!rtId) throw new Error('Тип заявки не определён — обновите страницу и повторите.');
// Пометки в календаре — на момент открытия формы; за это время объяснительную
// на те же дни могли подать из другой вкладки. Перечитываем прямо перед записью.
if (status !== REQUEST_STATUS.draft) {
const repeatError = submittedConflictMessage(data.MissedDays, await getSubmittedDays(data.Employee, requestId));
if (repeatError) throw new Error(repeatError);
}
// Текущую партицию спрашиваем прямо перед записью: этот вызов запускает архивацию по порогу.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveSkudExplanationRequest(
requestId,
rtId,
data,
toApprovers(matrix.rows),
toApprovers(executors.rows),
status,
writeListId
);
setRequestId(id);
setRequestStatus(status);
if (code) setRequestCode(code);
if (onSaved) onSaved();
// Отправка (на согласование или, без согласующих, сразу на исполнение) — уведомляем активный шаг.
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
});
}
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
// Согласующих в маршруте может не оказаться — тогда initialSubmitStatus сразу даёт «Выполнение».
const handleSubmit = (): Promise<void> =>
submit.run('submit', save(initialSubmitStatus(matrix.rows.length, executors.rows.length)), validateSubmit);
const handleDelete = getDraftDeleteHandler({
formKey: FORM_ID,
requestId,
requestStatus,
listId: editListId,
code: requestCode,
submit,
onDeleted: onCancel
});
const sectionProps = { data, set, context };
return (
<div>
<button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" />
<span>К списку заявок</span>
</button>
<FormCard
title={FORM_TITLE}
subtitle="Категория: Отдел кадров"
belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={
requestId || requestStatus ? (
<>
{requestStatus && <StatusBadge status={requestStatus} />}
{requestCode && <RequestNumber code={requestCode} />}
</>
) : undefined
}
>
{submit.savedKind && !submit.error && (
<Alert variant="ok">
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
</Alert>
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormModeProvider readOnly={readOnly}>
<EmployeeSection {...sectionProps} />
<DaysSection {...sectionProps} submittedDays={submittedDays} />
<ExplanationSection {...sectionProps} />
</FormModeProvider>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту."
/>
)}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
isValid={!validateSkudExplanation(data)}
readOnly={readOnly}
onCancel={onCancel}
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmit}
submitLabel="Отправить на согласование"
/>
</FormCard>
</div>
);
};
export default SkudExplanationForm;
@@ -0,0 +1,29 @@
@import '../../../../styles/design-tokens';
.state {
padding: $sp-8;
color: $gray-500;
font-size: $fs-sm;
text-align: center;
}
.documentBlock {
margin-top: $sp-6;
}
.documentTitle {
margin-bottom: $sp-3;
font-family: $font-serif;
font-size: $fs-section-title;
font-weight: 600;
color: $navy-900;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
}
@@ -0,0 +1,193 @@
import * as React from 'react';
import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './SkudExplanationView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
import {
ApproverService,
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
notifyActiveStep,
useRequestTypeId
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { useStableCallback } from '../../shared/useStableCallback';
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
import { loadSkudExplanationRequest } from './load';
import { skudExplanationApprovalService } from './submit';
import { useSkudRoute } from './useSkudRoute';
import { EmployeeSection } from './sections/EmployeeSection';
import { DaysSection } from './sections/DaysSection';
import { ExplanationSection } from './sections/ExplanationSection';
import { NoteDocument } from './components/NoteDocument';
interface ISkudExplanationViewProps {
context: WebPartContext;
itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string;
/** Партиция, где лежит эта заявка — см. IApprovalViewProps.listId. */
listId?: string;
onCodeLoaded?: (code: string) => void;
}
// В режиме чтения поля не редактируются — обновление пустышка.
const NOOP_SET: SetField = () => undefined;
/**
* Объяснительная для согласующего: данные заявки (только чтение) + документ
* записки. На печать идёт только документ (rp-print-root) — как бумажный бланк.
*/
const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [created, setCreated] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
const approval = useMemo(() => skudExplanationApprovalService(listId), [listId]);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// decisionSeq: после решения строки маршрута меняются — перечитываем обе фазы.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const { approvers, executors } = useSkudRoute({
context,
approval,
requestId: itemId,
employeeEmail: data.Employee,
requestTypeId,
editable: false,
reloadSeq: decisionSeq
});
useEffect(() => {
let alive = true;
setLoading(true);
loadSkudExplanationRequest(itemId, listId)
.then((loaded) => {
if (!alive) return;
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
setCreated(loaded.created);
notifyCode(loaded.code);
setError(null);
setLoading(false);
})
.catch((e) => {
if (!alive) return;
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
setLoading(false);
});
return () => {
alive = false;
};
}, [itemId, listId, notifyCode]);
if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>;
}
if (error) {
return (
<div className={styles.state}>
<Alert variant="err">{error}</Alert>
</div>
);
}
const notify = (): void => {
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
</div>
<FormCard
title={FORM_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<FormModeProvider readOnly>
<EmployeeSection {...sectionProps} />
<DaysSection {...sectionProps} />
<ExplanationSection {...sectionProps} />
</FormModeProvider>
{/* Не в CollapseSection: свёрнутая секция убрала бы rp-print-root из DOM, и печатать было бы нечего. */}
<div className={styles.documentBlock}>
<div className={styles.documentTitle}>Объяснительная записка</div>
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
{/* Created — запасной вариант для заявок, сохранённых до появления «Даты подачи». */}
<NoteDocument data={data} noteDate={data.SubmitDate || created} orgIndex={printOrgIndex} />
</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="Исполнители не назначены."
/>
)}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
notify();
}}
/>
</div>
</div>
);
};
export default SkudExplanationView;
@@ -0,0 +1,17 @@
{
"dataListTitle": "Объяснительная по СКУД — согласование",
"columns": [
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Объяснительная по СКУД — данные", "lookupShowField": "Title" },
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
]
}
@@ -0,0 +1,71 @@
@import '../../../../../styles/design-tokens';
// Документ имитирует бумажный бланк: шрифт с засечками, выравнивание по ширине,
// красная строка — как в образце объяснительной отдела кадров.
$doc-font: 'Times New Roman', Times, serif;
$paragraph-indent: 2.5em;
$sign-line-width: 120px;
.document {
width: 100%;
padding: $sp-4;
border: 1px solid $gray-200;
border-radius: $r-md;
background: $white;
color: $gray-900;
font-family: $doc-font;
font-size: 15px;
line-height: 1.5;
@media (min-width: 640px) {
padding: $sp-8;
}
}
.addressee {
margin-left: auto;
margin-bottom: $sp-8;
max-width: 320px;
text-align: right;
font-weight: 600;
}
.author {
margin-top: $sp-1;
}
.title {
margin-bottom: $sp-6;
text-align: center;
font-weight: 700;
text-transform: uppercase;
}
.paragraph {
margin: 0 0 $sp-2;
text-align: justify;
text-indent: $paragraph-indent;
overflow-wrap: anywhere;
}
.footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-end;
gap: $sp-4;
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;
}
@@ -0,0 +1,54 @@
import * as React from 'react';
import styles from './NoteDocument.module.scss';
import { IFormData } from '../model';
import { buildNoteParagraphs, formatShortDate, shortFio } from '../noteText';
interface INoteDocumentProps {
data: IFormData;
/** Дата записки, YYYY-MM-DD (дата подачи; у новой заявки — сегодня). */
noteDate: string;
/** Какая из организаций сотрудника идёт в шапку (см. PrintSplitButton). */
orgIndex?: number;
}
/** «Компания — должность» → компания; организаций несколько — берём выбранную для печати. */
function companyOf(organization: string, index: number): string {
const line = organization.split('\n')[index] || '';
return line.split(' — ')[0].trim();
}
/**
* Объяснительная записка в виде бумажного документа (как бланк отдела кадров):
* шапка справа, заголовок, текст, дата и подпись. Предпросмотр в форме и
* печатная форма в просмотре — один и тот же компонент.
*/
export const NoteDocument: React.FC<INoteDocumentProps> = ({ data, noteDate, orgIndex = 0 }) => {
const company = companyOf(data.Organization, orgIndex);
const name = shortFio(data.EmployeeFio);
return (
<div className={styles.document}>
<div className={styles.addressee}>
{company && <div>Директору {company}</div>}
{data.Position && <div>{data.Position}</div>}
{name && <div className={styles.author}>{name}</div>}
</div>
<div className={styles.title}>Объяснительная записка</div>
{buildNoteParagraphs(data).map((paragraph) => (
<p key={paragraph} className={styles.paragraph}>
{paragraph}
</p>
))}
<div className={styles.footer}>
<span>{formatShortDate(noteDate)} г.</span>
<span className={styles.signature}>
<span className={styles.signLine} />
{name}
</span>
</div>
</div>
);
};
@@ -0,0 +1,57 @@
@import '../../../../../styles/design-tokens';
.list {
display: flex;
flex-wrap: wrap;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
.chip {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-2;
max-width: 100%;
padding: $sp-1 $sp-2 $sp-1 $sp-3;
border: 1px solid $navy-100;
border-radius: $r-full;
background: $navy-50;
font-size: $fs-sm;
}
.date {
font-weight: 600;
color: $navy-900;
}
.kind {
font-size: $fs-label;
color: $red-500;
}
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $gray-500;
cursor: pointer;
&:hover {
background: $navy-100;
color: $navy-900;
}
}
.empty {
font-size: $fs-sm;
color: $gray-500;
}
@@ -0,0 +1,39 @@
import * as React from 'react';
import { Icon } from '../../../shared/ui';
import styles from './SelectedDaysList.module.scss';
import { IMissedDay } from '../model';
import { formatLongDate, formatMissedDetails } from '../noteText';
interface ISelectedDaysListProps {
days: IMissedDay[];
/** Не передан — список только для чтения (просмотр, отправленная заявка). */
onRemove?: (date: string) => void;
}
/** Выбранные в объяснительную дни — «чипсы» с тем, что было в СКУД на момент подачи. */
export const SelectedDaysList: React.FC<ISelectedDaysListProps> = ({ days, onRemove }) => {
if (days.length === 0) {
return <div className={styles.empty}>Дни не выбраны — отметьте их в календаре.</div>;
}
return (
<ul className={styles.list}>
{days.map((day) => (
<li key={day.date} className={styles.chip}>
<span className={styles.date}>{formatLongDate(day.date)}</span>
<span className={styles.kind}>{formatMissedDetails(day)}</span>
{onRemove && (
<button
type="button"
className={styles.remove}
onClick={() => onRemove(day.date)}
aria-label={`Убрать ${formatLongDate(day.date)}`}
>
<Icon name="close" size="sm" />
</button>
)}
</li>
))}
</ul>
);
};
@@ -0,0 +1,293 @@
@import '../../../../../styles/design-tokens';
// Цвета статусов — те же, что в «Мой СКУД» (portal-main-page), чтобы сотрудник
// видел одну и ту же картину в обоих местах.
$full-bg: #ECFDF5;
$full-border: #A7F3D0;
$full-text: $green-600;
$partial-bg: #FFFBEB;
$partial-border: #FDE68A;
$partial-text: #B45309;
$single-bg: $red-50;
$single-border: #FECACA;
$single-text: $red-500;
$holiday-bg: #EEF2FF;
$holiday-border: #C7D2FE;
$holiday-text: #4338CA;
$weekend-bg: #EDEFF2;
// Шире 7 колонок по ~64px ячейки перестают быть квадратными и читаются хуже —
// на широком экране календарь не растягивается на всю форму.
$calendar-max-width: 480px;
.calendar {
width: 100%;
max-width: $calendar-max-width;
}
.nav {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-3;
margin-bottom: $sp-1;
}
.navButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $white;
color: $navy-700;
cursor: pointer;
&:hover:not(:disabled) {
border-color: $gold-500;
}
&:disabled {
color: $gray-300;
cursor: default;
}
}
.month {
min-width: 150px;
text-align: center;
font-size: $fs-body;
font-weight: 600;
color: $navy-900;
}
.summary {
margin-bottom: $sp-2;
text-align: center;
font-size: $fs-label;
color: $gray-500;
}
.state {
padding: $sp-6 0;
text-align: center;
font-size: $fs-sm;
color: $gray-500;
}
.weekHead,
.grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: $sp-1;
@media (min-width: 480px) {
gap: 6px;
}
}
.weekHead {
margin-bottom: $sp-1;
}
.weekDay {
text-align: center;
font-size: $fs-caption;
font-weight: 600;
color: $gray-400;
text-transform: uppercase;
}
.empty {
aspect-ratio: 1 / 1;
}
.cell {
aspect-ratio: 1 / 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 0;
padding: 0;
border: 1px solid transparent;
border-radius: $r-sm;
background: $gray-50;
color: $gray-400;
font-family: inherit;
cursor: default;
transition: border-color .18s, box-shadow .18s;
}
.dayNum {
font-size: $fs-label;
font-weight: 500;
@media (min-width: 480px) {
font-size: $fs-sm;
}
}
.cellTime {
font-size: 9px;
font-weight: 600;
@media (min-width: 480px) {
font-size: 10px;
}
}
.full {
background: $full-bg;
border-color: $full-border;
color: $full-text;
}
.partial {
background: $partial-bg;
border-color: $partial-border;
color: $partial-text;
}
.single {
background: $single-bg;
border-color: $single-border;
color: $single-text;
}
.holiday {
background: $holiday-bg;
border-color: $holiday-border;
color: $holiday-text;
}
.weekend {
background: $weekend-bg;
.dayNum {
color: $gray-500;
}
}
// Прошедший будний день без проходов — диагональная штриховка «нет данных».
.absent {
background: repeating-linear-gradient(45deg, $white, $white 4px, $gray-200 4px, $gray-200 8px);
border-color: $gray-200;
.dayNum {
color: $gray-500;
}
}
.selectable {
cursor: pointer;
&:hover {
border-color: $navy-700;
}
}
// День уже в поданной объяснительной: синяя рамка + галочка поверх цвета СКУД,
// чтобы было видно и «что было», и «уже объяснено».
.cell {
position: relative;
}
.submitted {
border-color: $blue-600;
opacity: .75;
}
.submittedMark {
position: absolute;
top: 2px;
right: 4px;
font-size: 10px;
font-weight: 700;
line-height: 1;
color: $blue-600;
}
.selected {
border-color: $navy-800;
box-shadow: inset 0 0 0 2px $navy-800;
.dayNum {
font-weight: 700;
}
}
.today {
box-shadow: inset 0 0 0 2px $gold-500;
}
.legendWrap {
margin-top: $sp-3;
text-align: center;
}
.legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: $sp-2 $sp-3;
font-size: $fs-caption;
color: $gray-500;
}
.legendItem {
display: inline-flex;
align-items: center;
gap: 5px;
}
.dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
}
.dotFull {
background: $full-border;
border: 1px solid $full-text;
}
.dotPartial {
background: $partial-border;
border: 1px solid $partial-text;
}
.dotSingle {
background: $single-border;
border: 1px solid $single-text;
}
.dotHoliday {
background: $holiday-border;
border: 1px solid $holiday-text;
}
.dotSubmitted {
position: relative;
background: $blue-50;
border: 1px solid $blue-600;
}
.dotAbsent {
background: repeating-linear-gradient(45deg, $white, $white 2px, $gray-300 2px, $gray-300 4px);
border: 1px solid $gray-200;
}
.hint {
margin-top: $sp-2;
font-size: $fs-caption;
color: $gray-500;
}
@@ -0,0 +1,108 @@
import * as React from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { Alert, Icon } from '../../../shared/ui';
import styles from './SkudCalendar.module.scss';
import { SkudLegend } from './SkudLegend';
import { SkudDayCell } from './SkudDayCell';
import { useSkudMonth } from '../skud/useSkudMonth';
import { buildMonthCells, minSelectableDateKey, monthWorkedHours, todayDateKey } from '../skud/skudDays';
import { IMissedDay } from '../model';
import { ISubmittedDay } from '../submittedDays';
const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
interface ISkudCalendarProps {
context: WebPartContext;
/** Даты (YYYY-MM-DD), уже выбранные в объяснительную. */
selectedDates: string[];
/** Дни, уже закрытые поданными объяснительными (см. useSubmittedDays). */
submittedDays: Map<string, ISubmittedDay>;
onToggle: (day: IMissedDay) => void;
}
function monthTitle(year: number, month: number): string {
const text = new Date(year, month, 1).toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' });
const clean = text.replace(' г.', '');
return clean.charAt(0).toUpperCase() + clean.slice(1);
}
/** Календарь «Мой СКУД» с выбором дней (какие кликабельны — см. toMissedKind); листается на SELECTABLE_MONTHS_BACK назад. */
export const SkudCalendar: React.FC<ISkudCalendarProps> = ({ context, selectedDates, submittedDays, onToggle }) => {
const skud = useSkudMonth(context);
const cells = buildMonthCells(skud.year, skud.month, skud.byDay);
const bounds = { todayKey: todayDateKey(), minKey: minSelectableDateKey() };
if (skud.notLinked) {
return (
<Alert variant="warn">
Данные СКУД недоступны: ваша учётная запись не связана с системой 1С. Обратитесь в отдел кадров.
</Alert>
);
}
return (
<div className={styles.calendar}>
<div className={styles.nav}>
<button
type="button"
className={styles.navButton}
onClick={skud.goPrev}
disabled={!skud.canGoPrev}
aria-label="Предыдущий месяц"
>
<Icon name="chevron-left" size="sm" />
</button>
<span className={styles.month}>{monthTitle(skud.year, skud.month)}</span>
<button
type="button"
className={styles.navButton}
onClick={skud.goNext}
disabled={!skud.canGoNext}
aria-label="Следующий месяц"
>
<Icon name="chevron-right" size="sm" />
</button>
</div>
{!skud.loading && !skud.error && (
<div className={styles.summary}>
Дней: <b>{skud.byDay.size}</b> · ≈ <b>{monthWorkedHours(skud.byDay)} ч</b> за месяц
</div>
)}
{skud.loading && <div className={styles.state}>Загрузка отметок СКУД…</div>}
{!skud.loading && skud.error && <Alert variant="err">Не удалось загрузить отметки СКУД: {skud.error}</Alert>}
{!skud.loading && !skud.error && (
<>
<div className={styles.weekHead}>
{WEEKDAYS.map((w) => (
<span key={w} className={styles.weekDay}>
{w}
</span>
))}
</div>
<div className={styles.grid}>
{cells.map((cell, index) =>
cell ? (
<SkudDayCell
key={cell.dateKey}
info={cell}
cellIndex={index}
bounds={bounds}
holidayName={skud.holidays.get(cell.dateKey)}
submitted={submittedDays.get(cell.dateKey)}
selected={selectedDates.indexOf(cell.dateKey) >= 0}
onToggle={onToggle}
/>
) : (
<span key={`empty-${index}`} className={styles.empty} />
)
)}
</div>
<SkudLegend />
</>
)}
</div>
);
};
@@ -0,0 +1,88 @@
import * as React from 'react';
import styles from './SkudCalendar.module.scss';
import { CellKind, IDayInfo, cellKind, isWeekendColumn, toMissedKind } from '../skud/skudDays';
import { IMissedDay } from '../model';
import { ISubmittedDay } from '../submittedDays';
/** Границы выбора: сегодня и самая ранняя доступная дата (см. minSelectableDateKey). */
export interface IDayBounds {
todayKey: string;
minKey: string;
}
interface ISkudDayCellProps {
info: IDayInfo;
/** Позиция в сетке — по ней определяется выходной (Сб/Вс). */
cellIndex: number;
bounds: IDayBounds;
/** Название праздника, если день — праздничный выходной. */
holidayName?: string;
/** День уже закрыт поданной объяснительной — выбрать нельзя. */
submitted?: ISubmittedDay;
selected: boolean;
onToggle: (day: IMissedDay) => void;
}
const KIND_CLASS: Record<CellKind, string> = {
full: styles.full,
partial: styles.partial,
single: styles.single,
absent: styles.absent,
holiday: styles.holiday,
weekend: styles.weekend,
empty: ''
};
function hint(info: IDayInfo, kind: CellKind, holidayName: string | undefined): string {
if (kind === 'holiday') return holidayName || 'Праздник';
if (info.events.length === 1) return `Одна запись: ${info.first}`;
if (info.events.length > 1) return `${info.first} → ${info.last}`;
return kind === 'absent' ? 'Нет отметок' : '';
}
/** Ячейка дня: цвет по отметкам СКУД; «проблемный» день можно выбрать в объяснительную. */
export const SkudDayCell: React.FC<ISkudDayCellProps> = ({
info,
cellIndex,
bounds,
holidayName,
submitted,
selected,
onToggle
}) => {
const kind = cellKind(info, isWeekendColumn(cellIndex), !!holidayName, bounds.todayKey);
const missedKind = submitted ? undefined : toMissedKind(kind, info.dateKey, bounds.todayKey, bounds.minKey);
const className = [
styles.cell,
KIND_CLASS[kind],
missedKind ? styles.selectable : '',
submitted ? styles.submitted : '',
selected ? styles.selected : '',
info.dateKey === bounds.todayKey ? styles.today : ''
]
.filter(Boolean)
.join(' ');
const title = submitted ? `Уже подана: №${submitted.code} (${submitted.status})` : hint(info, kind, holidayName);
const handleClick = (): void => {
if (!missedKind) return;
// Снимок того, что было в СКУД: согласующий увидит это вместо календаря.
const hasExit = info.events.length > 1;
onToggle({ date: info.dateKey, kind: missedKind, first: info.first, last: hasExit ? info.last : '' });
};
return (
<button
type="button"
className={className}
disabled={!missedKind}
aria-pressed={missedKind ? selected : undefined}
title={title}
onClick={handleClick}
>
{submitted && <span className={styles.submittedMark}>✓</span>}
<span className={styles.dayNum}>{info.day}</span>
{info.first && <span className={styles.cellTime}>{info.first}</span>}
</button>
);
};
@@ -0,0 +1,29 @@
import * as React from 'react';
import styles from './SkudCalendar.module.scss';
const ITEMS: { className: string; label: string }[] = [
{ className: styles.dotFull, label: '≥ 8 ч' },
{ className: styles.dotPartial, label: '< 8 ч' },
{ className: styles.dotSingle, label: '1 запись' },
{ className: styles.dotHoliday, label: 'праздник' },
{ className: styles.dotAbsent, label: 'отсутствие' },
{ className: styles.dotSubmitted, label: '✓ уже подана' }
];
/** Легенда цветов календаря СКУД + подсказка, какие дни выбираются. */
export const SkudLegend: React.FC = () => (
<div className={styles.legendWrap}>
<div className={styles.legend}>
{ITEMS.map((item) => (
<span key={item.label} className={styles.legendItem}>
<i className={`${styles.dot} ${item.className}`} />
{item.label}
</span>
))}
</div>
<div className={styles.hint}>
Выбрать можно дни &lt; 8 ч, с одной записью или без отметок (сегодня — если отметок нет) за текущий и прошлый
месяц.
</div>
</div>
);
@@ -0,0 +1,73 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
import { parseMissedDays } from './missedDaysJson';
import { toDateKey } from './skud/skudDays';
export interface ILoadedRequest {
data: IFormData;
status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
/** Дата создания записи, YYYY-MM-DD — дата объяснительной в печатной форме. */
created: string;
}
// Created приходит в UTC: срез строки дал бы вчерашнюю дату для записей,
// созданных рано утром по местному времени.
function localDateKey(value: unknown): string {
if (typeof value !== 'string' || !value) return '';
const d = new Date(value);
return toDateKey(d.getFullYear(), d.getMonth(), d.getDate());
}
/**
* Прочитать объяснительную по id. listId — партиция, где лежит именно эта
* заявка (см. UnpaidLeave/load.ts): задан — читаем по url списка, иначе из
* текущей партиции.
*/
export async function loadSkudExplanationRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list.items
.getById(itemId)
.select(
'Id',
'Created',
'SubmitDate',
'SkudEmployeeFio',
'Position',
'Organization',
'SkudMissedDaysJson',
'SkudReason',
'SkudReasonDetails',
'SkudPresenceConfirmed',
'RequestStatus',
'RequestUniqueCode',
'Employee/Title',
'Employee/EMail'
)
.expand('Employee')()) as Record<string, unknown>;
const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
const data: IFormData = {
...EMPTY_DATA,
// Колонка «только дата» — время в ответе нулевое, берём дату как есть (как в Referral/load.ts).
SubmitDate: ((item.SubmitDate as string) || '').slice(0, 10),
Employee: employee?.EMail || '',
EmployeeFio: (item.SkudEmployeeFio as string) || employee?.Title || '',
Position: (item.Position as string) || '',
Organization: (item.Organization as string) || '',
MissedDays: parseMissedDays(item.SkudMissedDaysJson),
Reason: (item.SkudReason as string) || '',
ReasonDetails: (item.SkudReasonDetails as string) || '',
PresenceConfirmed: item.SkudPresenceConfirmed === true
};
return {
data,
status: (item.RequestStatus as string) || '',
code: (item.RequestUniqueCode as string) || '',
created: localDateKey(item.Created)
};
}
@@ -0,0 +1,16 @@
import { IMissedDay } from './model';
/**
* Разбор колонки SkudMissedDaysJson. Битый/пустой JSON не должен ронять ни
* открытие заявки, ни проверку повторов — считаем, что дней нет.
*/
export function parseMissedDays(json: unknown): IMissedDay[] {
if (typeof json !== 'string' || !json) return [];
try {
const parsed = JSON.parse(json) as unknown;
return Array.isArray(parsed) ? (parsed as IMissedDay[]) : [];
} catch (e) {
console.warn('[Объяснительная по СКУД] Не удалось разобрать SkudMissedDaysJson', e);
return [];
}
}
@@ -0,0 +1,101 @@
// import type — чтобы тесты noteText не тянули SPFx-рантайм через модель.
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import type { RequestTypeId } from '../../models/types';
import type { MissedKind } from './skud/skudDays';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
* SharePoint выводятся из него, см. shared/listTitles.ts.
*/
export const FORM_ID: RequestTypeId = 'hr-skud-explanation';
/** Полное название — заголовок формы, просмотра и элемента списка (плитка короче, см. data/requests.ts). */
export const FORM_TITLE = 'Объяснительная об отсутствии отметок в СКУД';
/** День, по которому пишется объяснительная, — снимок СКУД на момент подачи. */
export interface IMissedDay {
/** YYYY-MM-DD */
date: string;
kind: MissedKind;
/** HH:MM первой записи; '' — отметок не было вовсе. */
first: string;
/** HH:MM последней записи; '' — записей меньше двух. */
last: string;
}
/**
* Данные формы. Заявка только «на себя»: проходы СКУД API отдаёт по токену
* текущего пользователя, за другого сотрудника их не получить. Дни хранятся
* снимком (IMissedDay), потому что согласующий чужой СКУД открыть не может.
*/
export interface IFormData {
/**
* Дата подачи, YYYY-MM-DD — ставится сегодняшней автоматически, пока заявку
* можно править (новая / черновик / доработка), и печатается в бланке.
*/
SubmitDate: string;
/** E-mail сотрудника (People) — всегда текущий пользователь. */
Employee: string;
/** Ф.И.О. полностью «Фамилия Имя Отчество» из Employee/ByEmail — для текста записки. */
EmployeeFio: string;
Position: string;
/** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */
Organization: string;
/** Выбранные дни, по возрастанию даты. */
MissedDays: IMissedDay[];
/** Значение из SKUD_REASONS. */
Reason: string;
/** Своя формулировка причины — только при Reason = OTHER_REASON. */
ReasonDetails: string;
/** Сотрудник подтверждает, что был на рабочем месте. */
PresenceConfirmed: boolean;
}
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/** Индекс организации для печати (см. shared/ui/PrintSplitButton и OrganizationField.printIndex). */
printOrgIndex?: number;
}
export const EMPTY_DATA: IFormData = {
SubmitDate: '',
Employee: '',
EmployeeFio: '',
Position: '',
Organization: '',
MissedDays: [],
Reason: '',
ReasonDetails: '',
PresenceConfirmed: false
};
export const OTHER_REASON = 'Другое';
export interface ISkudReason {
/** Значение Choice-колонки SkudReason. */
value: string;
/** Продолжение фразы «…произошло по причине ___» в тексте записки. */
phrase: string;
}
/** Варианты причины. Меняя value — менять и choices колонки SkudReason в provisioning.json. */
export const SKUD_REASONS: ISkudReason[] = [
{ value: 'Невнимательность', phrase: 'невнимательности' },
{ value: 'Забыл(а) пропуск', phrase: 'того, что я забыл(а) пропуск' },
{ value: 'Неисправность турникета или считывателя', phrase: 'неисправности турникета или считывателя' },
{ value: OTHER_REASON, phrase: '' }
];
/** Подписи дня в списке выбранных и в колонке SkudMissedDates. */
export const MISSED_KIND_LABEL: Record<MissedKind, string> = {
partial: '< 8 ч',
single: '1 запись',
absent: 'нет отметок'
};
export const PRESENCE_CONFIRMATION_LABEL =
'Подтверждаю, что находился(ась) на рабочем месте и исполнял(а) трудовые обязанности в течение рабочего дня';
@@ -0,0 +1,54 @@
import { EMPTY_DATA, IFormData, OTHER_REASON } from './model';
import { buildNoteParagraphs, formatMissedDaysSummary, joinWithAnd, shortFio } from './noteText';
const DATA: IFormData = {
...EMPTY_DATA,
Employee: 'gorodetskiy.timur@tnsu.kz',
EmployeeFio: 'Городецкий Тимур Александрович',
MissedDays: [
{ date: '2026-07-21', kind: 'single', first: '08:35', last: '' },
{ date: '2026-07-22', kind: 'absent', first: '', last: '' },
{ date: '2026-07-23', kind: 'partial', first: '08:49', last: '13:10' }
],
Reason: 'Невнимательность',
PresenceConfirmed: true
};
describe('noteText', () => {
it('shortFio: фамилия + инициалы', () => {
expect(shortFio('Городецкий Тимур Александрович')).toBe('Городецкий Т. А.');
expect(shortFio('Городецкий')).toBe('Городецкий');
expect(shortFio(' ')).toBe('');
});
it('joinWithAnd', () => {
expect(joinWithAnd(['a'])).toBe('a');
expect(joinWithAnd(['a', 'b'])).toBe('a и b');
expect(joinWithAnd(['a', 'b', 'c'])).toBe('a, b и c');
});
it('текст записки как в бланке отдела кадров', () => {
expect(buildNoteParagraphs(DATA)).toEqual([
'Я, Городецкий Т. А., по факту отсутствия отметок в системе СКУД 21 июля 2026 г., 22 июля 2026 г. и 23 июля 2026 г. сообщаю следующее:',
'Отсутствие отметки в системе СКУД в указанные даты произошло по причине невнимательности. Факт моего нахождения на рабочем месте и исполнения трудовых обязанностей в течение рабочего дня подтверждаю.',
'В дальнейшем обязуюсь соблюдать установленный порядок фиксации рабочего времени и не допускать подобных случаев.'
]);
});
it('своя причина и одна дата', () => {
const [, second] = buildNoteParagraphs({
...DATA,
MissedDays: [DATA.MissedDays[0]],
Reason: OTHER_REASON,
ReasonDetails: 'был на объекте',
PresenceConfirmed: false
});
expect(second).toBe('Отсутствие отметки в системе СКУД в указанную дату произошло по следующей причине: был на объекте');
});
it('сводка дней для колонки SP', () => {
expect(formatMissedDaysSummary(DATA.MissedDays)).toBe(
'21.07.2026 (1 запись 08:35); 22.07.2026 (нет отметок); 23.07.2026 (< 8 ч 08:49–13:10)'
);
});
});
@@ -0,0 +1,89 @@
import { IFormData, IMissedDay, MISSED_KIND_LABEL, OTHER_REASON, SKUD_REASONS } from './model';
/**
* Текст объяснительной записки из данных формы — один источник и для
* предпросмотра в форме, и для печатного документа в просмотре. Без React —
* покрыт тестами (noteText.test.ts).
*/
const MONTHS_GENITIVE = [
'января',
'февраля',
'марта',
'апреля',
'мая',
'июня',
'июля',
'августа',
'сентября',
'октября',
'ноября',
'декабря'
];
/** Заглушка на месте незаполненной части текста в предпросмотре. */
const BLANK = '____';
/** YYYY-MM-DD → «21 июля 2026 г.» */
export function formatLongDate(iso: string): string {
const [y, m, d] = iso.split('-').map(Number);
if (!y || !m || !d) return iso;
return `${d} ${MONTHS_GENITIVE[m - 1]} ${y} г.`;
}
/** YYYY-MM-DD → «21.07.2026» */
export function formatShortDate(iso: string): string {
const [y, m, d] = iso.split('-');
return y && m && d ? `${d}.${m}.${y}` : iso;
}
/** ['a'] → 'a'; ['a','b'] → 'a и b'; ['a','b','c'] → 'a, b и c'. */
export function joinWithAnd(parts: string[]): string {
if (parts.length <= 1) return parts.join('');
return `${parts.slice(0, -1).join(', ')} и ${parts[parts.length - 1]}`;
}
/** «Городецкий Тимур Александрович» → «Городецкий Т. А.» */
export function shortFio(fio: string): string {
const [surname, ...rest] = fio.trim().split(/\s+/);
if (!surname) return '';
const initials = rest.map((name) => `${name.charAt(0).toUpperCase()}.`).join(' ');
return initials ? `${surname} ${initials}` : surname;
}
/** Что было в СКУД в этот день: «1 запись 08:35», «< 8 ч 08:49–13:10», «нет отметок». */
export function formatMissedDetails(day: IMissedDay): string {
const times = [day.first, day.last].filter(Boolean).join('–');
return times ? `${MISSED_KIND_LABEL[day.kind]} ${times}` : MISSED_KIND_LABEL[day.kind];
}
/** Выбранные дни одной строкой — для колонки SkudMissedDates (читается в представлениях SP). */
export function formatMissedDaysSummary(days: IMissedDay[]): string {
return days.map((d) => `${formatShortDate(d.date)} (${formatMissedDetails(d)})`).join('; ');
}
function reasonSentence(data: IFormData, dateWord: string): string {
if (data.Reason === OTHER_REASON) {
const details = data.ReasonDetails.trim() || BLANK;
return `Отсутствие отметки в системе СКУД в ${dateWord} произошло по следующей причине: ${details}`;
}
const reason = SKUD_REASONS.find((r) => r.value === data.Reason);
return `Отсутствие отметки в системе СКУД в ${dateWord} произошло по причине ${reason ? reason.phrase : BLANK}.`;
}
/** Абзацы тела записки (без шапки и подписи). */
export function buildNoteParagraphs(data: IFormData): string[] {
const dates = data.MissedDays.length > 0 ? joinWithAnd(data.MissedDays.map((d) => formatLongDate(d.date))) : BLANK;
const dateWord = data.MissedDays.length > 1 ? 'указанные даты' : 'указанную дату';
const name = shortFio(data.EmployeeFio) || BLANK;
const second = data.PresenceConfirmed
? `${reasonSentence(data, dateWord)} Факт моего нахождения на рабочем месте и исполнения трудовых обязанностей в течение рабочего дня подтверждаю.`
: reasonSentence(data, dateWord);
return [
`Я, ${name}, по факту отсутствия отметок в системе СКУД ${dates} сообщаю следующее:`,
second,
'В дальнейшем обязуюсь соблюдать установленный порядок фиксации рабочего времени и не допускать подобных случаев.'
];
}
@@ -0,0 +1,18 @@
{
"dataListTitle": "Объяснительная по СКУД — данные",
"columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "SubmitDate", "title": "Дата подачи", "type": "DateTime", "dateOnly": true },
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "SkudEmployeeFio", "title": "Ф.И.О. сотрудника", "type": "Text" },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "SkudMissedDates", "title": "Дни без отметок", "type": "Note" },
{ "internalName": "SkudMissedDaysJson", "title": "Дни без отметок (JSON)", "type": "Note" },
{ "internalName": "SkudReason", "title": "Причина", "type": "Choice", "choices": ["Невнимательность", "Забыл(а) пропуск", "Неисправность турникета или считывателя", "Другое"] },
{ "internalName": "SkudReasonDetails", "title": "Причина (подробно)", "type": "Note" },
{ "internalName": "SkudPresenceConfirmed", "title": "Присутствие подтверждено", "type": "Boolean" }
]
}
@@ -0,0 +1,21 @@
@import '../../../../../styles/design-tokens';
// Тело CollapseSection — сетка в 2 колонки под FormField; календарь и список
// дней не поля, им нужна вся ширина.
.fullRow {
grid-column: 1 / -1;
min-width: 0;
}
.calendar {
display: flex;
justify-content: center;
margin-bottom: $sp-4;
}
.label {
margin-bottom: $sp-2;
font-size: $fs-label;
font-weight: 500;
color: $gray-700;
}
@@ -0,0 +1,51 @@
import * as React from 'react';
import { CollapseSection, useFormReadOnly } from '../../../shared/ui';
import { IMissedDay, ISectionProps } from '../model';
import { SkudCalendar } from '../components/SkudCalendar';
import { SelectedDaysList } from '../components/SelectedDaysList';
import { ISubmittedDay } from '../submittedDays';
import styles from './DaysSection.module.scss';
/** Клик по уже выбранному дню снимает выбор, по новому — добавляет; порядок — по дате. */
function toggleDay(days: IMissedDay[], day: IMissedDay): IMissedDay[] {
const exists = days.some((d) => d.date === day.date);
if (exists) return days.filter((d) => d.date !== day.date);
return [...days, day].sort((a, b) => a.date.localeCompare(b.date));
}
interface IDaysSectionProps extends ISectionProps {
/** Дни, уже закрытые поданными объяснительными; нужны только форме подачи. */
submittedDays?: Map<string, ISubmittedDay>;
}
const NO_SUBMITTED_DAYS = new Map<string, ISubmittedDay>();
/**
* Дни без отметок. В редактируемой заявке — календарь СКУД с выбором; в
* режиме чтения календарь не показываем: он строится по СКУД ТЕКУЩЕГО
* пользователя, а согласующему нужен сохранённый снимок дней заявителя.
*/
export const DaysSection: React.FC<IDaysSectionProps> = ({ data, set, context, submittedDays }) => {
const readOnly = useFormReadOnly();
const selectedDates = data.MissedDays.map((d) => d.date);
const removeDay = (date: string): void => set('MissedDays', data.MissedDays.filter((d) => d.date !== date));
return (
<CollapseSection title="Дни без отметок СКУД" icon="calendar" defaultOpen>
<div className={styles.fullRow}>
{!readOnly && (
<div className={styles.calendar}>
<SkudCalendar
context={context}
selectedDates={selectedDates}
submittedDays={submittedDays || NO_SUBMITTED_DAYS}
onToggle={(day) => set('MissedDays', toggleDay(data.MissedDays, day))}
/>
</div>
)}
<div className={styles.label}>Выбрано дней: {data.MissedDays.length}</div>
<SelectedDaysList days={data.MissedDays} onRemove={readOnly ? undefined : removeDay} />
</div>
</CollapseSection>
);
};
@@ -0,0 +1,24 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { ISectionProps } from '../model';
/**
* Сотрудник — всегда текущий пользователь (СКУД отдаёт только свои проходы),
* поэтому поле «Сотрудник» не редактируется даже в новой заявке. Дата подачи
* тоже не редактируется — её ставит форма.
*/
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex }) => (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField context={context} value={data.Employee} onChange={(v) => set('Employee', v)} readOnly />
</FormField>
{/* Дата подачи — всегда сегодняшняя (ставит форма), руками не меняется. */}
<FormField label="Дата подачи" htmlFor="SubmitDate">
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
</FormField>
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
</FormGrid>
);
@@ -0,0 +1,37 @@
import * as React from 'react';
import { Checkbox, CollapseSection, FormField, Select, TextArea } from '../../../shared/ui';
import { ISectionProps, OTHER_REASON, PRESENCE_CONFIRMATION_LABEL, SKUD_REASONS } from '../model';
const REASON_OPTIONS = SKUD_REASONS.map((r) => ({ value: r.value, label: r.value }));
/**
* Причина отсутствия отметки + подтверждение присутствия на рабочем месте.
* Поля кладём прямо в CollapseSection: её тело уже сетка полей, лишний
* FormGrid внутри сжал бы всё в половину ширины.
*/
export const ExplanationSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Объяснение" icon="edit" defaultOpen>
<FormField label="Причина" htmlFor="Reason" required full>
<Select
id="Reason"
value={data.Reason}
onChange={(v) => set('Reason', v)}
options={REASON_OPTIONS}
placeholder="Выберите причину"
/>
</FormField>
{data.Reason === OTHER_REASON && (
<FormField label="Опишите причину" htmlFor="ReasonDetails" required full>
<TextArea id="ReasonDetails" value={data.ReasonDetails} onChange={(v) => set('ReasonDetails', v)} />
</FormField>
)}
<FormField label="Подтверждение" htmlFor="PresenceConfirmed" required full>
<Checkbox
id="PresenceConfirmed"
checked={data.PresenceConfirmed}
onChange={(v) => set('PresenceConfirmed', v)}
label={PRESENCE_CONFIRMATION_LABEL}
/>
</FormField>
</CollapseSection>
);
@@ -0,0 +1,49 @@
import { SPHttpClient } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { HOLIDAYS_LIST_TITLE, HOLIDAYS_SITE_URL } from '../../../shared/config';
interface IHolidayItem {
Title?: string;
holidayDate?: string;
}
// В списке выходные с переносом подписаны так: "Женский день (перенос с вс 8 марта)".
function stripParens(name: string): string {
const i = name.indexOf('(');
return (i >= 0 ? name.slice(0, i) : name).trim();
}
async function fetchHolidays(context: WebPartContext): Promise<Map<string, string>> {
const url =
`${HOLIDAYS_SITE_URL}/_api/web/lists/getByTitle('${encodeURIComponent(HOLIDAYS_LIST_TITLE)}')/items` +
`?$select=Title,holidayDate&$top=500`;
const response = await context.spHttpClient.get(url, SPHttpClient.configurations.v1, {
headers: { accept: 'application/json;odata=nometadata' }
});
if (!response.ok) throw new Error(`Производственный календарь: HTTP ${response.status}`);
const body = (await response.json()) as { value?: IHolidayItem[] };
const map = new Map<string, string>();
(body.value || []).forEach((item) => {
if (item.holidayDate) map.set(item.holidayDate.slice(0, 10), stripParens(item.Title || ''));
});
return map;
}
// Календарь один на всех и меняется раз в год — грузим один раз за сеанс.
let cached: Promise<Map<string, string>> | undefined;
/**
* Праздничные выходные: "YYYY-MM-DD" → название. Ошибка не критична: форма
* работает и без праздников, поэтому отдаём пустую карту (и не кешируем её).
*/
export function getHolidays(context: WebPartContext): Promise<Map<string, string>> {
if (!cached) {
cached = fetchHolidays(context).catch((e) => {
console.warn('[СКУД] Производственный календарь недоступен', e);
cached = undefined;
return new Map<string, string>();
});
}
return cached;
}
@@ -0,0 +1,94 @@
import {
ISkudEvent,
buildMonthCells,
cellKind,
groupEventsByDay,
minSelectableDateKey,
toMissedKind,
workedMinutes
} from './skudDays';
function event(eventTime: string, door = 'D1'): ISkudEvent {
return { personName: '', iin: '', eventTime, doorName: door, doorIndexCode: door, cardNo: '', eventType: '' };
}
const TODAY = '2026-07-25';
const MIN = '2026-06-01';
describe('minSelectableDateKey', () => {
it('первое число прошлого месяца, в том числе через границу года', () => {
expect(minSelectableDateKey(new Date(2026, 6, 25))).toBe(MIN);
expect(minSelectableDateKey(new Date(2027, 0, 10))).toBe('2026-12-01');
});
});
describe('groupEventsByDay', () => {
it('сортирует проходы и схлопывает двойной скан в одну минуту на одной точке', () => {
const byDay = groupEventsByDay([
event('2026-07-21T18:05:10+05:00'),
event('2026-07-21T08:35:01+05:00'),
event('2026-07-21T08:35:40+05:00')
]);
const day = byDay.get('2026-07-21');
expect(day?.events).toHaveLength(2);
expect(day?.first).toBe('08:35');
expect(day?.last).toBe('18:05');
});
});
describe('workedMinutes', () => {
it('вычитает час обеда и не уходит в минус', () => {
expect(workedMinutes('09:00', '18:00')).toBe(8 * 60);
expect(workedMinutes('09:00', '09:30')).toBe(0);
expect(workedMinutes('', '')).toBe(0);
});
});
describe('cellKind / toMissedKind', () => {
const byDay = groupEventsByDay([
event('2026-07-20T09:00:00+05:00'),
event('2026-07-20T18:00:00+05:00'),
event('2026-07-21T08:35:00+05:00'),
event('2026-07-25T08:10:00+05:00')
]);
const cells = buildMonthCells(2026, 6, byDay);
const kindOf = (dateKey: string, holiday = false): ReturnType<typeof cellKind> => {
const index = cells.findIndex((c) => c?.dateKey === dateKey);
const cell = cells[index];
if (!cell) throw new Error(`нет ячейки ${dateKey}`);
return cellKind(cell, index % 7 >= 5, holiday, TODAY);
};
it('раскрашивает дни по отметкам', () => {
expect(kindOf('2026-07-20')).toBe('full');
expect(kindOf('2026-07-21')).toBe('single');
expect(kindOf('2026-07-22')).toBe('absent');
expect(kindOf('2026-07-18')).toBe('weekend'); // суббота
expect(kindOf('2026-07-22', true)).toBe('holiday');
expect(kindOf('2026-07-27')).toBe('empty'); // будущее
});
it('прошедшие дни: < 8 ч, 1 запись и без отметок — да; ≥ 8 ч и выходные — нет', () => {
expect(toMissedKind('partial', '2026-07-09', TODAY, MIN)).toBe('partial');
expect(toMissedKind('single', '2026-07-21', TODAY, MIN)).toBe('single');
expect(toMissedKind('absent', '2026-07-22', TODAY, MIN)).toBe('absent');
expect(toMissedKind('full', '2026-07-20', TODAY, MIN)).toBeUndefined();
expect(toMissedKind('weekend', '2026-07-18', TODAY, MIN)).toBeUndefined();
});
it('сегодня — только без единой отметки', () => {
expect(toMissedKind('empty', TODAY, TODAY, MIN)).toBe('absent');
expect(toMissedKind('single', TODAY, TODAY, MIN)).toBeUndefined();
});
it('раньше лимита и в будущем — нельзя', () => {
expect(toMissedKind('absent', '2026-05-29', TODAY, MIN)).toBeUndefined();
expect(toMissedKind('empty', '2026-07-27', TODAY, MIN)).toBeUndefined();
});
it('месяц начинается с понедельника: 1 июля 2026 — среда, два пустых места', () => {
expect(cells[0]).toBeNull();
expect(cells[1]).toBeNull();
expect(cells[2]?.day).toBe(1);
});
});
@@ -0,0 +1,178 @@
/**
* Чистая логика календаря СКУД (без React/SharePoint — покрыта тестами).
* Правила раскраски дней перенесены из «Мой СКУД» (portal-main-page,
* SkudModal.tsx), чтобы сотрудник видел в форме ровно ту же картину.
*/
/** Один проход через точку СКУД (элемент ответа /Hikvision/Events/CurrentUser). */
export interface ISkudEvent {
personName: string;
iin: string;
/** ISO со смещением, напр. "2026-06-26T08:39:48+05:00" — время уже местное. */
eventTime: string;
doorName: string;
doorIndexCode: string;
cardNo: string;
eventType: string;
}
/** Проходы одного дня. */
export interface IDayInfo {
day: number;
/** YYYY-MM-DD */
dateKey: string;
/** По возрастанию времени, без двойных сканов. */
events: ISkudEvent[];
/** HH:MM первого прохода ('' — проходов нет). */
first: string;
/** HH:MM последнего прохода ('' — проходов нет). */
last: string;
}
/**
* Вид ячейки календаря:
* full / partial — есть приход и уход (≥ 8 ч / < 8 ч без обеда);
* single — ровно одна запись (нет прихода или ухода);
* absent — прошедший рабочий день без единого прохода;
* holiday / weekend — нерабочий день без проходов;
* empty — сегодня или будущее без проходов.
*/
export type CellKind = 'full' | 'partial' | 'single' | 'absent' | 'holiday' | 'weekend' | 'empty';
/** Что именно не так с днём, по которому пишется объяснительная. */
export type MissedKind = 'partial' | 'single' | 'absent';
/**
* Сколько прошлых месяцев доступно, кроме текущего: объяснительные пишут по
* свежим случаям, 1 = текущий + прошлый месяц.
*/
export const SELECTABLE_MONTHS_BACK = 1;
const FULL_DAY_MINUTES = 8 * 60;
const LUNCH_MINUTES = 60;
/** Индекс столбца Сб в сетке (неделя с понедельника: Пн = 0). */
const SATURDAY_COLUMN = 5;
const DAYS_IN_WEEK = 7;
export function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
export function toDateKey(year: number, month: number, day: number): string {
return `${year}-${pad2(month + 1)}-${pad2(day)}`;
}
export function todayDateKey(): string {
const d = new Date();
return toDateKey(d.getFullYear(), d.getMonth(), d.getDate());
}
/** Первое число самого раннего доступного месяца (YYYY-MM-DD), см. SELECTABLE_MONTHS_BACK. */
export function minSelectableDateKey(today: Date = new Date()): string {
const first = new Date(today.getFullYear(), today.getMonth() - SELECTABLE_MONTHS_BACK, 1);
return toDateKey(first.getFullYear(), first.getMonth(), 1);
}
function timeOf(e: ISkudEvent): string {
return e.eventTime.slice(11, 16);
}
// Двойной скан — два срабатывания одной точки в пределах одной минуты.
function dedupeByMinute(events: ISkudEvent[]): ISkudEvent[] {
const seen: Record<string, boolean> = {};
return events.filter((e) => {
const key = `${e.eventTime.slice(0, 16)}|${e.doorIndexCode}`;
if (seen[key]) return false;
seen[key] = true;
return true;
});
}
/** Проходы → по дням (ключ YYYY-MM-DD). */
export function groupEventsByDay(events: ISkudEvent[]): Map<string, IDayInfo> {
const buckets = new Map<string, ISkudEvent[]>();
events.forEach((e) => {
const key = e.eventTime.slice(0, 10);
const bucket = buckets.get(key);
if (bucket) bucket.push(e);
else buckets.set(key, [e]);
});
const result = new Map<string, IDayInfo>();
buckets.forEach((bucket, key) => {
const sorted = dedupeByMinute(bucket.slice().sort((a, b) => a.eventTime.localeCompare(b.eventTime)));
result.set(key, {
day: Number(key.slice(8, 10)),
dateKey: key,
events: sorted,
first: timeOf(sorted[0]),
last: timeOf(sorted[sorted.length - 1])
});
});
return result;
}
/** Рабочее время дня: последний проход минус первый минус час обеда (не в минус). */
export function workedMinutes(first: string, last: string): number {
if (!first || !last) return 0;
const [fh, fm] = first.split(':').map(Number);
const [lh, lm] = last.split(':').map(Number);
const span = lh * 60 + lm - (fh * 60 + fm);
return span > 0 ? Math.max(0, span - LUNCH_MINUTES) : 0;
}
/** Сумма полных часов по дням месяца — приблизительно, реальную формулу кадров не знаем. */
export function monthWorkedHours(byDay: Map<string, IDayInfo>): number {
let total = 0;
byDay.forEach((info) => {
total += Math.floor(workedMinutes(info.first, info.last) / 60);
});
return total;
}
/** Ячейки месяца: ведущие null — пустые места до первого числа (неделя с понедельника). */
export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (IDayInfo | null)[] {
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstColumn = (new Date(year, month, 1).getDay() + 6) % DAYS_IN_WEEK;
const cells: (IDayInfo | null)[] = [];
for (let i = 0; i < firstColumn; i += 1) cells.push(null);
for (let d = 1; d <= daysInMonth; d += 1) {
const key = toDateKey(year, month, d);
cells.push(byDay.get(key) ?? { day: d, dateKey: key, events: [], first: '', last: '' });
}
return cells;
}
export function isWeekendColumn(cellIndex: number): boolean {
return cellIndex % DAYS_IN_WEEK >= SATURDAY_COLUMN;
}
export function cellKind(info: IDayInfo, isWeekend: boolean, isHoliday: boolean, todayKey: string): CellKind {
if (info.events.length === 1) return 'single';
if (info.events.length > 1) {
return workedMinutes(info.first, info.last) >= FULL_DAY_MINUTES ? 'full' : 'partial';
}
if (isHoliday) return 'holiday';
if (isWeekend) return 'weekend';
return info.dateKey < todayKey ? 'absent' : 'empty';
}
/**
* Можно ли выбрать день в объяснительную (и с какой пометкой):
* - прошедший день < 8 ч, с одной записью или без отметок — да (у < 8 ч часто
* пропущен приход или уход: прошёл за коллегой, вышел через другую дверь);
* - сегодня — только если нет ни одной отметки (забыл пропуск). Одна запись
* сегодня — норма, сотрудник ещё не ушёл;
* - ≥ 8 ч, выходные, праздники, будущее и дни раньше minKey — нет.
*/
export function toMissedKind(
kind: CellKind,
dateKey: string,
todayKey: string,
minKey: string
): MissedKind | undefined {
if (dateKey < minKey || dateKey > todayKey) return undefined;
if (dateKey === todayKey) return kind === 'empty' ? 'absent' : undefined;
if (kind === 'partial' || kind === 'single' || kind === 'absent') return kind;
return undefined;
}
@@ -0,0 +1,63 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL, SKUD_EVENTS_PATH } from '../../../shared/config';
import { ISkudEvent, pad2 } from './skudDays';
const PAGE_SIZE = 100;
// Защита от зацикливания, если сервер игнорирует параметр page.
const MAX_PAGES = 100;
interface ISkudResponse {
total: number;
page: number;
pageSize: number;
items: ISkudEvent[];
}
/** 404 от API СКУД — учётная запись не связана с 1С (не сбой, а отдельное состояние UI). */
export class SkudNotLinkedError extends Error {
public constructor() {
super('Учётная запись не связана с системой 1С — данные СКУД недоступны.');
this.name = 'SkudNotLinkedError';
}
}
function monthQuery(year: number, month: number): string {
const lastDay = new Date(year, month + 1, 0).getDate();
const mm = pad2(month + 1);
return `&startDate=${year}-${mm}-01&endDate=${year}-${mm}-${pad2(lastDay)}`;
}
/**
* Все проходы текущего пользователя за месяц (month — 0-based). Сервер отдаёт
* страницами; поле total в ответе — размер страницы, а не общее число, поэтому
* читаем, пока страница не окажется неполной.
*/
export async function getSkudEventsForMonth(
context: WebPartContext,
year: number,
month: number
): Promise<ISkudEvent[]> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const all: ISkudEvent[] = [];
let previousFirst = '';
for (let page = 1; page <= MAX_PAGES; page += 1) {
const url =
`${DICTIONARY_API_BASE_URL}${SKUD_EVENTS_PATH}?page=${page}&pageSize=${PAGE_SIZE}` + monthQuery(year, month);
const response: HttpClientResponse = await client.get(url, AadHttpClient.configurations.v1);
if (response.status === 404) throw new SkudNotLinkedError();
if (!response.ok) throw new Error(`СКУД: ошибка загрузки (HTTP ${response.status})`);
const items = ((await response.json()) as ISkudResponse).items || [];
all.push(...items);
const currentFirst = items.length > 0 ? items[0].eventTime : '';
// Та же первая запись, что и на прошлой странице, — сервер не листает.
const pageRepeated = currentFirst !== '' && currentFirst === previousFirst;
if (items.length < PAGE_SIZE || pageRepeated) break;
previousFirst = currentFirst;
}
return all;
}
@@ -0,0 +1,103 @@
import { useEffect, useMemo, useState } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { friendlyErrorMessage } from '../../../shared/diagnostics';
import { IDayInfo, ISkudEvent, SELECTABLE_MONTHS_BACK, groupEventsByDay } from './skudDays';
import { SkudNotLinkedError, getSkudEventsForMonth } from './skudEvents.service';
import { getHolidays } from './holidays.service';
export interface ISkudMonth {
year: number;
/** 0-based */
month: number;
byDay: Map<string, IDayInfo>;
holidays: Map<string, string>;
loading: boolean;
error: string | undefined;
/** Учётная запись не связана с 1С — данных СКУД нет совсем. */
notLinked: boolean;
canGoNext: boolean;
/** Назад — не дальше SELECTABLE_MONTHS_BACK месяцев от текущего. */
canGoPrev: boolean;
goPrev: () => void;
goNext: () => void;
}
interface IMonthView {
year: number;
month: number;
}
// Сквозной номер месяца — чтобы сравнивать месяцы через границу года.
function monthIndex(year: number, month: number): number {
return year * 12 + month;
}
function shiftMonth(view: IMonthView, delta: number): IMonthView {
const d = new Date(view.year, view.month + delta, 1);
return { year: d.getFullYear(), month: d.getMonth() };
}
/** Проходы СКУД текущего пользователя по месяцам + праздники; листать можно от текущего месяца назад на SELECTABLE_MONTHS_BACK. */
export function useSkudMonth(context: WebPartContext): ISkudMonth {
const now = new Date();
const [view, setView] = useState<IMonthView>({ year: now.getFullYear(), month: now.getMonth() });
const [events, setEvents] = useState<ISkudEvent[]>([]);
const [holidays, setHolidays] = useState<Map<string, string>>(new Map());
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | undefined>(undefined);
const [notLinked, setNotLinked] = useState<boolean>(false);
useEffect(() => {
let alive = true;
getHolidays(context)
.then((map) => {
if (alive) setHolidays(map);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
useEffect(() => {
let alive = true;
setLoading(true);
setError(undefined);
getSkudEventsForMonth(context, view.year, view.month)
.then((items) => {
if (!alive) return;
setEvents(items);
setLoading(false);
})
.catch((e) => {
if (!alive) return;
if (e instanceof SkudNotLinkedError) setNotLinked(true);
else setError(friendlyErrorMessage(e));
setEvents([]);
setLoading(false);
});
return () => {
alive = false;
};
}, [context, view.year, view.month]);
const byDay = useMemo(() => groupEventsByDay(events), [events]);
const viewIndex = monthIndex(view.year, view.month);
const currentIndex = monthIndex(now.getFullYear(), now.getMonth());
const canGoNext = viewIndex < currentIndex;
const canGoPrev = viewIndex > currentIndex - SELECTABLE_MONTHS_BACK;
return {
year: view.year,
month: view.month,
byDay,
holidays,
loading,
error,
notLinked,
canGoNext,
canGoPrev,
goPrev: () => setView((v) => shiftMonth(v, -1)),
goNext: () => setView((v) => shiftMonth(v, 1))
};
}
@@ -0,0 +1,58 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { ensureEmployee } from '../../shared/employee';
import { FORM_ID, IFormData, OTHER_REASON } from './model';
import { formatMissedDaysSummary, formatShortDate } from './noteText';
import { todayDateKey } from './skud/skudDays';
/** Сервис списка согласования этой формы — см. shared/approval/approvalServiceFactory.ts. */
export function skudExplanationApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
}
/** Заголовок элемента (системная колонка Title). */
function buildTitle(employeeName: string): string {
return `Объяснительная по СКУД — ${employeeName} — ${formatShortDate(todayDateKey())}`;
}
function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record<string, unknown> {
return {
Title: buildTitle(employeeName),
RequestStatus: status,
SubmitDate: data.SubmitDate || null,
EmployeeId: employeeId,
SkudEmployeeFio: data.EmployeeFio || null,
Position: data.Position || null,
Organization: data.Organization || null,
// Читаемая строка — для представлений списка; источник для формы — JSON-снимок.
SkudMissedDates: formatMissedDaysSummary(data.MissedDays) || null,
SkudMissedDaysJson: JSON.stringify(data.MissedDays),
SkudReason: data.Reason || null,
SkudReasonDetails: data.Reason === OTHER_REASON ? data.ReasonDetails || null : null,
SkudPresenceConfirmed: data.PresenceConfirmed
};
}
/**
* Сохранить объяснительную: содержимое + маршрут (согласование и исполнение).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём.
* executors — шаги фазы «Исполнение» по маршруту (напр. кадры); пусто — после
* согласования заявка сразу станет «Согласовано».
* listId — партиция обновляемой заявки (см. UnpaidLeave/submit.ts, тот же смысл).
*/
export async function saveSkudExplanationRequest(
existingId: number | undefined,
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveItemResult> {
const employee = await ensureEmployee(data.Employee);
const item = buildItem(data, status, employee.Id, data.EmployeeFio || employee.Title || data.Employee);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(skudExplanationApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -0,0 +1,45 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ensureEmployee } from '../../shared/employee';
import { dataListTitle } from '../../shared/listTitles';
import { partitionsForPageUrl } from '../../shared/approval/listPartitions';
import { FORM_ID } from './model';
import { minSelectableDateKey } from './skud/skudDays';
import { ISubmittedDay, ISubmittedItem, collectSubmittedDays } from './submittedDays';
// Человек пишет единицы объяснительных за 2 месяца; 500 — с большим запасом.
const MAX_ITEMS_PER_LIST = 500;
async function readList(listUrl: string | undefined, employeeId: number, createdFrom: string): Promise<ISubmittedItem[]> {
const web = getSP().web;
const list = listUrl ? web.getList(listUrl) : web.lists.getByTitle(dataListTitle(FORM_ID));
return (await list.items
.select('Id', 'RequestStatus', 'RequestUniqueCode', 'SkudMissedDaysJson')
.filter(`EmployeeId eq ${employeeId} and Created ge datetime'${createdFrom}T00:00:00Z'`)
.top(MAX_ITEMS_PER_LIST)()) as ISubmittedItem[];
}
/**
* Дни сотрудника, уже закрытые поданными объяснительными (карта дата → заявка).
*
* Ищем по всем партициям серии: при переключении на новый список прошлый
* месяц может остаться в предыдущем. Заявки старше начала доступного периода
* не читаем: объяснительная пишется не раньше самого дня, а дни до
* minSelectableDateKey выбрать всё равно нельзя.
*
* excludeId — открытая сейчас заявка (черновик/доработка), чтобы она не
* блокировала собственные дни. Id сравнивается без учёта партиции: совпадение
* id у разных заявок в разных списках маловероятно и дало бы лишь пропуск
* проверки, а не ложную блокировку.
*/
export async function getSubmittedDays(employeeEmail: string, excludeId?: number): Promise<Map<string, ISubmittedDay>> {
if (!employeeEmail) return new Map();
const employee = await ensureEmployee(employeeEmail);
const createdFrom = minSelectableDateKey();
const partitions = partitionsForPageUrl(FORM_ID);
// Реестр партиций недоступен — читаем текущий список по конвенции имён.
const listUrls: (string | undefined)[] = partitions.length > 0 ? partitions.map((p) => p.listId) : [undefined];
const perList = await Promise.all(listUrls.map((url) => readList(url, employee.Id, createdFrom)));
const items = perList.reduce<ISubmittedItem[]>((all, part) => all.concat(part), []);
return collectSubmittedDays(items, excludeId);
}
@@ -0,0 +1,45 @@
import { IMissedDay } from './model';
import { ISubmittedItem, collectSubmittedDays, submittedConflictMessage } from './submittedDays';
function item(Id: number, RequestStatus: string, dates: string[]): ISubmittedItem {
const days: IMissedDay[] = dates.map((date) => ({ date, kind: 'absent', first: '', last: '' }));
return { Id, RequestStatus, RequestUniqueCode: `12-2609-${Id}`, SkudMissedDaysJson: JSON.stringify(days) };
}
describe('collectSubmittedDays', () => {
it('блокируют поданные, согласованные и возвращённые; отклонённые и черновики — нет', () => {
const map = collectSubmittedDays([
item(1, 'На согласовании', ['2026-09-01']),
item(2, 'Согласовано', ['2026-09-02']),
item(3, 'Возвращена на доработку', ['2026-09-03']),
item(4, 'Отклонено', ['2026-09-04']),
item(5, 'Черновик', ['2026-09-05'])
]);
expect(Array.from(map.keys()).sort()).toEqual(['2026-09-01', '2026-09-02', '2026-09-03']);
expect(map.get('2026-09-01')).toEqual({ code: '12-2609-1', status: 'На согласовании' });
});
it('открытая сейчас заявка не блокирует свои же дни', () => {
const map = collectSubmittedDays([item(3, 'Возвращена на доработку', ['2026-09-03'])], 3);
expect(map.size).toBe(0);
});
it('битый JSON не роняет проверку', () => {
const map = collectSubmittedDays([{ Id: 7, RequestStatus: 'Согласовано', SkudMissedDaysJson: '{oops' }]);
expect(map.size).toBe(0);
});
});
describe('submittedConflictMessage', () => {
it('перечисляет пересечения с номером и статусом заявки', () => {
const map = collectSubmittedDays([item(1, 'Согласовано', ['2026-09-01'])]);
const days: IMissedDay[] = [
{ date: '2026-09-01', kind: 'absent', first: '', last: '' },
{ date: '2026-09-02', kind: 'absent', first: '', last: '' }
];
expect(submittedConflictMessage(days, map)).toBe(
'По этим дням объяснительная уже подана, уберите их: 01.09.2026 — №12-2609-1 (Согласовано).'
);
expect(submittedConflictMessage([days[1]], map)).toBeUndefined();
});
});
@@ -0,0 +1,65 @@
import { REQUEST_STATUS } from '../../shared/approval/models/statuses';
import { IMissedDay } from './model';
import { parseMissedDays } from './missedDaysJson';
import { formatShortDate } from './noteText';
/**
* Защита от повторов: день, уже вошедший в поданную объяснительную, второй раз
* не выбирается. Чистая логика без SharePoint — покрыта тестами; чтение
* списков — в submittedDays.service.ts.
*/
/** Объяснительная, которая уже «закрывает» день. */
export interface ISubmittedDay {
code: string;
status: string;
}
/** Строка списка данных — только нужные для проверки поля. */
export interface ISubmittedItem {
Id: number;
RequestStatus?: string;
RequestUniqueCode?: string;
SkudMissedDaysJson?: string;
}
/**
* Какие статусы блокируют день. «Отклонено» — нет: можно написать заново.
* «Черновик» — нет: это ещё не подача. «Возвращена» — да: её надо поправить,
* а не подавать новую.
*/
const BLOCKING_STATUSES: string[] = [
REQUEST_STATUS.onApproval,
REQUEST_STATUS.approved,
REQUEST_STATUS.execution,
REQUEST_STATUS.closed,
REQUEST_STATUS.returned
];
/** Дата (YYYY-MM-DD) → заявка, которой она уже закрыта. excludeId — открытая сейчас заявка. */
export function collectSubmittedDays(items: ISubmittedItem[], excludeId?: number): Map<string, ISubmittedDay> {
const result = new Map<string, ISubmittedDay>();
items.forEach((item) => {
if (item.Id === excludeId) return;
const status = item.RequestStatus || '';
if (BLOCKING_STATUSES.indexOf(status) < 0) return;
const submitted: ISubmittedDay = { code: item.RequestUniqueCode || String(item.Id), status };
parseMissedDays(item.SkudMissedDaysJson).forEach((day) => result.set(day.date, submitted));
});
return result;
}
/** Сообщение о пересечении выбранных дней с уже поданными; undefined — пересечений нет. */
export function submittedConflictMessage(
days: IMissedDay[],
submitted: Map<string, ISubmittedDay>
): string | undefined {
const conflicts = days
.filter((d) => submitted.has(d.date))
.map((d) => {
const other = submitted.get(d.date) as ISubmittedDay;
return `${formatShortDate(d.date)} — №${other.code} (${other.status})`;
});
if (conflicts.length === 0) return undefined;
return `По этим дням объяснительная уже подана, уберите их: ${conflicts.join('; ')}.`;
}
@@ -0,0 +1,67 @@
import { useCallback } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
APPROVAL_PHASE,
ApprovalListService,
IMatrixRow,
getPlannedMatrix,
resolveApproverOrgInfo,
useMatrixEditor
} from '../../shared/approval';
type MatrixEditor = ReturnType<typeof useMatrixEditor>;
export interface ISkudRoute {
/** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */
approvers: MatrixEditor;
/** Исполнители по маршруту (фаза «Исполнение») — только чтение. */
executors: MatrixEditor;
}
interface IRouteSource {
context: WebPartContext;
approval: ApprovalListService;
/** Заявка уже записана — маршрут читаем из её строк, а не планируем заново. */
requestId: number | undefined;
employeeEmail: string;
requestTypeId: number | undefined;
/** Можно ли править согласующих. */
editable: boolean;
/** Меняется после решения согласующего — перечитать строки. */
reloadSeq?: number;
}
const byPhase = (execution: boolean) => (rows: IMatrixRow[]): IMatrixRow[] =>
rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution);
/**
* Маршрут объяснительной по двум фазам. Без отдельного запроса фазы
* 'Execution' getPlannedMatrix исполнителей не резолвит — шаг кадров из
* маршрута «пропадал» бы. Паттерн тот же, что в Referral.
*/
export function useSkudRoute(source: IRouteSource): ISkudRoute {
const { context, approval, requestId, employeeEmail, requestTypeId, editable, reloadSeq = 0 } = source;
const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId);
const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`;
const loadPhase = useCallback(
(execution: boolean) =>
requestId
? approval.getMatrixForRequest(requestId).then(byPhase(execution))
: getPlannedMatrix(
context,
requestTypeId as number,
employeeEmail,
approval,
execution ? 'Execution' : 'Approval'
),
[context, approval, requestId, employeeEmail, requestTypeId]
);
const loadApprovers = useCallback(() => loadPhase(false), [loadPhase]);
const loadExecutors = useCallback(() => loadPhase(true), [loadPhase]);
const jobTitle = (login: string): Promise<string> => approval.getJobTitle(login);
const approvers = useMatrixEditor(loadApprovers, reloadKey, editable, canLoad, jobTitle, resolveApproverOrgInfo(context));
const executors = useMatrixEditor(loadExecutors, reloadKey, false, canLoad, jobTitle, resolveApproverOrgInfo(context));
return { approvers, executors };
}
@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react';
import { ISubmittedDay } from './submittedDays';
import { getSubmittedDays } from './submittedDays.service';
/**
* Уже поданные дни для календаря. Ошибка не блокирует форму: календарь
* работает без пометок, а повтор всё равно поймает проверка при отправке.
*/
export function useSubmittedDays(employeeEmail: string, excludeId: number | undefined): Map<string, ISubmittedDay> {
const [submitted, setSubmitted] = useState<Map<string, ISubmittedDay>>(new Map());
useEffect(() => {
let alive = true;
getSubmittedDays(employeeEmail, excludeId)
.then((map) => {
if (alive) setSubmitted(map);
})
.catch((e) => console.warn('[Объяснительная по СКУД] Не удалось проверить уже поданные дни', e));
return () => {
alive = false;
};
}, [employeeEmail, excludeId]);
return submitted;
}
@@ -0,0 +1,21 @@
import { IFormData, OTHER_REASON } from './model';
import { formatShortDate } from './noteText';
import { minSelectableDateKey } from './skud/skudDays';
/** Ошибка заполнения формы или undefined, если всё обязательное заполнено. */
export function validateSkudExplanation(data: IFormData): string | undefined {
if (!data.Employee || !data.Position.trim()) return 'Заполните обязательные поля: сотрудник, должность.';
if (data.MissedDays.length === 0) return 'Выберите в календаре хотя бы один день без отметок.';
// Черновик мог пролежать дольше лимита — дни из него уже вне доступного периода.
const minKey = minSelectableDateKey();
const outdated = data.MissedDays.filter((d) => d.date < minKey);
if (outdated.length > 0) {
return `Объяснительную можно подать только за текущий и прошлый месяц — уберите дни раньше ${formatShortDate(minKey)}: ${outdated
.map((d) => formatShortDate(d.date))
.join(', ')}.`;
}
if (!data.Reason) return 'Укажите причину отсутствия отметки.';
if (data.Reason === OTHER_REASON && !data.ReasonDetails.trim()) return 'Опишите причину отсутствия отметки.';
if (!data.PresenceConfirmed) return 'Подтвердите, что находились на рабочем месте.';
return undefined;
}
@@ -34,6 +34,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
'hr-unpaid-leave': React.lazy(
() => import(/* webpackChunkName: 'form-unpaid-leave' */ './UnpaidLeave/UnpaidLeaveForm')
),
'hr-skud-explanation': React.lazy(
() => import(/* webpackChunkName: 'form-skud-explanation' */ './SkudExplanation/SkudExplanationForm')
),
'wh-tmc-transfer': React.lazy(
() => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm')
),
@@ -53,6 +53,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
'hr-unpaid-leave': React.lazy(
() => import(/* webpackChunkName: 'view-unpaid-leave' */ './UnpaidLeave/UnpaidLeaveView')
),
'hr-skud-explanation': React.lazy(
() => import(/* webpackChunkName: 'view-skud-explanation' */ './SkudExplanation/SkudExplanationView')
),
'wh-tmc-transfer': React.lazy(
() => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView')
),
@@ -74,6 +74,21 @@ export function getNotifyOnlyTo(): string {
*/
export const DICTIONARY_API_BASE_URL = 'https://api.tnsu.kz';
/**
* Проходы СКУД (Hikvision) текущего пользователя — тот же gateway и AAD-app,
* что у справочников. Сотрудника API определяет по токену, e-mail не передаём.
* Используется формой «Объяснительная по СКУД» (forms/SkudExplanation).
*/
export const SKUD_EVENTS_PATH = '/Hikvision/Events/CurrentUser';
/**
* Производственный календарь (праздничные выходные) — SP-список на корневом
* сайте, где живёт главная страница портала (portal-main-page читает его же).
* Недоступен — календарь СКУД просто не красит праздники.
*/
export const HOLIDAYS_SITE_URL = 'https://tnsukz.sharepoint.com';
export const HOLIDAYS_LIST_TITLE = 'Производственный Календарь';
/**
* Power Automate: поток оповещения согласующего/исполнителя.
* Триггер «When an HTTP request is received» защищён Entra («любой пользователь
@@ -32,6 +32,7 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
'hr-transfer': 'Заявка на перемещение сотрудника',
'hr-vacation': 'Заявка на отпуск',
'hr-unpaid-leave': 'Отпуск без содержания',
'hr-skud-explanation': 'Объяснительная по СКУД',
'it-1c-ticket': 'Заявка в отдел 1С',
'it-workplace-setup': 'Организация рабочего места',
'recruiting-hire': 'Прием на работу',