Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e9848e83d | |||
| 1dda76caae | |||
| 182c497139 | |||
| b12fcd0939 | |||
| e9702a53d4 | |||
| ab3593c170 | |||
| 153c966f0b | |||
| 5b1e94bf42 | |||
| 7ad5f79cdf | |||
| 27b3c8de2a |
@@ -48,6 +48,7 @@
|
|||||||
| [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ |
|
| [`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 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
|
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
|
||||||
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
|
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
|
||||||
|
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||||
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||||
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||||
|
|
||||||
|
|||||||
@@ -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-vacation', categoryId: 'hr', title: 'Заявка на трудовой отпуск', icon: 'calendar' },
|
||||||
{ id: 'hr-unpaid-leave', categoryId: 'hr', title: 'Заявка на отпуск без содержания', icon: 'document' },
|
{ 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: 'hr-dismissal', categoryId: 'hr', title: 'Заявление на увольнение', icon: 'document', comingSoon: true },
|
||||||
{ id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard', 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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Кнопки действий строки (история, корзина) — в ряд, прижаты вправо.
|
||||||
|
.rowActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: $sp-1;
|
||||||
|
}
|
||||||
|
|
||||||
// Пустой комментарий в колонке «Причина возврата/отклонения».
|
// Пустой комментарий в колонке «Причина возврата/отклонения».
|
||||||
.commentEmpty {
|
.commentEmpty {
|
||||||
color: $gray-400;
|
color: $gray-400;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
|||||||
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
import { RequestsFilterBar } from './RequestsFilterBar';
|
import { RequestsFilterBar } from './RequestsFilterBar';
|
||||||
|
import { RequestHistoryDialog } from './RequestHistoryDialog';
|
||||||
import { SortableHeader } from './SortableHeader';
|
import { SortableHeader } from './SortableHeader';
|
||||||
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
|
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
|
||||||
import { useTableRefinement } from './useTableRefinement';
|
import { useTableRefinement } from './useTableRefinement';
|
||||||
@@ -237,9 +238,11 @@ const Table: React.FC<{
|
|||||||
sort: ISortState;
|
sort: ISortState;
|
||||||
onSort: (key: SortKey) => void;
|
onSort: (key: SortKey) => void;
|
||||||
onOpen: (request: IMyRequest) => void;
|
onOpen: (request: IMyRequest) => void;
|
||||||
|
/** Показать историю согласования заявки (окно, без открытия заявки). */
|
||||||
|
onShowHistory: (request: IMyRequest) => void;
|
||||||
/** Удалить черновик; задан только там, где в таблице бывают свои черновики. */
|
/** Удалить черновик; задан только там, где в таблице бывают свои черновики. */
|
||||||
onDelete?: (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 cols = columnsFor(view, statusFilter);
|
||||||
const sortable = (label: string, key: SortKey): React.ReactNode => (
|
const sortable = (label: string, key: SortKey): React.ReactNode => (
|
||||||
<SortableHeader label={label} sortKey={key} sort={sort} onSort={onSort} />
|
<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>
|
render: (r) => r.decisionComment || <span className={styles.commentEmpty}>Без комментария</span>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (onDelete && rows.some(canDeleteRow)) {
|
// Действия строки: история согласования — у всех, корзина — только у своих
|
||||||
|
// черновиков. Клик по всей строке открывает заявку — кнопкам это не нужно,
|
||||||
|
// поэтому stopPropagation.
|
||||||
|
const hasDeletable = !!onDelete && rows.some(canDeleteRow);
|
||||||
columns.push({
|
columns.push({
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
title: '',
|
title: '',
|
||||||
width: '56px',
|
// Место под одну или две иконки-кнопки (sm) с зазором.
|
||||||
render: (r) =>
|
width: hasDeletable ? '92px' : '56px',
|
||||||
canDeleteRow(r) ? (
|
render: (r) => (
|
||||||
|
<div className={styles.rowActions}>
|
||||||
|
<IconButton
|
||||||
|
icon="calendar"
|
||||||
|
title="История согласования"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onShowHistory(r);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{onDelete && canDeleteRow(r) && (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="trash"
|
icon="trash"
|
||||||
title="Удалить черновик"
|
title="Удалить черновик"
|
||||||
danger
|
danger
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
// Клик по всей строке открывает заявку — кнопке это не нужно.
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(r);
|
onDelete(r);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.tableWrap}>
|
<div className={styles.tableWrap}>
|
||||||
@@ -379,6 +396,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
const [totalRows, setTotalRows] = React.useState<number>(0);
|
const [totalRows, setTotalRows] = React.useState<number>(0);
|
||||||
const [error, setError] = React.useState<string>('');
|
const [error, setError] = React.useState<string>('');
|
||||||
const [deleteError, setDeleteError] = React.useState<string>('');
|
const [deleteError, setDeleteError] = React.useState<string>('');
|
||||||
|
// Заявка, чья история согласования открыта в окне; undefined — окно закрыто.
|
||||||
|
const [historyFor, setHistoryFor] = React.useState<IMyRequest | undefined>(undefined);
|
||||||
|
|
||||||
// Список перечитается сам: confirmAndDeleteDraft шлёт rp-requests-changed.
|
// Список перечитается сам: confirmAndDeleteDraft шлёт rp-requests-changed.
|
||||||
const handleDelete = (request: IMyRequest): void => {
|
const handleDelete = (request: IMyRequest): void => {
|
||||||
@@ -729,11 +748,13 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
sort={sort}
|
sort={sort}
|
||||||
onSort={refinement.onSort}
|
onSort={refinement.onSort}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
|
onShowHistory={setHistoryFor}
|
||||||
onDelete={view === 'own:created' ? handleDelete : undefined}
|
onDelete={view === 'own:created' ? handleDelete : undefined}
|
||||||
/>
|
/>
|
||||||
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
|
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<RequestHistoryDialog request={historyFor} onClose={() => setHistoryFor(undefined)} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { DATA_SITE_URL } from '../../shared/config';
|
import { DATA_SITE_URL } from '../../shared/config';
|
||||||
import { IAssignee, IMyRequest } from './models/IMyRequest';
|
import { IAssignee, IMyRequest } from './models/IMyRequest';
|
||||||
|
import { parseAuthorOwsUser } from './parseAuthorOwsUser';
|
||||||
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
||||||
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
||||||
import { perfScope, traced } from '../../shared/perfTrace';
|
import { perfScope, traced } from '../../shared/perfTrace';
|
||||||
@@ -106,20 +107,6 @@ const MP_CODE = 'RequestUniqueCodeOWSTEXT';
|
|||||||
// создатель (см. toMyRequestFromSearch); основной — MP_AUTHOR/parseAuthorOwsUser.
|
// создатель (см. toMyRequestFromSearch); основной — MP_AUTHOR/parseAuthorOwsUser.
|
||||||
const OWN_SELECT_PROPERTIES = ['ListItemID', 'Path', 'Title', 'Created', 'Author', MP_AUTHOR, MP_REQUEST_STATUS, MP_CODE];
|
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 напрямую: при отладочной подмене личности
|
// effectiveUserEmail, а не pageContext напрямую: при отладочной подмене личности
|
||||||
// (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны
|
// (см. 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';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
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
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
@@ -319,6 +320,12 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
@@ -327,6 +328,19 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования."
|
waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||||
|
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||||
|
{editId && (
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory
|
||||||
|
approval={approval}
|
||||||
|
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||||
|
requestId={editId}
|
||||||
|
reloadKey={requestStatus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SubmitActions
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId
|
useRequestTypeId
|
||||||
@@ -155,6 +156,12 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
@@ -28,14 +29,14 @@ import {
|
|||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import { isoDatePlusMonths, formatIsoDate } from '../../shared/dateUtils';
|
import { lastWorkingDayError } from './lastWorkingDay';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
approvalListTitleForListId,
|
approvalListTitleForListId,
|
||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} 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 { saveDismissalRequest, dismissalApprovalService } from './submit';
|
||||||
import { loadDismissalRequest } from './load';
|
import { loadDismissalRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
@@ -197,15 +198,13 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
// Последний рабочий день нужно назначать минимум за MIN_NOTICE_MONTHS месяц —
|
// Последний рабочий день нужно назначать минимум за MIN_NOTICE_MONTHS месяц —
|
||||||
// календарь (min у input type=date, см. RequestSection) не даёт выбрать более
|
// календарь (min у input type=date, см. RequestSection) не даёт выбрать более
|
||||||
// раннюю дату, но проверяем и здесь на случай обхода UI.
|
// раннюю дату, но проверяем и здесь на случай обхода UI. Тот же текст
|
||||||
const minLastWorkingDay = isoDatePlusMonths(MIN_NOTICE_MONTHS);
|
// RequestSection показывает подсказкой под полем (см. lastWorkingDay.ts).
|
||||||
const isLastDayValid = !data.LastWorkingDay || data.LastWorkingDay >= minLastWorkingDay;
|
const lastDayError = lastWorkingDayError(data.LastWorkingDay);
|
||||||
const position = primaryPosition(data.Organization);
|
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 => {
|
const validate = (): string | undefined => {
|
||||||
if (data.LastWorkingDay && !isLastDayValid) {
|
if (lastDayError) return lastDayError;
|
||||||
return `Последний рабочий день нужно назначать минимум за ${MIN_NOTICE_MONTHS} месяц — минимально доступная дата: ${formatIsoDate(minLastWorkingDay)}.`;
|
|
||||||
}
|
|
||||||
if (!data.Employee || !position.trim() || !data.LastWorkingDay || !data.Phone.trim()) {
|
if (!data.Employee || !position.trim() || !data.LastWorkingDay || !data.Phone.trim()) {
|
||||||
return 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.';
|
return 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.';
|
||||||
}
|
}
|
||||||
@@ -343,6 +342,20 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<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
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
@@ -283,6 +284,12 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый).
|
{/* Панель решения — ВНЕ печати и 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 * as React from 'react';
|
||||||
import { FormGrid, FormField, TextInput, TextArea, PhoneInput, PeopleField, LinkField } from '../../../shared/ui';
|
import { FormGrid, FormField, TextInput, TextArea, PhoneInput, PeopleField, LinkField } from '../../../shared/ui';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, MIN_NOTICE_MONTHS } from '../model';
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
import { isoDatePlusMonths } from '../../../shared/dateUtils';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
||||||
|
import { lastWorkingDayError, minLastWorkingDay } from '../lastWorkingDay';
|
||||||
import { OrganizationsTable } from './OrganizationsTable';
|
import { OrganizationsTable } from './OrganizationsTable';
|
||||||
|
import styles from './RequestSection.module.scss';
|
||||||
|
|
||||||
interface IRequestSectionProps extends ISectionProps {
|
interface IRequestSectionProps extends ISectionProps {
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +29,12 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
onEmployeeChange,
|
onEmployeeChange,
|
||||||
onRemoveOrganization,
|
onRemoveOrganization,
|
||||||
assetsAcceptorEditable
|
assetsAcceptorEditable
|
||||||
}) => (
|
}) => {
|
||||||
|
// В просмотре (DismissalView) секция только для чтения — подсказка там ни к чему.
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
const lastDayError = lastWorkingDayError(data.LastWorkingDay);
|
||||||
|
|
||||||
|
return (
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
@@ -56,15 +63,18 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
onRemove={onRemoveOrganization}
|
onRemove={onRemoveOrganization}
|
||||||
/>
|
/>
|
||||||
{/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе,
|
{/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе,
|
||||||
чем через MIN_NOTICE_MONTHS месяц от сегодня (min). */}
|
чем через MIN_NOTICE_MONTHS месяц от сегодня (min). Если дата уже стоит
|
||||||
|
раньше (заявление пролежало на доработке) — подсказка под полем, иначе
|
||||||
|
непонятно, почему кнопки отправки неактивны. */}
|
||||||
<FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required>
|
<FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="LastWorkingDay"
|
id="LastWorkingDay"
|
||||||
type="date"
|
type="date"
|
||||||
value={data.LastWorkingDay}
|
value={data.LastWorkingDay}
|
||||||
onChange={(v) => set('LastWorkingDay', v)}
|
onChange={(v) => set('LastWorkingDay', v)}
|
||||||
min={isoDatePlusMonths(MIN_NOTICE_MONTHS)}
|
min={minLastWorkingDay()}
|
||||||
/>
|
/>
|
||||||
|
{!readOnly && lastDayError && <div className={styles.fieldError}>{lastDayError}</div>}
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Контактный телефон" htmlFor="Phone" required>
|
<FormField label="Контактный телефон" htmlFor="Phone" required>
|
||||||
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
|
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
|
||||||
@@ -92,4 +102,5 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
@@ -303,6 +304,19 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<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
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId
|
useRequestTypeId
|
||||||
@@ -152,6 +153,12 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
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
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
@@ -417,6 +418,12 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
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
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
@@ -380,6 +381,12 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
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
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
@@ -238,6 +239,12 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
@@ -387,6 +388,19 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
|
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||||
|
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||||
|
{editId && (
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory
|
||||||
|
approval={approval}
|
||||||
|
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||||
|
requestId={editId}
|
||||||
|
reloadKey={requestStatus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SubmitActions
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
@@ -181,6 +182,12 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
|||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $sp-2;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: $sp-1 $sp-2 $sp-1 $sp-3;
|
||||||
|
border: 1px solid $navy-100;
|
||||||
|
border-radius: $r-full;
|
||||||
|
background: $navy-50;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind {
|
||||||
|
font-size: $fs-label;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-100;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon } from '../../../shared/ui';
|
||||||
|
import styles from './SelectedDaysList.module.scss';
|
||||||
|
import { IMissedDay } from '../model';
|
||||||
|
import { formatLongDate, formatMissedDetails } from '../noteText';
|
||||||
|
|
||||||
|
interface ISelectedDaysListProps {
|
||||||
|
days: IMissedDay[];
|
||||||
|
/** Не передан — список только для чтения (просмотр, отправленная заявка). */
|
||||||
|
onRemove?: (date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выбранные в объяснительную дни — «чипсы» с тем, что было в СКУД на момент подачи. */
|
||||||
|
export const SelectedDaysList: React.FC<ISelectedDaysListProps> = ({ days, onRemove }) => {
|
||||||
|
if (days.length === 0) {
|
||||||
|
return <div className={styles.empty}>Дни не выбраны — отметьте их в календаре.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{days.map((day) => (
|
||||||
|
<li key={day.date} className={styles.chip}>
|
||||||
|
<span className={styles.date}>{formatLongDate(day.date)}</span>
|
||||||
|
<span className={styles.kind}>{formatMissedDetails(day)}</span>
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.remove}
|
||||||
|
onClick={() => onRemove(day.date)}
|
||||||
|
aria-label={`Убрать ${formatLongDate(day.date)}`}
|
||||||
|
>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Цвета статусов — те же, что в «Мой СКУД» (portal-main-page), чтобы сотрудник
|
||||||
|
// видел одну и ту же картину в обоих местах.
|
||||||
|
$full-bg: #ECFDF5;
|
||||||
|
$full-border: #A7F3D0;
|
||||||
|
$full-text: $green-600;
|
||||||
|
|
||||||
|
$partial-bg: #FFFBEB;
|
||||||
|
$partial-border: #FDE68A;
|
||||||
|
$partial-text: #B45309;
|
||||||
|
|
||||||
|
$single-bg: $red-50;
|
||||||
|
$single-border: #FECACA;
|
||||||
|
$single-text: $red-500;
|
||||||
|
|
||||||
|
$holiday-bg: #EEF2FF;
|
||||||
|
$holiday-border: #C7D2FE;
|
||||||
|
$holiday-text: #4338CA;
|
||||||
|
|
||||||
|
$weekend-bg: #EDEFF2;
|
||||||
|
|
||||||
|
// Шире 7 колонок по ~64px ячейки перестают быть квадратными и читаются хуже —
|
||||||
|
// на широком экране календарь не растягивается на всю форму.
|
||||||
|
$calendar-max-width: 480px;
|
||||||
|
|
||||||
|
.calendar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: $calendar-max-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-bottom: $sp-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
color: $navy-700;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $gold-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: $gray-300;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.month {
|
||||||
|
min-width: 150px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $fs-body;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
margin-bottom: $sp-2;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $fs-label;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: $sp-6 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekHead,
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: $sp-1;
|
||||||
|
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekHead {
|
||||||
|
margin-bottom: $sp-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekDay {
|
||||||
|
text-align: center;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gray-400;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $gray-50;
|
||||||
|
color: $gray-400;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: default;
|
||||||
|
transition: border-color .18s, box-shadow .18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayNum {
|
||||||
|
font-size: $fs-label;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellTime {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.full {
|
||||||
|
background: $full-bg;
|
||||||
|
border-color: $full-border;
|
||||||
|
color: $full-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partial {
|
||||||
|
background: $partial-bg;
|
||||||
|
border-color: $partial-border;
|
||||||
|
color: $partial-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single {
|
||||||
|
background: $single-bg;
|
||||||
|
border-color: $single-border;
|
||||||
|
color: $single-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holiday {
|
||||||
|
background: $holiday-bg;
|
||||||
|
border-color: $holiday-border;
|
||||||
|
color: $holiday-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekend {
|
||||||
|
background: $weekend-bg;
|
||||||
|
|
||||||
|
.dayNum {
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прошедший будний день без проходов — диагональная штриховка «нет данных».
|
||||||
|
.absent {
|
||||||
|
background: repeating-linear-gradient(45deg, $white, $white 4px, $gray-200 4px, $gray-200 8px);
|
||||||
|
border-color: $gray-200;
|
||||||
|
|
||||||
|
.dayNum {
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $navy-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// День уже в поданной объяснительной: синяя рамка + галочка поверх цвета СКУД,
|
||||||
|
// чтобы было видно и «что было», и «уже объяснено».
|
||||||
|
.cell {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitted {
|
||||||
|
border-color: $blue-600;
|
||||||
|
opacity: .75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submittedMark {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: $blue-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
border-color: $navy-800;
|
||||||
|
box-shadow: inset 0 0 0 2px $navy-800;
|
||||||
|
|
||||||
|
.dayNum {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.today {
|
||||||
|
box-shadow: inset 0 0 0 2px $gold-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendWrap {
|
||||||
|
margin-top: $sp-3;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $sp-2 $sp-3;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendItem {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotFull {
|
||||||
|
background: $full-border;
|
||||||
|
border: 1px solid $full-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotPartial {
|
||||||
|
background: $partial-border;
|
||||||
|
border: 1px solid $partial-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotSingle {
|
||||||
|
background: $single-border;
|
||||||
|
border: 1px solid $single-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotHoliday {
|
||||||
|
background: $holiday-border;
|
||||||
|
border: 1px solid $holiday-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotSubmitted {
|
||||||
|
position: relative;
|
||||||
|
background: $blue-50;
|
||||||
|
border: 1px solid $blue-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotAbsent {
|
||||||
|
background: repeating-linear-gradient(45deg, $white, $white 2px, $gray-300 2px, $gray-300 4px);
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: $sp-2;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { Alert, Icon } from '../../../shared/ui';
|
||||||
|
import styles from './SkudCalendar.module.scss';
|
||||||
|
import { SkudLegend } from './SkudLegend';
|
||||||
|
import { SkudDayCell } from './SkudDayCell';
|
||||||
|
import { useSkudMonth } from '../skud/useSkudMonth';
|
||||||
|
import { buildMonthCells, minSelectableDateKey, monthWorkedHours, todayDateKey } from '../skud/skudDays';
|
||||||
|
import { IMissedDay } from '../model';
|
||||||
|
import { ISubmittedDay } from '../submittedDays';
|
||||||
|
|
||||||
|
const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||||
|
|
||||||
|
interface ISkudCalendarProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
/** Даты (YYYY-MM-DD), уже выбранные в объяснительную. */
|
||||||
|
selectedDates: string[];
|
||||||
|
/** Дни, уже закрытые поданными объяснительными (см. useSubmittedDays). */
|
||||||
|
submittedDays: Map<string, ISubmittedDay>;
|
||||||
|
onToggle: (day: IMissedDay) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthTitle(year: number, month: number): string {
|
||||||
|
const text = new Date(year, month, 1).toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' });
|
||||||
|
const clean = text.replace(' г.', '');
|
||||||
|
return clean.charAt(0).toUpperCase() + clean.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Календарь «Мой СКУД» с выбором дней (какие кликабельны — см. toMissedKind); листается на SELECTABLE_MONTHS_BACK назад. */
|
||||||
|
export const SkudCalendar: React.FC<ISkudCalendarProps> = ({ context, selectedDates, submittedDays, onToggle }) => {
|
||||||
|
const skud = useSkudMonth(context);
|
||||||
|
const cells = buildMonthCells(skud.year, skud.month, skud.byDay);
|
||||||
|
const bounds = { todayKey: todayDateKey(), minKey: minSelectableDateKey() };
|
||||||
|
|
||||||
|
if (skud.notLinked) {
|
||||||
|
return (
|
||||||
|
<Alert variant="warn">
|
||||||
|
Данные СКУД недоступны: ваша учётная запись не связана с системой 1С. Обратитесь в отдел кадров.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.calendar}>
|
||||||
|
<div className={styles.nav}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.navButton}
|
||||||
|
onClick={skud.goPrev}
|
||||||
|
disabled={!skud.canGoPrev}
|
||||||
|
aria-label="Предыдущий месяц"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-left" size="sm" />
|
||||||
|
</button>
|
||||||
|
<span className={styles.month}>{monthTitle(skud.year, skud.month)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.navButton}
|
||||||
|
onClick={skud.goNext}
|
||||||
|
disabled={!skud.canGoNext}
|
||||||
|
aria-label="Следующий месяц"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-right" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!skud.loading && !skud.error && (
|
||||||
|
<div className={styles.summary}>
|
||||||
|
Дней: <b>{skud.byDay.size}</b> · ≈ <b>{monthWorkedHours(skud.byDay)} ч</b> за месяц
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{skud.loading && <div className={styles.state}>Загрузка отметок СКУД…</div>}
|
||||||
|
{!skud.loading && skud.error && <Alert variant="err">Не удалось загрузить отметки СКУД: {skud.error}</Alert>}
|
||||||
|
|
||||||
|
{!skud.loading && !skud.error && (
|
||||||
|
<>
|
||||||
|
<div className={styles.weekHead}>
|
||||||
|
{WEEKDAYS.map((w) => (
|
||||||
|
<span key={w} className={styles.weekDay}>
|
||||||
|
{w}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{cells.map((cell, index) =>
|
||||||
|
cell ? (
|
||||||
|
<SkudDayCell
|
||||||
|
key={cell.dateKey}
|
||||||
|
info={cell}
|
||||||
|
cellIndex={index}
|
||||||
|
bounds={bounds}
|
||||||
|
holidayName={skud.holidays.get(cell.dateKey)}
|
||||||
|
submitted={submittedDays.get(cell.dateKey)}
|
||||||
|
selected={selectedDates.indexOf(cell.dateKey) >= 0}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span key={`empty-${index}`} className={styles.empty} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SkudLegend />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './SkudCalendar.module.scss';
|
||||||
|
import { CellKind, IDayInfo, cellKind, isWeekendColumn, toMissedKind } from '../skud/skudDays';
|
||||||
|
import { IMissedDay } from '../model';
|
||||||
|
import { ISubmittedDay } from '../submittedDays';
|
||||||
|
|
||||||
|
/** Границы выбора: сегодня и самая ранняя доступная дата (см. minSelectableDateKey). */
|
||||||
|
export interface IDayBounds {
|
||||||
|
todayKey: string;
|
||||||
|
minKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISkudDayCellProps {
|
||||||
|
info: IDayInfo;
|
||||||
|
/** Позиция в сетке — по ней определяется выходной (Сб/Вс). */
|
||||||
|
cellIndex: number;
|
||||||
|
bounds: IDayBounds;
|
||||||
|
/** Название праздника, если день — праздничный выходной. */
|
||||||
|
holidayName?: string;
|
||||||
|
/** День уже закрыт поданной объяснительной — выбрать нельзя. */
|
||||||
|
submitted?: ISubmittedDay;
|
||||||
|
selected: boolean;
|
||||||
|
onToggle: (day: IMissedDay) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_CLASS: Record<CellKind, string> = {
|
||||||
|
full: styles.full,
|
||||||
|
partial: styles.partial,
|
||||||
|
single: styles.single,
|
||||||
|
absent: styles.absent,
|
||||||
|
holiday: styles.holiday,
|
||||||
|
weekend: styles.weekend,
|
||||||
|
empty: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
function hint(info: IDayInfo, kind: CellKind, holidayName: string | undefined): string {
|
||||||
|
if (kind === 'holiday') return holidayName || 'Праздник';
|
||||||
|
if (info.events.length === 1) return `Одна запись: ${info.first}`;
|
||||||
|
if (info.events.length > 1) return `${info.first} → ${info.last}`;
|
||||||
|
return kind === 'absent' ? 'Нет отметок' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ячейка дня: цвет по отметкам СКУД; «проблемный» день можно выбрать в объяснительную. */
|
||||||
|
export const SkudDayCell: React.FC<ISkudDayCellProps> = ({
|
||||||
|
info,
|
||||||
|
cellIndex,
|
||||||
|
bounds,
|
||||||
|
holidayName,
|
||||||
|
submitted,
|
||||||
|
selected,
|
||||||
|
onToggle
|
||||||
|
}) => {
|
||||||
|
const kind = cellKind(info, isWeekendColumn(cellIndex), !!holidayName, bounds.todayKey);
|
||||||
|
const missedKind = submitted ? undefined : toMissedKind(kind, info.dateKey, bounds.todayKey, bounds.minKey);
|
||||||
|
const className = [
|
||||||
|
styles.cell,
|
||||||
|
KIND_CLASS[kind],
|
||||||
|
missedKind ? styles.selectable : '',
|
||||||
|
submitted ? styles.submitted : '',
|
||||||
|
selected ? styles.selected : '',
|
||||||
|
info.dateKey === bounds.todayKey ? styles.today : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
const title = submitted ? `Уже подана: №${submitted.code} (${submitted.status})` : hint(info, kind, holidayName);
|
||||||
|
|
||||||
|
const handleClick = (): void => {
|
||||||
|
if (!missedKind) return;
|
||||||
|
// Снимок того, что было в СКУД: согласующий увидит это вместо календаря.
|
||||||
|
const hasExit = info.events.length > 1;
|
||||||
|
onToggle({ date: info.dateKey, kind: missedKind, first: info.first, last: hasExit ? info.last : '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={className}
|
||||||
|
disabled={!missedKind}
|
||||||
|
aria-pressed={missedKind ? selected : undefined}
|
||||||
|
title={title}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{submitted && <span className={styles.submittedMark}>✓</span>}
|
||||||
|
<span className={styles.dayNum}>{info.day}</span>
|
||||||
|
{info.first && <span className={styles.cellTime}>{info.first}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './SkudCalendar.module.scss';
|
||||||
|
|
||||||
|
const ITEMS: { className: string; label: string }[] = [
|
||||||
|
{ className: styles.dotFull, label: '≥ 8 ч' },
|
||||||
|
{ className: styles.dotPartial, label: '< 8 ч' },
|
||||||
|
{ className: styles.dotSingle, label: '1 запись' },
|
||||||
|
{ className: styles.dotHoliday, label: 'праздник' },
|
||||||
|
{ className: styles.dotAbsent, label: 'отсутствие' },
|
||||||
|
{ className: styles.dotSubmitted, label: '✓ уже подана' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Легенда цветов календаря СКУД + подсказка, какие дни выбираются. */
|
||||||
|
export const SkudLegend: React.FC = () => (
|
||||||
|
<div className={styles.legendWrap}>
|
||||||
|
<div className={styles.legend}>
|
||||||
|
{ITEMS.map((item) => (
|
||||||
|
<span key={item.label} className={styles.legendItem}>
|
||||||
|
<i className={`${styles.dot} ${item.className}`} />
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.hint}>
|
||||||
|
Выбрать можно дни < 8 ч, с одной записью или без отметок (сегодня — если отметок нет) за текущий и прошлый
|
||||||
|
месяц.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
|
import { dataListTitle } from '../../shared/listTitles';
|
||||||
|
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
|
||||||
|
import { parseMissedDays } from './missedDaysJson';
|
||||||
|
import { toDateKey } from './skud/skudDays';
|
||||||
|
|
||||||
|
export interface ILoadedRequest {
|
||||||
|
data: IFormData;
|
||||||
|
status: string;
|
||||||
|
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||||
|
code: string;
|
||||||
|
/** Дата создания записи, YYYY-MM-DD — дата объяснительной в печатной форме. */
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created приходит в UTC: срез строки дал бы вчерашнюю дату для записей,
|
||||||
|
// созданных рано утром по местному времени.
|
||||||
|
function localDateKey(value: unknown): string {
|
||||||
|
if (typeof value !== 'string' || !value) return '';
|
||||||
|
const d = new Date(value);
|
||||||
|
return toDateKey(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прочитать объяснительную по id. listId — партиция, где лежит именно эта
|
||||||
|
* заявка (см. UnpaidLeave/load.ts): задан — читаем по url списка, иначе из
|
||||||
|
* текущей партиции.
|
||||||
|
*/
|
||||||
|
export async function loadSkudExplanationRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||||
|
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
|
const item = (await list.items
|
||||||
|
.getById(itemId)
|
||||||
|
.select(
|
||||||
|
'Id',
|
||||||
|
'Created',
|
||||||
|
'SubmitDate',
|
||||||
|
'SkudEmployeeFio',
|
||||||
|
'Position',
|
||||||
|
'Organization',
|
||||||
|
'SkudMissedDaysJson',
|
||||||
|
'SkudReason',
|
||||||
|
'SkudReasonDetails',
|
||||||
|
'SkudPresenceConfirmed',
|
||||||
|
'RequestStatus',
|
||||||
|
'RequestUniqueCode',
|
||||||
|
'Employee/Title',
|
||||||
|
'Employee/EMail'
|
||||||
|
)
|
||||||
|
.expand('Employee')()) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
|
||||||
|
|
||||||
|
const data: IFormData = {
|
||||||
|
...EMPTY_DATA,
|
||||||
|
// Колонка «только дата» — время в ответе нулевое, берём дату как есть (как в Referral/load.ts).
|
||||||
|
SubmitDate: ((item.SubmitDate as string) || '').slice(0, 10),
|
||||||
|
Employee: employee?.EMail || '',
|
||||||
|
EmployeeFio: (item.SkudEmployeeFio as string) || employee?.Title || '',
|
||||||
|
Position: (item.Position as string) || '',
|
||||||
|
Organization: (item.Organization as string) || '',
|
||||||
|
MissedDays: parseMissedDays(item.SkudMissedDaysJson),
|
||||||
|
Reason: (item.SkudReason as string) || '',
|
||||||
|
ReasonDetails: (item.SkudReasonDetails as string) || '',
|
||||||
|
PresenceConfirmed: item.SkudPresenceConfirmed === true
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
status: (item.RequestStatus as string) || '',
|
||||||
|
code: (item.RequestUniqueCode as string) || '',
|
||||||
|
created: localDateKey(item.Created)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { IMissedDay } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разбор колонки SkudMissedDaysJson. Битый/пустой JSON не должен ронять ни
|
||||||
|
* открытие заявки, ни проверку повторов — считаем, что дней нет.
|
||||||
|
*/
|
||||||
|
export function parseMissedDays(json: unknown): IMissedDay[] {
|
||||||
|
if (typeof json !== 'string' || !json) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json) as unknown;
|
||||||
|
return Array.isArray(parsed) ? (parsed as IMissedDay[]) : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Объяснительная по СКУД] Не удалось разобрать SkudMissedDaysJson', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// import type — чтобы тесты noteText не тянули SPFx-рантайм через модель.
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import type { RequestTypeId } from '../../models/types';
|
||||||
|
import type { MissedKind } from './skud/skudDays';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
||||||
|
* SharePoint выводятся из него, см. shared/listTitles.ts.
|
||||||
|
*/
|
||||||
|
export const FORM_ID: RequestTypeId = 'hr-skud-explanation';
|
||||||
|
|
||||||
|
/** Полное название — заголовок формы, просмотра и элемента списка (плитка короче, см. data/requests.ts). */
|
||||||
|
export const FORM_TITLE = 'Объяснительная об отсутствии отметок в СКУД';
|
||||||
|
|
||||||
|
/** День, по которому пишется объяснительная, — снимок СКУД на момент подачи. */
|
||||||
|
export interface IMissedDay {
|
||||||
|
/** YYYY-MM-DD */
|
||||||
|
date: string;
|
||||||
|
kind: MissedKind;
|
||||||
|
/** HH:MM первой записи; '' — отметок не было вовсе. */
|
||||||
|
first: string;
|
||||||
|
/** HH:MM последней записи; '' — записей меньше двух. */
|
||||||
|
last: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные формы. Заявка только «на себя»: проходы СКУД API отдаёт по токену
|
||||||
|
* текущего пользователя, за другого сотрудника их не получить. Дни хранятся
|
||||||
|
* снимком (IMissedDay), потому что согласующий чужой СКУД открыть не может.
|
||||||
|
*/
|
||||||
|
export interface IFormData {
|
||||||
|
/**
|
||||||
|
* Дата подачи, YYYY-MM-DD — ставится сегодняшней автоматически, пока заявку
|
||||||
|
* можно править (новая / черновик / доработка), и печатается в бланке.
|
||||||
|
*/
|
||||||
|
SubmitDate: string;
|
||||||
|
/** E-mail сотрудника (People) — всегда текущий пользователь. */
|
||||||
|
Employee: string;
|
||||||
|
/** Ф.И.О. полностью «Фамилия Имя Отчество» из Employee/ByEmail — для текста записки. */
|
||||||
|
EmployeeFio: string;
|
||||||
|
Position: string;
|
||||||
|
/** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */
|
||||||
|
Organization: string;
|
||||||
|
/** Выбранные дни, по возрастанию даты. */
|
||||||
|
MissedDays: IMissedDay[];
|
||||||
|
/** Значение из SKUD_REASONS. */
|
||||||
|
Reason: string;
|
||||||
|
/** Своя формулировка причины — только при Reason = OTHER_REASON. */
|
||||||
|
ReasonDetails: string;
|
||||||
|
/** Сотрудник подтверждает, что был на рабочем месте. */
|
||||||
|
PresenceConfirmed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||||
|
|
||||||
|
export interface ISectionProps {
|
||||||
|
data: IFormData;
|
||||||
|
set: SetField;
|
||||||
|
context: WebPartContext;
|
||||||
|
/** Индекс организации для печати (см. shared/ui/PrintSplitButton и OrganizationField.printIndex). */
|
||||||
|
printOrgIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_DATA: IFormData = {
|
||||||
|
SubmitDate: '',
|
||||||
|
Employee: '',
|
||||||
|
EmployeeFio: '',
|
||||||
|
Position: '',
|
||||||
|
Organization: '',
|
||||||
|
MissedDays: [],
|
||||||
|
Reason: '',
|
||||||
|
ReasonDetails: '',
|
||||||
|
PresenceConfirmed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OTHER_REASON = 'Другое';
|
||||||
|
|
||||||
|
export interface ISkudReason {
|
||||||
|
/** Значение Choice-колонки SkudReason. */
|
||||||
|
value: string;
|
||||||
|
/** Продолжение фразы «…произошло по причине ___» в тексте записки. */
|
||||||
|
phrase: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Варианты причины. Меняя value — менять и choices колонки SkudReason в provisioning.json. */
|
||||||
|
export const SKUD_REASONS: ISkudReason[] = [
|
||||||
|
{ value: 'Невнимательность', phrase: 'невнимательности' },
|
||||||
|
{ value: 'Забыл(а) пропуск', phrase: 'того, что я забыл(а) пропуск' },
|
||||||
|
{ value: 'Неисправность турникета или считывателя', phrase: 'неисправности турникета или считывателя' },
|
||||||
|
{ value: OTHER_REASON, phrase: '' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Подписи дня в списке выбранных и в колонке SkudMissedDates. */
|
||||||
|
export const MISSED_KIND_LABEL: Record<MissedKind, string> = {
|
||||||
|
partial: '< 8 ч',
|
||||||
|
single: '1 запись',
|
||||||
|
absent: 'нет отметок'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRESENCE_CONFIRMATION_LABEL =
|
||||||
|
'Подтверждаю, что находился(ась) на рабочем месте и исполнял(а) трудовые обязанности в течение рабочего дня';
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { EMPTY_DATA, IFormData, OTHER_REASON } from './model';
|
||||||
|
import { buildNoteParagraphs, formatMissedDaysSummary, joinWithAnd, shortFio } from './noteText';
|
||||||
|
|
||||||
|
const DATA: IFormData = {
|
||||||
|
...EMPTY_DATA,
|
||||||
|
Employee: 'gorodetskiy.timur@tnsu.kz',
|
||||||
|
EmployeeFio: 'Городецкий Тимур Александрович',
|
||||||
|
MissedDays: [
|
||||||
|
{ date: '2026-07-21', kind: 'single', first: '08:35', last: '' },
|
||||||
|
{ date: '2026-07-22', kind: 'absent', first: '', last: '' },
|
||||||
|
{ date: '2026-07-23', kind: 'partial', first: '08:49', last: '13:10' }
|
||||||
|
],
|
||||||
|
Reason: 'Невнимательность',
|
||||||
|
PresenceConfirmed: true
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('noteText', () => {
|
||||||
|
it('shortFio: фамилия + инициалы', () => {
|
||||||
|
expect(shortFio('Городецкий Тимур Александрович')).toBe('Городецкий Т. А.');
|
||||||
|
expect(shortFio('Городецкий')).toBe('Городецкий');
|
||||||
|
expect(shortFio(' ')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joinWithAnd', () => {
|
||||||
|
expect(joinWithAnd(['a'])).toBe('a');
|
||||||
|
expect(joinWithAnd(['a', 'b'])).toBe('a и b');
|
||||||
|
expect(joinWithAnd(['a', 'b', 'c'])).toBe('a, b и c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('текст записки как в бланке отдела кадров', () => {
|
||||||
|
expect(buildNoteParagraphs(DATA)).toEqual([
|
||||||
|
'Я, Городецкий Т. А., по факту отсутствия отметок в системе СКУД 21 июля 2026 г., 22 июля 2026 г. и 23 июля 2026 г. сообщаю следующее:',
|
||||||
|
'Отсутствие отметки в системе СКУД в указанные даты произошло по причине невнимательности. Факт моего нахождения на рабочем месте и исполнения трудовых обязанностей в течение рабочего дня подтверждаю.',
|
||||||
|
'В дальнейшем обязуюсь соблюдать установленный порядок фиксации рабочего времени и не допускать подобных случаев.'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('своя причина и одна дата', () => {
|
||||||
|
const [, second] = buildNoteParagraphs({
|
||||||
|
...DATA,
|
||||||
|
MissedDays: [DATA.MissedDays[0]],
|
||||||
|
Reason: OTHER_REASON,
|
||||||
|
ReasonDetails: 'был на объекте',
|
||||||
|
PresenceConfirmed: false
|
||||||
|
});
|
||||||
|
expect(second).toBe('Отсутствие отметки в системе СКУД в указанную дату произошло по следующей причине: был на объекте');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('сводка дней для колонки SP', () => {
|
||||||
|
expect(formatMissedDaysSummary(DATA.MissedDays)).toBe(
|
||||||
|
'21.07.2026 (1 запись 08:35); 22.07.2026 (нет отметок); 23.07.2026 (< 8 ч 08:49–13:10)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { IFormData, IMissedDay, MISSED_KIND_LABEL, OTHER_REASON, SKUD_REASONS } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст объяснительной записки из данных формы — один источник и для
|
||||||
|
* предпросмотра в форме, и для печатного документа в просмотре. Без React —
|
||||||
|
* покрыт тестами (noteText.test.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MONTHS_GENITIVE = [
|
||||||
|
'января',
|
||||||
|
'февраля',
|
||||||
|
'марта',
|
||||||
|
'апреля',
|
||||||
|
'мая',
|
||||||
|
'июня',
|
||||||
|
'июля',
|
||||||
|
'августа',
|
||||||
|
'сентября',
|
||||||
|
'октября',
|
||||||
|
'ноября',
|
||||||
|
'декабря'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Заглушка на месте незаполненной части текста в предпросмотре. */
|
||||||
|
const BLANK = '____';
|
||||||
|
|
||||||
|
/** YYYY-MM-DD → «21 июля 2026 г.» */
|
||||||
|
export function formatLongDate(iso: string): string {
|
||||||
|
const [y, m, d] = iso.split('-').map(Number);
|
||||||
|
if (!y || !m || !d) return iso;
|
||||||
|
return `${d} ${MONTHS_GENITIVE[m - 1]} ${y} г.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD → «21.07.2026» */
|
||||||
|
export function formatShortDate(iso: string): string {
|
||||||
|
const [y, m, d] = iso.split('-');
|
||||||
|
return y && m && d ? `${d}.${m}.${y}` : iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ['a'] → 'a'; ['a','b'] → 'a и b'; ['a','b','c'] → 'a, b и c'. */
|
||||||
|
export function joinWithAnd(parts: string[]): string {
|
||||||
|
if (parts.length <= 1) return parts.join('');
|
||||||
|
return `${parts.slice(0, -1).join(', ')} и ${parts[parts.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Городецкий Тимур Александрович» → «Городецкий Т. А.» */
|
||||||
|
export function shortFio(fio: string): string {
|
||||||
|
const [surname, ...rest] = fio.trim().split(/\s+/);
|
||||||
|
if (!surname) return '';
|
||||||
|
const initials = rest.map((name) => `${name.charAt(0).toUpperCase()}.`).join(' ');
|
||||||
|
return initials ? `${surname} ${initials}` : surname;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Что было в СКУД в этот день: «1 запись 08:35», «< 8 ч 08:49–13:10», «нет отметок». */
|
||||||
|
export function formatMissedDetails(day: IMissedDay): string {
|
||||||
|
const times = [day.first, day.last].filter(Boolean).join('–');
|
||||||
|
return times ? `${MISSED_KIND_LABEL[day.kind]} ${times}` : MISSED_KIND_LABEL[day.kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выбранные дни одной строкой — для колонки SkudMissedDates (читается в представлениях SP). */
|
||||||
|
export function formatMissedDaysSummary(days: IMissedDay[]): string {
|
||||||
|
return days.map((d) => `${formatShortDate(d.date)} (${formatMissedDetails(d)})`).join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonSentence(data: IFormData, dateWord: string): string {
|
||||||
|
if (data.Reason === OTHER_REASON) {
|
||||||
|
const details = data.ReasonDetails.trim() || BLANK;
|
||||||
|
return `Отсутствие отметки в системе СКУД в ${dateWord} произошло по следующей причине: ${details}`;
|
||||||
|
}
|
||||||
|
const reason = SKUD_REASONS.find((r) => r.value === data.Reason);
|
||||||
|
return `Отсутствие отметки в системе СКУД в ${dateWord} произошло по причине ${reason ? reason.phrase : BLANK}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Абзацы тела записки (без шапки и подписи). */
|
||||||
|
export function buildNoteParagraphs(data: IFormData): string[] {
|
||||||
|
const dates = data.MissedDays.length > 0 ? joinWithAnd(data.MissedDays.map((d) => formatLongDate(d.date))) : BLANK;
|
||||||
|
const dateWord = data.MissedDays.length > 1 ? 'указанные даты' : 'указанную дату';
|
||||||
|
const name = shortFio(data.EmployeeFio) || BLANK;
|
||||||
|
|
||||||
|
const second = data.PresenceConfirmed
|
||||||
|
? `${reasonSentence(data, dateWord)} Факт моего нахождения на рабочем месте и исполнения трудовых обязанностей в течение рабочего дня подтверждаю.`
|
||||||
|
: reasonSentence(data, dateWord);
|
||||||
|
|
||||||
|
return [
|
||||||
|
`Я, ${name}, по факту отсутствия отметок в системе СКУД ${dates} сообщаю следующее:`,
|
||||||
|
second,
|
||||||
|
'В дальнейшем обязуюсь соблюдать установленный порядок фиксации рабочего времени и не допускать подобных случаев.'
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"dataListTitle": "Объяснительная по СКУД — данные",
|
||||||
|
"columns": [
|
||||||
|
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||||
|
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||||
|
|
||||||
|
{ "internalName": "SubmitDate", "title": "Дата подачи", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
||||||
|
{ "internalName": "SkudEmployeeFio", "title": "Ф.И.О. сотрудника", "type": "Text" },
|
||||||
|
{ "internalName": "Position", "title": "Должность", "type": "Text" },
|
||||||
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||||
|
{ "internalName": "SkudMissedDates", "title": "Дни без отметок", "type": "Note" },
|
||||||
|
{ "internalName": "SkudMissedDaysJson", "title": "Дни без отметок (JSON)", "type": "Note" },
|
||||||
|
{ "internalName": "SkudReason", "title": "Причина", "type": "Choice", "choices": ["Невнимательность", "Забыл(а) пропуск", "Неисправность турникета или считывателя", "Другое"] },
|
||||||
|
{ "internalName": "SkudReasonDetails", "title": "Причина (подробно)", "type": "Note" },
|
||||||
|
{ "internalName": "SkudPresenceConfirmed", "title": "Присутствие подтверждено", "type": "Boolean" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Тело CollapseSection — сетка в 2 колонки под FormField; календарь и список
|
||||||
|
// дней не поля, им нужна вся ширина.
|
||||||
|
.fullRow {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-bottom: $sp-2;
|
||||||
|
font-size: $fs-label;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CollapseSection, useFormReadOnly } from '../../../shared/ui';
|
||||||
|
import { IMissedDay, ISectionProps } from '../model';
|
||||||
|
import { SkudCalendar } from '../components/SkudCalendar';
|
||||||
|
import { SelectedDaysList } from '../components/SelectedDaysList';
|
||||||
|
import { ISubmittedDay } from '../submittedDays';
|
||||||
|
import styles from './DaysSection.module.scss';
|
||||||
|
|
||||||
|
/** Клик по уже выбранному дню снимает выбор, по новому — добавляет; порядок — по дате. */
|
||||||
|
function toggleDay(days: IMissedDay[], day: IMissedDay): IMissedDay[] {
|
||||||
|
const exists = days.some((d) => d.date === day.date);
|
||||||
|
if (exists) return days.filter((d) => d.date !== day.date);
|
||||||
|
return [...days, day].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IDaysSectionProps extends ISectionProps {
|
||||||
|
/** Дни, уже закрытые поданными объяснительными; нужны только форме подачи. */
|
||||||
|
submittedDays?: Map<string, ISubmittedDay>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_SUBMITTED_DAYS = new Map<string, ISubmittedDay>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дни без отметок. В редактируемой заявке — календарь СКУД с выбором; в
|
||||||
|
* режиме чтения календарь не показываем: он строится по СКУД ТЕКУЩЕГО
|
||||||
|
* пользователя, а согласующему нужен сохранённый снимок дней заявителя.
|
||||||
|
*/
|
||||||
|
export const DaysSection: React.FC<IDaysSectionProps> = ({ data, set, context, submittedDays }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
const selectedDates = data.MissedDays.map((d) => d.date);
|
||||||
|
const removeDay = (date: string): void => set('MissedDays', data.MissedDays.filter((d) => d.date !== date));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapseSection title="Дни без отметок СКУД" icon="calendar" defaultOpen>
|
||||||
|
<div className={styles.fullRow}>
|
||||||
|
{!readOnly && (
|
||||||
|
<div className={styles.calendar}>
|
||||||
|
<SkudCalendar
|
||||||
|
context={context}
|
||||||
|
selectedDates={selectedDates}
|
||||||
|
submittedDays={submittedDays || NO_SUBMITTED_DAYS}
|
||||||
|
onToggle={(day) => set('MissedDays', toggleDay(data.MissedDays, day))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.label}>Выбрано дней: {data.MissedDays.length}</div>
|
||||||
|
<SelectedDaysList days={data.MissedDays} onRemove={readOnly ? undefined : removeDay} />
|
||||||
|
</div>
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
||||||
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сотрудник — всегда текущий пользователь (СКУД отдаёт только свои проходы),
|
||||||
|
* поэтому поле «Сотрудник» не редактируется даже в новой заявке. Дата подачи
|
||||||
|
* тоже не редактируется — её ставит форма.
|
||||||
|
*/
|
||||||
|
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex }) => (
|
||||||
|
<FormGrid>
|
||||||
|
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||||
|
<PeopleField context={context} value={data.Employee} onChange={(v) => set('Employee', v)} readOnly />
|
||||||
|
</FormField>
|
||||||
|
{/* Дата подачи — всегда сегодняшняя (ставит форма), руками не меняется. */}
|
||||||
|
<FormField label="Дата подачи" htmlFor="SubmitDate">
|
||||||
|
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Должность" htmlFor="Position" required>
|
||||||
|
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||||
|
</FormField>
|
||||||
|
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Checkbox, CollapseSection, FormField, Select, TextArea } from '../../../shared/ui';
|
||||||
|
import { ISectionProps, OTHER_REASON, PRESENCE_CONFIRMATION_LABEL, SKUD_REASONS } from '../model';
|
||||||
|
|
||||||
|
const REASON_OPTIONS = SKUD_REASONS.map((r) => ({ value: r.value, label: r.value }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Причина отсутствия отметки + подтверждение присутствия на рабочем месте.
|
||||||
|
* Поля кладём прямо в CollapseSection: её тело уже сетка полей, лишний
|
||||||
|
* FormGrid внутри сжал бы всё в половину ширины.
|
||||||
|
*/
|
||||||
|
export const ExplanationSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
|
<CollapseSection title="Объяснение" icon="edit" defaultOpen>
|
||||||
|
<FormField label="Причина" htmlFor="Reason" required full>
|
||||||
|
<Select
|
||||||
|
id="Reason"
|
||||||
|
value={data.Reason}
|
||||||
|
onChange={(v) => set('Reason', v)}
|
||||||
|
options={REASON_OPTIONS}
|
||||||
|
placeholder="Выберите причину"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{data.Reason === OTHER_REASON && (
|
||||||
|
<FormField label="Опишите причину" htmlFor="ReasonDetails" required full>
|
||||||
|
<TextArea id="ReasonDetails" value={data.ReasonDetails} onChange={(v) => set('ReasonDetails', v)} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label="Подтверждение" htmlFor="PresenceConfirmed" required full>
|
||||||
|
<Checkbox
|
||||||
|
id="PresenceConfirmed"
|
||||||
|
checked={data.PresenceConfirmed}
|
||||||
|
onChange={(v) => set('PresenceConfirmed', v)}
|
||||||
|
label={PRESENCE_CONFIRMATION_LABEL}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { SPHttpClient } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { HOLIDAYS_LIST_TITLE, HOLIDAYS_SITE_URL } from '../../../shared/config';
|
||||||
|
|
||||||
|
interface IHolidayItem {
|
||||||
|
Title?: string;
|
||||||
|
holidayDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// В списке выходные с переносом подписаны так: "Женский день (перенос с вс 8 марта)".
|
||||||
|
function stripParens(name: string): string {
|
||||||
|
const i = name.indexOf('(');
|
||||||
|
return (i >= 0 ? name.slice(0, i) : name).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHolidays(context: WebPartContext): Promise<Map<string, string>> {
|
||||||
|
const url =
|
||||||
|
`${HOLIDAYS_SITE_URL}/_api/web/lists/getByTitle('${encodeURIComponent(HOLIDAYS_LIST_TITLE)}')/items` +
|
||||||
|
`?$select=Title,holidayDate&$top=500`;
|
||||||
|
const response = await context.spHttpClient.get(url, SPHttpClient.configurations.v1, {
|
||||||
|
headers: { accept: 'application/json;odata=nometadata' }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Производственный календарь: HTTP ${response.status}`);
|
||||||
|
|
||||||
|
const body = (await response.json()) as { value?: IHolidayItem[] };
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
(body.value || []).forEach((item) => {
|
||||||
|
if (item.holidayDate) map.set(item.holidayDate.slice(0, 10), stripParens(item.Title || ''));
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Календарь один на всех и меняется раз в год — грузим один раз за сеанс.
|
||||||
|
let cached: Promise<Map<string, string>> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Праздничные выходные: "YYYY-MM-DD" → название. Ошибка не критична: форма
|
||||||
|
* работает и без праздников, поэтому отдаём пустую карту (и не кешируем её).
|
||||||
|
*/
|
||||||
|
export function getHolidays(context: WebPartContext): Promise<Map<string, string>> {
|
||||||
|
if (!cached) {
|
||||||
|
cached = fetchHolidays(context).catch((e) => {
|
||||||
|
console.warn('[СКУД] Производственный календарь недоступен', e);
|
||||||
|
cached = undefined;
|
||||||
|
return new Map<string, string>();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
ISkudEvent,
|
||||||
|
buildMonthCells,
|
||||||
|
cellKind,
|
||||||
|
groupEventsByDay,
|
||||||
|
minSelectableDateKey,
|
||||||
|
toMissedKind,
|
||||||
|
workedMinutes
|
||||||
|
} from './skudDays';
|
||||||
|
|
||||||
|
function event(eventTime: string, door = 'D1'): ISkudEvent {
|
||||||
|
return { personName: '', iin: '', eventTime, doorName: door, doorIndexCode: door, cardNo: '', eventType: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TODAY = '2026-07-25';
|
||||||
|
const MIN = '2026-06-01';
|
||||||
|
|
||||||
|
describe('minSelectableDateKey', () => {
|
||||||
|
it('первое число прошлого месяца, в том числе через границу года', () => {
|
||||||
|
expect(minSelectableDateKey(new Date(2026, 6, 25))).toBe(MIN);
|
||||||
|
expect(minSelectableDateKey(new Date(2027, 0, 10))).toBe('2026-12-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupEventsByDay', () => {
|
||||||
|
it('сортирует проходы и схлопывает двойной скан в одну минуту на одной точке', () => {
|
||||||
|
const byDay = groupEventsByDay([
|
||||||
|
event('2026-07-21T18:05:10+05:00'),
|
||||||
|
event('2026-07-21T08:35:01+05:00'),
|
||||||
|
event('2026-07-21T08:35:40+05:00')
|
||||||
|
]);
|
||||||
|
const day = byDay.get('2026-07-21');
|
||||||
|
expect(day?.events).toHaveLength(2);
|
||||||
|
expect(day?.first).toBe('08:35');
|
||||||
|
expect(day?.last).toBe('18:05');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workedMinutes', () => {
|
||||||
|
it('вычитает час обеда и не уходит в минус', () => {
|
||||||
|
expect(workedMinutes('09:00', '18:00')).toBe(8 * 60);
|
||||||
|
expect(workedMinutes('09:00', '09:30')).toBe(0);
|
||||||
|
expect(workedMinutes('', '')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cellKind / toMissedKind', () => {
|
||||||
|
const byDay = groupEventsByDay([
|
||||||
|
event('2026-07-20T09:00:00+05:00'),
|
||||||
|
event('2026-07-20T18:00:00+05:00'),
|
||||||
|
event('2026-07-21T08:35:00+05:00'),
|
||||||
|
event('2026-07-25T08:10:00+05:00')
|
||||||
|
]);
|
||||||
|
const cells = buildMonthCells(2026, 6, byDay);
|
||||||
|
const kindOf = (dateKey: string, holiday = false): ReturnType<typeof cellKind> => {
|
||||||
|
const index = cells.findIndex((c) => c?.dateKey === dateKey);
|
||||||
|
const cell = cells[index];
|
||||||
|
if (!cell) throw new Error(`нет ячейки ${dateKey}`);
|
||||||
|
return cellKind(cell, index % 7 >= 5, holiday, TODAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('раскрашивает дни по отметкам', () => {
|
||||||
|
expect(kindOf('2026-07-20')).toBe('full');
|
||||||
|
expect(kindOf('2026-07-21')).toBe('single');
|
||||||
|
expect(kindOf('2026-07-22')).toBe('absent');
|
||||||
|
expect(kindOf('2026-07-18')).toBe('weekend'); // суббота
|
||||||
|
expect(kindOf('2026-07-22', true)).toBe('holiday');
|
||||||
|
expect(kindOf('2026-07-27')).toBe('empty'); // будущее
|
||||||
|
});
|
||||||
|
|
||||||
|
it('прошедшие дни: < 8 ч, 1 запись и без отметок — да; ≥ 8 ч и выходные — нет', () => {
|
||||||
|
expect(toMissedKind('partial', '2026-07-09', TODAY, MIN)).toBe('partial');
|
||||||
|
expect(toMissedKind('single', '2026-07-21', TODAY, MIN)).toBe('single');
|
||||||
|
expect(toMissedKind('absent', '2026-07-22', TODAY, MIN)).toBe('absent');
|
||||||
|
expect(toMissedKind('full', '2026-07-20', TODAY, MIN)).toBeUndefined();
|
||||||
|
expect(toMissedKind('weekend', '2026-07-18', TODAY, MIN)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('сегодня — только без единой отметки', () => {
|
||||||
|
expect(toMissedKind('empty', TODAY, TODAY, MIN)).toBe('absent');
|
||||||
|
expect(toMissedKind('single', TODAY, TODAY, MIN)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('раньше лимита и в будущем — нельзя', () => {
|
||||||
|
expect(toMissedKind('absent', '2026-05-29', TODAY, MIN)).toBeUndefined();
|
||||||
|
expect(toMissedKind('empty', '2026-07-27', TODAY, MIN)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('месяц начинается с понедельника: 1 июля 2026 — среда, два пустых места', () => {
|
||||||
|
expect(cells[0]).toBeNull();
|
||||||
|
expect(cells[1]).toBeNull();
|
||||||
|
expect(cells[2]?.day).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Чистая логика календаря СКУД (без React/SharePoint — покрыта тестами).
|
||||||
|
* Правила раскраски дней перенесены из «Мой СКУД» (portal-main-page,
|
||||||
|
* SkudModal.tsx), чтобы сотрудник видел в форме ровно ту же картину.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Один проход через точку СКУД (элемент ответа /Hikvision/Events/CurrentUser). */
|
||||||
|
export interface ISkudEvent {
|
||||||
|
personName: string;
|
||||||
|
iin: string;
|
||||||
|
/** ISO со смещением, напр. "2026-06-26T08:39:48+05:00" — время уже местное. */
|
||||||
|
eventTime: string;
|
||||||
|
doorName: string;
|
||||||
|
doorIndexCode: string;
|
||||||
|
cardNo: string;
|
||||||
|
eventType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проходы одного дня. */
|
||||||
|
export interface IDayInfo {
|
||||||
|
day: number;
|
||||||
|
/** YYYY-MM-DD */
|
||||||
|
dateKey: string;
|
||||||
|
/** По возрастанию времени, без двойных сканов. */
|
||||||
|
events: ISkudEvent[];
|
||||||
|
/** HH:MM первого прохода ('' — проходов нет). */
|
||||||
|
first: string;
|
||||||
|
/** HH:MM последнего прохода ('' — проходов нет). */
|
||||||
|
last: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вид ячейки календаря:
|
||||||
|
* full / partial — есть приход и уход (≥ 8 ч / < 8 ч без обеда);
|
||||||
|
* single — ровно одна запись (нет прихода или ухода);
|
||||||
|
* absent — прошедший рабочий день без единого прохода;
|
||||||
|
* holiday / weekend — нерабочий день без проходов;
|
||||||
|
* empty — сегодня или будущее без проходов.
|
||||||
|
*/
|
||||||
|
export type CellKind = 'full' | 'partial' | 'single' | 'absent' | 'holiday' | 'weekend' | 'empty';
|
||||||
|
|
||||||
|
/** Что именно не так с днём, по которому пишется объяснительная. */
|
||||||
|
export type MissedKind = 'partial' | 'single' | 'absent';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сколько прошлых месяцев доступно, кроме текущего: объяснительные пишут по
|
||||||
|
* свежим случаям, 1 = текущий + прошлый месяц.
|
||||||
|
*/
|
||||||
|
export const SELECTABLE_MONTHS_BACK = 1;
|
||||||
|
|
||||||
|
const FULL_DAY_MINUTES = 8 * 60;
|
||||||
|
const LUNCH_MINUTES = 60;
|
||||||
|
/** Индекс столбца Сб в сетке (неделя с понедельника: Пн = 0). */
|
||||||
|
const SATURDAY_COLUMN = 5;
|
||||||
|
const DAYS_IN_WEEK = 7;
|
||||||
|
|
||||||
|
export function pad2(n: number): string {
|
||||||
|
return n < 10 ? `0${n}` : String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDateKey(year: number, month: number, day: number): string {
|
||||||
|
return `${year}-${pad2(month + 1)}-${pad2(day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayDateKey(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return toDateKey(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Первое число самого раннего доступного месяца (YYYY-MM-DD), см. SELECTABLE_MONTHS_BACK. */
|
||||||
|
export function minSelectableDateKey(today: Date = new Date()): string {
|
||||||
|
const first = new Date(today.getFullYear(), today.getMonth() - SELECTABLE_MONTHS_BACK, 1);
|
||||||
|
return toDateKey(first.getFullYear(), first.getMonth(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeOf(e: ISkudEvent): string {
|
||||||
|
return e.eventTime.slice(11, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Двойной скан — два срабатывания одной точки в пределах одной минуты.
|
||||||
|
function dedupeByMinute(events: ISkudEvent[]): ISkudEvent[] {
|
||||||
|
const seen: Record<string, boolean> = {};
|
||||||
|
return events.filter((e) => {
|
||||||
|
const key = `${e.eventTime.slice(0, 16)}|${e.doorIndexCode}`;
|
||||||
|
if (seen[key]) return false;
|
||||||
|
seen[key] = true;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проходы → по дням (ключ YYYY-MM-DD). */
|
||||||
|
export function groupEventsByDay(events: ISkudEvent[]): Map<string, IDayInfo> {
|
||||||
|
const buckets = new Map<string, ISkudEvent[]>();
|
||||||
|
events.forEach((e) => {
|
||||||
|
const key = e.eventTime.slice(0, 10);
|
||||||
|
const bucket = buckets.get(key);
|
||||||
|
if (bucket) bucket.push(e);
|
||||||
|
else buckets.set(key, [e]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = new Map<string, IDayInfo>();
|
||||||
|
buckets.forEach((bucket, key) => {
|
||||||
|
const sorted = dedupeByMinute(bucket.slice().sort((a, b) => a.eventTime.localeCompare(b.eventTime)));
|
||||||
|
result.set(key, {
|
||||||
|
day: Number(key.slice(8, 10)),
|
||||||
|
dateKey: key,
|
||||||
|
events: sorted,
|
||||||
|
first: timeOf(sorted[0]),
|
||||||
|
last: timeOf(sorted[sorted.length - 1])
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Рабочее время дня: последний проход минус первый минус час обеда (не в минус). */
|
||||||
|
export function workedMinutes(first: string, last: string): number {
|
||||||
|
if (!first || !last) return 0;
|
||||||
|
const [fh, fm] = first.split(':').map(Number);
|
||||||
|
const [lh, lm] = last.split(':').map(Number);
|
||||||
|
const span = lh * 60 + lm - (fh * 60 + fm);
|
||||||
|
return span > 0 ? Math.max(0, span - LUNCH_MINUTES) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сумма полных часов по дням месяца — приблизительно, реальную формулу кадров не знаем. */
|
||||||
|
export function monthWorkedHours(byDay: Map<string, IDayInfo>): number {
|
||||||
|
let total = 0;
|
||||||
|
byDay.forEach((info) => {
|
||||||
|
total += Math.floor(workedMinutes(info.first, info.last) / 60);
|
||||||
|
});
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ячейки месяца: ведущие null — пустые места до первого числа (неделя с понедельника). */
|
||||||
|
export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (IDayInfo | null)[] {
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const firstColumn = (new Date(year, month, 1).getDay() + 6) % DAYS_IN_WEEK;
|
||||||
|
const cells: (IDayInfo | null)[] = [];
|
||||||
|
for (let i = 0; i < firstColumn; i += 1) cells.push(null);
|
||||||
|
for (let d = 1; d <= daysInMonth; d += 1) {
|
||||||
|
const key = toDateKey(year, month, d);
|
||||||
|
cells.push(byDay.get(key) ?? { day: d, dateKey: key, events: [], first: '', last: '' });
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeekendColumn(cellIndex: number): boolean {
|
||||||
|
return cellIndex % DAYS_IN_WEEK >= SATURDAY_COLUMN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cellKind(info: IDayInfo, isWeekend: boolean, isHoliday: boolean, todayKey: string): CellKind {
|
||||||
|
if (info.events.length === 1) return 'single';
|
||||||
|
if (info.events.length > 1) {
|
||||||
|
return workedMinutes(info.first, info.last) >= FULL_DAY_MINUTES ? 'full' : 'partial';
|
||||||
|
}
|
||||||
|
if (isHoliday) return 'holiday';
|
||||||
|
if (isWeekend) return 'weekend';
|
||||||
|
return info.dateKey < todayKey ? 'absent' : 'empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Можно ли выбрать день в объяснительную (и с какой пометкой):
|
||||||
|
* - прошедший день < 8 ч, с одной записью или без отметок — да (у < 8 ч часто
|
||||||
|
* пропущен приход или уход: прошёл за коллегой, вышел через другую дверь);
|
||||||
|
* - сегодня — только если нет ни одной отметки (забыл пропуск). Одна запись
|
||||||
|
* сегодня — норма, сотрудник ещё не ушёл;
|
||||||
|
* - ≥ 8 ч, выходные, праздники, будущее и дни раньше minKey — нет.
|
||||||
|
*/
|
||||||
|
export function toMissedKind(
|
||||||
|
kind: CellKind,
|
||||||
|
dateKey: string,
|
||||||
|
todayKey: string,
|
||||||
|
minKey: string
|
||||||
|
): MissedKind | undefined {
|
||||||
|
if (dateKey < minKey || dateKey > todayKey) return undefined;
|
||||||
|
if (dateKey === todayKey) return kind === 'empty' ? 'absent' : undefined;
|
||||||
|
if (kind === 'partial' || kind === 'single' || kind === 'absent') return kind;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL, SKUD_EVENTS_PATH } from '../../../shared/config';
|
||||||
|
import { ISkudEvent, pad2 } from './skudDays';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
// Защита от зацикливания, если сервер игнорирует параметр page.
|
||||||
|
const MAX_PAGES = 100;
|
||||||
|
|
||||||
|
interface ISkudResponse {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
items: ISkudEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 404 от API СКУД — учётная запись не связана с 1С (не сбой, а отдельное состояние UI). */
|
||||||
|
export class SkudNotLinkedError extends Error {
|
||||||
|
public constructor() {
|
||||||
|
super('Учётная запись не связана с системой 1С — данные СКУД недоступны.');
|
||||||
|
this.name = 'SkudNotLinkedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthQuery(year: number, month: number): string {
|
||||||
|
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||||
|
const mm = pad2(month + 1);
|
||||||
|
return `&startDate=${year}-${mm}-01&endDate=${year}-${mm}-${pad2(lastDay)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все проходы текущего пользователя за месяц (month — 0-based). Сервер отдаёт
|
||||||
|
* страницами; поле total в ответе — размер страницы, а не общее число, поэтому
|
||||||
|
* читаем, пока страница не окажется неполной.
|
||||||
|
*/
|
||||||
|
export async function getSkudEventsForMonth(
|
||||||
|
context: WebPartContext,
|
||||||
|
year: number,
|
||||||
|
month: number
|
||||||
|
): Promise<ISkudEvent[]> {
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
|
const all: ISkudEvent[] = [];
|
||||||
|
let previousFirst = '';
|
||||||
|
|
||||||
|
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
||||||
|
const url =
|
||||||
|
`${DICTIONARY_API_BASE_URL}${SKUD_EVENTS_PATH}?page=${page}&pageSize=${PAGE_SIZE}` + monthQuery(year, month);
|
||||||
|
const response: HttpClientResponse = await client.get(url, AadHttpClient.configurations.v1);
|
||||||
|
if (response.status === 404) throw new SkudNotLinkedError();
|
||||||
|
if (!response.ok) throw new Error(`СКУД: ошибка загрузки (HTTP ${response.status})`);
|
||||||
|
|
||||||
|
const items = ((await response.json()) as ISkudResponse).items || [];
|
||||||
|
all.push(...items);
|
||||||
|
|
||||||
|
const currentFirst = items.length > 0 ? items[0].eventTime : '';
|
||||||
|
// Та же первая запись, что и на прошлой странице, — сервер не листает.
|
||||||
|
const pageRepeated = currentFirst !== '' && currentFirst === previousFirst;
|
||||||
|
if (items.length < PAGE_SIZE || pageRepeated) break;
|
||||||
|
previousFirst = currentFirst;
|
||||||
|
}
|
||||||
|
|
||||||
|
return all;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { friendlyErrorMessage } from '../../../shared/diagnostics';
|
||||||
|
import { IDayInfo, ISkudEvent, SELECTABLE_MONTHS_BACK, groupEventsByDay } from './skudDays';
|
||||||
|
import { SkudNotLinkedError, getSkudEventsForMonth } from './skudEvents.service';
|
||||||
|
import { getHolidays } from './holidays.service';
|
||||||
|
|
||||||
|
export interface ISkudMonth {
|
||||||
|
year: number;
|
||||||
|
/** 0-based */
|
||||||
|
month: number;
|
||||||
|
byDay: Map<string, IDayInfo>;
|
||||||
|
holidays: Map<string, string>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | undefined;
|
||||||
|
/** Учётная запись не связана с 1С — данных СКУД нет совсем. */
|
||||||
|
notLinked: boolean;
|
||||||
|
canGoNext: boolean;
|
||||||
|
/** Назад — не дальше SELECTABLE_MONTHS_BACK месяцев от текущего. */
|
||||||
|
canGoPrev: boolean;
|
||||||
|
goPrev: () => void;
|
||||||
|
goNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IMonthView {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сквозной номер месяца — чтобы сравнивать месяцы через границу года.
|
||||||
|
function monthIndex(year: number, month: number): number {
|
||||||
|
return year * 12 + month;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftMonth(view: IMonthView, delta: number): IMonthView {
|
||||||
|
const d = new Date(view.year, view.month + delta, 1);
|
||||||
|
return { year: d.getFullYear(), month: d.getMonth() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проходы СКУД текущего пользователя по месяцам + праздники; листать можно от текущего месяца назад на SELECTABLE_MONTHS_BACK. */
|
||||||
|
export function useSkudMonth(context: WebPartContext): ISkudMonth {
|
||||||
|
const now = new Date();
|
||||||
|
const [view, setView] = useState<IMonthView>({ year: now.getFullYear(), month: now.getMonth() });
|
||||||
|
const [events, setEvents] = useState<ISkudEvent[]>([]);
|
||||||
|
const [holidays, setHolidays] = useState<Map<string, string>>(new Map());
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
const [notLinked, setNotLinked] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getHolidays(context)
|
||||||
|
.then((map) => {
|
||||||
|
if (alive) setHolidays(map);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
getSkudEventsForMonth(context, view.year, view.month)
|
||||||
|
.then((items) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setEvents(items);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!alive) return;
|
||||||
|
if (e instanceof SkudNotLinkedError) setNotLinked(true);
|
||||||
|
else setError(friendlyErrorMessage(e));
|
||||||
|
setEvents([]);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context, view.year, view.month]);
|
||||||
|
|
||||||
|
const byDay = useMemo(() => groupEventsByDay(events), [events]);
|
||||||
|
const viewIndex = monthIndex(view.year, view.month);
|
||||||
|
const currentIndex = monthIndex(now.getFullYear(), now.getMonth());
|
||||||
|
const canGoNext = viewIndex < currentIndex;
|
||||||
|
const canGoPrev = viewIndex > currentIndex - SELECTABLE_MONTHS_BACK;
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: view.year,
|
||||||
|
month: view.month,
|
||||||
|
byDay,
|
||||||
|
holidays,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
notLinked,
|
||||||
|
canGoNext,
|
||||||
|
canGoPrev,
|
||||||
|
goPrev: () => setView((v) => shiftMonth(v, -1)),
|
||||||
|
goNext: () => setView((v) => shiftMonth(v, 1))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
|
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||||
|
import { ensureEmployee } from '../../shared/employee';
|
||||||
|
import { FORM_ID, IFormData, OTHER_REASON } from './model';
|
||||||
|
import { formatMissedDaysSummary, formatShortDate } from './noteText';
|
||||||
|
import { todayDateKey } from './skud/skudDays';
|
||||||
|
|
||||||
|
/** Сервис списка согласования этой формы — см. shared/approval/approvalServiceFactory.ts. */
|
||||||
|
export function skudExplanationApprovalService(listId?: string): ApprovalListService {
|
||||||
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Заголовок элемента (системная колонка Title). */
|
||||||
|
function buildTitle(employeeName: string): string {
|
||||||
|
return `Объяснительная по СКУД — ${employeeName} — ${formatShortDate(todayDateKey())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
Title: buildTitle(employeeName),
|
||||||
|
RequestStatus: status,
|
||||||
|
SubmitDate: data.SubmitDate || null,
|
||||||
|
EmployeeId: employeeId,
|
||||||
|
SkudEmployeeFio: data.EmployeeFio || null,
|
||||||
|
Position: data.Position || null,
|
||||||
|
Organization: data.Organization || null,
|
||||||
|
// Читаемая строка — для представлений списка; источник для формы — JSON-снимок.
|
||||||
|
SkudMissedDates: formatMissedDaysSummary(data.MissedDays) || null,
|
||||||
|
SkudMissedDaysJson: JSON.stringify(data.MissedDays),
|
||||||
|
SkudReason: data.Reason || null,
|
||||||
|
SkudReasonDetails: data.Reason === OTHER_REASON ? data.ReasonDetails || null : null,
|
||||||
|
SkudPresenceConfirmed: data.PresenceConfirmed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить объяснительную: содержимое + маршрут (согласование и исполнение).
|
||||||
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём.
|
||||||
|
* executors — шаги фазы «Исполнение» по маршруту (напр. кадры); пусто — после
|
||||||
|
* согласования заявка сразу станет «Согласовано».
|
||||||
|
* listId — партиция обновляемой заявки (см. UnpaidLeave/submit.ts, тот же смысл).
|
||||||
|
*/
|
||||||
|
export async function saveSkudExplanationRequest(
|
||||||
|
existingId: number | undefined,
|
||||||
|
requestTypeId: number,
|
||||||
|
data: IFormData,
|
||||||
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
|
status: string,
|
||||||
|
listId?: string
|
||||||
|
): Promise<ISaveItemResult> {
|
||||||
|
const employee = await ensureEmployee(data.Employee);
|
||||||
|
const item = buildItem(data, status, employee.Id, data.EmployeeFio || employee.Title || data.Employee);
|
||||||
|
|
||||||
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
|
await writeRoute(skudExplanationApprovalService(listId), result.id, approvers, executors, status);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
|
import { ensureEmployee } from '../../shared/employee';
|
||||||
|
import { dataListTitle } from '../../shared/listTitles';
|
||||||
|
import { partitionsForPageUrl } from '../../shared/approval/listPartitions';
|
||||||
|
import { FORM_ID } from './model';
|
||||||
|
import { minSelectableDateKey } from './skud/skudDays';
|
||||||
|
import { ISubmittedDay, ISubmittedItem, collectSubmittedDays } from './submittedDays';
|
||||||
|
|
||||||
|
// Человек пишет единицы объяснительных за 2 месяца; 500 — с большим запасом.
|
||||||
|
const MAX_ITEMS_PER_LIST = 500;
|
||||||
|
|
||||||
|
async function readList(listUrl: string | undefined, employeeId: number, createdFrom: string): Promise<ISubmittedItem[]> {
|
||||||
|
const web = getSP().web;
|
||||||
|
const list = listUrl ? web.getList(listUrl) : web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
|
return (await list.items
|
||||||
|
.select('Id', 'RequestStatus', 'RequestUniqueCode', 'SkudMissedDaysJson')
|
||||||
|
.filter(`EmployeeId eq ${employeeId} and Created ge datetime'${createdFrom}T00:00:00Z'`)
|
||||||
|
.top(MAX_ITEMS_PER_LIST)()) as ISubmittedItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дни сотрудника, уже закрытые поданными объяснительными (карта дата → заявка).
|
||||||
|
*
|
||||||
|
* Ищем по всем партициям серии: при переключении на новый список прошлый
|
||||||
|
* месяц может остаться в предыдущем. Заявки старше начала доступного периода
|
||||||
|
* не читаем: объяснительная пишется не раньше самого дня, а дни до
|
||||||
|
* minSelectableDateKey выбрать всё равно нельзя.
|
||||||
|
*
|
||||||
|
* excludeId — открытая сейчас заявка (черновик/доработка), чтобы она не
|
||||||
|
* блокировала собственные дни. Id сравнивается без учёта партиции: совпадение
|
||||||
|
* id у разных заявок в разных списках маловероятно и дало бы лишь пропуск
|
||||||
|
* проверки, а не ложную блокировку.
|
||||||
|
*/
|
||||||
|
export async function getSubmittedDays(employeeEmail: string, excludeId?: number): Promise<Map<string, ISubmittedDay>> {
|
||||||
|
if (!employeeEmail) return new Map();
|
||||||
|
const employee = await ensureEmployee(employeeEmail);
|
||||||
|
const createdFrom = minSelectableDateKey();
|
||||||
|
const partitions = partitionsForPageUrl(FORM_ID);
|
||||||
|
// Реестр партиций недоступен — читаем текущий список по конвенции имён.
|
||||||
|
const listUrls: (string | undefined)[] = partitions.length > 0 ? partitions.map((p) => p.listId) : [undefined];
|
||||||
|
|
||||||
|
const perList = await Promise.all(listUrls.map((url) => readList(url, employee.Id, createdFrom)));
|
||||||
|
const items = perList.reduce<ISubmittedItem[]>((all, part) => all.concat(part), []);
|
||||||
|
return collectSubmittedDays(items, excludeId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { IMissedDay } from './model';
|
||||||
|
import { ISubmittedItem, collectSubmittedDays, submittedConflictMessage } from './submittedDays';
|
||||||
|
|
||||||
|
function item(Id: number, RequestStatus: string, dates: string[]): ISubmittedItem {
|
||||||
|
const days: IMissedDay[] = dates.map((date) => ({ date, kind: 'absent', first: '', last: '' }));
|
||||||
|
return { Id, RequestStatus, RequestUniqueCode: `12-2609-${Id}`, SkudMissedDaysJson: JSON.stringify(days) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('collectSubmittedDays', () => {
|
||||||
|
it('блокируют поданные, согласованные и возвращённые; отклонённые и черновики — нет', () => {
|
||||||
|
const map = collectSubmittedDays([
|
||||||
|
item(1, 'На согласовании', ['2026-09-01']),
|
||||||
|
item(2, 'Согласовано', ['2026-09-02']),
|
||||||
|
item(3, 'Возвращена на доработку', ['2026-09-03']),
|
||||||
|
item(4, 'Отклонено', ['2026-09-04']),
|
||||||
|
item(5, 'Черновик', ['2026-09-05'])
|
||||||
|
]);
|
||||||
|
expect(Array.from(map.keys()).sort()).toEqual(['2026-09-01', '2026-09-02', '2026-09-03']);
|
||||||
|
expect(map.get('2026-09-01')).toEqual({ code: '12-2609-1', status: 'На согласовании' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('открытая сейчас заявка не блокирует свои же дни', () => {
|
||||||
|
const map = collectSubmittedDays([item(3, 'Возвращена на доработку', ['2026-09-03'])], 3);
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('битый JSON не роняет проверку', () => {
|
||||||
|
const map = collectSubmittedDays([{ Id: 7, RequestStatus: 'Согласовано', SkudMissedDaysJson: '{oops' }]);
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('submittedConflictMessage', () => {
|
||||||
|
it('перечисляет пересечения с номером и статусом заявки', () => {
|
||||||
|
const map = collectSubmittedDays([item(1, 'Согласовано', ['2026-09-01'])]);
|
||||||
|
const days: IMissedDay[] = [
|
||||||
|
{ date: '2026-09-01', kind: 'absent', first: '', last: '' },
|
||||||
|
{ date: '2026-09-02', kind: 'absent', first: '', last: '' }
|
||||||
|
];
|
||||||
|
expect(submittedConflictMessage(days, map)).toBe(
|
||||||
|
'По этим дням объяснительная уже подана, уберите их: 01.09.2026 — №12-2609-1 (Согласовано).'
|
||||||
|
);
|
||||||
|
expect(submittedConflictMessage([days[1]], map)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { REQUEST_STATUS } from '../../shared/approval/models/statuses';
|
||||||
|
import { IMissedDay } from './model';
|
||||||
|
import { parseMissedDays } from './missedDaysJson';
|
||||||
|
import { formatShortDate } from './noteText';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Защита от повторов: день, уже вошедший в поданную объяснительную, второй раз
|
||||||
|
* не выбирается. Чистая логика без SharePoint — покрыта тестами; чтение
|
||||||
|
* списков — в submittedDays.service.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Объяснительная, которая уже «закрывает» день. */
|
||||||
|
export interface ISubmittedDay {
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка списка данных — только нужные для проверки поля. */
|
||||||
|
export interface ISubmittedItem {
|
||||||
|
Id: number;
|
||||||
|
RequestStatus?: string;
|
||||||
|
RequestUniqueCode?: string;
|
||||||
|
SkudMissedDaysJson?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Какие статусы блокируют день. «Отклонено» — нет: можно написать заново.
|
||||||
|
* «Черновик» — нет: это ещё не подача. «Возвращена» — да: её надо поправить,
|
||||||
|
* а не подавать новую.
|
||||||
|
*/
|
||||||
|
const BLOCKING_STATUSES: string[] = [
|
||||||
|
REQUEST_STATUS.onApproval,
|
||||||
|
REQUEST_STATUS.approved,
|
||||||
|
REQUEST_STATUS.execution,
|
||||||
|
REQUEST_STATUS.closed,
|
||||||
|
REQUEST_STATUS.returned
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Дата (YYYY-MM-DD) → заявка, которой она уже закрыта. excludeId — открытая сейчас заявка. */
|
||||||
|
export function collectSubmittedDays(items: ISubmittedItem[], excludeId?: number): Map<string, ISubmittedDay> {
|
||||||
|
const result = new Map<string, ISubmittedDay>();
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.Id === excludeId) return;
|
||||||
|
const status = item.RequestStatus || '';
|
||||||
|
if (BLOCKING_STATUSES.indexOf(status) < 0) return;
|
||||||
|
const submitted: ISubmittedDay = { code: item.RequestUniqueCode || String(item.Id), status };
|
||||||
|
parseMissedDays(item.SkudMissedDaysJson).forEach((day) => result.set(day.date, submitted));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сообщение о пересечении выбранных дней с уже поданными; undefined — пересечений нет. */
|
||||||
|
export function submittedConflictMessage(
|
||||||
|
days: IMissedDay[],
|
||||||
|
submitted: Map<string, ISubmittedDay>
|
||||||
|
): string | undefined {
|
||||||
|
const conflicts = days
|
||||||
|
.filter((d) => submitted.has(d.date))
|
||||||
|
.map((d) => {
|
||||||
|
const other = submitted.get(d.date) as ISubmittedDay;
|
||||||
|
return `${formatShortDate(d.date)} — №${other.code} (${other.status})`;
|
||||||
|
});
|
||||||
|
if (conflicts.length === 0) return undefined;
|
||||||
|
return `По этим дням объяснительная уже подана, уберите их: ${conflicts.join('; ')}.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import {
|
||||||
|
APPROVAL_PHASE,
|
||||||
|
ApprovalListService,
|
||||||
|
IMatrixRow,
|
||||||
|
getPlannedMatrix,
|
||||||
|
resolveApproverOrgInfo,
|
||||||
|
useMatrixEditor
|
||||||
|
} from '../../shared/approval';
|
||||||
|
|
||||||
|
type MatrixEditor = ReturnType<typeof useMatrixEditor>;
|
||||||
|
|
||||||
|
export interface ISkudRoute {
|
||||||
|
/** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */
|
||||||
|
approvers: MatrixEditor;
|
||||||
|
/** Исполнители по маршруту (фаза «Исполнение») — только чтение. */
|
||||||
|
executors: MatrixEditor;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IRouteSource {
|
||||||
|
context: WebPartContext;
|
||||||
|
approval: ApprovalListService;
|
||||||
|
/** Заявка уже записана — маршрут читаем из её строк, а не планируем заново. */
|
||||||
|
requestId: number | undefined;
|
||||||
|
employeeEmail: string;
|
||||||
|
requestTypeId: number | undefined;
|
||||||
|
/** Можно ли править согласующих. */
|
||||||
|
editable: boolean;
|
||||||
|
/** Меняется после решения согласующего — перечитать строки. */
|
||||||
|
reloadSeq?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byPhase = (execution: boolean) => (rows: IMatrixRow[]): IMatrixRow[] =>
|
||||||
|
rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маршрут объяснительной по двум фазам. Без отдельного запроса фазы
|
||||||
|
* 'Execution' getPlannedMatrix исполнителей не резолвит — шаг кадров из
|
||||||
|
* маршрута «пропадал» бы. Паттерн тот же, что в Referral.
|
||||||
|
*/
|
||||||
|
export function useSkudRoute(source: IRouteSource): ISkudRoute {
|
||||||
|
const { context, approval, requestId, employeeEmail, requestTypeId, editable, reloadSeq = 0 } = source;
|
||||||
|
const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId);
|
||||||
|
const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`;
|
||||||
|
|
||||||
|
const loadPhase = useCallback(
|
||||||
|
(execution: boolean) =>
|
||||||
|
requestId
|
||||||
|
? approval.getMatrixForRequest(requestId).then(byPhase(execution))
|
||||||
|
: getPlannedMatrix(
|
||||||
|
context,
|
||||||
|
requestTypeId as number,
|
||||||
|
employeeEmail,
|
||||||
|
approval,
|
||||||
|
execution ? 'Execution' : 'Approval'
|
||||||
|
),
|
||||||
|
[context, approval, requestId, employeeEmail, requestTypeId]
|
||||||
|
);
|
||||||
|
const loadApprovers = useCallback(() => loadPhase(false), [loadPhase]);
|
||||||
|
const loadExecutors = useCallback(() => loadPhase(true), [loadPhase]);
|
||||||
|
const jobTitle = (login: string): Promise<string> => approval.getJobTitle(login);
|
||||||
|
|
||||||
|
const approvers = useMatrixEditor(loadApprovers, reloadKey, editable, canLoad, jobTitle, resolveApproverOrgInfo(context));
|
||||||
|
const executors = useMatrixEditor(loadExecutors, reloadKey, false, canLoad, jobTitle, resolveApproverOrgInfo(context));
|
||||||
|
return { approvers, executors };
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ISubmittedDay } from './submittedDays';
|
||||||
|
import { getSubmittedDays } from './submittedDays.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уже поданные дни для календаря. Ошибка не блокирует форму: календарь
|
||||||
|
* работает без пометок, а повтор всё равно поймает проверка при отправке.
|
||||||
|
*/
|
||||||
|
export function useSubmittedDays(employeeEmail: string, excludeId: number | undefined): Map<string, ISubmittedDay> {
|
||||||
|
const [submitted, setSubmitted] = useState<Map<string, ISubmittedDay>>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getSubmittedDays(employeeEmail, excludeId)
|
||||||
|
.then((map) => {
|
||||||
|
if (alive) setSubmitted(map);
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn('[Объяснительная по СКУД] Не удалось проверить уже поданные дни', e));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [employeeEmail, excludeId]);
|
||||||
|
|
||||||
|
return submitted;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { IFormData, OTHER_REASON } from './model';
|
||||||
|
import { formatShortDate } from './noteText';
|
||||||
|
import { minSelectableDateKey } from './skud/skudDays';
|
||||||
|
|
||||||
|
/** Ошибка заполнения формы или undefined, если всё обязательное заполнено. */
|
||||||
|
export function validateSkudExplanation(data: IFormData): string | undefined {
|
||||||
|
if (!data.Employee || !data.Position.trim()) return 'Заполните обязательные поля: сотрудник, должность.';
|
||||||
|
if (data.MissedDays.length === 0) return 'Выберите в календаре хотя бы один день без отметок.';
|
||||||
|
// Черновик мог пролежать дольше лимита — дни из него уже вне доступного периода.
|
||||||
|
const minKey = minSelectableDateKey();
|
||||||
|
const outdated = data.MissedDays.filter((d) => d.date < minKey);
|
||||||
|
if (outdated.length > 0) {
|
||||||
|
return `Объяснительную можно подать только за текущий и прошлый месяц — уберите дни раньше ${formatShortDate(minKey)}: ${outdated
|
||||||
|
.map((d) => formatShortDate(d.date))
|
||||||
|
.join(', ')}.`;
|
||||||
|
}
|
||||||
|
if (!data.Reason) return 'Укажите причину отсутствия отметки.';
|
||||||
|
if (data.Reason === OTHER_REASON && !data.ReasonDetails.trim()) return 'Опишите причину отсутствия отметки.';
|
||||||
|
if (!data.PresenceConfirmed) return 'Подтвердите, что находились на рабочем месте.';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
@@ -421,6 +422,19 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
|
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||||
|
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||||
|
{editId && (
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory
|
||||||
|
approval={approval}
|
||||||
|
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||||
|
requestId={editId}
|
||||||
|
reloadKey={requestStatus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SubmitActions
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
@@ -247,6 +248,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
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
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
@@ -207,6 +208,12 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
@@ -315,6 +316,19 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<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
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId
|
useRequestTypeId
|
||||||
@@ -152,6 +153,12 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
@@ -314,6 +315,19 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<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
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId
|
useRequestTypeId
|
||||||
@@ -152,6 +153,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
@@ -219,7 +220,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
const isValid =
|
const isValid =
|
||||||
data.FullName.trim().length > 0 && data.Position.trim().length > 0 && data.Companies.length > 0;
|
data.FullName.trim().length > 0 && data.Position.trim().length > 0 && data.Companies.length > 0;
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (!isValid) return 'Заполните обязательные поля: Ф.И.О., Должность, Организация заявителя.';
|
if (!isValid) return 'Заполните обязательные поля: Ф.И.О., Должность, Организация.';
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
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
|
<SubmitActions
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
@@ -274,6 +275,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
||||||
{ "internalName": "Location", "title": "Местоположение", "type": "Text" },
|
{ "internalName": "Location", "title": "Местоположение", "type": "Text" },
|
||||||
{ "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "Organization", "title": "Организация заявителя", "type": "Note" },
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||||
{ "internalName": "WorkplaceCompaniesJson", "title": "Организации заявителя (JSON)", "type": "Note" },
|
{ "internalName": "WorkplaceCompaniesJson", "title": "Организации заявителя (JSON)", "type": "Note" },
|
||||||
|
|
||||||
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
|
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import { FormField, OrganizationField, useFormReadOnly } from '../../../shared/u
|
|||||||
import { CompanyPickerMulti } from '../../../shared/dictionary';
|
import { CompanyPickerMulti } from '../../../shared/dictionary';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
const ORGANIZATION_LABEL = 'Организация заявителя';
|
const ORGANIZATION_LABEL = 'Организация';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Организации заявителя: мультивыбор из справочника (как «1.5 Организация» в
|
* Организации заявителя: мультивыбор из справочника (как «1.5 Организация» в
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
|
|||||||
'hr-unpaid-leave': React.lazy(
|
'hr-unpaid-leave': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'form-unpaid-leave' */ './UnpaidLeave/UnpaidLeaveForm')
|
() => import(/* webpackChunkName: 'form-unpaid-leave' */ './UnpaidLeave/UnpaidLeaveForm')
|
||||||
),
|
),
|
||||||
|
'hr-skud-explanation': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'form-skud-explanation' */ './SkudExplanation/SkudExplanationForm')
|
||||||
|
),
|
||||||
'wh-tmc-transfer': React.lazy(
|
'wh-tmc-transfer': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm')
|
() => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm')
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
|
|||||||
'hr-unpaid-leave': React.lazy(
|
'hr-unpaid-leave': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'view-unpaid-leave' */ './UnpaidLeave/UnpaidLeaveView')
|
() => import(/* webpackChunkName: 'view-unpaid-leave' */ './UnpaidLeave/UnpaidLeaveView')
|
||||||
),
|
),
|
||||||
|
'hr-skud-explanation': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'view-skud-explanation' */ './SkudExplanation/SkudExplanationView')
|
||||||
|
),
|
||||||
'wh-tmc-transfer': React.lazy(
|
'wh-tmc-transfer': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView')
|
() => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView')
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { IMatrixRow } from './models/IMatrixRow';
|
|||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
||||||
import { userSearchVariants } from './userSearchVariants';
|
import { userSearchVariants } from './userSearchVariants';
|
||||||
|
import { planRoutePatch } from './routePatchPlan';
|
||||||
|
|
||||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||||
const FIELDS = {
|
const FIELDS = {
|
||||||
@@ -141,10 +142,26 @@ export class ApprovalListService {
|
|||||||
// он разрешится вместе со всеми остальными после execute() ниже.
|
// он разрешится вместе со всеми остальными после execute() ниже.
|
||||||
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
|
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
|
||||||
void batchedList.items.add({
|
void batchedList.items.add({
|
||||||
Title: a.position || a.email,
|
...this.stepFields(a, approverIds[i], status, phase, order),
|
||||||
[`${FIELDS.approver}Id`]: approverIds[i],
|
|
||||||
[FIELDS.status]: status,
|
|
||||||
[`${FIELDS.ticket}Id`]: requestId,
|
[`${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.order]: order,
|
||||||
[FIELDS.allowReassign]: a.allowReassign,
|
[FIELDS.allowReassign]: a.allowReassign,
|
||||||
[FIELDS.phase]: phase,
|
[FIELDS.phase]: phase,
|
||||||
@@ -152,8 +169,73 @@ export class ApprovalListService {
|
|||||||
[FIELDS.slaHours]: a.slaHours ?? null,
|
[FIELDS.slaHours]: a.slaHours ?? null,
|
||||||
[FIELDS.askOverdueReason]: a.askOverdueReason ?? null,
|
[FIELDS.askOverdueReason]: a.askOverdueReason ?? null,
|
||||||
[FIELDS.autoApprove]: a.autoApprove ?? false,
|
[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();
|
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).
|
// Строки согласования по заявке, в порядке согласования (по Order).
|
||||||
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
|
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
|
||||||
const rows = (await this.list()
|
const rows = (await this.list()
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { IMatrixRow } from './models/IMatrixRow';
|
|||||||
import { ApprovalStatus, IResolveFieldValue } from './models/IApprovalRouteApi';
|
import { ApprovalStatus, IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||||
import { APPROVAL_PHASE, REQUEST_STATUS, isSubmittedStatus } from './models/statuses';
|
import { APPROVAL_PHASE, REQUEST_STATUS, isSubmittedStatus } from './models/statuses';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выключатель пересохранения маршрута патчем — ДЛЯ ВСЕХ форм сразу (см.
|
||||||
|
* writeRoute). Проверено пилотами на «Заявлении на увольнение» и «Организации
|
||||||
|
* рабочего места» 2026-09-29: обновление строк на месте, обе фазы, уход
|
||||||
|
* выпавших шагов в корзину. false — аварийный откат к прежнему «удалить всё и
|
||||||
|
* создать заново» (история прошлых отправок снова перестанет сохраняться).
|
||||||
|
*/
|
||||||
|
const ROUTE_PATCH_ENABLED = true;
|
||||||
|
|
||||||
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
||||||
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
|
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
|
||||||
// (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность).
|
// (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность).
|
||||||
@@ -72,6 +81,12 @@ export async function writeApprovers(
|
|||||||
// очереди. Первого исполнителя переключит на «На исполнении»
|
// очереди. Первого исполнителя переключит на «На исполнении»
|
||||||
// approval.activateNextQueued, когда фаза согласования будет пройдена целиком
|
// approval.activateNextQueued, когда фаза согласования будет пройдена целиком
|
||||||
// (см. approverDecision.service.ts/decide).
|
// (см. approverDecision.service.ts/decide).
|
||||||
|
//
|
||||||
|
// Пересохранение — ПАТЧЕМ (ApprovalListService.patchSteps, см. ROUTE_PATCH_ENABLED):
|
||||||
|
// строки, что остаются в маршруте, обновляются на месте, и их прошлые решения
|
||||||
|
// (кто вернул/отклонил и с каким комментарием) сохраняются в истории версий —
|
||||||
|
// из неё строится «История согласования». Раньше маршрут удалялся и создавался
|
||||||
|
// заново, и история прошлых отправок терялась.
|
||||||
export async function writeRoute(
|
export async function writeRoute(
|
||||||
approval: ApprovalListService,
|
approval: ApprovalListService,
|
||||||
requestId: number,
|
requestId: number,
|
||||||
@@ -79,8 +94,6 @@ export async function writeRoute(
|
|||||||
executors: IApproverToWrite[],
|
executors: IApproverToWrite[],
|
||||||
status: string
|
status: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await approval.deleteForRequest(requestId);
|
|
||||||
|
|
||||||
// При ОТПРАВКЕ все шаги пишем «Ждёт очереди», затем активируем первый —
|
// При ОТПРАВКЕ все шаги пишем «Ждёт очереди», затем активируем первый —
|
||||||
// так активен ровно текущий шаг, остальные ждут. В черновике — статус заявки
|
// так активен ровно текущий шаг, остальные ждут. В черновике — статус заявки
|
||||||
// (Черновик), без активации.
|
// (Черновик), без активации.
|
||||||
@@ -102,6 +115,17 @@ export async function writeRoute(
|
|||||||
0
|
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, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
|
||||||
await approval.createSteps(
|
await approval.createSteps(
|
||||||
requestId,
|
requestId,
|
||||||
@@ -110,6 +134,7 @@ export async function writeRoute(
|
|||||||
APPROVAL_PHASE.execution,
|
APPROVAL_PHASE.execution,
|
||||||
lastApprovalStep + 1
|
lastApprovalStep + 1
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (submitted) {
|
if (submitted) {
|
||||||
await approval.activateNextQueued(requestId);
|
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 { canEditRoute } from './routeEditRights';
|
||||||
export { canEditAllFields } from './fullEditRights';
|
export { canEditAllFields } from './fullEditRights';
|
||||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
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 { UserPicker } from './ui/UserPicker';
|
||||||
export { DecisionPanel } from './ui/DecisionPanel';
|
export { DecisionPanel } from './ui/DecisionPanel';
|
||||||
export { StatusBadge } from './ui/StatusBadge';
|
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('—');
|
expect(statusForNewRow(rows)).toBe('—');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('решение согласующего не копируется, даже если оно у всех одинаковое', () => {
|
||||||
|
expect(statusForNewRow([row({ status: 'Возвращена на доработку' })])).toBe('—');
|
||||||
|
expect(statusForNewRow([row({ status: 'Согласовано' }), row({ status: 'Согласовано' })])).toBe('—');
|
||||||
|
});
|
||||||
|
|
||||||
it('пустой статус приравнивается к прочерку', () => {
|
it('пустой статус приравнивается к прочерку', () => {
|
||||||
expect(statusForNewRow([row({ status: '' }), row({ status: '—' })])).toBe('—');
|
expect(statusForNewRow([row({ status: '' }), row({ status: '—' })])).toBe('—');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { friendlyErrorMessage } from '../diagnostics';
|
|||||||
import { IMatrixRow } from './models/IMatrixRow';
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
import { IApproverOrgInfo } from './approverOrgInfo';
|
import { IApproverOrgInfo } from './approverOrgInfo';
|
||||||
|
import { REQUEST_STATUS } from './models/statuses';
|
||||||
|
|
||||||
export interface IUseMatrixEditor {
|
export interface IUseMatrixEditor {
|
||||||
rows: IMatrixRow[];
|
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 {
|
export function statusForNewRow(rows: IMatrixRow[]): string {
|
||||||
if (rows.length === 0) return '—';
|
if (rows.length === 0) return '—';
|
||||||
const first = rows[0].status || '—';
|
const first = rows[0].status || '—';
|
||||||
|
if (DECISION_STATUSES.indexOf(first) >= 0) return '—';
|
||||||
return rows.every((r) => (r.status || '—') === first) ? first : '—';
|
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.text && <div className={styles.text}>{message.text}</div>}
|
||||||
{message.attachments.length > 0 && (
|
{message.attachments.length > 0 && (
|
||||||
<div className={styles.attachments}>
|
<div className={styles.attachments}>
|
||||||
<AttachmentList items={message.attachments} />
|
{/* В сообщении чата «Скачать все» ни к чему — там обычно один-два файла. */}
|
||||||
|
<AttachmentList items={message.attachments} downloadAll={false} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,6 +74,21 @@ export function getNotifyOnlyTo(): string {
|
|||||||
*/
|
*/
|
||||||
export const DICTIONARY_API_BASE_URL = 'https://api.tnsu.kz';
|
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: поток оповещения согласующего/исполнителя.
|
* Power Automate: поток оповещения согласующего/исполнителя.
|
||||||
* Триггер «When an HTTP request is received» защищён Entra («любой пользователь
|
* Триггер «When an HTTP request is received» защищён Entra («любой пользователь
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
|
|||||||
'hr-transfer': 'Заявка на перемещение сотрудника',
|
'hr-transfer': 'Заявка на перемещение сотрудника',
|
||||||
'hr-vacation': 'Заявка на отпуск',
|
'hr-vacation': 'Заявка на отпуск',
|
||||||
'hr-unpaid-leave': 'Отпуск без содержания',
|
'hr-unpaid-leave': 'Отпуск без содержания',
|
||||||
|
'hr-skud-explanation': 'Объяснительная по СКУД',
|
||||||
'it-1c-ticket': 'Заявка в отдел 1С',
|
'it-1c-ticket': 'Заявка в отдел 1С',
|
||||||
'it-workplace-setup': 'Организация рабочего места',
|
'it-workplace-setup': 'Организация рабочего места',
|
||||||
'recruiting-hire': 'Прием на работу',
|
'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
Reference in New Issue
Block a user