10 Commits

Author SHA1 Message Date
Тимур Городецкий 7e9848e83d feat: форма «Объяснительная по СКУД»
Tests / frontend-test (push) Successful in 1m17s
Сотрудник пишет объяснительную за дни без отметок СКУД, выбирая их кликом
в календаре «Мой СКУД» (перенесён из portal-main-page: те же цвета и
правила, проходы Hikvision текущего пользователя, праздники из
«Производственного календаря»). Текст записки собирается автоматически,
в просмотре печатается бланк объяснительной.

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 17:27:46 +05:00
Тимур Городецкий 1dda76caae test: разбор автора вынесен в модуль без SharePoint
Тест parseAuthorOwsUser падал: он импортировал myRequests.service, а тот
тянет PnPjs (ESM), который Jest проекта не разбирает. Функция вынесена в
parseAuthorOwsUser.ts без SharePoint-импортов, сервис берёт её оттуда.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:55:40 +05:00
Тимур Городецкий 182c497139 fix: служебный хвост в ФИО автора и исполнителя
Поисковое свойство AuthorOWSUSER хранит «почта | ФИО | служебный id и
claims-логин», а parseAuthorOwsUser брал как ФИО всё после первого
разделителя. Пока ФИО бралось из свойства Author, это было не видно;
после 13ba9c9 (ФИО из AuthorOWSUSER) хвост вылез в колонках «Автор» и
«Исполнитель». Теперь ФИО — ровно вторая часть строки.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:51:57 +05:00
Тимур Городецкий b12fcd0939 chore: скрипт проверки индексов в списках согласования
Только чтение: по каждому списку «… — согласование» показывает число
строк и проиндексированы ли Ticket, ApprovalStatus и TansuSiteApprover —
без индекса фильтр по колонке в списке больше 5000 строк падает.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:42:42 +05:00
Тимур Городецкий e9702a53d4 feat: «Скачать все» для вложений заявки
Над списком вложений (от двух файлов) — кнопка «Скачать все»: файлы
скачиваются по очереди, без архива, через download.aspx SharePoint, чтобы
PDF и картинки не открывались в просмотре. Обёртка над AttachmentList
из ui-kit подменена в shared/ui, поэтому кнопка появилась во всех формах
и карточках без правок в них; в чате выключена.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:42:42 +05:00
Тимур Городецкий ab3593c170 feat: история согласования по кнопке в строке таблиц заявок
В каждой строке таблиц заявок — кнопка «История согласования»: окно с
той же лентой, что в карточке, без открытия самой заявки. Колонка
действий теперь есть всегда (история у всех строк, корзина — у своих
черновиков).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:42:42 +05:00
Тимур Городецкий 153c966f0b feat: история согласования — маршрут пересохраняется патчем
При доработке и повторной отправке writeRoute раньше удалял все строки
маршрута и создавал заново — кто вернул или отклонил заявку и с каким
комментарием, терялось. Теперь маршрут пересохраняется патчем
(planRoutePatch + ApprovalListService.patchSteps): строки, что остаются в
маршруте, обновляются на месте, и их прошлые решения лежат в истории
версий; недостающие шаги создаются, выпавшие и строки делегатов уходят в
корзину. Всё одним пакетом. Проверено пилотами на увольнении и рабочем
месте (обе фазы, уход в корзину). Аварийный откат для всех форм —
ROUTE_PATCH_ENABLED = false.

