Сотрудник пишет объяснительную за дни без отметок СКУД, выбирая их кликом в календаре «Мой СКУД» (перенесён из 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:
@@ -48,6 +48,7 @@
|
||||
| [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ |
|
||||
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
|
||||
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
|
||||
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+57
@@ -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}>
|
||||
Выбрать можно дни < 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': 'Прием на работу',
|
||||
|
||||
Reference in New Issue
Block a user