Из версий строк маршрута и заявки собирается лента «История
согласования» (shared/approval/history): отправки, решения с
комментариями и временем на шаге, делегирование, замена исполнителя,
у кого заявка сейчас. Свёрнутый раздел в карточке и в форме доработки
всех форм, грузится только при раскрытии.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:42:42 +05:00
Тимур Городецкий 5b1e94bf42 feat: подсказка о недопустимой дате увольнения
Если последний рабочий день раньше, чем через месяц от сегодня, под полем
показывается причина и минимальная дата. Раньше кнопки отправки просто
становились неактивными без объяснения — это случалось у заявлений,
пролежавших на доработке: дата, допустимая при подаче, переставала
подходить. Правило и текст вынесены в lastWorkingDay.ts и общие для
подсказки и проверки перед отправкой.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:42:13 +05:00
Тимур Городецкий 7ad5f79cdf style: поле «Организация» в заявке на рабочее место
Подпись «Организация заявителя» заменена на «Организация» — в форме,
в подсказке незаполненных полей и в манифесте колонки (как у остальных
форм; на существующую колонку SharePoint манифест не влияет).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:41:40 +05:00
Тимур Городецкий 27b3c8de2a fix: новый шаг в матрице не копирует решение согласующего
После возврата заявки с одним согласующим каждый добавленный на доработке
шаг показывался «Возвращена на доработку»: statusForNewRow копировал общий
статус строк. Решения (согласовано/возвращено/отклонено/выполнено) больше
не копируются — новый шаг показывается с «—». В SharePoint это не
попадало: writeRoute и раньше писал всем строкам «Ждёт очереди».

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:41:25 +05:00
102 changed files with 4494 additions and 126 deletions
+1
View File
@@ -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 × цена), итог дублируется числовой колонкой для отчётов | ✅ |
+54
View File
@@ -0,0 +1,54 @@
<#
.SYNOPSIS
Диагностика: для каждого списка «… — согласование» на сайте печатает число
строк и проиндексированы ли колонки, по которым портал фильтрует маршрут.
Только чтение, ничего не меняет.
.DESCRIPTION
Портал ищет шаги маршрута фильтрами по Ticket (TicketId eq …), ApprovalStatus
и TansuSiteApprover. В списке больше 5000 строк такой фильтр работает только
по проиндексированной колонке — иначе SharePoint отвечает ошибкой порога
представления. Скрипт показывает, где индекса нет и как близко список к 5000.
.EXAMPLE
./Inspect-Indexes.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
-Thumbprint "224B64C8E0CAB1E09AF373E92B3AF4AC33C1233C"
#>
param(
[Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[Parameter(Mandatory = $true)][string]$Thumbprint
)
$ErrorActionPreference = 'Stop'
# Колонки списков согласования, по которым портал фильтрует (см.
# shared/approval/approvalList.service.ts и features/myRequests/myRequests.service.ts).
$filteredColumns = @('Ticket', 'ApprovalStatus', 'TansuSiteApprover')
# Порог представления SharePoint: выше него фильтр по неиндексированной колонке падает.
$listViewThreshold = 5000
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
$approvalLists = Get-PnPList | Where-Object { $_.Title -like '*— согласование' } | Sort-Object Title
$report = foreach ($list in $approvalLists) {
$row = [ordered]@{
'Список' = $list.Title
'Строк' = $list.ItemCount
'До 5000' = $listViewThreshold - $list.ItemCount
}
foreach ($name in $filteredColumns) {
$field = Get-PnPField -List $list -Identity $name -ErrorAction SilentlyContinue
$row[$name] = if (-not $field) { 'нет колонки' } elseif ($field.Indexed) { 'индекс' } else { 'НЕТ индекса' }
}
[pscustomobject]$row
}
Write-Host "`nИндексы в списках согласования ($SiteUrl):`n" -ForegroundColor Cyan
$report | Format-Table -AutoSize
Disconnect-PnPOnline
@@ -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,42 @@
import * as React from 'react';
import { Alert, Dialog } from '@tnsu/ui-kit';
import { ApprovalHistoryFeed, ApprovalListService, approvalServiceFor, HISTORY_TEXT } from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { IMyRequest } from './models/IMyRequest';
interface Props {
/** Заявка, чью историю показываем; undefined — окно закрыто. */
request: IMyRequest | undefined;
onClose: () => void;
}
/**
* Список согласования ИМЕННО той партиции, где лежит заявка (listId строки), —
* как и остальные дочитывания в таблице. Если партицию не удалось сопоставить
* с формой, approvalServiceFor бросает — показываем ошибку в окне, а не роняем таблицу.
*/
function approvalFor(request: IMyRequest): { approval?: ApprovalListService; error?: string } {
try {
return { approval: approvalServiceFor(request.formKey, request.listId) };
} catch (e) {
return { error: friendlyErrorMessage(e) };
}
}
/**
* «История согласования» заявки прямо из таблицы — по кнопке в строке, без
* открытия самой заявки. Та же лента, что в карточке (ApprovalHistoryFeed).
*/
export const RequestHistoryDialog: React.FC<Props> = ({ request, onClose }) => {
const target = React.useMemo(() => (request ? approvalFor(request) : {}), [request]);
const number = request ? request.code || `#${request.id}` : '';
return (
<Dialog open={!!request} title={`${HISTORY_TEXT.title} · №${number}`} onDismiss={onClose} width="md">
{request && target.error && <Alert variant="err">{target.error}</Alert>}
{request && target.approval && (
<ApprovalHistoryFeed approval={target.approval} dataListRef={request.listId} requestId={request.id} />
)}
</Dialog>
);
};
@@ -79,6 +79,13 @@
overflow: hidden;
}
// Кнопки действий строки (история, корзина) — в ряд, прижаты вправо.
.rowActions {
display: flex;
justify-content: flex-end;
gap: $sp-1;
}
// Пустой комментарий в колонке «Причина возврата/отклонения».
.commentEmpty {
color: $gray-400;
@@ -29,6 +29,7 @@ import { useRoutedQuery } from '../../shared/usePersistedQuery';
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
import { Pager } from './Pager';
import { RequestsFilterBar } from './RequestsFilterBar';
import { RequestHistoryDialog } from './RequestHistoryDialog';
import { SortableHeader } from './SortableHeader';
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
import { useTableRefinement } from './useTableRefinement';
@@ -237,9 +238,11 @@ const Table: React.FC<{
sort: ISortState;
onSort: (key: SortKey) => void;
onOpen: (request: IMyRequest) => void;
/** Показать историю согласования заявки (окно, без открытия заявки). */
onShowHistory: (request: IMyRequest) => void;
/** Удалить черновик; задан только там, где в таблице бывают свои черновики. */
onDelete?: (request: IMyRequest) => void;
}> = ({ rows, view, statusFilter, sort, onSort, onOpen, onDelete }) => {
}> = ({ rows, view, statusFilter, sort, onSort, onOpen, onShowHistory, onDelete }) => {
const cols = columnsFor(view, statusFilter);
const sortable = (label: string, key: SortKey): React.ReactNode => (
<SortableHeader label={label} sortKey={key} sort={sort} onSort={onSort} />
@@ -297,27 +300,41 @@ const Table: React.FC<{
render: (r) => r.decisionComment || <span className={styles.commentEmpty}>Без комментария</span>
});
}
if (onDelete && rows.some(canDeleteRow)) {
// Действия строки: история согласования — у всех, корзина — только у своих
// черновиков. Клик по всей строке открывает заявку — кнопкам это не нужно,
// поэтому stopPropagation.
const hasDeletable = !!onDelete && rows.some(canDeleteRow);
columns.push({
key: 'actions',
title: '',
width: '56px',
render: (r) =>
canDeleteRow(r) ? (
// Место под одну или две иконки-кнопки (sm) с зазором.
width: hasDeletable ? '92px' : '56px',
render: (r) => (
<div className={styles.rowActions}>
<IconButton
icon="calendar"
title="История согласования"
size="sm"
onClick={(e) => {
e.stopPropagation();
onShowHistory(r);
}}
/>
{onDelete && canDeleteRow(r) && (
<IconButton
icon="trash"
title="Удалить черновик"
danger
size="sm"
onClick={(e) => {
// Клик по всей строке открывает заявку — кнопке это не нужно.
e.stopPropagation();
onDelete(r);
}}
/>
) : null
)}
</div>
)
});
}
return (
<div className={styles.tableWrap}>
@@ -379,6 +396,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const [totalRows, setTotalRows] = React.useState<number>(0);
const [error, setError] = React.useState<string>('');
const [deleteError, setDeleteError] = React.useState<string>('');
// Заявка, чья история согласования открыта в окне; undefined — окно закрыто.
const [historyFor, setHistoryFor] = React.useState<IMyRequest | undefined>(undefined);
// Список перечитается сам: confirmAndDeleteDraft шлёт rp-requests-changed.
const handleDelete = (request: IMyRequest): void => {
@@ -729,11 +748,13 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
sort={sort}
onSort={refinement.onSort}
onOpen={onOpen}
onShowHistory={setHistoryFor}
onDelete={view === 'own:created' ? handleDelete : undefined}
/>
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
</>
)}
<RequestHistoryDialog request={historyFor} onClose={() => setHistoryFor(undefined)} />
</section>
);
};
@@ -25,6 +25,7 @@ import {
import { effectiveUserEmail } from '../../shared/impersonation';
import { DATA_SITE_URL } from '../../shared/config';
import { IAssignee, IMyRequest } from './models/IMyRequest';
import { parseAuthorOwsUser } from './parseAuthorOwsUser';
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
import { perfScope, traced } from '../../shared/perfTrace';
@@ -106,20 +107,6 @@ const MP_CODE = 'RequestUniqueCodeOWSTEXT';
// создатель (см. toMyRequestFromSearch); основной — MP_AUTHOR/parseAuthorOwsUser.
const OWN_SELECT_PROPERTIES = ['ListItemID', 'Path', 'Title', 'Created', 'Author', MP_AUTHOR, MP_REQUEST_STATUS, MP_CODE];
/**
* Managed property Person-поля хранит и логин, и ФИО одной строкой:
* «<логин> | <ФИО>» (проверено вручную, tools/sp props, 2026-08-20). Разделитель
* — именно " | " С ПРОБЕЛАМИ: сам логин при claims-аутентификации выглядит как
* `i:0#.f|membership|user@domain` и содержит "|" без пробелов вокруг, поэтому
* делить по ПЕРВОМУ "|" нельзя — часть логина отрезалась бы вместе с почтой.
*/
function parseAuthorOwsUser(value: unknown): { email: string; name: string } {
const text = typeof value === 'string' ? value : '';
const sep = text.indexOf(' | ');
if (sep < 0) return { email: text.trim(), name: '' };
return { email: text.slice(0, sep).trim(), name: text.slice(sep + 3).trim() };
}
// effectiveUserEmail, а не pageContext напрямую: при отладочной подмене личности
// (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны
// отбираться по подменённому сотруднику — иначе подмена показывала бы чужой
@@ -0,0 +1,21 @@
import { parseAuthorOwsUser } from './parseAuthorOwsUser';
describe('parseAuthorOwsUser', () => {
it('ФИО — ровно второй кусок, служебный хвост отбрасывается', () => {
const value =
'gorodetskiy.timur@tnsu.kz | Тимур Городецкий | 693A30232E667C6D656D626572736869707C676F726F646574736B69792E74696D757240746E73752E6B7A i:0#.f|membership|gorodetskiy.timur@tnsu.kz';
expect(parseAuthorOwsUser(value)).toEqual({ email: 'gorodetskiy.timur@tnsu.kz', name: 'Тимур Городецкий' });
});
it('формат из двух частей — как раньше', () => {
expect(parseAuthorOwsUser('a@tnsu.kz | Анна Ким')).toEqual({ email: 'a@tnsu.kz', name: 'Анна Ким' });
});
it('без разделителя — только почта', () => {
expect(parseAuthorOwsUser('a@tnsu.kz')).toEqual({ email: 'a@tnsu.kz', name: '' });
});
it('не строка — пусто', () => {
expect(parseAuthorOwsUser(undefined)).toEqual({ email: '', name: '' });
});
});
@@ -0,0 +1,18 @@
/**
* Managed property Person-поля хранит несколько значений одной строкой через
* " | ": «<почта> | <ФИО> | <служебный id> <claims-логин>» (первые два —
* проверено вручную, tools/sp props, 2026-08-20; хвост вылез в UI 2026-09-29,
* когда ФИО стали брать отсюда). Поэтому ФИО — ровно ВТОРОЙ кусок, а не «всё
* после первого разделителя».
*
* Разделитель — именно " | " С ПРОБЕЛАМИ: claims-логин
* `i:0#.f|membership|user@domain` содержит "|" без пробелов вокруг, и деление по
* голому "|" порезало бы его на части.
*
* Отдельным модулем, без SharePoint-импортов: myRequests.service тянет PnPjs,
* который Jest проекта не разбирает (ESM), — так функцию можно тестировать.
*/
export function parseAuthorOwsUser(value: unknown): { email: string; name: string } {
const parts = (typeof value === 'string' ? value : '').split(' | ').map((p) => p.trim());
return { email: parts[0] ?? '', name: parts[1] ?? '' };
}
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -339,6 +340,19 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
/>
)}
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -8,6 +8,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
@@ -319,6 +320,12 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -327,6 +328,19 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования."
/>
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -8,6 +8,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId
@@ -155,6 +156,12 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -28,14 +29,14 @@ import {
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { isoDatePlusMonths, formatIsoDate } from '../../shared/dateUtils';
import { lastWorkingDayError } from './lastWorkingDay';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_MONTHS, IOrganizationRecord, primaryPosition } from './model';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, IOrganizationRecord, primaryPosition } from './model';
import { saveDismissalRequest, dismissalApprovalService } from './submit';
import { loadDismissalRequest } from './load';
import { RequestSection } from './sections/RequestSection';
@@ -197,15 +198,13 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
// Последний рабочий день нужно назначать минимум за MIN_NOTICE_MONTHS месяц —
// календарь (min у input type=date, см. RequestSection) не даёт выбрать более
// раннюю дату, но проверяем и здесь на случай обхода UI.
const minLastWorkingDay = isoDatePlusMonths(MIN_NOTICE_MONTHS);
const isLastDayValid = !data.LastWorkingDay || data.LastWorkingDay >= minLastWorkingDay;
// раннюю дату, но проверяем и здесь на случай обхода UI. Тот же текст
// RequestSection показывает подсказкой под полем (см. lastWorkingDay.ts).
const lastDayError = lastWorkingDayError(data.LastWorkingDay);
const position = primaryPosition(data.Organization);
const isValid = !!(data.Employee && position.trim() && data.LastWorkingDay && isLastDayValid && data.Phone.trim());
const isValid = !!(data.Employee && position.trim() && data.LastWorkingDay && !lastDayError && data.Phone.trim());
const validate = (): string | undefined => {
if (data.LastWorkingDay && !isLastDayValid) {
return `Последний рабочий день нужно назначать минимум за ${MIN_NOTICE_MONTHS} месяц — минимально доступная дата: ${formatIsoDate(minLastWorkingDay)}.`;
}
if (lastDayError) return lastDayError;
if (!data.Employee || !position.trim() || !data.LastWorkingDay || !data.Phone.trim()) {
return 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.';
}
@@ -343,6 +342,20 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили, — поэтому история и в форме, а не только в просмотре.
Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -8,6 +8,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
@@ -283,6 +284,12 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
</div>
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый).
Итог автосоздания обходного листа — тут же, а не в шапке: он появляется
именно как следствие решения «Согласовать», логичнее видеть его рядом. */}
@@ -0,0 +1,23 @@
import { formatIsoDate, isoDatePlusMonths } from '../../shared/dateUtils';
import { MIN_NOTICE_MONTHS } from './model';
/** Самый ранний допустимый последний рабочий день ('YYYY-MM-DD'): сегодня + MIN_NOTICE_MONTHS. */
export function minLastWorkingDay(): string {
return isoDatePlusMonths(MIN_NOTICE_MONTHS);
}
/**
* Почему последний рабочий день не подходит; undefined — подходит или ещё не
* выбран. Одна функция и для подсказки под полем (RequestSection), и для
* проверки перед отправкой (DismissalForm) — чтобы текст и правило не разошлись.
*
* Правило считается от СЕГОДНЯ: у заявления, пролежавшего на доработке, дата,
* бывшая допустимой при подаче, может перестать подходить — подсказка сразу
* объясняет, почему кнопки отправки неактивны.
*/
export function lastWorkingDayError(value: string): string | undefined {
if (!value) return undefined;
const min = minLastWorkingDay();
if (value >= min) return undefined;
return `Последний рабочий день нужно назначать минимум за ${MIN_NOTICE_MONTHS} месяц — минимально доступная дата: ${formatIsoDate(min)}.`;
}
@@ -0,0 +1,8 @@
@import '../../../../../styles/design-tokens';
// Пояснение под полем, почему значение не подходит (см. lastWorkingDayError).
.fieldError {
margin-top: $sp-1;
font-size: $fs-label;
color: $red-500;
}
@@ -1,8 +1,10 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, TextArea, PhoneInput, PeopleField, LinkField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, MIN_NOTICE_MONTHS } from '../model';
import { isoDatePlusMonths } from '../../../shared/dateUtils';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
import { lastWorkingDayError, minLastWorkingDay } from '../lastWorkingDay';
import { OrganizationsTable } from './OrganizationsTable';
import styles from './RequestSection.module.scss';
interface IRequestSectionProps extends ISectionProps {
/**
@@ -27,7 +29,12 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
onEmployeeChange,
onRemoveOrganization,
assetsAcceptorEditable
}) => (
}) => {
// В просмотре (DismissalView) секция только для чтения — подсказка там ни к чему.
const readOnly = useFormReadOnly();
const lastDayError = lastWorkingDayError(data.LastWorkingDay);
return (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
@@ -56,15 +63,18 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
onRemove={onRemoveOrganization}
/>
{/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе,
чем через MIN_NOTICE_MONTHS месяц от сегодня (min). */}
чем через MIN_NOTICE_MONTHS месяц от сегодня (min). Если дата уже стоит
раньше (заявление пролежало на доработке) — подсказка под полем, иначе
непонятно, почему кнопки отправки неактивны. */}
<FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required>
<TextInput
id="LastWorkingDay"
type="date"
value={data.LastWorkingDay}
onChange={(v) => set('LastWorkingDay', v)}
min={isoDatePlusMonths(MIN_NOTICE_MONTHS)}
min={minLastWorkingDay()}
/>
{!readOnly && lastDayError && <div className={styles.fieldError}>{lastDayError}</div>}
</FormField>
<FormField label="Контактный телефон" htmlFor="Phone" required>
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
@@ -92,4 +102,5 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
/>
</FormField>
</FormGrid>
);
);
};
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -303,6 +304,19 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -8,6 +8,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId
@@ -152,6 +153,12 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
@@ -21,6 +21,7 @@ import {
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -469,6 +470,19 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
/>
)}
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -23,6 +23,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
@@ -417,6 +418,12 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
@@ -21,6 +21,7 @@ import {
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -459,6 +460,19 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
/>
)}
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -22,6 +22,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
@@ -380,6 +381,12 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
@@ -18,6 +18,7 @@ import {
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -478,6 +479,19 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
/>
)}
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -18,6 +18,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
@@ -238,6 +239,12 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -387,6 +388,19 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
/>
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -18,6 +18,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
@@ -181,6 +182,12 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
</div>
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
@@ -0,0 +1,61 @@
# Объяснительная об отсутствии отметок в СКУД (hr-skud-explanation)
**Назначение:** объяснительная сотрудника за дни, когда в СКУД нет отметки прихода/ухода
(одна запись) или нет отметок вовсе.
**Категория:** Отдел кадров (плитка «Объяснительная по СКУД»)
**Списки SP:** «Объяснительная по СКУД — данные» / «Объяснительная по СКУД — согласование»
**RequestTypeId (внешний API маршрутов):** _плейсхолдер_ — получит числовой id при заведении
типа в админке approval-route с `pageUrl` «hr-skud-explanation».
## Маршрут
- Фазы: согласование (напр. непосредственный руководитель) + исполнение (напр. кадры) —
обе фазы грузятся отдельными запросами `getPlannedMatrix(..., 'Approval' | 'Execution')`
в `useSkudRoute` (паттерн Referral). Без запроса 'Execution' шаг исполнения пропадает.
- Планируемый маршрут резолвится по e-mail сотрудника; согласующих заявитель может
править (новая / черновик / возврат), исполнителей — нет (только чтение).
- Нет согласующих вовсе — `initialSubmitStatus` сразу ставит «Выполнение».
## Особое поведение
- **Только «на себя».** Проходы берутся из `GET api.tnsu.kz/Hikvision/Events/CurrentUser` —
API определяет сотрудника по токену, за другого их не получить. Поле «Сотрудник» не
редактируется. 404 от API = учётная запись не связана с 1С → вместо календаря сообщение.
- **Календарь «Мой СКУД»** (перенесён из portal-main-page, те же цвета и правила):
зелёный ≥ 8 ч, жёлтый < 8 ч (рабочее время = последний проход − первый − 1 ч обеда),
красный — 1 запись, штриховка — прошедший будний день без проходов, индиго — праздник
(SP-список «Производственный Календарь» на корневом сайте, `HOLIDAYS_SITE_URL`).
- **Какие дни выбираются** (`toMissedKind`): прошедшие < 8 ч (часто пропущен приход/уход),
с 1 записью и без отметок; сегодня — только если нет ни одной отметки (одна запись утром —
норма, сотрудник ещё не ушёл). ≥ 8 ч, выходные, праздники — нет.
- **Лимит — текущий и прошлый месяц** (`SELECTABLE_MONTHS_BACK = 1`): дальше календарь не
листается, а при отправке черновика с устаревшими днями валидация просит их убрать.
- **Защита от повторов** (`submittedDays.ts` / `.service.ts`): день, уже вошедший в свою
объяснительную со статусом На согласовании / Согласовано / Выполнение / Закрыта /
Возвращена на доработку, в календаре помечен ✓ и не выбирается (подсказка — номер и
статус той заявки). «Отклонено» и «Черновик» не блокируют. Ищем по всем партициям,
фильтр `EmployeeId` + `Created ≥ начала доступного периода`; открытая заявка себя не
блокирует. Перед отправкой список перечитывается (вторая вкладка). Ошибка чтения
форму не блокирует — календарь без пометок. Индекс на `Employee` провижинер не ставит;
понадобится, только если партиция перерастёт 5000 элементов.
- **Дни сохраняются снимком** (`SkudMissedDaysJson`: дата, вид, первая/последняя запись),
т.к. согласующий чужой СКУД открыть не может. В режиме чтения календаря нет — только
список дней. `SkudMissedDates` — та же информация читаемой строкой для представлений SP.
- **Ф.И.О. полностью** — из `Employee/ByEmail` (`SkudEmployeeFio`), а не из профиля SP
(там «Имя Фамилия»): в тексте записки нужно «Фамилия И. О.».
- **Текст записки собирается автоматически** (`noteText.ts`) из дат, причины и
подтверждения; показывается только в просмотре (документ для печати) — в форме подачи
предпросмотра нет (убран по просьбе Тимура).
- **Печать** — только документ (бланк: шапка «Директору {организация}», должность, Ф.И.О.,
заголовок, текст, дата подачи, подпись), без данных формы и маршрута.
- **Дата подачи** (`SubmitDate`, общий столбец сайта «только дата») — не редактируется,
ставится сегодняшней у новой заявки и при открытии черновика/доработки; у отправленной
остаётся прежней. Печатается внизу бланка (для старых записей без неё — `Created`).
- **Заголовок элемента** (`Title`): `Объяснительная по СКУД — {ФИО} — дд.мм.гггг`.
## Секции формы
EmployeeSection (сотрудник · должность · организация) → DaysSection (календарь + выбранные
дни) → ExplanationSection (причина · «другое» · подтверждение).
## Точки входа
- rptype: `hr-skud-explanation`
- Режим согласующего (просмотр): есть (`SkudExplanationView`)
- «Создать на основе»: нет
@@ -0,0 +1,20 @@
@import '../../../../styles/design-tokens';
.back {
display: inline-flex;
align-items: center;
gap: $sp-2;
margin-bottom: $sp-4;
padding: 0;
border: 0;
background: transparent;
color: $navy-700;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: color .18s;
&:hover {
color: $gold-600;
}
}
@@ -0,0 +1,309 @@
import * as React from 'react';
import { useState, useMemo, useEffect } from 'react';
import styles from './SkudExplanationForm.module.scss';
import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
Icon,
RequestNumber,
SubmitActions,
getDraftDeleteHandler,
useRequestSubmit
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
isEditableStatus,
initialSubmitStatus,
IApproverToWrite,
unassignedStepMessage,
resolveWriteListId
} from '../../shared/approval';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { useStableCallback } from '../../shared/useStableCallback';
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
import { validateSkudExplanation } from './validation';
import { saveSkudExplanationRequest, skudExplanationApprovalService } from './submit';
import { loadSkudExplanationRequest } from './load';
import { submittedConflictMessage } from './submittedDays';
import { getSubmittedDays } from './submittedDays.service';
import { useSubmittedDays } from './useSubmittedDays';
import { useSkudRoute } from './useSkudRoute';
import { todayDateKey } from './skud/skudDays';
import { EmployeeSection } from './sections/EmployeeSection';
import { DaysSection } from './sections/DaysSection';
import { ExplanationSection } from './sections/ExplanationSection';
const SkudExplanationForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId,
onCodeLoaded
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit();
// Стабильная обёртка: ссылка onCodeLoaded у родителя меняется на каждом
// рендере, а в зависимостях эффекта загрузки она перезапрашивала бы заявку.
const notifyCode = useStableCallback(onCodeLoaded);
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Отправленную заявку уже не правят — пустой e-mail не делает запрос.
const submittedDays = useSubmittedDays(readOnly ? '' : data.Employee, requestId);
const approval = useMemo(() => skudExplanationApprovalService(editListId), [editListId]);
const requestTypeIdState = useRequestTypeId(context, requestType.id);
const requestTypeId = requestTypeIdState.id;
// Открыта своя заявка на редактирование (черновик/возврат).
useEffect(() => {
if (!editId) return;
let alive = true;
loadSkudExplanationRequest(editId, editListId)
.then((loaded) => {
if (!alive) return;
// Черновик/доработку подают заново — дата подачи становится сегодняшней;
// у отправленной заявки остаётся та, с которой её подали.
const submitDate = isEditableStatus(loaded.status) ? todayDateKey() : loaded.data.SubmitDate;
setData({ ...loaded.data, SubmitDate: submitDate });
setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
if (loaded.code) notifyCode(loaded.code);
})
.catch((e) => {
if (alive) setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
});
return () => {
alive = false;
};
}, [editId, editListId, notifyCode, setSubmitError]);
// Новая заявка — всегда на себя: ФИО полностью, должность и организации из
// Employee/ByEmail (ФИО профиля SharePoint — «Имя Фамилия», для записки не годится).
useEffect(() => {
if (editId) return;
let alive = true;
const user = context.pageContext.user;
setData((prev) => ({
...prev,
SubmitDate: todayDateKey(),
Employee: user.email,
EmployeeFio: prev.EmployeeFio || user.displayName
}));
getEmployeeOrganizations(context, user.email)
.then((records) => {
if (!alive) return;
setData((prev) => ({
...prev,
EmployeeFio: records[0]?.fio || prev.EmployeeFio,
Position: prev.Position || records[0]?.position || '',
Organization: formatEmployeeOrganizations(records)
}));
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [editId, context]);
// Маршрут по сотруднику; у открытой заявки — из уже записанных строк, чтобы не затереть правки.
const { approvers: matrix, executors } = useSkudRoute({
context,
approval,
requestId: editId,
employeeEmail: data.Employee,
requestTypeId,
editable: !readOnly
});
const validate = (): string | undefined => {
const formError = validateSkudExplanation(data);
if (formError) return formError;
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
};
// На отправку маршрут должен быть укомплектован — шаг без человека молча пропал бы при записи.
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
const repeatError = submittedConflictMessage(data.MissedDays, submittedDays);
if (repeatError) return repeatError;
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
}
const unassignedError = unassignedStepMessage(
matrix.rows,
(r) =>
`Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
return unassignedStepMessage(
executors.rows,
(r) =>
`Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
);
};
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
order: r.order,
slaHours: r.slaHours,
askOverdueReason: r.askOverdueReason,
autoApprove: r.autoApprove
}));
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
// validate() уже гарантировал requestTypeId, но TS не видит этого через замыкание.
const rtId = requestTypeId;
if (!rtId) throw new Error('Тип заявки не определён — обновите страницу и повторите.');
// Пометки в календаре — на момент открытия формы; за это время объяснительную
// на те же дни могли подать из другой вкладки. Перечитываем прямо перед записью.
if (status !== REQUEST_STATUS.draft) {
const repeatError = submittedConflictMessage(data.MissedDays, await getSubmittedDays(data.Employee, requestId));
if (repeatError) throw new Error(repeatError);
}
// Текущую партицию спрашиваем прямо перед записью: этот вызов запускает архивацию по порогу.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveSkudExplanationRequest(
requestId,
rtId,
data,
toApprovers(matrix.rows),
toApprovers(executors.rows),
status,
writeListId
);
setRequestId(id);
setRequestStatus(status);
if (code) setRequestCode(code);
if (onSaved) onSaved();
// Отправка (на согласование или, без согласующих, сразу на исполнение) — уведомляем активный шаг.
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
});
}
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
// Согласующих в маршруте может не оказаться — тогда initialSubmitStatus сразу даёт «Выполнение».
const handleSubmit = (): Promise<void> =>
submit.run('submit', save(initialSubmitStatus(matrix.rows.length, executors.rows.length)), validateSubmit);
const handleDelete = getDraftDeleteHandler({
formKey: FORM_ID,
requestId,
requestStatus,
listId: editListId,
code: requestCode,
submit,
onDeleted: onCancel
});
const sectionProps = { data, set, context };
return (
<div>
<button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" />
<span>К списку заявок</span>
</button>
<FormCard
title={FORM_TITLE}
subtitle="Категория: Отдел кадров"
belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={
requestId || requestStatus ? (
<>
{requestStatus && <StatusBadge status={requestStatus} />}
{requestCode && <RequestNumber code={requestCode} />}
</>
) : undefined
}
>
{submit.savedKind && !submit.error && (
<Alert variant="ok">
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
</Alert>
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormModeProvider readOnly={readOnly}>
<EmployeeSection {...sectionProps} />
<DaysSection {...sectionProps} submittedDays={submittedDays} />
<ExplanationSection {...sectionProps} />
</FormModeProvider>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту."
/>
)}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
isValid={!validateSkudExplanation(data)}
readOnly={readOnly}
onCancel={onCancel}
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmit}
submitLabel="Отправить на согласование"
/>
</FormCard>
</div>
);
};
export default SkudExplanationForm;
@@ -0,0 +1,29 @@
@import '../../../../styles/design-tokens';
.state {
padding: $sp-8;
color: $gray-500;
font-size: $fs-sm;
text-align: center;
}
.documentBlock {
margin-top: $sp-6;
}
.documentTitle {
margin-bottom: $sp-3;
font-family: $font-serif;
font-size: $fs-section-title;
font-weight: 600;
color: $navy-900;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
}
@@ -0,0 +1,193 @@
import * as React from 'react';
import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './SkudExplanationView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
import {
ApproverService,
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
notifyActiveStep,
useRequestTypeId
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { useStableCallback } from '../../shared/useStableCallback';
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
import { loadSkudExplanationRequest } from './load';
import { skudExplanationApprovalService } from './submit';
import { useSkudRoute } from './useSkudRoute';
import { EmployeeSection } from './sections/EmployeeSection';
import { DaysSection } from './sections/DaysSection';
import { ExplanationSection } from './sections/ExplanationSection';
import { NoteDocument } from './components/NoteDocument';
interface ISkudExplanationViewProps {
context: WebPartContext;
itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string;
/** Партиция, где лежит эта заявка — см. IApprovalViewProps.listId. */
listId?: string;
onCodeLoaded?: (code: string) => void;
}
// В режиме чтения поля не редактируются — обновление пустышка.
const NOOP_SET: SetField = () => undefined;
/**
* Объяснительная для согласующего: данные заявки (только чтение) + документ
* записки. На печать идёт только документ (rp-print-root) — как бумажный бланк.
*/
const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [created, setCreated] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
const approval = useMemo(() => skudExplanationApprovalService(listId), [listId]);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// decisionSeq: после решения строки маршрута меняются — перечитываем обе фазы.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const { approvers, executors } = useSkudRoute({
context,
approval,
requestId: itemId,
employeeEmail: data.Employee,
requestTypeId,
editable: false,
reloadSeq: decisionSeq
});
useEffect(() => {
let alive = true;
setLoading(true);
loadSkudExplanationRequest(itemId, listId)
.then((loaded) => {
if (!alive) return;
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
setCreated(loaded.created);
notifyCode(loaded.code);
setError(null);
setLoading(false);
})
.catch((e) => {
if (!alive) return;
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
setLoading(false);
});
return () => {
alive = false;
};
}, [itemId, listId, notifyCode]);
if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>;
}
if (error) {
return (
<div className={styles.state}>
<Alert variant="err">{error}</Alert>
</div>
);
}
const notify = (): void => {
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
</div>
<FormCard
title={FORM_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<FormModeProvider readOnly>
<EmployeeSection {...sectionProps} />
<DaysSection {...sectionProps} />
<ExplanationSection {...sectionProps} />
</FormModeProvider>
{/* Не в CollapseSection: свёрнутая секция убрала бы rp-print-root из DOM, и печатать было бы нечего. */}
<div className={styles.documentBlock}>
<div className={styles.documentTitle}>Объяснительная записка</div>
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
{/* Created — запасной вариант для заявок, сохранённых до появления «Даты подачи». */}
<NoteDocument data={data} noteDate={data.SubmitDate || created} orgIndex={printOrgIndex} />
</div>
</div>
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение"
roleLabel="Исполнитель"
emptyText="Исполнители не назначены."
/>
)}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
notify();
}}
/>
</div>
</div>
);
};
export default SkudExplanationView;
@@ -0,0 +1,17 @@
{
"dataListTitle": "Объяснительная по СКУД — согласование",
"columns": [
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Объяснительная по СКУД — данные", "lookupShowField": "Title" },
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
]
}
@@ -0,0 +1,71 @@
@import '../../../../../styles/design-tokens';
// Документ имитирует бумажный бланк: шрифт с засечками, выравнивание по ширине,
// красная строка — как в образце объяснительной отдела кадров.
$doc-font: 'Times New Roman', Times, serif;
$paragraph-indent: 2.5em;
$sign-line-width: 120px;
.document {
width: 100%;
padding: $sp-4;
border: 1px solid $gray-200;
border-radius: $r-md;
background: $white;
color: $gray-900;
font-family: $doc-font;
font-size: 15px;
line-height: 1.5;
@media (min-width: 640px) {
padding: $sp-8;
}
}
.addressee {
margin-left: auto;
margin-bottom: $sp-8;
max-width: 320px;
text-align: right;
font-weight: 600;
}
.author {
margin-top: $sp-1;
}
.title {
margin-bottom: $sp-6;
text-align: center;
font-weight: 700;
text-transform: uppercase;
}
.paragraph {
margin: 0 0 $sp-2;
text-align: justify;
text-indent: $paragraph-indent;
overflow-wrap: anywhere;
}
.footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-end;
gap: $sp-4;
margin-top: $sp-8;
font-weight: 600;
}
.signature {
display: inline-flex;
align-items: flex-end;
gap: $sp-2;
}
.signLine {
display: inline-block;
width: $sign-line-width;
border-bottom: 1px solid $gray-900;
}
@@ -0,0 +1,54 @@
import * as React from 'react';
import styles from './NoteDocument.module.scss';
import { IFormData } from '../model';
import { buildNoteParagraphs, formatShortDate, shortFio } from '../noteText';
interface INoteDocumentProps {
data: IFormData;
/** Дата записки, YYYY-MM-DD (дата подачи; у новой заявки — сегодня). */
noteDate: string;
/** Какая из организаций сотрудника идёт в шапку (см. PrintSplitButton). */
orgIndex?: number;
}
/** «Компания — должность» → компания; организаций несколько — берём выбранную для печати. */
function companyOf(organization: string, index: number): string {
const line = organization.split('\n')[index] || '';
return line.split(' — ')[0].trim();
}
/**
* Объяснительная записка в виде бумажного документа (как бланк отдела кадров):
* шапка справа, заголовок, текст, дата и подпись. Предпросмотр в форме и
* печатная форма в просмотре — один и тот же компонент.
*/
export const NoteDocument: React.FC<INoteDocumentProps> = ({ data, noteDate, orgIndex = 0 }) => {
const company = companyOf(data.Organization, orgIndex);
const name = shortFio(data.EmployeeFio);
return (
<div className={styles.document}>
<div className={styles.addressee}>
{company && <div>Директору {company}</div>}
{data.Position && <div>{data.Position}</div>}
{name && <div className={styles.author}>{name}</div>}
</div>
<div className={styles.title}>Объяснительная записка</div>
{buildNoteParagraphs(data).map((paragraph) => (
<p key={paragraph} className={styles.paragraph}>
{paragraph}
</p>
))}
<div className={styles.footer}>
<span>{formatShortDate(noteDate)} г.</span>
<span className={styles.signature}>
<span className={styles.signLine} />
{name}
</span>
</div>
</div>
);
};
@@ -0,0 +1,57 @@
@import '../../../../../styles/design-tokens';
.list {
display: flex;
flex-wrap: wrap;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
.chip {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-2;
max-width: 100%;
padding: $sp-1 $sp-2 $sp-1 $sp-3;
border: 1px solid $navy-100;
border-radius: $r-full;
background: $navy-50;
font-size: $fs-sm;
}
.date {
font-weight: 600;
color: $navy-900;
}
.kind {
font-size: $fs-label;
color: $red-500;
}
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $gray-500;
cursor: pointer;
&:hover {
background: $navy-100;
color: $navy-900;
}
}
.empty {
font-size: $fs-sm;
color: $gray-500;
}
@@ -0,0 +1,39 @@
import * as React from 'react';
import { Icon } from '../../../shared/ui';
import styles from './SelectedDaysList.module.scss';
import { IMissedDay } from '../model';
import { formatLongDate, formatMissedDetails } from '../noteText';
interface ISelectedDaysListProps {
days: IMissedDay[];
/** Не передан — список только для чтения (просмотр, отправленная заявка). */
onRemove?: (date: string) => void;
}
/** Выбранные в объяснительную дни — «чипсы» с тем, что было в СКУД на момент подачи. */
export const SelectedDaysList: React.FC<ISelectedDaysListProps> = ({ days, onRemove }) => {
if (days.length === 0) {
return <div className={styles.empty}>Дни не выбраны — отметьте их в календаре.</div>;
}
return (
<ul className={styles.list}>
{days.map((day) => (
<li key={day.date} className={styles.chip}>
<span className={styles.date}>{formatLongDate(day.date)}</span>
<span className={styles.kind}>{formatMissedDetails(day)}</span>
{onRemove && (
<button
type="button"
className={styles.remove}
onClick={() => onRemove(day.date)}
aria-label={`Убрать ${formatLongDate(day.date)}`}
>
<Icon name="close" size="sm" />
</button>
)}
</li>
))}
</ul>
);
};
@@ -0,0 +1,293 @@
@import '../../../../../styles/design-tokens';
// Цвета статусов — те же, что в «Мой СКУД» (portal-main-page), чтобы сотрудник
// видел одну и ту же картину в обоих местах.
$full-bg: #ECFDF5;
$full-border: #A7F3D0;
$full-text: $green-600;
$partial-bg: #FFFBEB;
$partial-border: #FDE68A;
$partial-text: #B45309;
$single-bg: $red-50;
$single-border: #FECACA;
$single-text: $red-500;
$holiday-bg: #EEF2FF;
$holiday-border: #C7D2FE;
$holiday-text: #4338CA;
$weekend-bg: #EDEFF2;
// Шире 7 колонок по ~64px ячейки перестают быть квадратными и читаются хуже —
// на широком экране календарь не растягивается на всю форму.
$calendar-max-width: 480px;
.calendar {
width: 100%;
max-width: $calendar-max-width;
}
.nav {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-3;
margin-bottom: $sp-1;
}
.navButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $white;
color: $navy-700;
cursor: pointer;
&:hover:not(:disabled) {
border-color: $gold-500;
}
&:disabled {
color: $gray-300;
cursor: default;
}
}
.month {
min-width: 150px;
text-align: center;
font-size: $fs-body;
font-weight: 600;
color: $navy-900;
}
.summary {
margin-bottom: $sp-2;
text-align: center;
font-size: $fs-label;
color: $gray-500;
}
.state {
padding: $sp-6 0;
text-align: center;
font-size: $fs-sm;
color: $gray-500;
}
.weekHead,
.grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: $sp-1;
@media (min-width: 480px) {
gap: 6px;
}
}
.weekHead {
margin-bottom: $sp-1;
}
.weekDay {
text-align: center;
font-size: $fs-caption;
font-weight: 600;
color: $gray-400;
text-transform: uppercase;
}
.empty {
aspect-ratio: 1 / 1;
}
.cell {
aspect-ratio: 1 / 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 0;
padding: 0;
border: 1px solid transparent;
border-radius: $r-sm;
background: $gray-50;
color: $gray-400;
font-family: inherit;
cursor: default;
transition: border-color .18s, box-shadow .18s;
}
.dayNum {
font-size: $fs-label;
font-weight: 500;
@media (min-width: 480px) {
font-size: $fs-sm;
}
}
.cellTime {
font-size: 9px;
font-weight: 600;
@media (min-width: 480px) {
font-size: 10px;
}
}
.full {
background: $full-bg;
border-color: $full-border;
color: $full-text;
}
.partial {
background: $partial-bg;
border-color: $partial-border;
color: $partial-text;
}
.single {
background: $single-bg;
border-color: $single-border;
color: $single-text;
}
.holiday {
background: $holiday-bg;
border-color: $holiday-border;
color: $holiday-text;
}
.weekend {
background: $weekend-bg;
.dayNum {
color: $gray-500;
}
}
// Прошедший будний день без проходов — диагональная штриховка «нет данных».
.absent {
background: repeating-linear-gradient(45deg, $white, $white 4px, $gray-200 4px, $gray-200 8px);
border-color: $gray-200;
.dayNum {
color: $gray-500;
}
}
.selectable {
cursor: pointer;
&:hover {
border-color: $navy-700;
}
}
// День уже в поданной объяснительной: синяя рамка + галочка поверх цвета СКУД,
// чтобы было видно и «что было», и «уже объяснено».
.cell {
position: relative;
}
.submitted {
border-color: $blue-600;
opacity: .75;
}
.submittedMark {
position: absolute;
top: 2px;
right: 4px;
font-size: 10px;
font-weight: 700;
line-height: 1;
color: $blue-600;
}
.selected {
border-color: $navy-800;
box-shadow: inset 0 0 0 2px $navy-800;
.dayNum {
font-weight: 700;
}
}
.today {
box-shadow: inset 0 0 0 2px $gold-500;
}
.legendWrap {
margin-top: $sp-3;
text-align: center;
}
.legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: $sp-2 $sp-3;
font-size: $fs-caption;
color: $gray-500;
}
.legendItem {
display: inline-flex;
align-items: center;
gap: 5px;
}
.dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
}
.dotFull {
background: $full-border;
border: 1px solid $full-text;
}
.dotPartial {
background: $partial-border;
border: 1px solid $partial-text;
}
.dotSingle {
background: $single-border;
border: 1px solid $single-text;
}
.dotHoliday {
background: $holiday-border;
border: 1px solid $holiday-text;
}
.dotSubmitted {
position: relative;
background: $blue-50;
border: 1px solid $blue-600;
}
.dotAbsent {
background: repeating-linear-gradient(45deg, $white, $white 2px, $gray-300 2px, $gray-300 4px);
border: 1px solid $gray-200;
}
.hint {
margin-top: $sp-2;
font-size: $fs-caption;
color: $gray-500;
}
@@ -0,0 +1,108 @@
import * as React from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { Alert, Icon } from '../../../shared/ui';
import styles from './SkudCalendar.module.scss';
import { SkudLegend } from './SkudLegend';
import { SkudDayCell } from './SkudDayCell';
import { useSkudMonth } from '../skud/useSkudMonth';
import { buildMonthCells, minSelectableDateKey, monthWorkedHours, todayDateKey } from '../skud/skudDays';
import { IMissedDay } from '../model';
import { ISubmittedDay } from '../submittedDays';
const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
interface ISkudCalendarProps {
context: WebPartContext;
/** Даты (YYYY-MM-DD), уже выбранные в объяснительную. */
selectedDates: string[];
/** Дни, уже закрытые поданными объяснительными (см. useSubmittedDays). */
submittedDays: Map<string, ISubmittedDay>;
onToggle: (day: IMissedDay) => void;
}
function monthTitle(year: number, month: number): string {
const text = new Date(year, month, 1).toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' });
const clean = text.replace(' г.', '');
return clean.charAt(0).toUpperCase() + clean.slice(1);
}
/** Календарь «Мой СКУД» с выбором дней (какие кликабельны — см. toMissedKind); листается на SELECTABLE_MONTHS_BACK назад. */
export const SkudCalendar: React.FC<ISkudCalendarProps> = ({ context, selectedDates, submittedDays, onToggle }) => {
const skud = useSkudMonth(context);
const cells = buildMonthCells(skud.year, skud.month, skud.byDay);
const bounds = { todayKey: todayDateKey(), minKey: minSelectableDateKey() };
if (skud.notLinked) {
return (
<Alert variant="warn">
Данные СКУД недоступны: ваша учётная запись не связана с системой 1С. Обратитесь в отдел кадров.
</Alert>
);
}
return (
<div className={styles.calendar}>
<div className={styles.nav}>
<button
type="button"
className={styles.navButton}
onClick={skud.goPrev}
disabled={!skud.canGoPrev}
aria-label="Предыдущий месяц"
>
<Icon name="chevron-left" size="sm" />
</button>
<span className={styles.month}>{monthTitle(skud.year, skud.month)}</span>
<button
type="button"
className={styles.navButton}
onClick={skud.goNext}
disabled={!skud.canGoNext}
aria-label="Следующий месяц"
>
<Icon name="chevron-right" size="sm" />
</button>
</div>
{!skud.loading && !skud.error && (
<div className={styles.summary}>
Дней: <b>{skud.byDay.size}</b> · ≈ <b>{monthWorkedHours(skud.byDay)} ч</b> за месяц
</div>
)}
{skud.loading && <div className={styles.state}>Загрузка отметок СКУД…</div>}
{!skud.loading && skud.error && <Alert variant="err">Не удалось загрузить отметки СКУД: {skud.error}</Alert>}
{!skud.loading && !skud.error && (
<>
<div className={styles.weekHead}>
{WEEKDAYS.map((w) => (
<span key={w} className={styles.weekDay}>
{w}
</span>
))}
</div>
<div className={styles.grid}>
{cells.map((cell, index) =>
cell ? (
<SkudDayCell
key={cell.dateKey}
info={cell}
cellIndex={index}
bounds={bounds}
holidayName={skud.holidays.get(cell.dateKey)}
submitted={submittedDays.get(cell.dateKey)}
selected={selectedDates.indexOf(cell.dateKey) >= 0}
onToggle={onToggle}
/>
) : (
<span key={`empty-${index}`} className={styles.empty} />
)
)}
</div>
<SkudLegend />
</>
)}
</div>
);
};
@@ -0,0 +1,88 @@
import * as React from 'react';
import styles from './SkudCalendar.module.scss';
import { CellKind, IDayInfo, cellKind, isWeekendColumn, toMissedKind } from '../skud/skudDays';
import { IMissedDay } from '../model';
import { ISubmittedDay } from '../submittedDays';
/** Границы выбора: сегодня и самая ранняя доступная дата (см. minSelectableDateKey). */
export interface IDayBounds {
todayKey: string;
minKey: string;
}
interface ISkudDayCellProps {
info: IDayInfo;
/** Позиция в сетке — по ней определяется выходной (Сб/Вс). */
cellIndex: number;
bounds: IDayBounds;
/** Название праздника, если день — праздничный выходной. */
holidayName?: string;
/** День уже закрыт поданной объяснительной — выбрать нельзя. */
submitted?: ISubmittedDay;
selected: boolean;
onToggle: (day: IMissedDay) => void;
}
const KIND_CLASS: Record<CellKind, string> = {
full: styles.full,
partial: styles.partial,
single: styles.single,
absent: styles.absent,
holiday: styles.holiday,
weekend: styles.weekend,
empty: ''
};
function hint(info: IDayInfo, kind: CellKind, holidayName: string | undefined): string {
if (kind === 'holiday') return holidayName || 'Праздник';
if (info.events.length === 1) return `Одна запись: ${info.first}`;
if (info.events.length > 1) return `${info.first} → ${info.last}`;
return kind === 'absent' ? 'Нет отметок' : '';
}
/** Ячейка дня: цвет по отметкам СКУД; «проблемный» день можно выбрать в объяснительную. */
export const SkudDayCell: React.FC<ISkudDayCellProps> = ({
info,
cellIndex,
bounds,
holidayName,
submitted,
selected,
onToggle
}) => {
const kind = cellKind(info, isWeekendColumn(cellIndex), !!holidayName, bounds.todayKey);
const missedKind = submitted ? undefined : toMissedKind(kind, info.dateKey, bounds.todayKey, bounds.minKey);
const className = [
styles.cell,
KIND_CLASS[kind],
missedKind ? styles.selectable : '',
submitted ? styles.submitted : '',
selected ? styles.selected : '',
info.dateKey === bounds.todayKey ? styles.today : ''
]
.filter(Boolean)
.join(' ');
const title = submitted ? `Уже подана: №${submitted.code} (${submitted.status})` : hint(info, kind, holidayName);
const handleClick = (): void => {
if (!missedKind) return;
// Снимок того, что было в СКУД: согласующий увидит это вместо календаря.
const hasExit = info.events.length > 1;
onToggle({ date: info.dateKey, kind: missedKind, first: info.first, last: hasExit ? info.last : '' });
};
return (
<button
type="button"
className={className}
disabled={!missedKind}
aria-pressed={missedKind ? selected : undefined}
title={title}
onClick={handleClick}
>
{submitted && <span className={styles.submittedMark}>✓</span>}
<span className={styles.dayNum}>{info.day}</span>
{info.first && <span className={styles.cellTime}>{info.first}</span>}
</button>
);
};
@@ -0,0 +1,29 @@
import * as React from 'react';
import styles from './SkudCalendar.module.scss';
const ITEMS: { className: string; label: string }[] = [
{ className: styles.dotFull, label: '≥ 8 ч' },
{ className: styles.dotPartial, label: '< 8 ч' },
{ className: styles.dotSingle, label: '1 запись' },
{ className: styles.dotHoliday, label: 'праздник' },
{ className: styles.dotAbsent, label: 'отсутствие' },
{ className: styles.dotSubmitted, label: '✓ уже подана' }
];
/** Легенда цветов календаря СКУД + подсказка, какие дни выбираются. */
export const SkudLegend: React.FC = () => (
<div className={styles.legendWrap}>
<div className={styles.legend}>
{ITEMS.map((item) => (
<span key={item.label} className={styles.legendItem}>
<i className={`${styles.dot} ${item.className}`} />
{item.label}
</span>
))}
</div>
<div className={styles.hint}>
Выбрать можно дни &lt; 8 ч, с одной записью или без отметок (сегодня — если отметок нет) за текущий и прошлый
месяц.
</div>
</div>
);
@@ -0,0 +1,73 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
import { parseMissedDays } from './missedDaysJson';
import { toDateKey } from './skud/skudDays';
export interface ILoadedRequest {
data: IFormData;
status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
/** Дата создания записи, YYYY-MM-DD — дата объяснительной в печатной форме. */
created: string;
}
// Created приходит в UTC: срез строки дал бы вчерашнюю дату для записей,
// созданных рано утром по местному времени.
function localDateKey(value: unknown): string {
if (typeof value !== 'string' || !value) return '';
const d = new Date(value);
return toDateKey(d.getFullYear(), d.getMonth(), d.getDate());
}
/**
* Прочитать объяснительную по id. listId — партиция, где лежит именно эта
* заявка (см. UnpaidLeave/load.ts): задан — читаем по url списка, иначе из
* текущей партиции.
*/
export async function loadSkudExplanationRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list.items
.getById(itemId)
.select(
'Id',
'Created',
'SubmitDate',
'SkudEmployeeFio',
'Position',
'Organization',
'SkudMissedDaysJson',
'SkudReason',
'SkudReasonDetails',
'SkudPresenceConfirmed',
'RequestStatus',
'RequestUniqueCode',
'Employee/Title',
'Employee/EMail'
)
.expand('Employee')()) as Record<string, unknown>;
const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
const data: IFormData = {
...EMPTY_DATA,
// Колонка «только дата» — время в ответе нулевое, берём дату как есть (как в Referral/load.ts).
SubmitDate: ((item.SubmitDate as string) || '').slice(0, 10),
Employee: employee?.EMail || '',
EmployeeFio: (item.SkudEmployeeFio as string) || employee?.Title || '',
Position: (item.Position as string) || '',
Organization: (item.Organization as string) || '',
MissedDays: parseMissedDays(item.SkudMissedDaysJson),
Reason: (item.SkudReason as string) || '',
ReasonDetails: (item.SkudReasonDetails as string) || '',
PresenceConfirmed: item.SkudPresenceConfirmed === true
};
return {
data,
status: (item.RequestStatus as string) || '',
code: (item.RequestUniqueCode as string) || '',
created: localDateKey(item.Created)
};
}
@@ -0,0 +1,16 @@
import { IMissedDay } from './model';
/**
* Разбор колонки SkudMissedDaysJson. Битый/пустой JSON не должен ронять ни
* открытие заявки, ни проверку повторов — считаем, что дней нет.
*/
export function parseMissedDays(json: unknown): IMissedDay[] {
if (typeof json !== 'string' || !json) return [];
try {
const parsed = JSON.parse(json) as unknown;
return Array.isArray(parsed) ? (parsed as IMissedDay[]) : [];
} catch (e) {
console.warn('[Объяснительная по СКУД] Не удалось разобрать SkudMissedDaysJson', e);
return [];
}
}
@@ -0,0 +1,101 @@
// import type — чтобы тесты noteText не тянули SPFx-рантайм через модель.
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import type { RequestTypeId } from '../../models/types';
import type { MissedKind } from './skud/skudDays';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
* SharePoint выводятся из него, см. shared/listTitles.ts.
*/
export const FORM_ID: RequestTypeId = 'hr-skud-explanation';
/** Полное название — заголовок формы, просмотра и элемента списка (плитка короче, см. data/requests.ts). */
export const FORM_TITLE = 'Объяснительная об отсутствии отметок в СКУД';
/** День, по которому пишется объяснительная, — снимок СКУД на момент подачи. */
export interface IMissedDay {
/** YYYY-MM-DD */
date: string;
kind: MissedKind;
/** HH:MM первой записи; '' — отметок не было вовсе. */
first: string;
/** HH:MM последней записи; '' — записей меньше двух. */
last: string;
}
/**
* Данные формы. Заявка только «на себя»: проходы СКУД API отдаёт по токену
* текущего пользователя, за другого сотрудника их не получить. Дни хранятся
* снимком (IMissedDay), потому что согласующий чужой СКУД открыть не может.
*/
export interface IFormData {
/**
* Дата подачи, YYYY-MM-DD — ставится сегодняшней автоматически, пока заявку
* можно править (новая / черновик / доработка), и печатается в бланке.
*/
SubmitDate: string;
/** E-mail сотрудника (People) — всегда текущий пользователь. */
Employee: string;
/** Ф.И.О. полностью «Фамилия Имя Отчество» из Employee/ByEmail — для текста записки. */
EmployeeFio: string;
Position: string;
/** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */
Organization: string;
/** Выбранные дни, по возрастанию даты. */
MissedDays: IMissedDay[];
/** Значение из SKUD_REASONS. */
Reason: string;
/** Своя формулировка причины — только при Reason = OTHER_REASON. */
ReasonDetails: string;
/** Сотрудник подтверждает, что был на рабочем месте. */
PresenceConfirmed: boolean;
}
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/** Индекс организации для печати (см. shared/ui/PrintSplitButton и OrganizationField.printIndex). */
printOrgIndex?: number;
}
export const EMPTY_DATA: IFormData = {
SubmitDate: '',
Employee: '',
EmployeeFio: '',
Position: '',
Organization: '',
MissedDays: [],
Reason: '',
ReasonDetails: '',
PresenceConfirmed: false
};
export const OTHER_REASON = 'Другое';
export interface ISkudReason {
/** Значение Choice-колонки SkudReason. */
value: string;
/** Продолжение фразы «…произошло по причине ___» в тексте записки. */
phrase: string;
}
/** Варианты причины. Меняя value — менять и choices колонки SkudReason в provisioning.json. */
export const SKUD_REASONS: ISkudReason[] = [
{ value: 'Невнимательность', phrase: 'невнимательности' },
{ value: 'Забыл(а) пропуск', phrase: 'того, что я забыл(а) пропуск' },
{ value: 'Неисправность турникета или считывателя', phrase: 'неисправности турникета или считывателя' },
{ value: OTHER_REASON, phrase: '' }
];
/** Подписи дня в списке выбранных и в колонке SkudMissedDates. */
export const MISSED_KIND_LABEL: Record<MissedKind, string> = {
partial: '< 8 ч',
single: '1 запись',
absent: 'нет отметок'
};
export const PRESENCE_CONFIRMATION_LABEL =
'Подтверждаю, что находился(ась) на рабочем месте и исполнял(а) трудовые обязанности в течение рабочего дня';
@@ -0,0 +1,54 @@
import { EMPTY_DATA, IFormData, OTHER_REASON } from './model';
import { buildNoteParagraphs, formatMissedDaysSummary, joinWithAnd, shortFio } from './noteText';
const DATA: IFormData = {
...EMPTY_DATA,
Employee: 'gorodetskiy.timur@tnsu.kz',
EmployeeFio: 'Городецкий Тимур Александрович',
MissedDays: [
{ date: '2026-07-21', kind: 'single', first: '08:35', last: '' },
{ date: '2026-07-22', kind: 'absent', first: '', last: '' },
{ date: '2026-07-23', kind: 'partial', first: '08:49', last: '13:10' }
],
Reason: 'Невнимательность',
PresenceConfirmed: true
};
describe('noteText', () => {
it('shortFio: фамилия + инициалы', () => {
expect(shortFio('Городецкий Тимур Александрович')).toBe('Городецкий Т. А.');
expect(shortFio('Городецкий')).toBe('Городецкий');
expect(shortFio(' ')).toBe('');
});
it('joinWithAnd', () => {
expect(joinWithAnd(['a'])).toBe('a');
expect(joinWithAnd(['a', 'b'])).toBe('a и b');
expect(joinWithAnd(['a', 'b', 'c'])).toBe('a, b и c');
});
it('текст записки как в бланке отдела кадров', () => {
expect(buildNoteParagraphs(DATA)).toEqual([
'Я, Городецкий Т. А., по факту отсутствия отметок в системе СКУД 21 июля 2026 г., 22 июля 2026 г. и 23 июля 2026 г. сообщаю следующее:',
'Отсутствие отметки в системе СКУД в указанные даты произошло по причине невнимательности. Факт моего нахождения на рабочем месте и исполнения трудовых обязанностей в течение рабочего дня подтверждаю.',
'В дальнейшем обязуюсь соблюдать установленный порядок фиксации рабочего времени и не допускать подобных случаев.'
]);
});
it('своя причина и одна дата', () => {
const [, second] = buildNoteParagraphs({
...DATA,
MissedDays: [DATA.MissedDays[0]],
Reason: OTHER_REASON,
ReasonDetails: 'был на объекте',
PresenceConfirmed: false
});
expect(second).toBe('Отсутствие отметки в системе СКУД в указанную дату произошло по следующей причине: был на объекте');
});
it('сводка дней для колонки SP', () => {
expect(formatMissedDaysSummary(DATA.MissedDays)).toBe(
'21.07.2026 (1 запись 08:35); 22.07.2026 (нет отметок); 23.07.2026 (< 8 ч 08:49–13:10)'
);
});
});
@@ -0,0 +1,89 @@
import { IFormData, IMissedDay, MISSED_KIND_LABEL, OTHER_REASON, SKUD_REASONS } from './model';
/**
* Текст объяснительной записки из данных формы — один источник и для
* предпросмотра в форме, и для печатного документа в просмотре. Без React —
* покрыт тестами (noteText.test.ts).
*/
const MONTHS_GENITIVE = [
'января',
'февраля',
'марта',
'апреля',
'мая',
'июня',
'июля',
'августа',
'сентября',
'октября',
'ноября',
'декабря'
];
/** Заглушка на месте незаполненной части текста в предпросмотре. */
const BLANK = '____';
/** YYYY-MM-DD → «21 июля 2026 г.» */
export function formatLongDate(iso: string): string {
const [y, m, d] = iso.split('-').map(Number);
if (!y || !m || !d) return iso;
return `${d} ${MONTHS_GENITIVE[m - 1]} ${y} г.`;
}
/** YYYY-MM-DD → «21.07.2026» */
export function formatShortDate(iso: string): string {
const [y, m, d] = iso.split('-');
return y && m && d ? `${d}.${m}.${y}` : iso;
}
/** ['a'] → 'a'; ['a','b'] → 'a и b'; ['a','b','c'] → 'a, b и c'. */
export function joinWithAnd(parts: string[]): string {
if (parts.length <= 1) return parts.join('');
return `${parts.slice(0, -1).join(', ')} и ${parts[parts.length - 1]}`;
}
/** «Городецкий Тимур Александрович» → «Городецкий Т. А.» */
export function shortFio(fio: string): string {
const [surname, ...rest] = fio.trim().split(/\s+/);
if (!surname) return '';
const initials = rest.map((name) => `${name.charAt(0).toUpperCase()}.`).join(' ');
return initials ? `${surname} ${initials}` : surname;
}
/** Что было в СКУД в этот день: «1 запись 08:35», «< 8 ч 08:49–13:10», «нет отметок». */
export function formatMissedDetails(day: IMissedDay): string {
const times = [day.first, day.last].filter(Boolean).join('–');
return times ? `${MISSED_KIND_LABEL[day.kind]} ${times}` : MISSED_KIND_LABEL[day.kind];
}
/** Выбранные дни одной строкой — для колонки SkudMissedDates (читается в представлениях SP). */
export function formatMissedDaysSummary(days: IMissedDay[]): string {
return days.map((d) => `${formatShortDate(d.date)} (${formatMissedDetails(d)})`).join('; ');
}
function reasonSentence(data: IFormData, dateWord: string): string {
if (data.Reason === OTHER_REASON) {
const details = data.ReasonDetails.trim() || BLANK;
return `Отсутствие отметки в системе СКУД в ${dateWord} произошло по следующей причине: ${details}`;
}
const reason = SKUD_REASONS.find((r) => r.value === data.Reason);
return `Отсутствие отметки в системе СКУД в ${dateWord} произошло по причине ${reason ? reason.phrase : BLANK}.`;
}
/** Абзацы тела записки (без шапки и подписи). */
export function buildNoteParagraphs(data: IFormData): string[] {
const dates = data.MissedDays.length > 0 ? joinWithAnd(data.MissedDays.map((d) => formatLongDate(d.date))) : BLANK;
const dateWord = data.MissedDays.length > 1 ? 'указанные даты' : 'указанную дату';
const name = shortFio(data.EmployeeFio) || BLANK;
const second = data.PresenceConfirmed
? `${reasonSentence(data, dateWord)} Факт моего нахождения на рабочем месте и исполнения трудовых обязанностей в течение рабочего дня подтверждаю.`
: reasonSentence(data, dateWord);
return [
`Я, ${name}, по факту отсутствия отметок в системе СКУД ${dates} сообщаю следующее:`,
second,
'В дальнейшем обязуюсь соблюдать установленный порядок фиксации рабочего времени и не допускать подобных случаев.'
];
}
@@ -0,0 +1,18 @@
{
"dataListTitle": "Объяснительная по СКУД — данные",
"columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "SubmitDate", "title": "Дата подачи", "type": "DateTime", "dateOnly": true },
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "SkudEmployeeFio", "title": "Ф.И.О. сотрудника", "type": "Text" },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "SkudMissedDates", "title": "Дни без отметок", "type": "Note" },
{ "internalName": "SkudMissedDaysJson", "title": "Дни без отметок (JSON)", "type": "Note" },
{ "internalName": "SkudReason", "title": "Причина", "type": "Choice", "choices": ["Невнимательность", "Забыл(а) пропуск", "Неисправность турникета или считывателя", "Другое"] },
{ "internalName": "SkudReasonDetails", "title": "Причина (подробно)", "type": "Note" },
{ "internalName": "SkudPresenceConfirmed", "title": "Присутствие подтверждено", "type": "Boolean" }
]
}
@@ -0,0 +1,21 @@
@import '../../../../../styles/design-tokens';
// Тело CollapseSection — сетка в 2 колонки под FormField; календарь и список
// дней не поля, им нужна вся ширина.
.fullRow {
grid-column: 1 / -1;
min-width: 0;
}
.calendar {
display: flex;
justify-content: center;
margin-bottom: $sp-4;
}
.label {
margin-bottom: $sp-2;
font-size: $fs-label;
font-weight: 500;
color: $gray-700;
}
@@ -0,0 +1,51 @@
import * as React from 'react';
import { CollapseSection, useFormReadOnly } from '../../../shared/ui';
import { IMissedDay, ISectionProps } from '../model';
import { SkudCalendar } from '../components/SkudCalendar';
import { SelectedDaysList } from '../components/SelectedDaysList';
import { ISubmittedDay } from '../submittedDays';
import styles from './DaysSection.module.scss';
/** Клик по уже выбранному дню снимает выбор, по новому — добавляет; порядок — по дате. */
function toggleDay(days: IMissedDay[], day: IMissedDay): IMissedDay[] {
const exists = days.some((d) => d.date === day.date);
if (exists) return days.filter((d) => d.date !== day.date);
return [...days, day].sort((a, b) => a.date.localeCompare(b.date));
}
interface IDaysSectionProps extends ISectionProps {
/** Дни, уже закрытые поданными объяснительными; нужны только форме подачи. */
submittedDays?: Map<string, ISubmittedDay>;
}
const NO_SUBMITTED_DAYS = new Map<string, ISubmittedDay>();
/**
* Дни без отметок. В редактируемой заявке — календарь СКУД с выбором; в
* режиме чтения календарь не показываем: он строится по СКУД ТЕКУЩЕГО
* пользователя, а согласующему нужен сохранённый снимок дней заявителя.
*/
export const DaysSection: React.FC<IDaysSectionProps> = ({ data, set, context, submittedDays }) => {
const readOnly = useFormReadOnly();
const selectedDates = data.MissedDays.map((d) => d.date);
const removeDay = (date: string): void => set('MissedDays', data.MissedDays.filter((d) => d.date !== date));
return (
<CollapseSection title="Дни без отметок СКУД" icon="calendar" defaultOpen>
<div className={styles.fullRow}>
{!readOnly && (
<div className={styles.calendar}>
<SkudCalendar
context={context}
selectedDates={selectedDates}
submittedDays={submittedDays || NO_SUBMITTED_DAYS}
onToggle={(day) => set('MissedDays', toggleDay(data.MissedDays, day))}
/>
</div>
)}
<div className={styles.label}>Выбрано дней: {data.MissedDays.length}</div>
<SelectedDaysList days={data.MissedDays} onRemove={readOnly ? undefined : removeDay} />
</div>
</CollapseSection>
);
};
@@ -0,0 +1,24 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { ISectionProps } from '../model';
/**
* Сотрудник — всегда текущий пользователь (СКУД отдаёт только свои проходы),
* поэтому поле «Сотрудник» не редактируется даже в новой заявке. Дата подачи
* тоже не редактируется — её ставит форма.
*/
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex }) => (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField context={context} value={data.Employee} onChange={(v) => set('Employee', v)} readOnly />
</FormField>
{/* Дата подачи — всегда сегодняшняя (ставит форма), руками не меняется. */}
<FormField label="Дата подачи" htmlFor="SubmitDate">
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
</FormField>
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
</FormGrid>
);
@@ -0,0 +1,37 @@
import * as React from 'react';
import { Checkbox, CollapseSection, FormField, Select, TextArea } from '../../../shared/ui';
import { ISectionProps, OTHER_REASON, PRESENCE_CONFIRMATION_LABEL, SKUD_REASONS } from '../model';
const REASON_OPTIONS = SKUD_REASONS.map((r) => ({ value: r.value, label: r.value }));
/**
* Причина отсутствия отметки + подтверждение присутствия на рабочем месте.
* Поля кладём прямо в CollapseSection: её тело уже сетка полей, лишний
* FormGrid внутри сжал бы всё в половину ширины.
*/
export const ExplanationSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Объяснение" icon="edit" defaultOpen>
<FormField label="Причина" htmlFor="Reason" required full>
<Select
id="Reason"
value={data.Reason}
onChange={(v) => set('Reason', v)}
options={REASON_OPTIONS}
placeholder="Выберите причину"
/>
</FormField>
{data.Reason === OTHER_REASON && (
<FormField label="Опишите причину" htmlFor="ReasonDetails" required full>
<TextArea id="ReasonDetails" value={data.ReasonDetails} onChange={(v) => set('ReasonDetails', v)} />
</FormField>
)}
<FormField label="Подтверждение" htmlFor="PresenceConfirmed" required full>
<Checkbox
id="PresenceConfirmed"
checked={data.PresenceConfirmed}
onChange={(v) => set('PresenceConfirmed', v)}
label={PRESENCE_CONFIRMATION_LABEL}
/>
</FormField>
</CollapseSection>
);
@@ -0,0 +1,49 @@
import { SPHttpClient } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { HOLIDAYS_LIST_TITLE, HOLIDAYS_SITE_URL } from '../../../shared/config';
interface IHolidayItem {
Title?: string;
holidayDate?: string;
}
// В списке выходные с переносом подписаны так: "Женский день (перенос с вс 8 марта)".
function stripParens(name: string): string {
const i = name.indexOf('(');
return (i >= 0 ? name.slice(0, i) : name).trim();
}
async function fetchHolidays(context: WebPartContext): Promise<Map<string, string>> {
const url =
`${HOLIDAYS_SITE_URL}/_api/web/lists/getByTitle('${encodeURIComponent(HOLIDAYS_LIST_TITLE)}')/items` +
`?$select=Title,holidayDate&$top=500`;
const response = await context.spHttpClient.get(url, SPHttpClient.configurations.v1, {
headers: { accept: 'application/json;odata=nometadata' }
});
if (!response.ok) throw new Error(`Производственный календарь: HTTP ${response.status}`);
const body = (await response.json()) as { value?: IHolidayItem[] };
const map = new Map<string, string>();
(body.value || []).forEach((item) => {
if (item.holidayDate) map.set(item.holidayDate.slice(0, 10), stripParens(item.Title || ''));
});
return map;
}
// Календарь один на всех и меняется раз в год — грузим один раз за сеанс.
let cached: Promise<Map<string, string>> | undefined;
/**
* Праздничные выходные: "YYYY-MM-DD" → название. Ошибка не критична: форма
* работает и без праздников, поэтому отдаём пустую карту (и не кешируем её).
*/
export function getHolidays(context: WebPartContext): Promise<Map<string, string>> {
if (!cached) {
cached = fetchHolidays(context).catch((e) => {
console.warn('[СКУД] Производственный календарь недоступен', e);
cached = undefined;
return new Map<string, string>();
});
}
return cached;
}
@@ -0,0 +1,94 @@
import {
ISkudEvent,
buildMonthCells,
cellKind,
groupEventsByDay,
minSelectableDateKey,
toMissedKind,
workedMinutes
} from './skudDays';
function event(eventTime: string, door = 'D1'): ISkudEvent {
return { personName: '', iin: '', eventTime, doorName: door, doorIndexCode: door, cardNo: '', eventType: '' };
}
const TODAY = '2026-07-25';
const MIN = '2026-06-01';
describe('minSelectableDateKey', () => {
it('первое число прошлого месяца, в том числе через границу года', () => {
expect(minSelectableDateKey(new Date(2026, 6, 25))).toBe(MIN);
expect(minSelectableDateKey(new Date(2027, 0, 10))).toBe('2026-12-01');
});
});
describe('groupEventsByDay', () => {
it('сортирует проходы и схлопывает двойной скан в одну минуту на одной точке', () => {
const byDay = groupEventsByDay([
event('2026-07-21T18:05:10+05:00'),
event('2026-07-21T08:35:01+05:00'),
event('2026-07-21T08:35:40+05:00')
]);
const day = byDay.get('2026-07-21');
expect(day?.events).toHaveLength(2);
expect(day?.first).toBe('08:35');
expect(day?.last).toBe('18:05');
});
});
describe('workedMinutes', () => {
it('вычитает час обеда и не уходит в минус', () => {
expect(workedMinutes('09:00', '18:00')).toBe(8 * 60);
expect(workedMinutes('09:00', '09:30')).toBe(0);
expect(workedMinutes('', '')).toBe(0);
});
});
describe('cellKind / toMissedKind', () => {
const byDay = groupEventsByDay([
event('2026-07-20T09:00:00+05:00'),
event('2026-07-20T18:00:00+05:00'),
event('2026-07-21T08:35:00+05:00'),
event('2026-07-25T08:10:00+05:00')
]);
const cells = buildMonthCells(2026, 6, byDay);
const kindOf = (dateKey: string, holiday = false): ReturnType<typeof cellKind> => {
const index = cells.findIndex((c) => c?.dateKey === dateKey);
const cell = cells[index];
if (!cell) throw new Error(`нет ячейки ${dateKey}`);
return cellKind(cell, index % 7 >= 5, holiday, TODAY);
};
it('раскрашивает дни по отметкам', () => {
expect(kindOf('2026-07-20')).toBe('full');
expect(kindOf('2026-07-21')).toBe('single');
expect(kindOf('2026-07-22')).toBe('absent');
expect(kindOf('2026-07-18')).toBe('weekend'); // суббота
expect(kindOf('2026-07-22', true)).toBe('holiday');
expect(kindOf('2026-07-27')).toBe('empty'); // будущее
});
it('прошедшие дни: < 8 ч, 1 запись и без отметок — да; ≥ 8 ч и выходные — нет', () => {
expect(toMissedKind('partial', '2026-07-09', TODAY, MIN)).toBe('partial');
expect(toMissedKind('single', '2026-07-21', TODAY, MIN)).toBe('single');
expect(toMissedKind('absent', '2026-07-22', TODAY, MIN)).toBe('absent');
expect(toMissedKind('full', '2026-07-20', TODAY, MIN)).toBeUndefined();
expect(toMissedKind('weekend', '2026-07-18', TODAY, MIN)).toBeUndefined();
});
it('сегодня — только без единой отметки', () => {
expect(toMissedKind('empty', TODAY, TODAY, MIN)).toBe('absent');
expect(toMissedKind('single', TODAY, TODAY, MIN)).toBeUndefined();
});
it('раньше лимита и в будущем — нельзя', () => {
expect(toMissedKind('absent', '2026-05-29', TODAY, MIN)).toBeUndefined();
expect(toMissedKind('empty', '2026-07-27', TODAY, MIN)).toBeUndefined();
});
it('месяц начинается с понедельника: 1 июля 2026 — среда, два пустых места', () => {
expect(cells[0]).toBeNull();
expect(cells[1]).toBeNull();
expect(cells[2]?.day).toBe(1);
});
});
@@ -0,0 +1,178 @@
/**
* Чистая логика календаря СКУД (без React/SharePoint — покрыта тестами).
* Правила раскраски дней перенесены из «Мой СКУД» (portal-main-page,
* SkudModal.tsx), чтобы сотрудник видел в форме ровно ту же картину.
*/
/** Один проход через точку СКУД (элемент ответа /Hikvision/Events/CurrentUser). */
export interface ISkudEvent {
personName: string;
iin: string;
/** ISO со смещением, напр. "2026-06-26T08:39:48+05:00" — время уже местное. */
eventTime: string;
doorName: string;
doorIndexCode: string;
cardNo: string;
eventType: string;
}
/** Проходы одного дня. */
export interface IDayInfo {
day: number;
/** YYYY-MM-DD */
dateKey: string;
/** По возрастанию времени, без двойных сканов. */
events: ISkudEvent[];
/** HH:MM первого прохода ('' — проходов нет). */
first: string;
/** HH:MM последнего прохода ('' — проходов нет). */
last: string;
}
/**
* Вид ячейки календаря:
* full / partial — есть приход и уход (≥ 8 ч / < 8 ч без обеда);
* single — ровно одна запись (нет прихода или ухода);
* absent — прошедший рабочий день без единого прохода;
* holiday / weekend — нерабочий день без проходов;
* empty — сегодня или будущее без проходов.
*/
export type CellKind = 'full' | 'partial' | 'single' | 'absent' | 'holiday' | 'weekend' | 'empty';
/** Что именно не так с днём, по которому пишется объяснительная. */
export type MissedKind = 'partial' | 'single' | 'absent';
/**
* Сколько прошлых месяцев доступно, кроме текущего: объяснительные пишут по
* свежим случаям, 1 = текущий + прошлый месяц.
*/
export const SELECTABLE_MONTHS_BACK = 1;
const FULL_DAY_MINUTES = 8 * 60;
const LUNCH_MINUTES = 60;
/** Индекс столбца Сб в сетке (неделя с понедельника: Пн = 0). */
const SATURDAY_COLUMN = 5;
const DAYS_IN_WEEK = 7;
export function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
export function toDateKey(year: number, month: number, day: number): string {
return `${year}-${pad2(month + 1)}-${pad2(day)}`;
}
export function todayDateKey(): string {
const d = new Date();
return toDateKey(d.getFullYear(), d.getMonth(), d.getDate());
}
/** Первое число самого раннего доступного месяца (YYYY-MM-DD), см. SELECTABLE_MONTHS_BACK. */
export function minSelectableDateKey(today: Date = new Date()): string {
const first = new Date(today.getFullYear(), today.getMonth() - SELECTABLE_MONTHS_BACK, 1);
return toDateKey(first.getFullYear(), first.getMonth(), 1);
}
function timeOf(e: ISkudEvent): string {
return e.eventTime.slice(11, 16);
}
// Двойной скан — два срабатывания одной точки в пределах одной минуты.
function dedupeByMinute(events: ISkudEvent[]): ISkudEvent[] {
const seen: Record<string, boolean> = {};
return events.filter((e) => {
const key = `${e.eventTime.slice(0, 16)}|${e.doorIndexCode}`;
if (seen[key]) return false;
seen[key] = true;
return true;
});
}
/** Проходы → по дням (ключ YYYY-MM-DD). */
export function groupEventsByDay(events: ISkudEvent[]): Map<string, IDayInfo> {
const buckets = new Map<string, ISkudEvent[]>();
events.forEach((e) => {
const key = e.eventTime.slice(0, 10);
const bucket = buckets.get(key);
if (bucket) bucket.push(e);
else buckets.set(key, [e]);
});
const result = new Map<string, IDayInfo>();
buckets.forEach((bucket, key) => {
const sorted = dedupeByMinute(bucket.slice().sort((a, b) => a.eventTime.localeCompare(b.eventTime)));
result.set(key, {
day: Number(key.slice(8, 10)),
dateKey: key,
events: sorted,
first: timeOf(sorted[0]),
last: timeOf(sorted[sorted.length - 1])
});
});
return result;
}
/** Рабочее время дня: последний проход минус первый минус час обеда (не в минус). */
export function workedMinutes(first: string, last: string): number {
if (!first || !last) return 0;
const [fh, fm] = first.split(':').map(Number);
const [lh, lm] = last.split(':').map(Number);
const span = lh * 60 + lm - (fh * 60 + fm);
return span > 0 ? Math.max(0, span - LUNCH_MINUTES) : 0;
}
/** Сумма полных часов по дням месяца — приблизительно, реальную формулу кадров не знаем. */
export function monthWorkedHours(byDay: Map<string, IDayInfo>): number {
let total = 0;
byDay.forEach((info) => {
total += Math.floor(workedMinutes(info.first, info.last) / 60);
});
return total;
}
/** Ячейки месяца: ведущие null — пустые места до первого числа (неделя с понедельника). */
export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (IDayInfo | null)[] {
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstColumn = (new Date(year, month, 1).getDay() + 6) % DAYS_IN_WEEK;
const cells: (IDayInfo | null)[] = [];
for (let i = 0; i < firstColumn; i += 1) cells.push(null);
for (let d = 1; d <= daysInMonth; d += 1) {
const key = toDateKey(year, month, d);
cells.push(byDay.get(key) ?? { day: d, dateKey: key, events: [], first: '', last: '' });
}
return cells;
}
export function isWeekendColumn(cellIndex: number): boolean {
return cellIndex % DAYS_IN_WEEK >= SATURDAY_COLUMN;
}
export function cellKind(info: IDayInfo, isWeekend: boolean, isHoliday: boolean, todayKey: string): CellKind {
if (info.events.length === 1) return 'single';
if (info.events.length > 1) {
return workedMinutes(info.first, info.last) >= FULL_DAY_MINUTES ? 'full' : 'partial';
}
if (isHoliday) return 'holiday';
if (isWeekend) return 'weekend';
return info.dateKey < todayKey ? 'absent' : 'empty';
}
/**
* Можно ли выбрать день в объяснительную (и с какой пометкой):
* - прошедший день < 8 ч, с одной записью или без отметок — да (у < 8 ч часто
* пропущен приход или уход: прошёл за коллегой, вышел через другую дверь);
* - сегодня — только если нет ни одной отметки (забыл пропуск). Одна запись
* сегодня — норма, сотрудник ещё не ушёл;
* - ≥ 8 ч, выходные, праздники, будущее и дни раньше minKey — нет.
*/
export function toMissedKind(
kind: CellKind,
dateKey: string,
todayKey: string,
minKey: string
): MissedKind | undefined {
if (dateKey < minKey || dateKey > todayKey) return undefined;
if (dateKey === todayKey) return kind === 'empty' ? 'absent' : undefined;
if (kind === 'partial' || kind === 'single' || kind === 'absent') return kind;
return undefined;
}
@@ -0,0 +1,63 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL, SKUD_EVENTS_PATH } from '../../../shared/config';
import { ISkudEvent, pad2 } from './skudDays';
const PAGE_SIZE = 100;
// Защита от зацикливания, если сервер игнорирует параметр page.
const MAX_PAGES = 100;
interface ISkudResponse {
total: number;
page: number;
pageSize: number;
items: ISkudEvent[];
}
/** 404 от API СКУД — учётная запись не связана с 1С (не сбой, а отдельное состояние UI). */
export class SkudNotLinkedError extends Error {
public constructor() {
super('Учётная запись не связана с системой 1С — данные СКУД недоступны.');
this.name = 'SkudNotLinkedError';
}
}
function monthQuery(year: number, month: number): string {
const lastDay = new Date(year, month + 1, 0).getDate();
const mm = pad2(month + 1);
return `&startDate=${year}-${mm}-01&endDate=${year}-${mm}-${pad2(lastDay)}`;
}
/**
* Все проходы текущего пользователя за месяц (month — 0-based). Сервер отдаёт
* страницами; поле total в ответе — размер страницы, а не общее число, поэтому
* читаем, пока страница не окажется неполной.
*/
export async function getSkudEventsForMonth(
context: WebPartContext,
year: number,
month: number
): Promise<ISkudEvent[]> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const all: ISkudEvent[] = [];
let previousFirst = '';
for (let page = 1; page <= MAX_PAGES; page += 1) {
const url =
`${DICTIONARY_API_BASE_URL}${SKUD_EVENTS_PATH}?page=${page}&pageSize=${PAGE_SIZE}` + monthQuery(year, month);
const response: HttpClientResponse = await client.get(url, AadHttpClient.configurations.v1);
if (response.status === 404) throw new SkudNotLinkedError();
if (!response.ok) throw new Error(`СКУД: ошибка загрузки (HTTP ${response.status})`);
const items = ((await response.json()) as ISkudResponse).items || [];
all.push(...items);
const currentFirst = items.length > 0 ? items[0].eventTime : '';
// Та же первая запись, что и на прошлой странице, — сервер не листает.
const pageRepeated = currentFirst !== '' && currentFirst === previousFirst;
if (items.length < PAGE_SIZE || pageRepeated) break;
previousFirst = currentFirst;
}
return all;
}
@@ -0,0 +1,103 @@
import { useEffect, useMemo, useState } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { friendlyErrorMessage } from '../../../shared/diagnostics';
import { IDayInfo, ISkudEvent, SELECTABLE_MONTHS_BACK, groupEventsByDay } from './skudDays';
import { SkudNotLinkedError, getSkudEventsForMonth } from './skudEvents.service';
import { getHolidays } from './holidays.service';
export interface ISkudMonth {
year: number;
/** 0-based */
month: number;
byDay: Map<string, IDayInfo>;
holidays: Map<string, string>;
loading: boolean;
error: string | undefined;
/** Учётная запись не связана с 1С — данных СКУД нет совсем. */
notLinked: boolean;
canGoNext: boolean;
/** Назад — не дальше SELECTABLE_MONTHS_BACK месяцев от текущего. */
canGoPrev: boolean;
goPrev: () => void;
goNext: () => void;
}
interface IMonthView {
year: number;
month: number;
}
// Сквозной номер месяца — чтобы сравнивать месяцы через границу года.
function monthIndex(year: number, month: number): number {
return year * 12 + month;
}
function shiftMonth(view: IMonthView, delta: number): IMonthView {
const d = new Date(view.year, view.month + delta, 1);
return { year: d.getFullYear(), month: d.getMonth() };
}
/** Проходы СКУД текущего пользователя по месяцам + праздники; листать можно от текущего месяца назад на SELECTABLE_MONTHS_BACK. */
export function useSkudMonth(context: WebPartContext): ISkudMonth {
const now = new Date();
const [view, setView] = useState<IMonthView>({ year: now.getFullYear(), month: now.getMonth() });
const [events, setEvents] = useState<ISkudEvent[]>([]);
const [holidays, setHolidays] = useState<Map<string, string>>(new Map());
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | undefined>(undefined);
const [notLinked, setNotLinked] = useState<boolean>(false);
useEffect(() => {
let alive = true;
getHolidays(context)
.then((map) => {
if (alive) setHolidays(map);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
useEffect(() => {
let alive = true;
setLoading(true);
setError(undefined);
getSkudEventsForMonth(context, view.year, view.month)
.then((items) => {
if (!alive) return;
setEvents(items);
setLoading(false);
})
.catch((e) => {
if (!alive) return;
if (e instanceof SkudNotLinkedError) setNotLinked(true);
else setError(friendlyErrorMessage(e));
setEvents([]);
setLoading(false);
});
return () => {
alive = false;
};
}, [context, view.year, view.month]);
const byDay = useMemo(() => groupEventsByDay(events), [events]);
const viewIndex = monthIndex(view.year, view.month);
const currentIndex = monthIndex(now.getFullYear(), now.getMonth());
const canGoNext = viewIndex < currentIndex;
const canGoPrev = viewIndex > currentIndex - SELECTABLE_MONTHS_BACK;
return {
year: view.year,
month: view.month,
byDay,
holidays,
loading,
error,
notLinked,
canGoNext,
canGoPrev,
goPrev: () => setView((v) => shiftMonth(v, -1)),
goNext: () => setView((v) => shiftMonth(v, 1))
};
}
@@ -0,0 +1,58 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { ensureEmployee } from '../../shared/employee';
import { FORM_ID, IFormData, OTHER_REASON } from './model';
import { formatMissedDaysSummary, formatShortDate } from './noteText';
import { todayDateKey } from './skud/skudDays';
/** Сервис списка согласования этой формы — см. shared/approval/approvalServiceFactory.ts. */
export function skudExplanationApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
}
/** Заголовок элемента (системная колонка Title). */
function buildTitle(employeeName: string): string {
return `Объяснительная по СКУД — ${employeeName} — ${formatShortDate(todayDateKey())}`;
}
function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record<string, unknown> {
return {
Title: buildTitle(employeeName),
RequestStatus: status,
SubmitDate: data.SubmitDate || null,
EmployeeId: employeeId,
SkudEmployeeFio: data.EmployeeFio || null,
Position: data.Position || null,
Organization: data.Organization || null,
// Читаемая строка — для представлений списка; источник для формы — JSON-снимок.
SkudMissedDates: formatMissedDaysSummary(data.MissedDays) || null,
SkudMissedDaysJson: JSON.stringify(data.MissedDays),
SkudReason: data.Reason || null,
SkudReasonDetails: data.Reason === OTHER_REASON ? data.ReasonDetails || null : null,
SkudPresenceConfirmed: data.PresenceConfirmed
};
}
/**
* Сохранить объяснительную: содержимое + маршрут (согласование и исполнение).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём.
* executors — шаги фазы «Исполнение» по маршруту (напр. кадры); пусто — после
* согласования заявка сразу станет «Согласовано».
* listId — партиция обновляемой заявки (см. UnpaidLeave/submit.ts, тот же смысл).
*/
export async function saveSkudExplanationRequest(
existingId: number | undefined,
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveItemResult> {
const employee = await ensureEmployee(data.Employee);
const item = buildItem(data, status, employee.Id, data.EmployeeFio || employee.Title || data.Employee);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(skudExplanationApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -0,0 +1,45 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ensureEmployee } from '../../shared/employee';
import { dataListTitle } from '../../shared/listTitles';
import { partitionsForPageUrl } from '../../shared/approval/listPartitions';
import { FORM_ID } from './model';
import { minSelectableDateKey } from './skud/skudDays';
import { ISubmittedDay, ISubmittedItem, collectSubmittedDays } from './submittedDays';
// Человек пишет единицы объяснительных за 2 месяца; 500 — с большим запасом.
const MAX_ITEMS_PER_LIST = 500;
async function readList(listUrl: string | undefined, employeeId: number, createdFrom: string): Promise<ISubmittedItem[]> {
const web = getSP().web;
const list = listUrl ? web.getList(listUrl) : web.lists.getByTitle(dataListTitle(FORM_ID));
return (await list.items
.select('Id', 'RequestStatus', 'RequestUniqueCode', 'SkudMissedDaysJson')
.filter(`EmployeeId eq ${employeeId} and Created ge datetime'${createdFrom}T00:00:00Z'`)
.top(MAX_ITEMS_PER_LIST)()) as ISubmittedItem[];
}
/**
* Дни сотрудника, уже закрытые поданными объяснительными (карта дата → заявка).
*
* Ищем по всем партициям серии: при переключении на новый список прошлый
* месяц может остаться в предыдущем. Заявки старше начала доступного периода
* не читаем: объяснительная пишется не раньше самого дня, а дни до
* minSelectableDateKey выбрать всё равно нельзя.
*
* excludeId — открытая сейчас заявка (черновик/доработка), чтобы она не
* блокировала собственные дни. Id сравнивается без учёта партиции: совпадение
* id у разных заявок в разных списках маловероятно и дало бы лишь пропуск
* проверки, а не ложную блокировку.
*/
export async function getSubmittedDays(employeeEmail: string, excludeId?: number): Promise<Map<string, ISubmittedDay>> {
if (!employeeEmail) return new Map();
const employee = await ensureEmployee(employeeEmail);
const createdFrom = minSelectableDateKey();
const partitions = partitionsForPageUrl(FORM_ID);
// Реестр партиций недоступен — читаем текущий список по конвенции имён.
const listUrls: (string | undefined)[] = partitions.length > 0 ? partitions.map((p) => p.listId) : [undefined];
const perList = await Promise.all(listUrls.map((url) => readList(url, employee.Id, createdFrom)));
const items = perList.reduce<ISubmittedItem[]>((all, part) => all.concat(part), []);
return collectSubmittedDays(items, excludeId);
}
@@ -0,0 +1,45 @@
import { IMissedDay } from './model';
import { ISubmittedItem, collectSubmittedDays, submittedConflictMessage } from './submittedDays';
function item(Id: number, RequestStatus: string, dates: string[]): ISubmittedItem {
const days: IMissedDay[] = dates.map((date) => ({ date, kind: 'absent', first: '', last: '' }));
return { Id, RequestStatus, RequestUniqueCode: `12-2609-${Id}`, SkudMissedDaysJson: JSON.stringify(days) };
}
describe('collectSubmittedDays', () => {
it('блокируют поданные, согласованные и возвращённые; отклонённые и черновики — нет', () => {
const map = collectSubmittedDays([
item(1, 'На согласовании', ['2026-09-01']),
item(2, 'Согласовано', ['2026-09-02']),
item(3, 'Возвращена на доработку', ['2026-09-03']),
item(4, 'Отклонено', ['2026-09-04']),
item(5, 'Черновик', ['2026-09-05'])
]);
expect(Array.from(map.keys()).sort()).toEqual(['2026-09-01', '2026-09-02', '2026-09-03']);
expect(map.get('2026-09-01')).toEqual({ code: '12-2609-1', status: 'На согласовании' });
});
it('открытая сейчас заявка не блокирует свои же дни', () => {
const map = collectSubmittedDays([item(3, 'Возвращена на доработку', ['2026-09-03'])], 3);
expect(map.size).toBe(0);
});
it('битый JSON не роняет проверку', () => {
const map = collectSubmittedDays([{ Id: 7, RequestStatus: 'Согласовано', SkudMissedDaysJson: '{oops' }]);
expect(map.size).toBe(0);
});
});
describe('submittedConflictMessage', () => {
it('перечисляет пересечения с номером и статусом заявки', () => {
const map = collectSubmittedDays([item(1, 'Согласовано', ['2026-09-01'])]);
const days: IMissedDay[] = [
{ date: '2026-09-01', kind: 'absent', first: '', last: '' },
{ date: '2026-09-02', kind: 'absent', first: '', last: '' }
];
expect(submittedConflictMessage(days, map)).toBe(
'По этим дням объяснительная уже подана, уберите их: 01.09.2026 — №12-2609-1 (Согласовано).'
);
expect(submittedConflictMessage([days[1]], map)).toBeUndefined();
});
});
@@ -0,0 +1,65 @@
import { REQUEST_STATUS } from '../../shared/approval/models/statuses';
import { IMissedDay } from './model';
import { parseMissedDays } from './missedDaysJson';
import { formatShortDate } from './noteText';
/**
* Защита от повторов: день, уже вошедший в поданную объяснительную, второй раз
* не выбирается. Чистая логика без SharePoint — покрыта тестами; чтение
* списков — в submittedDays.service.ts.
*/
/** Объяснительная, которая уже «закрывает» день. */
export interface ISubmittedDay {
code: string;
status: string;
}
/** Строка списка данных — только нужные для проверки поля. */
export interface ISubmittedItem {
Id: number;
RequestStatus?: string;
RequestUniqueCode?: string;
SkudMissedDaysJson?: string;
}
/**
* Какие статусы блокируют день. «Отклонено» — нет: можно написать заново.
* «Черновик» — нет: это ещё не подача. «Возвращена» — да: её надо поправить,
* а не подавать новую.
*/
const BLOCKING_STATUSES: string[] = [
REQUEST_STATUS.onApproval,
REQUEST_STATUS.approved,
REQUEST_STATUS.execution,
REQUEST_STATUS.closed,
REQUEST_STATUS.returned
];
/** Дата (YYYY-MM-DD) → заявка, которой она уже закрыта. excludeId — открытая сейчас заявка. */
export function collectSubmittedDays(items: ISubmittedItem[], excludeId?: number): Map<string, ISubmittedDay> {
const result = new Map<string, ISubmittedDay>();
items.forEach((item) => {
if (item.Id === excludeId) return;
const status = item.RequestStatus || '';
if (BLOCKING_STATUSES.indexOf(status) < 0) return;
const submitted: ISubmittedDay = { code: item.RequestUniqueCode || String(item.Id), status };
parseMissedDays(item.SkudMissedDaysJson).forEach((day) => result.set(day.date, submitted));
});
return result;
}
/** Сообщение о пересечении выбранных дней с уже поданными; undefined — пересечений нет. */
export function submittedConflictMessage(
days: IMissedDay[],
submitted: Map<string, ISubmittedDay>
): string | undefined {
const conflicts = days
.filter((d) => submitted.has(d.date))
.map((d) => {
const other = submitted.get(d.date) as ISubmittedDay;
return `${formatShortDate(d.date)} — №${other.code} (${other.status})`;
});
if (conflicts.length === 0) return undefined;
return `По этим дням объяснительная уже подана, уберите их: ${conflicts.join('; ')}.`;
}
@@ -0,0 +1,67 @@
import { useCallback } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
APPROVAL_PHASE,
ApprovalListService,
IMatrixRow,
getPlannedMatrix,
resolveApproverOrgInfo,
useMatrixEditor
} from '../../shared/approval';
type MatrixEditor = ReturnType<typeof useMatrixEditor>;
export interface ISkudRoute {
/** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */
approvers: MatrixEditor;
/** Исполнители по маршруту (фаза «Исполнение») — только чтение. */
executors: MatrixEditor;
}
interface IRouteSource {
context: WebPartContext;
approval: ApprovalListService;
/** Заявка уже записана — маршрут читаем из её строк, а не планируем заново. */
requestId: number | undefined;
employeeEmail: string;
requestTypeId: number | undefined;
/** Можно ли править согласующих. */
editable: boolean;
/** Меняется после решения согласующего — перечитать строки. */
reloadSeq?: number;
}
const byPhase = (execution: boolean) => (rows: IMatrixRow[]): IMatrixRow[] =>
rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution);
/**
* Маршрут объяснительной по двум фазам. Без отдельного запроса фазы
* 'Execution' getPlannedMatrix исполнителей не резолвит — шаг кадров из
* маршрута «пропадал» бы. Паттерн тот же, что в Referral.
*/
export function useSkudRoute(source: IRouteSource): ISkudRoute {
const { context, approval, requestId, employeeEmail, requestTypeId, editable, reloadSeq = 0 } = source;
const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId);
const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`;
const loadPhase = useCallback(
(execution: boolean) =>
requestId
? approval.getMatrixForRequest(requestId).then(byPhase(execution))
: getPlannedMatrix(
context,
requestTypeId as number,
employeeEmail,
approval,
execution ? 'Execution' : 'Approval'
),
[context, approval, requestId, employeeEmail, requestTypeId]
);
const loadApprovers = useCallback(() => loadPhase(false), [loadPhase]);
const loadExecutors = useCallback(() => loadPhase(true), [loadPhase]);
const jobTitle = (login: string): Promise<string> => approval.getJobTitle(login);
const approvers = useMatrixEditor(loadApprovers, reloadKey, editable, canLoad, jobTitle, resolveApproverOrgInfo(context));
const executors = useMatrixEditor(loadExecutors, reloadKey, false, canLoad, jobTitle, resolveApproverOrgInfo(context));
return { approvers, executors };
}
@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react';
import { ISubmittedDay } from './submittedDays';
import { getSubmittedDays } from './submittedDays.service';
/**
* Уже поданные дни для календаря. Ошибка не блокирует форму: календарь
* работает без пометок, а повтор всё равно поймает проверка при отправке.
*/
export function useSubmittedDays(employeeEmail: string, excludeId: number | undefined): Map<string, ISubmittedDay> {
const [submitted, setSubmitted] = useState<Map<string, ISubmittedDay>>(new Map());
useEffect(() => {
let alive = true;
getSubmittedDays(employeeEmail, excludeId)
.then((map) => {
if (alive) setSubmitted(map);
})
.catch((e) => console.warn('[Объяснительная по СКУД] Не удалось проверить уже поданные дни', e));
return () => {
alive = false;
};
}, [employeeEmail, excludeId]);
return submitted;
}
@@ -0,0 +1,21 @@
import { IFormData, OTHER_REASON } from './model';
import { formatShortDate } from './noteText';
import { minSelectableDateKey } from './skud/skudDays';
/** Ошибка заполнения формы или undefined, если всё обязательное заполнено. */
export function validateSkudExplanation(data: IFormData): string | undefined {
if (!data.Employee || !data.Position.trim()) return 'Заполните обязательные поля: сотрудник, должность.';
if (data.MissedDays.length === 0) return 'Выберите в календаре хотя бы один день без отметок.';
// Черновик мог пролежать дольше лимита — дни из него уже вне доступного периода.
const minKey = minSelectableDateKey();
const outdated = data.MissedDays.filter((d) => d.date < minKey);
if (outdated.length > 0) {
return `Объяснительную можно подать только за текущий и прошлый месяц — уберите дни раньше ${formatShortDate(minKey)}: ${outdated
.map((d) => formatShortDate(d.date))
.join(', ')}.`;
}
if (!data.Reason) return 'Укажите причину отсутствия отметки.';
if (data.Reason === OTHER_REASON && !data.ReasonDetails.trim()) return 'Опишите причину отсутствия отметки.';
if (!data.PresenceConfirmed) return 'Подтвердите, что находились на рабочем месте.';
return undefined;
}
@@ -18,6 +18,7 @@ import {
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -421,6 +422,19 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
/>
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -20,6 +20,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
@@ -247,6 +248,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
/>
</div>
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
@@ -18,6 +18,7 @@ import {
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -514,6 +515,19 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
/>
)}
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -21,6 +21,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
@@ -207,6 +208,12 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
)}
</div>
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -315,6 +316,19 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -8,6 +8,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId
@@ -152,6 +153,12 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -314,6 +315,19 @@ const VacationForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -8,6 +8,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId
@@ -152,6 +153,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
@@ -18,6 +18,7 @@ import {
} from '../../shared/ui';
import {
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
@@ -219,7 +220,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
const isValid =
data.FullName.trim().length > 0 && data.Position.trim().length > 0 && data.Companies.length > 0;
const validate = (): string | undefined => {
if (!isValid) return 'Заполните обязательные поля: Ф.И.О., Должность, Организация заявителя.';
if (!isValid) return 'Заполните обязательные поля: Ф.И.О., Должность, Организация.';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
@@ -402,6 +403,19 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
/>
)}
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
@@ -21,6 +21,7 @@ import {
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
@@ -274,6 +275,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
)}
</div>
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
@@ -15,7 +15,7 @@
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
{ "internalName": "Location", "title": "Местоположение", "type": "Text" },
{ "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
{ "internalName": "Organization", "title": "Организация заявителя", "type": "Note" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "WorkplaceCompaniesJson", "title": "Организации заявителя (JSON)", "type": "Note" },
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
@@ -3,7 +3,7 @@ import { FormField, OrganizationField, useFormReadOnly } from '../../../shared/u
import { CompanyPickerMulti } from '../../../shared/dictionary';
import { ISectionProps } from '../model';
const ORGANIZATION_LABEL = 'Организация заявителя';
const ORGANIZATION_LABEL = 'Организация';
/**
* Организации заявителя: мультивыбор из справочника (как «1.5 Организация» в
@@ -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')
),
@@ -12,6 +12,7 @@ import { IMatrixRow } from './models/IMatrixRow';
import { ISiteUser } from './models/ISiteUser';
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
import { userSearchVariants } from './userSearchVariants';
import { planRoutePatch } from './routePatchPlan';
// Внутренние имена колонок списка согласования (создаются провижинингом).
const FIELDS = {
@@ -141,10 +142,26 @@ export class ApprovalListService {
// он разрешится вместе со всеми остальными после execute() ниже.
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
void batchedList.items.add({
Title: a.position || a.email,
[`${FIELDS.approver}Id`]: approverIds[i],
[FIELDS.status]: status,
...this.stepFields(a, approverIds[i], status, phase, order),
[`${FIELDS.ticket}Id`]: requestId,
});
});
await execute();
}
// Поля строки маршрута, которые задаёт сам шаг (без привязки к заявке) —
// общие для создания (createSteps) и обновления на месте (patchSteps).
private stepFields(
a: IApproverToWrite,
approverId: number,
status: string,
phase: string,
order: number
): Record<string, unknown> {
return {
Title: a.position || a.email,
[`${FIELDS.approver}Id`]: approverId,
[FIELDS.status]: status,
[FIELDS.order]: order,
[FIELDS.allowReassign]: a.allowReassign,
[FIELDS.phase]: phase,
@@ -152,8 +169,73 @@ export class ApprovalListService {
[FIELDS.slaHours]: a.slaHours ?? null,
[FIELDS.askOverdueReason]: a.askOverdueReason ?? null,
[FIELDS.autoApprove]: a.autoApprove ?? false,
};
}
/**
* Пересохранить маршрут заявки ПАТЧЕМ вместо «удалить всё и создать заново»
* (deleteForRequest + createSteps). Строки, которые остаются в маршруте,
* обновляются на месте — их прошлые значения (кто, когда, с каким
* комментарием решил) SharePoint сохраняет в истории версий. Лишние строки
* уходят в корзину, недостающие шаги создаются. Какую строку куда — решает
* planRoutePatch (см. routePatchPlan.ts).
*
* phases — фазы маршрута с их смещением порядка, как в writeRoute. Всё
* пишется ОДНИМ пакетом. Вызывается из writeRoute (выключатель —
* ROUTE_PATCH_ENABLED в approvalSubmission.service.ts).
*/
public async patchSteps(
requestId: number,
phases: Array<{ approvers: IApproverToWrite[]; phase: string; orderStart: number }>,
status: string
): Promise<void> {
// Шаги всех фаз одним списком, с итоговым порядком — как их записал бы createSteps.
const steps = phases.reduce<Array<{ a: IApproverToWrite; phase: string; order: number }>>(
(all, p) =>
all.concat(p.approvers.map((a, i) => ({ a, phase: p.phase, order: p.orderStart - 1 + (a.order ?? i + 1) }))),
[]
);
// Шаг без человека не записать — см. тот же случай в createSteps.
const toWrite = steps.filter((s) => !!s.a.email);
if (toWrite.length < steps.length) {
console.error(`[Согласование] patchSteps: пропущены шаги без назначенного человека — заявка №${requestId}`);
}
const [existing, approverIds] = await Promise.all([
this.getForRequest(requestId),
Promise.all(toWrite.map((s) => this.resolveUserId(s.a.email))),
]);
const plan = planRoutePatch(
existing,
toWrite.map((s, i) => ({ phase: s.phase, approverId: approverIds[i] }))
);
const fieldsOf = (stepIndex: number): Record<string, unknown> => {
const s = toWrite[stepIndex];
return this.stepFields(s.a, approverIds[stepIndex], status, s.phase, s.order);
};
// Батч — см. createSteps: список переиспользуем, .items/.getById берём заново.
const [batchedSP, execute] = getSP().batched();
const batchedList = batchedSP.web.lists.getByTitle(this.listTitle);
/* eslint-disable no-void -- промисы батча разрешатся вместе после execute(), см. createSteps */
plan.update.forEach(({ rowId, stepIndex }) => {
void batchedList.items.getById(rowId).update({
...fieldsOf(stepIndex),
// Состояние прошлого круга сбрасываем — оно остаётся в истории версий строки.
[FIELDS.date]: null,
[FIELDS.comment]: null,
[`${FIELDS.delegatedBy}Id`]: null,
});
});
plan.create.forEach((stepIndex) => {
void batchedList.items.add({ ...fieldsOf(stepIndex), [`${FIELDS.ticket}Id`]: requestId });
});
// В корзину, а не насовсем: у выпавшего шага могла быть история решений.
plan.remove.forEach((rowId) => {
void batchedList.items.getById(rowId).recycle();
});
/* eslint-enable no-void */
await execute();
}
@@ -247,6 +329,25 @@ export class ApprovalListService {
});
}
/**
* История версий каждой строки маршрута заявки — сырьё для «Истории
* согласования» (см. history/approvalHistory.service.ts). Версии всех строк —
* одним пакетом. Порядок версий внутри строки не гарантирован, сортирует
* вызывающая сторона.
*/
public async getRowVersions(requestId: number): Promise<Array<Array<Record<string, unknown>>>> {
const rows = await this.getForRequest(requestId);
if (rows.length === 0) return [];
const [batchedSP, execute] = getSP().batched();
const batchedList = batchedSP.web.lists.getByTitle(this.listTitle);
const pending = rows.map(
(r) => batchedList.items.getById(r.id).versions() as Promise<Array<Record<string, unknown>>>
);
await execute();
return Promise.all(pending);
}
// Строки согласования по заявке, в порядке согласования (по Order).
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
const rows = (await this.list()
@@ -5,6 +5,15 @@ import { IMatrixRow } from './models/IMatrixRow';
import { ApprovalStatus, IResolveFieldValue } from './models/IApprovalRouteApi';
import { APPROVAL_PHASE, REQUEST_STATUS, isSubmittedStatus } from './models/statuses';
/**
* Выключатель пересохранения маршрута патчем — ДЛЯ ВСЕХ форм сразу (см.
* writeRoute). Проверено пилотами на «Заявлении на увольнение» и «Организации
* рабочего места» 2026-09-29: обновление строк на месте, обе фазы, уход
* выпавших шагов в корзину. false — аварийный откат к прежнему «удалить всё и
* создать заново» (история прошлых отправок снова перестанет сохраняться).
*/
const ROUTE_PATCH_ENABLED = true;
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
// (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность).
@@ -72,6 +81,12 @@ export async function writeApprovers(
// очереди. Первого исполнителя переключит на «На исполнении»
// approval.activateNextQueued, когда фаза согласования будет пройдена целиком
// (см. approverDecision.service.ts/decide).
//
// Пересохранение — ПАТЧЕМ (ApprovalListService.patchSteps, см. ROUTE_PATCH_ENABLED):
// строки, что остаются в маршруте, обновляются на месте, и их прошлые решения
// (кто вернул/отклонил и с каким комментарием) сохраняются в истории версий —
// из неё строится «История согласования». Раньше маршрут удалялся и создавался
// заново, и история прошлых отправок терялась.
export async function writeRoute(
approval: ApprovalListService,
requestId: number,
@@ -79,8 +94,6 @@ export async function writeRoute(
executors: IApproverToWrite[],
status: string
): Promise<void> {
await approval.deleteForRequest(requestId);
// При ОТПРАВКЕ все шаги пишем «Ждёт очереди», затем активируем первый —
// так активен ровно текущий шаг, остальные ждут. В черновике — статус заявки
// (Черновик), без активации.
@@ -102,6 +115,17 @@ export async function writeRoute(
0
);
if (ROUTE_PATCH_ENABLED) {
await approval.patchSteps(
requestId,
[
{ approvers, phase: APPROVAL_PHASE.approval, orderStart: 1 },
{ approvers: executors, phase: APPROVAL_PHASE.execution, orderStart: lastApprovalStep + 1 },
],
rowStatus
);
} else {
await approval.deleteForRequest(requestId);
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
await approval.createSteps(
requestId,
@@ -110,6 +134,7 @@ export async function writeRoute(
APPROVAL_PHASE.execution,
lastApprovalStep + 1
);
}
if (submitted) {
await approval.activateNextQueued(requestId);
@@ -0,0 +1,110 @@
@import '../../../../../styles/design-tokens';
// Ширина колонки со временем — под «29.09.2026 11:02»; уже — текст переносится.
$time-col-width: 124px;
// Ниже этой ширины время встаёт над текстом события, а не слева от него.
$feed-stack-breakpoint: 640px;
.feed {
display: flex;
flex-direction: column;
gap: $sp-4;
}
.round {
display: flex;
flex-direction: column;
gap: $sp-2;
}
.roundTitle {
padding-bottom: $sp-1;
border-bottom: 1px solid $gray-200;
font-size: $fs-overline;
font-weight: 600;
letter-spacing: 1.4px;
text-transform: uppercase;
color: $gray-500;
}
.events {
display: flex;
flex-direction: column;
gap: $sp-3;
margin: 0;
padding: 0;
list-style: none;
}
.event {
display: flex;
align-items: flex-start;
gap: $sp-3;
font-size: $fs-sm;
color: $gray-900;
}
.current {
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: $gold-50;
}
.marker {
flex: 0 0 auto;
width: 20px;
text-align: center;
font-weight: 700;
line-height: 1.4;
}
.markerNeutral { color: $navy-700; }
.markerPositive { color: $green-600; }
.markerWarning { color: $amber-700; }
.markerNegative { color: $red-500; }
.markerCurrent { color: $gold-600; }
.eventBody {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.eventLine {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: $sp-1 $sp-3;
}
.time {
flex: 0 0 $time-col-width;
color: $gray-500;
font-variant-numeric: tabular-nums;
@media (max-width: $feed-stack-breakpoint) {
flex-basis: 100%;
}
}
.meta {
font-size: $fs-label;
color: $gray-500;
}
.comment {
color: $gray-700;
font-style: italic;
overflow-wrap: anywhere;
}
.note {
font-size: $fs-label;
color: $gray-500;
}
.error {
font-size: $fs-sm;
color: $red-500;
}
@@ -0,0 +1,28 @@
import * as React from 'react';
import { CollapseSection } from '@tnsu/ui-kit';
import { ApprovalListService } from '../approvalList.service';
import { ApprovalHistoryFeed } from './ApprovalHistoryFeed';
import { HISTORY_TEXT } from './historyTexts';
interface Props {
approval: ApprovalListService;
/** Title или серверный url списка данных — как у ApproverService. */
dataListRef: string;
requestId: number;
/**
* Меняется после решения/делегирования — лента перечитывается (тот же
* decisionSeq, что у матрицы маршрута в просмотре заявки).
*/
reloadKey?: string | number;
}
/**
* Раздел «История согласования» в карточке заявки — свёрнут по умолчанию:
* лента грузит версии всех строк маршрута, и делать это при каждом открытии
* заявки незачем. Не печатается — оборачивает вызывающая сторона (rp-no-print).
*/
export const ApprovalHistory: React.FC<Props> = ({ approval, dataListRef, requestId, reloadKey }) => (
<CollapseSection title={HISTORY_TEXT.title} icon="calendar" defaultOpen={false}>
<ApprovalHistoryFeed key={reloadKey} approval={approval} dataListRef={dataListRef} requestId={requestId} />
</CollapseSection>
);
@@ -0,0 +1,67 @@
import * as React from 'react';
import { ApprovalListService } from '../approvalList.service';
import { IApprovalHistory } from './historyModel';
import { loadApprovalHistory } from './approvalHistory.service';
import { CurrentHolderRow } from './CurrentHolderRow';
import { HistoryEventRow } from './HistoryEventRow';
import { HISTORY_TEXT } from './historyTexts';
import styles from './ApprovalHistory.module.scss';
interface Props {
approval: ApprovalListService;
dataListRef: string;
requestId: number;
}
type LoadState =
| { kind: 'loading' }
| { kind: 'error'; message: string }
| { kind: 'ready'; history: IApprovalHistory };
// Лента «Истории согласования». Монтируется только в раскрытом разделе
// (CollapseSection рендерит тело лишь открытым), поэтому версии грузятся не
// при каждом открытии заявки, а когда историю действительно смотрят.
export const ApprovalHistoryFeed: React.FC<Props> = ({ approval, dataListRef, requestId }) => {
const [state, setState] = React.useState<LoadState>({ kind: 'loading' });
React.useEffect(() => {
let alive = true;
setState({ kind: 'loading' });
loadApprovalHistory(approval, dataListRef, requestId)
.then((history) => {
if (alive) setState({ kind: 'ready', history });
})
.catch((e) => {
if (alive) setState({ kind: 'error', message: (e as Error).message });
});
return () => {
alive = false;
};
}, [approval, dataListRef, requestId]);
if (state.kind === 'loading') return <div className={styles.note}>{HISTORY_TEXT.loading}</div>;
if (state.kind === 'error') {
return <div className={styles.error}>{`${HISTORY_TEXT.errorPrefix} ${state.message}`}</div>;
}
const { rounds, current } = state.history;
if (rounds.length === 0) return <div className={styles.note}>{HISTORY_TEXT.empty}</div>;
return (
<div className={styles.feed}>
{rounds.map((round, i) => (
<section key={round.number} className={styles.round}>
<div className={styles.roundTitle}>{HISTORY_TEXT.round(round.number)}</div>
<ul className={styles.events}>
{/* «Сейчас» — только у последней (верхней) отправки. */}
{i === 0 && current.map((holder, j) => <CurrentHolderRow key={`current-${j}`} holder={holder} />)}
{round.events.map((event, j) => (
<HistoryEventRow key={`${event.time}-${event.kind}-${j}`} event={event} />
))}
</ul>
</section>
))}
<div className={styles.note}>{HISTORY_TEXT.partialNote}</div>
</div>
);
};
@@ -0,0 +1,28 @@
import * as React from 'react';
import { formatDuration, ICurrentHolder } from './historyModel';
import { HISTORY_TEXT } from './historyTexts';
import styles from './ApprovalHistory.module.scss';
interface Props {
holder: ICurrentHolder;
}
/** Верхняя строка ленты: у кого заявка сейчас и сколько уже ждёт. */
export const CurrentHolderRow: React.FC<Props> = ({ holder }) => {
const waitedMs = holder.since ? Date.now() - new Date(holder.since).getTime() : NaN;
return (
<li className={`${styles.event} ${styles.current}`}>
<span className={`${styles.marker} ${styles.markerCurrent}`} aria-hidden="true">
●
</span>
<div className={styles.eventBody}>
<div className={styles.eventLine}>
<span className={styles.time}>{HISTORY_TEXT.now}</span>
<strong>{holder.person || '—'}</strong>
{waitedMs > 0 && <span className={styles.meta}>{HISTORY_TEXT.waiting(formatDuration(waitedMs))}</span>}
</div>
{holder.stepTitle && <div className={styles.meta}>{holder.stepTitle}</div>}
</div>
</li>
);
};
@@ -0,0 +1,52 @@
import * as React from 'react';
import { formatDuration, HistoryEventKind, IHistoryEvent } from './historyModel';
import { eventAction, HISTORY_TEXT } from './historyTexts';
import styles from './ApprovalHistory.module.scss';
interface Props {
event: IHistoryEvent;
}
// Значок и цвет события — по смыслу решения, как у статусов в StatusBadge.
const MARKER: Record<HistoryEventKind, { symbol: string; className: string }> = {
submitted: { symbol: '↗', className: styles.markerNeutral },
resubmitted: { symbol: '↗', className: styles.markerNeutral },
approved: { symbol: '✓', className: styles.markerPositive },
executed: { symbol: '✓', className: styles.markerPositive },
returned: { symbol: '↩', className: styles.markerWarning },
rejected: { symbol: '✕', className: styles.markerNegative },
delegated: { symbol: '→', className: styles.markerNeutral },
replaced: { symbol: '⇄', className: styles.markerNeutral },
};
export function formatDateTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const pad = (n: number): string => (n < 10 ? `0${n}` : `${n}`);
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** Одно событие ленты: время · кто — что сделал · за сколько + комментарий. */
export const HistoryEventRow: React.FC<Props> = ({ event }) => {
const marker = MARKER[event.kind];
return (
<li className={styles.event}>
<span className={`${styles.marker} ${marker.className}`} aria-hidden="true">
{marker.symbol}
</span>
<div className={styles.eventBody}>
<div className={styles.eventLine}>
<span className={styles.time}>{formatDateTime(event.time)}</span>
<span>
<strong>{event.actor || '—'}</strong> — {eventAction(event)}
</span>
{event.durationMs !== undefined && (
<span className={styles.meta}>{HISTORY_TEXT.took(formatDuration(event.durationMs))}</span>
)}
</div>
{event.stepTitle && <div className={styles.meta}>{event.stepTitle}</div>}
{event.comment && <div className={styles.comment}>«{event.comment}»</div>}
</div>
</li>
);
};
@@ -0,0 +1,86 @@
import { getSP } from '../../pnp/pnpjs';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import { ApprovalListService } from '../approvalList.service';
import { buildApprovalHistory, IApprovalHistory, IRequestVersion, IRouteRowVersion } from './historyModel';
// Внутренние имена колонок строки маршрута — те же, что FIELDS в approvalList.service.ts.
const ROW_FIELDS = {
status: 'ApprovalStatus',
approver: 'TansuSiteApprover',
comment: 'Comment',
date: 'Date',
delegatedBy: 'DelegatedBy',
};
/**
* Имя человека из поля версии. В версиях SharePoint Person-поле приходит
* объектом { LookupId, LookupValue, Email }, но на всякий случай принимаем и
* строку, и Title — пустая строка лучше, чем падение всей ленты.
*/
function personName(value: unknown): string {
if (!value) return '';
if (typeof value === 'string') return value;
const user = value as { LookupValue?: string; Title?: string };
return (user.LookupValue || user.Title || '').trim();
}
function text(value: unknown): string {
return typeof value === 'string' ? value : '';
}
// Версии — от старой к новой: VersionId растёт с каждой версией (512 на мажорную).
function byVersion(a: Record<string, unknown>, b: Record<string, unknown>): number {
return Number(a.VersionId) - Number(b.VersionId);
}
// Момент сохранения версии: Modified этой версии. Created — запасной вариант
// (у элемента он один на все версии, у версии — время её создания).
function versionTime(v: Record<string, unknown>): string {
return text(v.Modified) || text(v.Created);
}
function toRowVersion(v: Record<string, unknown>): IRouteRowVersion {
return {
time: versionTime(v),
status: text(v[ROW_FIELDS.status]),
approverName: personName(v[ROW_FIELDS.approver]),
editorName: personName(v.Editor),
stepTitle: text(v.Title),
comment: text(v[ROW_FIELDS.comment]).trim(),
date: text(v[ROW_FIELDS.date]),
delegatedByName: personName(v[ROW_FIELDS.delegatedBy]),
};
}
// dataListRef — Title или серверный url списка данных, как у ApproverService.
function dataList(dataListRef: string): IList {
return dataListRef.startsWith('/')
? getSP().web.getList(dataListRef)
: getSP().web.lists.getByTitle(dataListRef);
}
async function requestVersions(dataListRef: string, requestId: number): Promise<IRequestVersion[]> {
const versions = (await dataList(dataListRef).items.getById(requestId).versions()) as Array<Record<string, unknown>>;
return [...versions].sort(byVersion).map((v) => ({
time: versionTime(v),
status: text(v.RequestStatus),
editorName: personName(v.Editor),
}));
}
/** Загрузить и собрать «Историю согласования» заявки. */
export async function loadApprovalHistory(
approval: ApprovalListService,
dataListRef: string,
requestId: number
): Promise<IApprovalHistory> {
const [rowVersions, reqVersions] = await Promise.all([
approval.getRowVersions(requestId),
requestVersions(dataListRef, requestId),
]);
const rows = rowVersions.map((versions) => [...versions].sort(byVersion).map(toRowVersion));
return buildApprovalHistory(rows, reqVersions);
}
@@ -0,0 +1,141 @@
import {
buildApprovalHistory,
formatDuration,
IRequestVersion,
IRouteRowVersion,
rowEvents,
submitEvents,
} from './historyModel';
function rv(over: Partial<IRouteRowVersion>): IRouteRowVersion {
return {
time: '2026-09-24T10:00:00Z',
status: 'Ждёт очереди',
approverName: 'Анна Ким',
editorName: 'Антон Огай',
stepTitle: 'Начальник расчётной группы',
comment: '',
date: '',
delegatedByName: '',
...over,
};
}
function req(time: string, status: string, editorName = 'Антон Огай'): IRequestVersion {
return { time, status, editorName };
}
describe('rowEvents', () => {
it('решение даёт событие с комментарием и временем, сколько шаг был у человека', () => {
const events = rowEvents([
rv({ time: '2026-09-24T10:00:00Z', status: 'Ждёт очереди' }),
rv({ time: '2026-09-24T10:00:05Z', status: 'На согласовании', date: '2026-09-24T10:00:05Z' }),
rv({ time: '2026-09-24T13:00:05Z', status: 'Возвращена на доработку', date: '2026-09-24T13:00:05Z', comment: 'Уточните оклад' }),
]);
expect(events).toEqual([
{
kind: 'returned',
time: '2026-09-24T13:00:05Z',
actor: 'Анна Ким',
stepTitle: 'Начальник расчётной группы',
comment: 'Уточните оклад',
durationMs: 3 * 60 * 60 * 1000,
},
]);
});
it('после пересохранения патчем в той же строке видны оба круга', () => {
const events = rowEvents([
rv({ status: 'На согласовании', date: '2026-09-24T10:00:00Z' }),
rv({ status: 'Возвращена на доработку', date: '2026-09-25T10:00:00Z', comment: 'Исправьте' }),
rv({ time: '2026-09-26T09:00:00Z', status: 'Ждёт очереди' }),
rv({ status: 'На согласовании', date: '2026-09-26T09:00:00Z' }),
rv({ status: 'Согласовано', date: '2026-09-26T10:00:00Z' }),
]);
expect(events.map((e) => e.kind)).toEqual(['returned', 'approved']);
expect(events[1].durationMs).toBe(60 * 60 * 1000);
});
it('первая версия с «Кем переназначено» — событие делегирования', () => {
const events = rowEvents([
rv({ time: '2026-09-25T09:00:00Z', approverName: 'Асель Самаева', delegatedByName: 'Анна Ким' }),
]);
expect(events).toEqual([
{
kind: 'delegated',
time: '2026-09-25T09:00:00Z',
actor: 'Анна Ким',
target: 'Асель Самаева',
stepTitle: 'Начальник расчётной группы',
},
]);
});
it('смена человека без смены статуса — замена исполнителя', () => {
const events = rowEvents([
rv({ status: 'На исполнении', approverName: 'Петров Пётр' }),
rv({ time: '2026-09-26T12:00:00Z', status: 'На исполнении', approverName: 'Сидорова Анна', editorName: 'Алтынай Бултурукова' }),
]);
expect(events).toEqual([
{
kind: 'replaced',
time: '2026-09-26T12:00:00Z',
actor: 'Алтынай Бултурукова',
target: 'Сидорова Анна',
previous: 'Петров Пётр',
stepTitle: 'Начальник расчётной группы',
},
]);
});
});
describe('submitEvents', () => {
it('отправка из черновика и повторная после возврата; ход по маршруту отправкой не считается', () => {
const events = submitEvents([
req('2026-09-24T09:00:00Z', 'Черновик'),
req('2026-09-24T10:00:00Z', 'На согласовании'),
req('2026-09-25T10:00:00Z', 'Возвращена на доработку', 'Анна Ким'),
req('2026-09-26T09:00:00Z', 'На согласовании'),
req('2026-09-26T10:00:00Z', 'Выполнение', 'Анна Ким'),
]);
expect(events.map((e) => [e.kind, e.time])).toEqual([
['submitted', '2026-09-24T10:00:00Z'],
['resubmitted', '2026-09-26T09:00:00Z'],
]);
});
});
describe('buildApprovalHistory', () => {
it('разбивает ленту на отправки, новые сверху, и показывает, у кого заявка сейчас', () => {
const history = buildApprovalHistory(
[
[
rv({ status: 'На согласовании', date: '2026-09-24T10:00:00Z' }),
rv({ status: 'Возвращена на доработку', date: '2026-09-25T10:00:00Z' }),
rv({ time: '2026-09-26T09:00:00Z', status: 'На согласовании', date: '2026-09-26T09:00:00Z' }),
],
],
[req('2026-09-24T10:00:00Z', 'На согласовании'), req('2026-09-25T10:00:00Z', 'Возвращена на доработку'), req('2026-09-26T09:00:00Z', 'На согласовании')]
);
expect(history.rounds.map((r) => r.number)).toEqual([2, 1]);
expect(history.rounds[0].events.map((e) => e.kind)).toEqual(['resubmitted']);
expect(history.rounds[1].events.map((e) => e.kind)).toEqual(['returned', 'submitted']);
expect(history.current).toEqual([
{ person: 'Анна Ким', stepTitle: 'Начальник расчётной группы', since: '2026-09-26T09:00:00Z' },
]);
});
it('без версий — пусто', () => {
expect(buildApprovalHistory([], [])).toEqual({ rounds: [], current: [] });
});
});
describe('formatDuration', () => {
it('минуты, часы, дни', () => {
expect(formatDuration(30 * 1000)).toBe('1 мин');
expect(formatDuration(15 * 60 * 1000)).toBe('15 мин');
expect(formatDuration(3 * 60 * 60 * 1000)).toBe('3 ч');
expect(formatDuration(2 * 24 * 60 * 60 * 1000)).toBe('2 дн');
});
});
@@ -0,0 +1,193 @@
import { REQUEST_STATUS } from '../models/statuses';
/**
* «История согласования» заявки — лента событий, собранная из истории версий
* SharePoint: строк маршрута (решения, делегирование, замены) и самой заявки
* (отправки). Чистые функции, без SharePoint — загрузка в approvalHistory.service.ts.
*
* Прошлые отправки видны, потому что маршрут пересохраняется патчем (см.
* writeRoute): строки остаются, и их прошлые решения лежат в версиях. Решения
* из отправок ДО включения патча (2026-09-29) были удалены вместе со строками —
* у старых заявок видна только их последняя отправка.
*/
/** Одна версия строки маршрута — поля, нужные для ленты. */
export interface IRouteRowVersion {
/** Когда создана версия (ISO). */
time: string;
status: string;
/** Человек в строке («Согласующий»). */
approverName: string;
/** Кто сохранил эту версию (Editor). */
editorName: string;
/** Должность шага — Title строки. */
stepTitle: string;
comment: string;
/** Колонка «Дата»: момент активации или решения (ISO), '' — пусто. */
date: string;
/** «Кем переназначено» — у строк делегатов. */
delegatedByName: string;
}
/** Одна версия заявки — для событий «отправлено». */
export interface IRequestVersion {
time: string;
status: string;
editorName: string;
}
export type HistoryEventKind =
| 'submitted'
| 'resubmitted'
| 'approved'
| 'executed'
| 'returned'
| 'rejected'
| 'delegated'
| 'replaced';
export interface IHistoryEvent {
kind: HistoryEventKind;
time: string;
/** Кто сделал. */
actor: string;
/** Над кем: кому передали / кого поставили вместо. */
target?: string;
/** Кого заменили (для 'replaced'). */
previous?: string;
stepTitle?: string;
comment?: string;
/** Сколько шаг был у человека до решения, мс. */
durationMs?: number;
}
/** Шаг, который сейчас у человека. */
export interface ICurrentHolder {
person: string;
stepTitle: string;
/** С какого момента у него (ISO), '' — неизвестно. */
since: string;
}
export interface IHistoryRound {
/** Номер отправки, с 1. */
number: number;
/** События отправки, НОВЫЕ СВЕРХУ. */
events: IHistoryEvent[];
}
export interface IApprovalHistory {
/** Отправки, НОВЫЕ СВЕРХУ. */
rounds: IHistoryRound[];
current: ICurrentHolder[];
}
const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
const DECISION_KINDS: Record<string, HistoryEventKind> = {
[REQUEST_STATUS.approved]: 'approved',
[REQUEST_STATUS.executed]: 'executed',
[REQUEST_STATUS.returned]: 'returned',
[REQUEST_STATUS.rejected]: 'rejected',
};
// Статусы заявки, ИЗ которых переход в работу — это отправка автором, а не
// движение по маршруту (На согласовании → Выполнение отправкой не считается).
const BEFORE_SUBMIT_STATUSES: string[] = ['', REQUEST_STATUS.draft, REQUEST_STATUS.returned, REQUEST_STATUS.rejected];
const SUBMITTED_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution];
function msBetween(fromIso: string, toIso: string): number | undefined {
const diff = new Date(toIso).getTime() - new Date(fromIso).getTime();
return isNaN(diff) || diff < 0 ? undefined : diff;
}
/** События одной строки маршрута по её версиям (от старой к новой). */
export function rowEvents(versions: IRouteRowVersion[]): IHistoryEvent[] {
const events: IHistoryEvent[] = [];
let arrivedAt = '';
versions.forEach((v, i) => {
const prev = i > 0 ? versions[i - 1] : undefined;
const time = v.date || v.time;
if (!prev && v.delegatedByName) {
events.push({ kind: 'delegated', time: v.time, actor: v.delegatedByName, target: v.approverName, stepTitle: v.stepTitle });
}
// Тот же шаг, другой человек — замена (не пересохранение: статус не менялся).
if (prev && prev.approverName !== v.approverName && prev.status === v.status) {
events.push({ kind: 'replaced', time: v.time, actor: v.editorName, target: v.approverName, previous: prev.approverName, stepTitle: v.stepTitle });
}
if (ACTIVE_STATUSES.indexOf(v.status) >= 0 && (!prev || ACTIVE_STATUSES.indexOf(prev.status) < 0)) {
arrivedAt = time;
}
const decision = DECISION_KINDS[v.status];
if (decision && (!prev || prev.status !== v.status)) {
events.push({
kind: decision,
time,
actor: v.approverName,
stepTitle: v.stepTitle,
comment: v.comment || undefined,
durationMs: arrivedAt ? msBetween(arrivedAt, time) : undefined,
});
arrivedAt = '';
}
});
return events;
}
/** Отправки заявки автором — по версиям заявки (от старой к новой). */
export function submitEvents(versions: IRequestVersion[]): IHistoryEvent[] {
const events: IHistoryEvent[] = [];
versions.forEach((v, i) => {
const prevStatus = i > 0 ? versions[i - 1].status : '';
if (SUBMITTED_STATUSES.indexOf(v.status) < 0 || BEFORE_SUBMIT_STATUSES.indexOf(prevStatus) < 0) return;
const kind: HistoryEventKind = events.length === 0 ? 'submitted' : 'resubmitted';
events.push({ kind, time: v.time, actor: v.editorName });
});
return events;
}
/** Кто держит заявку сейчас — по последним версиям строк. */
function currentHolders(rows: IRouteRowVersion[][]): ICurrentHolder[] {
return rows
.filter((versions) => versions.length > 0 && ACTIVE_STATUSES.indexOf(versions[versions.length - 1].status) >= 0)
.map((versions) => {
const last = versions[versions.length - 1];
return { person: last.approverName, stepTitle: last.stepTitle, since: last.date };
});
}
/**
* Лента целиком: события по времени, разбитые на отправки (каждая отправка
* автором открывает новую), новые сверху.
*/
export function buildApprovalHistory(rows: IRouteRowVersion[][], requestVersions: IRequestVersion[]): IApprovalHistory {
const all = rows
.reduce<IHistoryEvent[]>((acc, versions) => acc.concat(rowEvents(versions)), [])
.concat(submitEvents(requestVersions))
.sort((a, b) => a.time.localeCompare(b.time));
const rounds: IHistoryRound[] = [];
all.forEach((event) => {
const startsRound = event.kind === 'submitted' || event.kind === 'resubmitted';
if (startsRound || rounds.length === 0) rounds.push({ number: rounds.length + 1, events: [] });
rounds[rounds.length - 1].events.push(event);
});
return {
rounds: rounds.map((r) => ({ ...r, events: [...r.events].reverse() })).reverse(),
current: currentHolders(rows),
};
}
const MINUTE_MS = 60 * 1000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
/** «15 мин», «3 ч», «2 дн» — грубо, для подписи «сколько держал шаг». */
export function formatDuration(ms: number): string {
if (ms < HOUR_MS) return `${Math.max(1, Math.round(ms / MINUTE_MS))} мин`;
if (ms < DAY_MS) return `${Math.round(ms / HOUR_MS)} ч`;
return `${Math.round(ms / DAY_MS)} дн`;
}
@@ -0,0 +1,36 @@
import { HistoryEventKind, IHistoryEvent } from './historyModel';
// Тексты «Истории согласования» — в одном месте (задел под локализацию).
// Формулировки безличные («согласовано», а не «согласовала»): пол по имени не
// угадываем.
export const HISTORY_TEXT = {
title: 'История согласования',
loading: 'Загрузка истории…',
empty: 'Пока нет событий: заявка ещё не отправлялась.',
errorPrefix: 'Не удалось загрузить историю:',
round: (n: number): string => `Отправка ${n}`,
now: 'сейчас',
waiting: (duration: string): string => `ждёт ${duration}`,
took: (duration: string): string => `за ${duration}`,
partialNote:
'Показаны события, сохранённые в SharePoint. Решения из отправок до включения истории могли не сохраниться.',
};
const ACTION: Record<HistoryEventKind, string> = {
submitted: 'отправлено на согласование',
resubmitted: 'отправлено повторно',
approved: 'согласовано',
executed: 'выполнено',
returned: 'возвращено на доработку',
rejected: 'отклонено',
delegated: 'передано',
replaced: 'заменён исполнитель',
};
/** Что произошло — «согласовано», «передано: Асель Самаева», «заменён исполнитель: А → Б». */
export function eventAction(event: IHistoryEvent): string {
const action = ACTION[event.kind];
if (event.kind === 'delegated' && event.target) return `${action}: ${event.target}`;
if (event.kind === 'replaced') return `${action}: ${event.previous || '—'} → ${event.target || '—'}`;
return action;
}
@@ -68,6 +68,9 @@ export type { IApproverOrgInfo } from './approverOrgInfo';
export { canEditRoute } from './routeEditRights';
export { canEditAllFields } from './fullEditRights';
export { ApprovalMatrix } from './ui/ApprovalMatrix';
export { ApprovalHistory } from './history/ApprovalHistory';
export { ApprovalHistoryFeed } from './history/ApprovalHistoryFeed';
export { HISTORY_TEXT } from './history/historyTexts';
export { UserPicker } from './ui/UserPicker';
export { DecisionPanel } from './ui/DecisionPanel';
export { StatusBadge } from './ui/StatusBadge';
@@ -0,0 +1,94 @@
import { IExistingRouteRow, planRoutePatch } from './routePatchPlan';
const APPROVAL = 'Согласование';
const EXECUTION = 'Исполнение';
function row(id: number, approverId: number, phase = APPROVAL, delegatedById = 0): IExistingRouteRow {
return { id, approverId, phase, delegatedById };
}
describe('planRoutePatch', () => {
it('маршрут не изменился — все строки обновляются на месте, ничего не создаётся и не удаляется', () => {
const plan = planRoutePatch(
[row(10, 1), row(11, 2), row(12, 3)],
[
{ phase: APPROVAL, approverId: 1 },
{ phase: APPROVAL, approverId: 2 },
{ phase: APPROVAL, approverId: 3 },
]
);
expect(plan.update).toEqual([
{ rowId: 10, stepIndex: 0 },
{ rowId: 11, stepIndex: 1 },
{ rowId: 12, stepIndex: 2 },
]);
expect(plan.create).toEqual([]);
expect(plan.remove).toEqual([]);
});
it('согласующий сменился — старая строка убирается, новая создаётся', () => {
const plan = planRoutePatch(
[row(10, 1), row(11, 2)],
[
{ phase: APPROVAL, approverId: 1 },
{ phase: APPROVAL, approverId: 9 },
]
);
expect(plan.update).toEqual([{ rowId: 10, stepIndex: 0 }]);
expect(plan.create).toEqual([1]);
expect(plan.remove).toEqual([11]);
});
it('порядок шагов поменялся — строки сопоставляются по человеку, а не по позиции', () => {
const plan = planRoutePatch(
[row(10, 1), row(11, 2)],
[
{ phase: APPROVAL, approverId: 2 },
{ phase: APPROVAL, approverId: 1 },
]
);
expect(plan.update).toEqual([
{ rowId: 11, stepIndex: 0 },
{ rowId: 10, stepIndex: 1 },
]);
expect(plan.create).toEqual([]);
expect(plan.remove).toEqual([]);
});
it('один человек дважды в фазе — пары по порядку, лишняя строка убирается', () => {
const plan = planRoutePatch([row(10, 1), row(11, 1), row(12, 1)], [
{ phase: APPROVAL, approverId: 1 },
{ phase: APPROVAL, approverId: 1 },
]);
expect(plan.update).toEqual([
{ rowId: 10, stepIndex: 0 },
{ rowId: 11, stepIndex: 1 },
]);
expect(plan.remove).toEqual([12]);
});
it('фаза важна — тот же человек в другой фазе считается другим шагом', () => {
const plan = planRoutePatch([row(10, 1, APPROVAL)], [{ phase: EXECUTION, approverId: 1 }]);
expect(plan.update).toEqual([]);
expect(plan.create).toEqual([0]);
expect(plan.remove).toEqual([10]);
});
it('строка делегата не сопоставляется и убирается, даже если человек есть в новом маршруте', () => {
const plan = planRoutePatch([row(10, 1), row(11, 5, APPROVAL, 1)], [
{ phase: APPROVAL, approverId: 1 },
{ phase: APPROVAL, approverId: 5 },
]);
expect(plan.update).toEqual([{ rowId: 10, stepIndex: 0 }]);
expect(plan.create).toEqual([1]);
expect(plan.remove).toEqual([11]);
});
it('первая отправка (строк ещё нет) — всё создаётся', () => {
const plan = planRoutePatch([], [
{ phase: APPROVAL, approverId: 1 },
{ phase: EXECUTION, approverId: 2 },
]);
expect(plan).toEqual({ update: [], create: [0, 1], remove: [] });
});
});
@@ -0,0 +1,76 @@
/**
* План пересохранения маршрута «патчем»: какие существующие строки обновить,
* какие шаги создать заново и какие строки убрать. Чистая функция, без
* SharePoint — её выполняет ApprovalListService.patchSteps.
*
* Зачем патч, а не «удалить всё и создать заново» (см. writeRoute): строка,
* которая остаётся в маршруте, обновляется на месте, и SharePoint сохраняет её
* прошлые значения в истории версий — кто, когда и с каким комментарием решал
* в прошлом круге (напр. вернул на доработку). При удалении это терялось.
*
* Строка и шаг считаются «одним и тем же», если совпадают фаза и согласующий.
* Если один человек стоит в фазе несколько раз, пары сопоставляются по порядку.
* Строки делегатов (delegatedById > 0) добавлены переназначением, а не
* маршрутом — новому маршруту они не соответствуют и убираются.
*/
/** Существующая строка маршрута — то, что нужно для сопоставления. */
export interface IExistingRouteRow {
id: number;
phase: string;
approverId: number;
/** Кто переназначил — у строк делегатов; 0 у обычных строк маршрута. */
delegatedById: number;
}
/** Шаг нового маршрута. index — его позиция во входном массиве. */
export interface IPlannedRouteStep {
phase: string;
approverId: number;
}
export interface IRoutePatchPlan {
/** Обновить строку rowId значениями шага stepIndex. */
update: Array<{ rowId: number; stepIndex: number }>;
/** Индексы шагов, для которых подходящей строки нет, — создать. */
create: number[];
/** Строки, которым в новом маршруте ничего не соответствует, — убрать. */
remove: number[];
}
function matchKey(phase: string, approverId: number): string {
return `${phase}|${approverId}`;
}
/**
* existing — в порядке маршрута (как отдаёт getForRequest, по StepOrder):
* от этого зависит, какая строка достанется какому шагу, если один человек
* стоит в фазе дважды.
*/
export function planRoutePatch(existing: IExistingRouteRow[], planned: IPlannedRouteStep[]): IRoutePatchPlan {
// Свободные строки по ключу, в порядке маршрута — первый шаг забирает первую.
const freeRows = new Map<string, number[]>();
existing
.filter((row) => row.delegatedById === 0)
.forEach((row) => {
const key = matchKey(row.phase, row.approverId);
freeRows.set(key, (freeRows.get(key) ?? []).concat(row.id));
});
const plan: IRoutePatchPlan = { update: [], create: [], remove: [] };
const usedRowIds = new Set<number>();
planned.forEach((step, stepIndex) => {
const candidates = freeRows.get(matchKey(step.phase, step.approverId)) ?? [];
const rowId = candidates.shift();
if (rowId === undefined) {
plan.create.push(stepIndex);
return;
}
usedRowIds.add(rowId);
plan.update.push({ rowId, stepIndex });
});
plan.remove = existing.filter((row) => !usedRowIds.has(row.id)).map((row) => row.id);
return plan;
}
@@ -165,6 +165,11 @@ describe('statusForNewRow', () => {
expect(statusForNewRow(rows)).toBe('—');
});
it('решение согласующего не копируется, даже если оно у всех одинаковое', () => {
expect(statusForNewRow([row({ status: 'Возвращена на доработку' })])).toBe('—');
expect(statusForNewRow([row({ status: 'Согласовано' }), row({ status: 'Согласовано' })])).toBe('—');
});
it('пустой статус приравнивается к прочерку', () => {
expect(statusForNewRow([row({ status: '' }), row({ status: '—' })])).toBe('—');
});
@@ -3,6 +3,7 @@ import { friendlyErrorMessage } from '../diagnostics';
import { IMatrixRow } from './models/IMatrixRow';
import { ISiteUser } from './models/ISiteUser';
import { IApproverOrgInfo } from './approverOrgInfo';
import { REQUEST_STATUS } from './models/statuses';
export interface IUseMatrixEditor {
rows: IMatrixRow[];
@@ -79,9 +80,22 @@ export function withOrders(rows: IMatrixRow[]): IMatrixRow[] {
// строкам общий статус, и показывать «—» рядом с «Черновик» было бы враньём.
// Если статусы разные (возвращённая заявка, часть шагов уже с решениями) —
// честнее оставить «—»: в какое состояние попадёт новый шаг, ещё не решено.
//
// Решение согласующего (Согласовано / Возвращена / Отклонено / Выполнено) не
// копируем никогда, даже если оно у всех строк одинаковое: у только что
// добавленного человека решения нет. Иначе после возврата заявки с одним
// согласующим каждый новый шаг показывался «Возвращена на доработку».
const DECISION_STATUSES: string[] = [
REQUEST_STATUS.approved,
REQUEST_STATUS.returned,
REQUEST_STATUS.rejected,
REQUEST_STATUS.executed,
];
export function statusForNewRow(rows: IMatrixRow[]): string {
if (rows.length === 0) return '—';
const first = rows[0].status || '—';
if (DECISION_STATUSES.indexOf(first) >= 0) return '—';
return rows.every((r) => (r.status || '—') === first) ? first : '—';
}
@@ -0,0 +1,24 @@
import { forcedDownloadUrl } from './downloadAll';
const SITE = 'https://tnsukz.sharepoint.com/sites/requests';
describe('forcedDownloadUrl', () => {
it('серверный относительный путь — через download.aspx с абсолютным SourceUrl', () => {
expect(forcedDownloadUrl('/sites/requests/Lists/List4/Attachments/5/скан.pdf', SITE)).toBe(
`${SITE}/_layouts/15/download.aspx?SourceUrl=${encodeURIComponent(
'https://tnsukz.sharepoint.com/sites/requests/Lists/List4/Attachments/5/скан.pdf'
)}`
);
});
it('абсолютный url остаётся как есть внутри SourceUrl', () => {
const file = 'https://tnsukz.sharepoint.com/sites/requests/Shared Documents/a.docx';
expect(forcedDownloadUrl(file, SITE)).toBe(
`${SITE}/_layouts/15/download.aspx?SourceUrl=${encodeURIComponent(file)}`
);
});
it('слеш на конце адреса сайта не удваивается', () => {
expect(forcedDownloadUrl('/x.pdf', `${SITE}/`)).toContain(`${SITE}/_layouts/15/download.aspx`);
});
});
@@ -0,0 +1,57 @@
import { DATA_SITE_URL } from '../config';
/** Вложение в том виде, в каком его показывает AttachmentList. */
export interface IDownloadableFile {
fileName: string;
/** Серверный относительный (как у вложений элементов) или абсолютный url. */
url: string;
}
// Пауза между загрузками. Если запустить все сразу, браузер часть загрузок
// молча отбрасывает; полсекунды хватает, чтобы каждая успела стартовать.
const DOWNLOAD_INTERVAL_MS = 500;
function absoluteUrl(url: string, siteUrl: string): string {
if (/^https?:\/\//i.test(url)) return url;
return `${new URL(siteUrl).origin}${url.startsWith('/') ? '' : '/'}${url}`;
}
/**
* Ссылка, по которой SharePoint ОТДАЁТ файл загрузкой, а не открывает его:
* прямая ссылка на PDF или картинку открылась бы в просмотре браузера.
* download.aspx — стандартная страница SharePoint, отдаёт файл с
* Content-Disposition: attachment.
*/
export function forcedDownloadUrl(url: string, siteUrl: string = DATA_SITE_URL): string {
const site = siteUrl.replace(/\/+$/, '');
return `${site}/_layouts/15/download.aspx?SourceUrl=${encodeURIComponent(absoluteUrl(url, siteUrl))}`;
}
function startDownload(file: IDownloadableFile): void {
const link = document.createElement('a');
link.href = forcedDownloadUrl(file.url);
link.download = file.fileName;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Скачать все файлы по очереди — отдельными загрузками, без архива. При первом
* использовании браузер спросит, разрешить ли сайту скачивать несколько файлов:
* это его защита, обойти её нельзя, достаточно один раз разрешить.
* Промис завершается, когда запущена последняя загрузка.
*/
export function downloadAllFiles(files: IDownloadableFile[]): Promise<void> {
const withUrl = files.filter((f) => !!f.url);
return new Promise((resolve) => {
withUrl.forEach((file, i) => {
window.setTimeout(() => {
startDownload(file);
if (i === withUrl.length - 1) resolve();
}, i * DOWNLOAD_INTERVAL_MS);
});
if (withUrl.length === 0) resolve();
});
}
@@ -34,7 +34,8 @@ export const ChatMessage: React.FC<IChatMessageProps> = ({ message }) => (
{message.text && <div className={styles.text}>{message.text}</div>}
{message.attachments.length > 0 && (
<div className={styles.attachments}>
<AttachmentList items={message.attachments} />
{/* В сообщении чата «Скачать все» ни к чему — там обычно один-два файла. */}
<AttachmentList items={message.attachments} downloadAll={false} />
</div>
)}
</div>
@@ -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': 'Прием на работу',
@@ -0,0 +1,14 @@
@import '../../../../../styles/design-tokens';
.wrap {
display: flex;
flex-direction: column;
gap: $sp-2;
width: 100%;
}
// Кнопка над списком, прижата вправо — не спорит с именами файлов.
.toolbar {
display: flex;
justify-content: flex-end;
}

Some files were not shown because too many files have changed in this diff Show More