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 (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ |
|
||||
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
|
||||
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
|
||||
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Диагностика: для каждого списка «… — согласование» на сайте печатает число
|
||||
строк и проиндексированы ли колонки, по которым портал фильтрует маршрут.
|
||||
Только чтение, ничего не меняет.
|
||||
|
||||
.DESCRIPTION
|
||||
Портал ищет шаги маршрута фильтрами по Ticket (TicketId eq …), ApprovalStatus
|
||||
и TansuSiteApprover. В списке больше 5000 строк такой фильтр работает только
|
||||
по проиндексированной колонке — иначе SharePoint отвечает ошибкой порога
|
||||
представления. Скрипт показывает, где индекса нет и как близко список к 5000.
|
||||
|
||||
.EXAMPLE
|
||||
./Inspect-Indexes.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
|
||||
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
|
||||
-Thumbprint "224B64C8E0CAB1E09AF373E92B3AF4AC33C1233C"
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$SiteUrl,
|
||||
[Parameter(Mandatory = $true)][string]$ClientId,
|
||||
[Parameter(Mandatory = $true)][string]$Tenant,
|
||||
[Parameter(Mandatory = $true)][string]$Thumbprint
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Колонки списков согласования, по которым портал фильтрует (см.
|
||||
# shared/approval/approvalList.service.ts и features/myRequests/myRequests.service.ts).
|
||||
$filteredColumns = @('Ticket', 'ApprovalStatus', 'TansuSiteApprover')
|
||||
|
||||
# Порог представления SharePoint: выше него фильтр по неиндексированной колонке падает.
|
||||
$listViewThreshold = 5000
|
||||
|
||||
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
|
||||
|
||||
$approvalLists = Get-PnPList | Where-Object { $_.Title -like '*— согласование' } | Sort-Object Title
|
||||
|
||||
$report = foreach ($list in $approvalLists) {
|
||||
$row = [ordered]@{
|
||||
'Список' = $list.Title
|
||||
'Строк' = $list.ItemCount
|
||||
'До 5000' = $listViewThreshold - $list.ItemCount
|
||||
}
|
||||
foreach ($name in $filteredColumns) {
|
||||
$field = Get-PnPField -List $list -Identity $name -ErrorAction SilentlyContinue
|
||||
$row[$name] = if (-not $field) { 'нет колонки' } elseif ($field.Indexed) { 'индекс' } else { 'НЕТ индекса' }
|
||||
}
|
||||
[pscustomobject]$row
|
||||
}
|
||||
|
||||
Write-Host "`nИндексы в списках согласования ($SiteUrl):`n" -ForegroundColor Cyan
|
||||
$report | Format-Table -AutoSize
|
||||
|
||||
Disconnect-PnPOnline
|
||||
@@ -17,6 +17,14 @@ export const REQUEST_TYPES: IRequestType[] = [
|
||||
// Отдел кадров
|
||||
{ id: 'hr-vacation', categoryId: 'hr', title: 'Заявка на трудовой отпуск', icon: 'calendar' },
|
||||
{ id: 'hr-unpaid-leave', categoryId: 'hr', title: 'Заявка на отпуск без содержания', icon: 'document' },
|
||||
// Пока только тестировщикам: тип заявки и маршрут ещё не заведены в админке approval-route.
|
||||
{
|
||||
id: 'hr-skud-explanation',
|
||||
categoryId: 'hr',
|
||||
title: 'Объяснительная по СКУД',
|
||||
icon: 'user-check',
|
||||
comingSoon: true
|
||||
},
|
||||
{ id: 'hr-dismissal', categoryId: 'hr', title: 'Заявление на увольнение', icon: 'document', comingSoon: true },
|
||||
{ id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard', comingSoon: true },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
import { Alert, Dialog } from '@tnsu/ui-kit';
|
||||
import { ApprovalHistoryFeed, ApprovalListService, approvalServiceFor, HISTORY_TEXT } from '../../shared/approval';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
|
||||
interface Props {
|
||||
/** Заявка, чью историю показываем; undefined — окно закрыто. */
|
||||
request: IMyRequest | undefined;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Список согласования ИМЕННО той партиции, где лежит заявка (listId строки), —
|
||||
* как и остальные дочитывания в таблице. Если партицию не удалось сопоставить
|
||||
* с формой, approvalServiceFor бросает — показываем ошибку в окне, а не роняем таблицу.
|
||||
*/
|
||||
function approvalFor(request: IMyRequest): { approval?: ApprovalListService; error?: string } {
|
||||
try {
|
||||
return { approval: approvalServiceFor(request.formKey, request.listId) };
|
||||
} catch (e) {
|
||||
return { error: friendlyErrorMessage(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* «История согласования» заявки прямо из таблицы — по кнопке в строке, без
|
||||
* открытия самой заявки. Та же лента, что в карточке (ApprovalHistoryFeed).
|
||||
*/
|
||||
export const RequestHistoryDialog: React.FC<Props> = ({ request, onClose }) => {
|
||||
const target = React.useMemo(() => (request ? approvalFor(request) : {}), [request]);
|
||||
const number = request ? request.code || `#${request.id}` : '';
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} title={`${HISTORY_TEXT.title} · №${number}`} onDismiss={onClose} width="md">
|
||||
{request && target.error && <Alert variant="err">{target.error}</Alert>}
|
||||
{request && target.approval && (
|
||||
<ApprovalHistoryFeed approval={target.approval} dataListRef={request.listId} requestId={request.id} />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -79,6 +79,13 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Кнопки действий строки (история, корзина) — в ряд, прижаты вправо.
|
||||
.rowActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: $sp-1;
|
||||
}
|
||||
|
||||
// Пустой комментарий в колонке «Причина возврата/отклонения».
|
||||
.commentEmpty {
|
||||
color: $gray-400;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
import { Pager } from './Pager';
|
||||
import { RequestsFilterBar } from './RequestsFilterBar';
|
||||
import { RequestHistoryDialog } from './RequestHistoryDialog';
|
||||
import { SortableHeader } from './SortableHeader';
|
||||
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
|
||||
import { useTableRefinement } from './useTableRefinement';
|
||||
@@ -237,9 +238,11 @@ const Table: React.FC<{
|
||||
sort: ISortState;
|
||||
onSort: (key: SortKey) => void;
|
||||
onOpen: (request: IMyRequest) => void;
|
||||
/** Показать историю согласования заявки (окно, без открытия заявки). */
|
||||
onShowHistory: (request: IMyRequest) => void;
|
||||
/** Удалить черновик; задан только там, где в таблице бывают свои черновики. */
|
||||
onDelete?: (request: IMyRequest) => void;
|
||||
}> = ({ rows, view, statusFilter, sort, onSort, onOpen, onDelete }) => {
|
||||
}> = ({ rows, view, statusFilter, sort, onSort, onOpen, onShowHistory, onDelete }) => {
|
||||
const cols = columnsFor(view, statusFilter);
|
||||
const sortable = (label: string, key: SortKey): React.ReactNode => (
|
||||
<SortableHeader label={label} sortKey={key} sort={sort} onSort={onSort} />
|
||||
@@ -297,27 +300,41 @@ const Table: React.FC<{
|
||||
render: (r) => r.decisionComment || <span className={styles.commentEmpty}>Без комментария</span>
|
||||
});
|
||||
}
|
||||
if (onDelete && rows.some(canDeleteRow)) {
|
||||
// Действия строки: история согласования — у всех, корзина — только у своих
|
||||
// черновиков. Клик по всей строке открывает заявку — кнопкам это не нужно,
|
||||
// поэтому stopPropagation.
|
||||
const hasDeletable = !!onDelete && rows.some(canDeleteRow);
|
||||
columns.push({
|
||||
key: 'actions',
|
||||
title: '',
|
||||
width: '56px',
|
||||
render: (r) =>
|
||||
canDeleteRow(r) ? (
|
||||
// Место под одну или две иконки-кнопки (sm) с зазором.
|
||||
width: hasDeletable ? '92px' : '56px',
|
||||
render: (r) => (
|
||||
<div className={styles.rowActions}>
|
||||
<IconButton
|
||||
icon="calendar"
|
||||
title="История согласования"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShowHistory(r);
|
||||
}}
|
||||
/>
|
||||
{onDelete && canDeleteRow(r) && (
|
||||
<IconButton
|
||||
icon="trash"
|
||||
title="Удалить черновик"
|
||||
danger
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
// Клик по всей строке открывает заявку — кнопке это не нужно.
|
||||
e.stopPropagation();
|
||||
onDelete(r);
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
@@ -379,6 +396,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
const [totalRows, setTotalRows] = React.useState<number>(0);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
const [deleteError, setDeleteError] = React.useState<string>('');
|
||||
// Заявка, чья история согласования открыта в окне; undefined — окно закрыто.
|
||||
const [historyFor, setHistoryFor] = React.useState<IMyRequest | undefined>(undefined);
|
||||
|
||||
// Список перечитается сам: confirmAndDeleteDraft шлёт rp-requests-changed.
|
||||
const handleDelete = (request: IMyRequest): void => {
|
||||
@@ -729,11 +748,13 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
sort={sort}
|
||||
onSort={refinement.onSort}
|
||||
onOpen={onOpen}
|
||||
onShowHistory={setHistoryFor}
|
||||
onDelete={view === 'own:created' ? handleDelete : undefined}
|
||||
/>
|
||||
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
|
||||
</>
|
||||
)}
|
||||
<RequestHistoryDialog request={historyFor} onClose={() => setHistoryFor(undefined)} />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { DATA_SITE_URL } from '../../shared/config';
|
||||
import { IAssignee, IMyRequest } from './models/IMyRequest';
|
||||
import { parseAuthorOwsUser } from './parseAuthorOwsUser';
|
||||
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
||||
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
||||
import { perfScope, traced } from '../../shared/perfTrace';
|
||||
@@ -106,20 +107,6 @@ const MP_CODE = 'RequestUniqueCodeOWSTEXT';
|
||||
// создатель (см. toMyRequestFromSearch); основной — MP_AUTHOR/parseAuthorOwsUser.
|
||||
const OWN_SELECT_PROPERTIES = ['ListItemID', 'Path', 'Title', 'Created', 'Author', MP_AUTHOR, MP_REQUEST_STATUS, MP_CODE];
|
||||
|
||||
/**
|
||||
* Managed property Person-поля хранит и логин, и ФИО одной строкой:
|
||||
* «<логин> | <ФИО>» (проверено вручную, tools/sp props, 2026-08-20). Разделитель
|
||||
* — именно " | " С ПРОБЕЛАМИ: сам логин при claims-аутентификации выглядит как
|
||||
* `i:0#.f|membership|user@domain` и содержит "|" без пробелов вокруг, поэтому
|
||||
* делить по ПЕРВОМУ "|" нельзя — часть логина отрезалась бы вместе с почтой.
|
||||
*/
|
||||
function parseAuthorOwsUser(value: unknown): { email: string; name: string } {
|
||||
const text = typeof value === 'string' ? value : '';
|
||||
const sep = text.indexOf(' | ');
|
||||
if (sep < 0) return { email: text.trim(), name: '' };
|
||||
return { email: text.slice(0, sep).trim(), name: text.slice(sep + 3).trim() };
|
||||
}
|
||||
|
||||
// effectiveUserEmail, а не pageContext напрямую: при отладочной подмене личности
|
||||
// (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны
|
||||
// отбираться по подменённому сотруднику — иначе подмена показывала бы чужой
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { parseAuthorOwsUser } from './parseAuthorOwsUser';
|
||||
|
||||
describe('parseAuthorOwsUser', () => {
|
||||
it('ФИО — ровно второй кусок, служебный хвост отбрасывается', () => {
|
||||
const value =
|
||||
'gorodetskiy.timur@tnsu.kz | Тимур Городецкий | 693A30232E667C6D656D626572736869707C676F726F646574736B69792E74696D757240746E73752E6B7A i:0#.f|membership|gorodetskiy.timur@tnsu.kz';
|
||||
expect(parseAuthorOwsUser(value)).toEqual({ email: 'gorodetskiy.timur@tnsu.kz', name: 'Тимур Городецкий' });
|
||||
});
|
||||
|
||||
it('формат из двух частей — как раньше', () => {
|
||||
expect(parseAuthorOwsUser('a@tnsu.kz | Анна Ким')).toEqual({ email: 'a@tnsu.kz', name: 'Анна Ким' });
|
||||
});
|
||||
|
||||
it('без разделителя — только почта', () => {
|
||||
expect(parseAuthorOwsUser('a@tnsu.kz')).toEqual({ email: 'a@tnsu.kz', name: '' });
|
||||
});
|
||||
|
||||
it('не строка — пусто', () => {
|
||||
expect(parseAuthorOwsUser(undefined)).toEqual({ email: '', name: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Managed property Person-поля хранит несколько значений одной строкой через
|
||||
* " | ": «<почта> | <ФИО> | <служебный id> <claims-логин>» (первые два —
|
||||
* проверено вручную, tools/sp props, 2026-08-20; хвост вылез в UI 2026-09-29,
|
||||
* когда ФИО стали брать отсюда). Поэтому ФИО — ровно ВТОРОЙ кусок, а не «всё
|
||||
* после первого разделителя».
|
||||
*
|
||||
* Разделитель — именно " | " С ПРОБЕЛАМИ: claims-логин
|
||||
* `i:0#.f|membership|user@domain` содержит "|" без пробелов вокруг, и деление по
|
||||
* голому "|" порезало бы его на части.
|
||||
*
|
||||
* Отдельным модулем, без SharePoint-импортов: myRequests.service тянет PnPjs,
|
||||
* который Jest проекта не разбирает (ESM), — так функцию можно тестировать.
|
||||
*/
|
||||
export function parseAuthorOwsUser(value: unknown): { email: string; name: string } {
|
||||
const parts = (typeof value === 'string' ? value : '').split(' | ').map((p) => p.trim());
|
||||
return { email: parts[0] ?? '', name: parts[1] ?? '' };
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -339,6 +340,19 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -319,6 +320,12 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -327,6 +328,19 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
||||
waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования."
|
||||
/>
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
@@ -155,6 +156,12 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -28,14 +29,14 @@ import {
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { isoDatePlusMonths, formatIsoDate } from '../../shared/dateUtils';
|
||||
import { lastWorkingDayError } from './lastWorkingDay';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_MONTHS, IOrganizationRecord, primaryPosition } from './model';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, IOrganizationRecord, primaryPosition } from './model';
|
||||
import { saveDismissalRequest, dismissalApprovalService } from './submit';
|
||||
import { loadDismissalRequest } from './load';
|
||||
import { RequestSection } from './sections/RequestSection';
|
||||
@@ -197,15 +198,13 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
// Последний рабочий день нужно назначать минимум за MIN_NOTICE_MONTHS месяц —
|
||||
// календарь (min у input type=date, см. RequestSection) не даёт выбрать более
|
||||
// раннюю дату, но проверяем и здесь на случай обхода UI.
|
||||
const minLastWorkingDay = isoDatePlusMonths(MIN_NOTICE_MONTHS);
|
||||
const isLastDayValid = !data.LastWorkingDay || data.LastWorkingDay >= minLastWorkingDay;
|
||||
// раннюю дату, но проверяем и здесь на случай обхода UI. Тот же текст
|
||||
// RequestSection показывает подсказкой под полем (см. lastWorkingDay.ts).
|
||||
const lastDayError = lastWorkingDayError(data.LastWorkingDay);
|
||||
const position = primaryPosition(data.Organization);
|
||||
const isValid = !!(data.Employee && position.trim() && data.LastWorkingDay && isLastDayValid && data.Phone.trim());
|
||||
const isValid = !!(data.Employee && position.trim() && data.LastWorkingDay && !lastDayError && data.Phone.trim());
|
||||
const validate = (): string | undefined => {
|
||||
if (data.LastWorkingDay && !isLastDayValid) {
|
||||
return `Последний рабочий день нужно назначать минимум за ${MIN_NOTICE_MONTHS} месяц — минимально доступная дата: ${formatIsoDate(minLastWorkingDay)}.`;
|
||||
}
|
||||
if (lastDayError) return lastDayError;
|
||||
if (!data.Employee || !position.trim() || !data.LastWorkingDay || !data.Phone.trim()) {
|
||||
return 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.';
|
||||
}
|
||||
@@ -343,6 +342,20 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили, — поэтому история и в форме, а не только в просмотре.
|
||||
Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -283,6 +284,12 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый).
|
||||
Итог автосоздания обходного листа — тут же, а не в шапке: он появляется
|
||||
именно как следствие решения «Согласовать», логичнее видеть его рядом. */}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { formatIsoDate, isoDatePlusMonths } from '../../shared/dateUtils';
|
||||
import { MIN_NOTICE_MONTHS } from './model';
|
||||
|
||||
/** Самый ранний допустимый последний рабочий день ('YYYY-MM-DD'): сегодня + MIN_NOTICE_MONTHS. */
|
||||
export function minLastWorkingDay(): string {
|
||||
return isoDatePlusMonths(MIN_NOTICE_MONTHS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Почему последний рабочий день не подходит; undefined — подходит или ещё не
|
||||
* выбран. Одна функция и для подсказки под полем (RequestSection), и для
|
||||
* проверки перед отправкой (DismissalForm) — чтобы текст и правило не разошлись.
|
||||
*
|
||||
* Правило считается от СЕГОДНЯ: у заявления, пролежавшего на доработке, дата,
|
||||
* бывшая допустимой при подаче, может перестать подходить — подсказка сразу
|
||||
* объясняет, почему кнопки отправки неактивны.
|
||||
*/
|
||||
export function lastWorkingDayError(value: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const min = minLastWorkingDay();
|
||||
if (value >= min) return undefined;
|
||||
return `Последний рабочий день нужно назначать минимум за ${MIN_NOTICE_MONTHS} месяц — минимально доступная дата: ${formatIsoDate(min)}.`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Пояснение под полем, почему значение не подходит (см. lastWorkingDayError).
|
||||
.fieldError {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-label;
|
||||
color: $red-500;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput, TextArea, PhoneInput, PeopleField, LinkField } from '../../../shared/ui';
|
||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, MIN_NOTICE_MONTHS } from '../model';
|
||||
import { isoDatePlusMonths } from '../../../shared/dateUtils';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
||||
import { lastWorkingDayError, minLastWorkingDay } from '../lastWorkingDay';
|
||||
import { OrganizationsTable } from './OrganizationsTable';
|
||||
import styles from './RequestSection.module.scss';
|
||||
|
||||
interface IRequestSectionProps extends ISectionProps {
|
||||
/**
|
||||
@@ -27,7 +29,12 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
||||
onEmployeeChange,
|
||||
onRemoveOrganization,
|
||||
assetsAcceptorEditable
|
||||
}) => (
|
||||
}) => {
|
||||
// В просмотре (DismissalView) секция только для чтения — подсказка там ни к чему.
|
||||
const readOnly = useFormReadOnly();
|
||||
const lastDayError = lastWorkingDayError(data.LastWorkingDay);
|
||||
|
||||
return (
|
||||
<FormGrid>
|
||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||
<PeopleField
|
||||
@@ -56,15 +63,18 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
||||
onRemove={onRemoveOrganization}
|
||||
/>
|
||||
{/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе,
|
||||
чем через MIN_NOTICE_MONTHS месяц от сегодня (min). */}
|
||||
чем через MIN_NOTICE_MONTHS месяц от сегодня (min). Если дата уже стоит
|
||||
раньше (заявление пролежало на доработке) — подсказка под полем, иначе
|
||||
непонятно, почему кнопки отправки неактивны. */}
|
||||
<FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required>
|
||||
<TextInput
|
||||
id="LastWorkingDay"
|
||||
type="date"
|
||||
value={data.LastWorkingDay}
|
||||
onChange={(v) => set('LastWorkingDay', v)}
|
||||
min={isoDatePlusMonths(MIN_NOTICE_MONTHS)}
|
||||
min={minLastWorkingDay()}
|
||||
/>
|
||||
{!readOnly && lastDayError && <div className={styles.fieldError}>{lastDayError}</div>}
|
||||
</FormField>
|
||||
<FormField label="Контактный телефон" htmlFor="Phone" required>
|
||||
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
|
||||
@@ -93,3 +103,4 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -303,6 +304,19 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
@@ -152,6 +153,12 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -469,6 +470,19 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -417,6 +418,12 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -459,6 +460,19 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -380,6 +381,12 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -478,6 +479,19 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -238,6 +239,12 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -387,6 +388,19 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
||||
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
|
||||
/>
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -181,6 +182,12 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Объяснительная об отсутствии отметок в СКУД (hr-skud-explanation)
|
||||
|
||||
**Назначение:** объяснительная сотрудника за дни, когда в СКУД нет отметки прихода/ухода
|
||||
(одна запись) или нет отметок вовсе.
|
||||
**Категория:** Отдел кадров (плитка «Объяснительная по СКУД»)
|
||||
**Списки SP:** «Объяснительная по СКУД — данные» / «Объяснительная по СКУД — согласование»
|
||||
**RequestTypeId (внешний API маршрутов):** _плейсхолдер_ — получит числовой id при заведении
|
||||
типа в админке approval-route с `pageUrl` «hr-skud-explanation».
|
||||
|
||||
## Маршрут
|
||||
- Фазы: согласование (напр. непосредственный руководитель) + исполнение (напр. кадры) —
|
||||
обе фазы грузятся отдельными запросами `getPlannedMatrix(..., 'Approval' | 'Execution')`
|
||||
в `useSkudRoute` (паттерн Referral). Без запроса 'Execution' шаг исполнения пропадает.
|
||||
- Планируемый маршрут резолвится по e-mail сотрудника; согласующих заявитель может
|
||||
править (новая / черновик / возврат), исполнителей — нет (только чтение).
|
||||
- Нет согласующих вовсе — `initialSubmitStatus` сразу ставит «Выполнение».
|
||||
|
||||
## Особое поведение
|
||||
- **Только «на себя».** Проходы берутся из `GET api.tnsu.kz/Hikvision/Events/CurrentUser` —
|
||||
API определяет сотрудника по токену, за другого их не получить. Поле «Сотрудник» не
|
||||
редактируется. 404 от API = учётная запись не связана с 1С → вместо календаря сообщение.
|
||||
- **Календарь «Мой СКУД»** (перенесён из portal-main-page, те же цвета и правила):
|
||||
зелёный ≥ 8 ч, жёлтый < 8 ч (рабочее время = последний проход − первый − 1 ч обеда),
|
||||
красный — 1 запись, штриховка — прошедший будний день без проходов, индиго — праздник
|
||||
(SP-список «Производственный Календарь» на корневом сайте, `HOLIDAYS_SITE_URL`).
|
||||
- **Какие дни выбираются** (`toMissedKind`): прошедшие < 8 ч (часто пропущен приход/уход),
|
||||
с 1 записью и без отметок; сегодня — только если нет ни одной отметки (одна запись утром —
|
||||
норма, сотрудник ещё не ушёл). ≥ 8 ч, выходные, праздники — нет.
|
||||
- **Лимит — текущий и прошлый месяц** (`SELECTABLE_MONTHS_BACK = 1`): дальше календарь не
|
||||
листается, а при отправке черновика с устаревшими днями валидация просит их убрать.
|
||||
- **Защита от повторов** (`submittedDays.ts` / `.service.ts`): день, уже вошедший в свою
|
||||
объяснительную со статусом На согласовании / Согласовано / Выполнение / Закрыта /
|
||||
Возвращена на доработку, в календаре помечен ✓ и не выбирается (подсказка — номер и
|
||||
статус той заявки). «Отклонено» и «Черновик» не блокируют. Ищем по всем партициям,
|
||||
фильтр `EmployeeId` + `Created ≥ начала доступного периода`; открытая заявка себя не
|
||||
блокирует. Перед отправкой список перечитывается (вторая вкладка). Ошибка чтения
|
||||
форму не блокирует — календарь без пометок. Индекс на `Employee` провижинер не ставит;
|
||||
понадобится, только если партиция перерастёт 5000 элементов.
|
||||
- **Дни сохраняются снимком** (`SkudMissedDaysJson`: дата, вид, первая/последняя запись),
|
||||
т.к. согласующий чужой СКУД открыть не может. В режиме чтения календаря нет — только
|
||||
список дней. `SkudMissedDates` — та же информация читаемой строкой для представлений SP.
|
||||
- **Ф.И.О. полностью** — из `Employee/ByEmail` (`SkudEmployeeFio`), а не из профиля SP
|
||||
(там «Имя Фамилия»): в тексте записки нужно «Фамилия И. О.».
|
||||
- **Текст записки собирается автоматически** (`noteText.ts`) из дат, причины и
|
||||
подтверждения; показывается только в просмотре (документ для печати) — в форме подачи
|
||||
предпросмотра нет (убран по просьбе Тимура).
|
||||
- **Печать** — только документ (бланк: шапка «Директору {организация}», должность, Ф.И.О.,
|
||||
заголовок, текст, дата подачи, подпись), без данных формы и маршрута.
|
||||
- **Дата подачи** (`SubmitDate`, общий столбец сайта «только дата») — не редактируется,
|
||||
ставится сегодняшней у новой заявки и при открытии черновика/доработки; у отправленной
|
||||
остаётся прежней. Печатается внизу бланка (для старых записей без неё — `Created`).
|
||||
- **Заголовок элемента** (`Title`): `Объяснительная по СКУД — {ФИО} — дд.мм.гггг`.
|
||||
|
||||
## Секции формы
|
||||
EmployeeSection (сотрудник · должность · организация) → DaysSection (календарь + выбранные
|
||||
дни) → ExplanationSection (причина · «другое» · подтверждение).
|
||||
|
||||
## Точки входа
|
||||
- rptype: `hr-skud-explanation`
|
||||
- Режим согласующего (просмотр): есть (`SkudExplanationView`)
|
||||
- «Создать на основе»: нет
|
||||
@@ -0,0 +1,20 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin-bottom: $sp-4;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $navy-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import styles from './SkudExplanationForm.module.scss';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
Icon,
|
||||
RequestNumber,
|
||||
SubmitActions,
|
||||
getDraftDeleteHandler,
|
||||
useRequestSubmit
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
initialSubmitStatus,
|
||||
IApproverToWrite,
|
||||
unassignedStepMessage,
|
||||
resolveWriteListId
|
||||
} from '../../shared/approval';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { validateSkudExplanation } from './validation';
|
||||
import { saveSkudExplanationRequest, skudExplanationApprovalService } from './submit';
|
||||
import { loadSkudExplanationRequest } from './load';
|
||||
import { submittedConflictMessage } from './submittedDays';
|
||||
import { getSubmittedDays } from './submittedDays.service';
|
||||
import { useSubmittedDays } from './useSubmittedDays';
|
||||
import { useSkudRoute } from './useSkudRoute';
|
||||
import { todayDateKey } from './skud/skudDays';
|
||||
import { EmployeeSection } from './sections/EmployeeSection';
|
||||
import { DaysSection } from './sections/DaysSection';
|
||||
import { ExplanationSection } from './sections/ExplanationSection';
|
||||
|
||||
const SkudExplanationForm: React.FC<IRequestFormProps> = ({
|
||||
requestType,
|
||||
context,
|
||||
onCancel,
|
||||
onSaved,
|
||||
editId,
|
||||
editListId,
|
||||
onCodeLoaded
|
||||
}) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||
const submit = useRequestSubmit();
|
||||
// Стабильная обёртка: ссылка onCodeLoaded у родителя меняется на каждом
|
||||
// рендере, а в зависимостях эффекта загрузки она перезапрашивала бы заявку.
|
||||
const notifyCode = useStableCallback(onCodeLoaded);
|
||||
const { setError: setSubmitError } = submit;
|
||||
|
||||
const readOnly = !isEditableStatus(requestStatus);
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
// Отправленную заявку уже не правят — пустой e-mail не делает запрос.
|
||||
const submittedDays = useSubmittedDays(readOnly ? '' : data.Employee, requestId);
|
||||
|
||||
const approval = useMemo(() => skudExplanationApprovalService(editListId), [editListId]);
|
||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||
const requestTypeId = requestTypeIdState.id;
|
||||
|
||||
// Открыта своя заявка на редактирование (черновик/возврат).
|
||||
useEffect(() => {
|
||||
if (!editId) return;
|
||||
let alive = true;
|
||||
loadSkudExplanationRequest(editId, editListId)
|
||||
.then((loaded) => {
|
||||
if (!alive) return;
|
||||
// Черновик/доработку подают заново — дата подачи становится сегодняшней;
|
||||
// у отправленной заявки остаётся та, с которой её подали.
|
||||
const submitDate = isEditableStatus(loaded.status) ? todayDateKey() : loaded.data.SubmitDate;
|
||||
setData({ ...loaded.data, SubmitDate: submitDate });
|
||||
setRequestStatus(loaded.status);
|
||||
setRequestCode(loaded.code || undefined);
|
||||
if (loaded.code) notifyCode(loaded.code);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||
|
||||
// Новая заявка — всегда на себя: ФИО полностью, должность и организации из
|
||||
// Employee/ByEmail (ФИО профиля SharePoint — «Имя Фамилия», для записки не годится).
|
||||
useEffect(() => {
|
||||
if (editId) return;
|
||||
let alive = true;
|
||||
const user = context.pageContext.user;
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
SubmitDate: todayDateKey(),
|
||||
Employee: user.email,
|
||||
EmployeeFio: prev.EmployeeFio || user.displayName
|
||||
}));
|
||||
getEmployeeOrganizations(context, user.email)
|
||||
.then((records) => {
|
||||
if (!alive) return;
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
EmployeeFio: records[0]?.fio || prev.EmployeeFio,
|
||||
Position: prev.Position || records[0]?.position || '',
|
||||
Organization: formatEmployeeOrganizations(records)
|
||||
}));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [editId, context]);
|
||||
|
||||
// Маршрут по сотруднику; у открытой заявки — из уже записанных строк, чтобы не затереть правки.
|
||||
const { approvers: matrix, executors } = useSkudRoute({
|
||||
context,
|
||||
approval,
|
||||
requestId: editId,
|
||||
employeeEmail: data.Employee,
|
||||
requestTypeId,
|
||||
editable: !readOnly
|
||||
});
|
||||
|
||||
const validate = (): string | undefined => {
|
||||
const formError = validateSkudExplanation(data);
|
||||
if (formError) return formError;
|
||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// На отправку маршрут должен быть укомплектован — шаг без человека молча пропал бы при записи.
|
||||
const validateSubmit = (): string | undefined => {
|
||||
const formError = validate();
|
||||
if (formError) return formError;
|
||||
const repeatError = submittedConflictMessage(data.MissedDays, submittedDays);
|
||||
if (repeatError) return repeatError;
|
||||
if (matrix.error) {
|
||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||
}
|
||||
const unassignedError = unassignedStepMessage(
|
||||
matrix.rows,
|
||||
(r) =>
|
||||
`Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
return unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) =>
|
||||
`Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||
);
|
||||
};
|
||||
|
||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||
rows.map((r) => ({
|
||||
email: r.email,
|
||||
position: r.position,
|
||||
allowReassign: r.allowReassign,
|
||||
order: r.order,
|
||||
slaHours: r.slaHours,
|
||||
askOverdueReason: r.askOverdueReason,
|
||||
autoApprove: r.autoApprove
|
||||
}));
|
||||
|
||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||
// validate() уже гарантировал requestTypeId, но TS не видит этого через замыкание.
|
||||
const rtId = requestTypeId;
|
||||
if (!rtId) throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||
// Пометки в календаре — на момент открытия формы; за это время объяснительную
|
||||
// на те же дни могли подать из другой вкладки. Перечитываем прямо перед записью.
|
||||
if (status !== REQUEST_STATUS.draft) {
|
||||
const repeatError = submittedConflictMessage(data.MissedDays, await getSubmittedDays(data.Employee, requestId));
|
||||
if (repeatError) throw new Error(repeatError);
|
||||
}
|
||||
// Текущую партицию спрашиваем прямо перед записью: этот вызов запускает архивацию по порогу.
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
const { id, code } = await saveSkudExplanationRequest(
|
||||
requestId,
|
||||
rtId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
toApprovers(executors.rows),
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
if (code) setRequestCode(code);
|
||||
if (onSaved) onSaved();
|
||||
// Отправка (на согласование или, без согласующих, сразу на исполнение) — уведомляем активный шаг.
|
||||
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: rtId,
|
||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
// Согласующих в маршруте может не оказаться — тогда initialSubmitStatus сразу даёт «Выполнение».
|
||||
const handleSubmit = (): Promise<void> =>
|
||||
submit.run('submit', save(initialSubmitStatus(matrix.rows.length, executors.rows.length)), validateSubmit);
|
||||
|
||||
const handleDelete = getDraftDeleteHandler({
|
||||
formKey: FORM_ID,
|
||||
requestId,
|
||||
requestStatus,
|
||||
listId: editListId,
|
||||
code: requestCode,
|
||||
submit,
|
||||
onDeleted: onCancel
|
||||
});
|
||||
|
||||
const sectionProps = { data, set, context };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" className={styles.back} onClick={onCancel}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<span>К списку заявок</span>
|
||||
</button>
|
||||
|
||||
<FormCard
|
||||
title={FORM_TITLE}
|
||||
subtitle="Категория: Отдел кадров"
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
|
||||
itemId={requestId}
|
||||
/>
|
||||
}
|
||||
headerRight={
|
||||
requestId || requestStatus ? (
|
||||
<>
|
||||
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||
{requestCode && <RequestNumber code={requestCode} />}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{submit.savedKind && !submit.error && (
|
||||
<Alert variant="ok">
|
||||
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
|
||||
</Alert>
|
||||
)}
|
||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||
|
||||
<FormModeProvider readOnly={readOnly}>
|
||||
<EmployeeSection {...sectionProps} />
|
||||
<DaysSection {...sectionProps} submittedDays={submittedDays} />
|
||||
<ExplanationSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
|
||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение (после согласования)"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не заданы по маршруту."
|
||||
/>
|
||||
)}
|
||||
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
isValid={!validateSkudExplanation(data)}
|
||||
readOnly={readOnly}
|
||||
onCancel={onCancel}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Отправить на согласование"
|
||||
/>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkudExplanationForm;
|
||||
@@ -0,0 +1,29 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.state {
|
||||
padding: $sp-8;
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.documentBlock {
|
||||
margin-top: $sp-6;
|
||||
}
|
||||
|
||||
.documentTitle {
|
||||
margin-bottom: $sp-3;
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-section-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: $sp-3;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './SkudExplanationView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadSkudExplanationRequest } from './load';
|
||||
import { skudExplanationApprovalService } from './submit';
|
||||
import { useSkudRoute } from './useSkudRoute';
|
||||
import { EmployeeSection } from './sections/EmployeeSection';
|
||||
import { DaysSection } from './sections/DaysSection';
|
||||
import { ExplanationSection } from './sections/ExplanationSection';
|
||||
import { NoteDocument } from './components/NoteDocument';
|
||||
|
||||
interface ISkudExplanationViewProps {
|
||||
context: WebPartContext;
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит эта заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
// В режиме чтения поля не редактируются — обновление пустышка.
|
||||
const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
/**
|
||||
* Объяснительная для согласующего: данные заявки (только чтение) + документ
|
||||
* записки. На печать идёт только документ (rp-print-root) — как бумажный бланк.
|
||||
*/
|
||||
const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
const notifyCode = useStableCallback(onCodeLoaded);
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [created, setCreated] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
|
||||
|
||||
const approval = useMemo(() => skudExplanationApprovalService(listId), [listId]);
|
||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
|
||||
// decisionSeq: после решения строки маршрута меняются — перечитываем обе фазы.
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const { approvers, executors } = useSkudRoute({
|
||||
context,
|
||||
approval,
|
||||
requestId: itemId,
|
||||
employeeEmail: data.Employee,
|
||||
requestTypeId,
|
||||
editable: false,
|
||||
reloadSeq: decisionSeq
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
loadSkudExplanationRequest(itemId, listId)
|
||||
.then((loaded) => {
|
||||
if (!alive) return;
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
setCreated(loaded.created);
|
||||
notifyCode(loaded.code);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId, listId, notifyCode]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
<Alert variant="err">{error}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const notify = (): void => {
|
||||
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
</div>
|
||||
|
||||
<FormCard
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
|
||||
itemId={itemId}
|
||||
/>
|
||||
}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
<EmployeeSection {...sectionProps} />
|
||||
<DaysSection {...sectionProps} />
|
||||
<ExplanationSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
|
||||
{/* Не в CollapseSection: свёрнутая секция убрала бы rp-print-root из DOM, и печатать было бы нечего. */}
|
||||
<div className={styles.documentBlock}>
|
||||
<div className={styles.documentTitle}>Объяснительная записка</div>
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
{/* Created — запасной вариант для заявок, сохранённых до появления «Даты подачи». */}
|
||||
<NoteDocument data={data} noteDate={data.SubmitDate || created} orgIndex={printOrgIndex} />
|
||||
</div>
|
||||
</div>
|
||||
</FormCard>
|
||||
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не назначены."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
requestId={itemId}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkudExplanationView;
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"dataListTitle": "Объяснительная по СКУД — согласование",
|
||||
"columns": [
|
||||
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Объяснительная по СКУД — данные", "lookupShowField": "Title" },
|
||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
|
||||
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Документ имитирует бумажный бланк: шрифт с засечками, выравнивание по ширине,
|
||||
// красная строка — как в образце объяснительной отдела кадров.
|
||||
$doc-font: 'Times New Roman', Times, serif;
|
||||
$paragraph-indent: 2.5em;
|
||||
$sign-line-width: 120px;
|
||||
|
||||
.document {
|
||||
width: 100%;
|
||||
padding: $sp-4;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
background: $white;
|
||||
color: $gray-900;
|
||||
font-family: $doc-font;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
padding: $sp-8;
|
||||
}
|
||||
}
|
||||
|
||||
.addressee {
|
||||
margin-left: auto;
|
||||
margin-bottom: $sp-8;
|
||||
max-width: 320px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.author {
|
||||
margin-top: $sp-1;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: $sp-6;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin: 0 0 $sp-2;
|
||||
text-align: justify;
|
||||
text-indent: $paragraph-indent;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: $sp-4;
|
||||
margin-top: $sp-8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.signature {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.signLine {
|
||||
display: inline-block;
|
||||
width: $sign-line-width;
|
||||
border-bottom: 1px solid $gray-900;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import styles from './NoteDocument.module.scss';
|
||||
import { IFormData } from '../model';
|
||||
import { buildNoteParagraphs, formatShortDate, shortFio } from '../noteText';
|
||||
|
||||
interface INoteDocumentProps {
|
||||
data: IFormData;
|
||||
/** Дата записки, YYYY-MM-DD (дата подачи; у новой заявки — сегодня). */
|
||||
noteDate: string;
|
||||
/** Какая из организаций сотрудника идёт в шапку (см. PrintSplitButton). */
|
||||
orgIndex?: number;
|
||||
}
|
||||
|
||||
/** «Компания — должность» → компания; организаций несколько — берём выбранную для печати. */
|
||||
function companyOf(organization: string, index: number): string {
|
||||
const line = organization.split('\n')[index] || '';
|
||||
return line.split(' — ')[0].trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Объяснительная записка в виде бумажного документа (как бланк отдела кадров):
|
||||
* шапка справа, заголовок, текст, дата и подпись. Предпросмотр в форме и
|
||||
* печатная форма в просмотре — один и тот же компонент.
|
||||
*/
|
||||
export const NoteDocument: React.FC<INoteDocumentProps> = ({ data, noteDate, orgIndex = 0 }) => {
|
||||
const company = companyOf(data.Organization, orgIndex);
|
||||
const name = shortFio(data.EmployeeFio);
|
||||
|
||||
return (
|
||||
<div className={styles.document}>
|
||||
<div className={styles.addressee}>
|
||||
{company && <div>Директору {company}</div>}
|
||||
{data.Position && <div>{data.Position}</div>}
|
||||
{name && <div className={styles.author}>{name}</div>}
|
||||
</div>
|
||||
|
||||
<div className={styles.title}>Объяснительная записка</div>
|
||||
|
||||
{buildNoteParagraphs(data).map((paragraph) => (
|
||||
<p key={paragraph} className={styles.paragraph}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span>{formatShortDate(noteDate)} г.</span>
|
||||
<span className={styles.signature}>
|
||||
<span className={styles.signLine} />
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+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';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -421,6 +422,19 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
|
||||
/>
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -247,6 +248,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -514,6 +515,19 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -207,6 +208,12 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -315,6 +316,19 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
@@ -152,6 +153,12 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -314,6 +315,19 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
@@ -152,6 +153,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -219,7 +220,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
||||
const isValid =
|
||||
data.FullName.trim().length > 0 && data.Position.trim().length > 0 && data.Companies.length > 0;
|
||||
const validate = (): string | undefined => {
|
||||
if (!isValid) return 'Заполните обязательные поля: Ф.И.О., Должность, Организация заявителя.';
|
||||
if (!isValid) return 'Заполните обязательные поля: Ф.И.О., Должность, Организация.';
|
||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||
return undefined;
|
||||
@@ -402,6 +403,19 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -274,6 +275,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
||||
{ "internalName": "Location", "title": "Местоположение", "type": "Text" },
|
||||
{ "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "Organization", "title": "Организация заявителя", "type": "Note" },
|
||||
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||
{ "internalName": "WorkplaceCompaniesJson", "title": "Организации заявителя (JSON)", "type": "Note" },
|
||||
|
||||
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { FormField, OrganizationField, useFormReadOnly } from '../../../shared/u
|
||||
import { CompanyPickerMulti } from '../../../shared/dictionary';
|
||||
import { ISectionProps } from '../model';
|
||||
|
||||
const ORGANIZATION_LABEL = 'Организация заявителя';
|
||||
const ORGANIZATION_LABEL = 'Организация';
|
||||
|
||||
/**
|
||||
* Организации заявителя: мультивыбор из справочника (как «1.5 Организация» в
|
||||
|
||||
@@ -34,6 +34,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
|
||||
'hr-unpaid-leave': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-unpaid-leave' */ './UnpaidLeave/UnpaidLeaveForm')
|
||||
),
|
||||
'hr-skud-explanation': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-skud-explanation' */ './SkudExplanation/SkudExplanationForm')
|
||||
),
|
||||
'wh-tmc-transfer': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm')
|
||||
),
|
||||
|
||||
@@ -53,6 +53,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
|
||||
'hr-unpaid-leave': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-unpaid-leave' */ './UnpaidLeave/UnpaidLeaveView')
|
||||
),
|
||||
'hr-skud-explanation': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-skud-explanation' */ './SkudExplanation/SkudExplanationView')
|
||||
),
|
||||
'wh-tmc-transfer': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView')
|
||||
),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
||||
import { userSearchVariants } from './userSearchVariants';
|
||||
import { planRoutePatch } from './routePatchPlan';
|
||||
|
||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||
const FIELDS = {
|
||||
@@ -141,10 +142,26 @@ export class ApprovalListService {
|
||||
// он разрешится вместе со всеми остальными после execute() ниже.
|
||||
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
|
||||
void batchedList.items.add({
|
||||
Title: a.position || a.email,
|
||||
[`${FIELDS.approver}Id`]: approverIds[i],
|
||||
[FIELDS.status]: status,
|
||||
...this.stepFields(a, approverIds[i], status, phase, order),
|
||||
[`${FIELDS.ticket}Id`]: requestId,
|
||||
});
|
||||
});
|
||||
await execute();
|
||||
}
|
||||
|
||||
// Поля строки маршрута, которые задаёт сам шаг (без привязки к заявке) —
|
||||
// общие для создания (createSteps) и обновления на месте (patchSteps).
|
||||
private stepFields(
|
||||
a: IApproverToWrite,
|
||||
approverId: number,
|
||||
status: string,
|
||||
phase: string,
|
||||
order: number
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
Title: a.position || a.email,
|
||||
[`${FIELDS.approver}Id`]: approverId,
|
||||
[FIELDS.status]: status,
|
||||
[FIELDS.order]: order,
|
||||
[FIELDS.allowReassign]: a.allowReassign,
|
||||
[FIELDS.phase]: phase,
|
||||
@@ -152,8 +169,73 @@ export class ApprovalListService {
|
||||
[FIELDS.slaHours]: a.slaHours ?? null,
|
||||
[FIELDS.askOverdueReason]: a.askOverdueReason ?? null,
|
||||
[FIELDS.autoApprove]: a.autoApprove ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересохранить маршрут заявки ПАТЧЕМ вместо «удалить всё и создать заново»
|
||||
* (deleteForRequest + createSteps). Строки, которые остаются в маршруте,
|
||||
* обновляются на месте — их прошлые значения (кто, когда, с каким
|
||||
* комментарием решил) SharePoint сохраняет в истории версий. Лишние строки
|
||||
* уходят в корзину, недостающие шаги создаются. Какую строку куда — решает
|
||||
* planRoutePatch (см. routePatchPlan.ts).
|
||||
*
|
||||
* phases — фазы маршрута с их смещением порядка, как в writeRoute. Всё
|
||||
* пишется ОДНИМ пакетом. Вызывается из writeRoute (выключатель —
|
||||
* ROUTE_PATCH_ENABLED в approvalSubmission.service.ts).
|
||||
*/
|
||||
public async patchSteps(
|
||||
requestId: number,
|
||||
phases: Array<{ approvers: IApproverToWrite[]; phase: string; orderStart: number }>,
|
||||
status: string
|
||||
): Promise<void> {
|
||||
// Шаги всех фаз одним списком, с итоговым порядком — как их записал бы createSteps.
|
||||
const steps = phases.reduce<Array<{ a: IApproverToWrite; phase: string; order: number }>>(
|
||||
(all, p) =>
|
||||
all.concat(p.approvers.map((a, i) => ({ a, phase: p.phase, order: p.orderStart - 1 + (a.order ?? i + 1) }))),
|
||||
[]
|
||||
);
|
||||
// Шаг без человека не записать — см. тот же случай в createSteps.
|
||||
const toWrite = steps.filter((s) => !!s.a.email);
|
||||
if (toWrite.length < steps.length) {
|
||||
console.error(`[Согласование] patchSteps: пропущены шаги без назначенного человека — заявка №${requestId}`);
|
||||
}
|
||||
|
||||
const [existing, approverIds] = await Promise.all([
|
||||
this.getForRequest(requestId),
|
||||
Promise.all(toWrite.map((s) => this.resolveUserId(s.a.email))),
|
||||
]);
|
||||
const plan = planRoutePatch(
|
||||
existing,
|
||||
toWrite.map((s, i) => ({ phase: s.phase, approverId: approverIds[i] }))
|
||||
);
|
||||
|
||||
const fieldsOf = (stepIndex: number): Record<string, unknown> => {
|
||||
const s = toWrite[stepIndex];
|
||||
return this.stepFields(s.a, approverIds[stepIndex], status, s.phase, s.order);
|
||||
};
|
||||
|
||||
// Батч — см. createSteps: список переиспользуем, .items/.getById берём заново.
|
||||
const [batchedSP, execute] = getSP().batched();
|
||||
const batchedList = batchedSP.web.lists.getByTitle(this.listTitle);
|
||||
/* eslint-disable no-void -- промисы батча разрешатся вместе после execute(), см. createSteps */
|
||||
plan.update.forEach(({ rowId, stepIndex }) => {
|
||||
void batchedList.items.getById(rowId).update({
|
||||
...fieldsOf(stepIndex),
|
||||
// Состояние прошлого круга сбрасываем — оно остаётся в истории версий строки.
|
||||
[FIELDS.date]: null,
|
||||
[FIELDS.comment]: null,
|
||||
[`${FIELDS.delegatedBy}Id`]: null,
|
||||
});
|
||||
});
|
||||
plan.create.forEach((stepIndex) => {
|
||||
void batchedList.items.add({ ...fieldsOf(stepIndex), [`${FIELDS.ticket}Id`]: requestId });
|
||||
});
|
||||
// В корзину, а не насовсем: у выпавшего шага могла быть история решений.
|
||||
plan.remove.forEach((rowId) => {
|
||||
void batchedList.items.getById(rowId).recycle();
|
||||
});
|
||||
/* eslint-enable no-void */
|
||||
await execute();
|
||||
}
|
||||
|
||||
@@ -247,6 +329,25 @@ export class ApprovalListService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* История версий каждой строки маршрута заявки — сырьё для «Истории
|
||||
* согласования» (см. history/approvalHistory.service.ts). Версии всех строк —
|
||||
* одним пакетом. Порядок версий внутри строки не гарантирован, сортирует
|
||||
* вызывающая сторона.
|
||||
*/
|
||||
public async getRowVersions(requestId: number): Promise<Array<Array<Record<string, unknown>>>> {
|
||||
const rows = await this.getForRequest(requestId);
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const [batchedSP, execute] = getSP().batched();
|
||||
const batchedList = batchedSP.web.lists.getByTitle(this.listTitle);
|
||||
const pending = rows.map(
|
||||
(r) => batchedList.items.getById(r.id).versions() as Promise<Array<Record<string, unknown>>>
|
||||
);
|
||||
await execute();
|
||||
return Promise.all(pending);
|
||||
}
|
||||
|
||||
// Строки согласования по заявке, в порядке согласования (по Order).
|
||||
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
|
||||
const rows = (await this.list()
|
||||
|
||||
@@ -5,6 +5,15 @@ import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { ApprovalStatus, IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||
import { APPROVAL_PHASE, REQUEST_STATUS, isSubmittedStatus } from './models/statuses';
|
||||
|
||||
/**
|
||||
* Выключатель пересохранения маршрута патчем — ДЛЯ ВСЕХ форм сразу (см.
|
||||
* writeRoute). Проверено пилотами на «Заявлении на увольнение» и «Организации
|
||||
* рабочего места» 2026-09-29: обновление строк на месте, обе фазы, уход
|
||||
* выпавших шагов в корзину. false — аварийный откат к прежнему «удалить всё и
|
||||
* создать заново» (история прошлых отправок снова перестанет сохраняться).
|
||||
*/
|
||||
const ROUTE_PATCH_ENABLED = true;
|
||||
|
||||
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
||||
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
|
||||
// (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность).
|
||||
@@ -72,6 +81,12 @@ export async function writeApprovers(
|
||||
// очереди. Первого исполнителя переключит на «На исполнении»
|
||||
// approval.activateNextQueued, когда фаза согласования будет пройдена целиком
|
||||
// (см. approverDecision.service.ts/decide).
|
||||
//
|
||||
// Пересохранение — ПАТЧЕМ (ApprovalListService.patchSteps, см. ROUTE_PATCH_ENABLED):
|
||||
// строки, что остаются в маршруте, обновляются на месте, и их прошлые решения
|
||||
// (кто вернул/отклонил и с каким комментарием) сохраняются в истории версий —
|
||||
// из неё строится «История согласования». Раньше маршрут удалялся и создавался
|
||||
// заново, и история прошлых отправок терялась.
|
||||
export async function writeRoute(
|
||||
approval: ApprovalListService,
|
||||
requestId: number,
|
||||
@@ -79,8 +94,6 @@ export async function writeRoute(
|
||||
executors: IApproverToWrite[],
|
||||
status: string
|
||||
): Promise<void> {
|
||||
await approval.deleteForRequest(requestId);
|
||||
|
||||
// При ОТПРАВКЕ все шаги пишем «Ждёт очереди», затем активируем первый —
|
||||
// так активен ровно текущий шаг, остальные ждут. В черновике — статус заявки
|
||||
// (Черновик), без активации.
|
||||
@@ -102,6 +115,17 @@ export async function writeRoute(
|
||||
0
|
||||
);
|
||||
|
||||
if (ROUTE_PATCH_ENABLED) {
|
||||
await approval.patchSteps(
|
||||
requestId,
|
||||
[
|
||||
{ approvers, phase: APPROVAL_PHASE.approval, orderStart: 1 },
|
||||
{ approvers: executors, phase: APPROVAL_PHASE.execution, orderStart: lastApprovalStep + 1 },
|
||||
],
|
||||
rowStatus
|
||||
);
|
||||
} else {
|
||||
await approval.deleteForRequest(requestId);
|
||||
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
|
||||
await approval.createSteps(
|
||||
requestId,
|
||||
@@ -110,6 +134,7 @@ export async function writeRoute(
|
||||
APPROVAL_PHASE.execution,
|
||||
lastApprovalStep + 1
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
await approval.activateNextQueued(requestId);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Ширина колонки со временем — под «29.09.2026 11:02»; уже — текст переносится.
|
||||
$time-col-width: 124px;
|
||||
// Ниже этой ширины время встаёт над текстом события, а не слева от него.
|
||||
$feed-stack-breakpoint: 640px;
|
||||
|
||||
.feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-4;
|
||||
}
|
||||
|
||||
.round {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.roundTitle {
|
||||
padding-bottom: $sp-1;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
font-size: $fs-overline;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.4px;
|
||||
text-transform: uppercase;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-3;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.event {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $sp-3;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.current {
|
||||
padding: $sp-2 $sp-3;
|
||||
border-radius: $r-sm;
|
||||
background: $gold-50;
|
||||
}
|
||||
|
||||
.marker {
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.markerNeutral { color: $navy-700; }
|
||||
.markerPositive { color: $green-600; }
|
||||
.markerWarning { color: $amber-700; }
|
||||
.markerNegative { color: $red-500; }
|
||||
.markerCurrent { color: $gold-600; }
|
||||
|
||||
.eventBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eventLine {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: $sp-1 $sp-3;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: 0 0 $time-col-width;
|
||||
color: $gray-500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
@media (max-width: $feed-stack-breakpoint) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: $fs-label;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.comment {
|
||||
color: $gray-700;
|
||||
font-style: italic;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: $fs-label;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: $fs-sm;
|
||||
color: $red-500;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection } from '@tnsu/ui-kit';
|
||||
import { ApprovalListService } from '../approvalList.service';
|
||||
import { ApprovalHistoryFeed } from './ApprovalHistoryFeed';
|
||||
import { HISTORY_TEXT } from './historyTexts';
|
||||
|
||||
interface Props {
|
||||
approval: ApprovalListService;
|
||||
/** Title или серверный url списка данных — как у ApproverService. */
|
||||
dataListRef: string;
|
||||
requestId: number;
|
||||
/**
|
||||
* Меняется после решения/делегирования — лента перечитывается (тот же
|
||||
* decisionSeq, что у матрицы маршрута в просмотре заявки).
|
||||
*/
|
||||
reloadKey?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Раздел «История согласования» в карточке заявки — свёрнут по умолчанию:
|
||||
* лента грузит версии всех строк маршрута, и делать это при каждом открытии
|
||||
* заявки незачем. Не печатается — оборачивает вызывающая сторона (rp-no-print).
|
||||
*/
|
||||
export const ApprovalHistory: React.FC<Props> = ({ approval, dataListRef, requestId, reloadKey }) => (
|
||||
<CollapseSection title={HISTORY_TEXT.title} icon="calendar" defaultOpen={false}>
|
||||
<ApprovalHistoryFeed key={reloadKey} approval={approval} dataListRef={dataListRef} requestId={requestId} />
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import { ApprovalListService } from '../approvalList.service';
|
||||
import { IApprovalHistory } from './historyModel';
|
||||
import { loadApprovalHistory } from './approvalHistory.service';
|
||||
import { CurrentHolderRow } from './CurrentHolderRow';
|
||||
import { HistoryEventRow } from './HistoryEventRow';
|
||||
import { HISTORY_TEXT } from './historyTexts';
|
||||
import styles from './ApprovalHistory.module.scss';
|
||||
|
||||
interface Props {
|
||||
approval: ApprovalListService;
|
||||
dataListRef: string;
|
||||
requestId: number;
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'ready'; history: IApprovalHistory };
|
||||
|
||||
// Лента «Истории согласования». Монтируется только в раскрытом разделе
|
||||
// (CollapseSection рендерит тело лишь открытым), поэтому версии грузятся не
|
||||
// при каждом открытии заявки, а когда историю действительно смотрят.
|
||||
export const ApprovalHistoryFeed: React.FC<Props> = ({ approval, dataListRef, requestId }) => {
|
||||
const [state, setState] = React.useState<LoadState>({ kind: 'loading' });
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
setState({ kind: 'loading' });
|
||||
loadApprovalHistory(approval, dataListRef, requestId)
|
||||
.then((history) => {
|
||||
if (alive) setState({ kind: 'ready', history });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setState({ kind: 'error', message: (e as Error).message });
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [approval, dataListRef, requestId]);
|
||||
|
||||
if (state.kind === 'loading') return <div className={styles.note}>{HISTORY_TEXT.loading}</div>;
|
||||
if (state.kind === 'error') {
|
||||
return <div className={styles.error}>{`${HISTORY_TEXT.errorPrefix} ${state.message}`}</div>;
|
||||
}
|
||||
|
||||
const { rounds, current } = state.history;
|
||||
if (rounds.length === 0) return <div className={styles.note}>{HISTORY_TEXT.empty}</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.feed}>
|
||||
{rounds.map((round, i) => (
|
||||
<section key={round.number} className={styles.round}>
|
||||
<div className={styles.roundTitle}>{HISTORY_TEXT.round(round.number)}</div>
|
||||
<ul className={styles.events}>
|
||||
{/* «Сейчас» — только у последней (верхней) отправки. */}
|
||||
{i === 0 && current.map((holder, j) => <CurrentHolderRow key={`current-${j}`} holder={holder} />)}
|
||||
{round.events.map((event, j) => (
|
||||
<HistoryEventRow key={`${event.time}-${event.kind}-${j}`} event={event} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
<div className={styles.note}>{HISTORY_TEXT.partialNote}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { formatDuration, ICurrentHolder } from './historyModel';
|
||||
import { HISTORY_TEXT } from './historyTexts';
|
||||
import styles from './ApprovalHistory.module.scss';
|
||||
|
||||
interface Props {
|
||||
holder: ICurrentHolder;
|
||||
}
|
||||
|
||||
/** Верхняя строка ленты: у кого заявка сейчас и сколько уже ждёт. */
|
||||
export const CurrentHolderRow: React.FC<Props> = ({ holder }) => {
|
||||
const waitedMs = holder.since ? Date.now() - new Date(holder.since).getTime() : NaN;
|
||||
return (
|
||||
<li className={`${styles.event} ${styles.current}`}>
|
||||
<span className={`${styles.marker} ${styles.markerCurrent}`} aria-hidden="true">
|
||||
●
|
||||
</span>
|
||||
<div className={styles.eventBody}>
|
||||
<div className={styles.eventLine}>
|
||||
<span className={styles.time}>{HISTORY_TEXT.now}</span>
|
||||
<strong>{holder.person || '—'}</strong>
|
||||
{waitedMs > 0 && <span className={styles.meta}>{HISTORY_TEXT.waiting(formatDuration(waitedMs))}</span>}
|
||||
</div>
|
||||
{holder.stepTitle && <div className={styles.meta}>{holder.stepTitle}</div>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
import { formatDuration, HistoryEventKind, IHistoryEvent } from './historyModel';
|
||||
import { eventAction, HISTORY_TEXT } from './historyTexts';
|
||||
import styles from './ApprovalHistory.module.scss';
|
||||
|
||||
interface Props {
|
||||
event: IHistoryEvent;
|
||||
}
|
||||
|
||||
// Значок и цвет события — по смыслу решения, как у статусов в StatusBadge.
|
||||
const MARKER: Record<HistoryEventKind, { symbol: string; className: string }> = {
|
||||
submitted: { symbol: '↗', className: styles.markerNeutral },
|
||||
resubmitted: { symbol: '↗', className: styles.markerNeutral },
|
||||
approved: { symbol: '✓', className: styles.markerPositive },
|
||||
executed: { symbol: '✓', className: styles.markerPositive },
|
||||
returned: { symbol: '↩', className: styles.markerWarning },
|
||||
rejected: { symbol: '✕', className: styles.markerNegative },
|
||||
delegated: { symbol: '→', className: styles.markerNeutral },
|
||||
replaced: { symbol: '⇄', className: styles.markerNeutral },
|
||||
};
|
||||
|
||||
export function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const pad = (n: number): string => (n < 10 ? `0${n}` : `${n}`);
|
||||
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** Одно событие ленты: время · кто — что сделал · за сколько + комментарий. */
|
||||
export const HistoryEventRow: React.FC<Props> = ({ event }) => {
|
||||
const marker = MARKER[event.kind];
|
||||
return (
|
||||
<li className={styles.event}>
|
||||
<span className={`${styles.marker} ${marker.className}`} aria-hidden="true">
|
||||
{marker.symbol}
|
||||
</span>
|
||||
<div className={styles.eventBody}>
|
||||
<div className={styles.eventLine}>
|
||||
<span className={styles.time}>{formatDateTime(event.time)}</span>
|
||||
<span>
|
||||
<strong>{event.actor || '—'}</strong> — {eventAction(event)}
|
||||
</span>
|
||||
{event.durationMs !== undefined && (
|
||||
<span className={styles.meta}>{HISTORY_TEXT.took(formatDuration(event.durationMs))}</span>
|
||||
)}
|
||||
</div>
|
||||
{event.stepTitle && <div className={styles.meta}>{event.stepTitle}</div>}
|
||||
{event.comment && <div className={styles.comment}>«{event.comment}»</div>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getSP } from '../../pnp/pnpjs';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import { IList } from '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import { ApprovalListService } from '../approvalList.service';
|
||||
import { buildApprovalHistory, IApprovalHistory, IRequestVersion, IRouteRowVersion } from './historyModel';
|
||||
|
||||
// Внутренние имена колонок строки маршрута — те же, что FIELDS в approvalList.service.ts.
|
||||
const ROW_FIELDS = {
|
||||
status: 'ApprovalStatus',
|
||||
approver: 'TansuSiteApprover',
|
||||
comment: 'Comment',
|
||||
date: 'Date',
|
||||
delegatedBy: 'DelegatedBy',
|
||||
};
|
||||
|
||||
/**
|
||||
* Имя человека из поля версии. В версиях SharePoint Person-поле приходит
|
||||
* объектом { LookupId, LookupValue, Email }, но на всякий случай принимаем и
|
||||
* строку, и Title — пустая строка лучше, чем падение всей ленты.
|
||||
*/
|
||||
function personName(value: unknown): string {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
const user = value as { LookupValue?: string; Title?: string };
|
||||
return (user.LookupValue || user.Title || '').trim();
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
// Версии — от старой к новой: VersionId растёт с каждой версией (512 на мажорную).
|
||||
function byVersion(a: Record<string, unknown>, b: Record<string, unknown>): number {
|
||||
return Number(a.VersionId) - Number(b.VersionId);
|
||||
}
|
||||
|
||||
// Момент сохранения версии: Modified этой версии. Created — запасной вариант
|
||||
// (у элемента он один на все версии, у версии — время её создания).
|
||||
function versionTime(v: Record<string, unknown>): string {
|
||||
return text(v.Modified) || text(v.Created);
|
||||
}
|
||||
|
||||
function toRowVersion(v: Record<string, unknown>): IRouteRowVersion {
|
||||
return {
|
||||
time: versionTime(v),
|
||||
status: text(v[ROW_FIELDS.status]),
|
||||
approverName: personName(v[ROW_FIELDS.approver]),
|
||||
editorName: personName(v.Editor),
|
||||
stepTitle: text(v.Title),
|
||||
comment: text(v[ROW_FIELDS.comment]).trim(),
|
||||
date: text(v[ROW_FIELDS.date]),
|
||||
delegatedByName: personName(v[ROW_FIELDS.delegatedBy]),
|
||||
};
|
||||
}
|
||||
|
||||
// dataListRef — Title или серверный url списка данных, как у ApproverService.
|
||||
function dataList(dataListRef: string): IList {
|
||||
return dataListRef.startsWith('/')
|
||||
? getSP().web.getList(dataListRef)
|
||||
: getSP().web.lists.getByTitle(dataListRef);
|
||||
}
|
||||
|
||||
async function requestVersions(dataListRef: string, requestId: number): Promise<IRequestVersion[]> {
|
||||
const versions = (await dataList(dataListRef).items.getById(requestId).versions()) as Array<Record<string, unknown>>;
|
||||
return [...versions].sort(byVersion).map((v) => ({
|
||||
time: versionTime(v),
|
||||
status: text(v.RequestStatus),
|
||||
editorName: personName(v.Editor),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Загрузить и собрать «Историю согласования» заявки. */
|
||||
export async function loadApprovalHistory(
|
||||
approval: ApprovalListService,
|
||||
dataListRef: string,
|
||||
requestId: number
|
||||
): Promise<IApprovalHistory> {
|
||||
const [rowVersions, reqVersions] = await Promise.all([
|
||||
approval.getRowVersions(requestId),
|
||||
requestVersions(dataListRef, requestId),
|
||||
]);
|
||||
const rows = rowVersions.map((versions) => [...versions].sort(byVersion).map(toRowVersion));
|
||||
return buildApprovalHistory(rows, reqVersions);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
buildApprovalHistory,
|
||||
formatDuration,
|
||||
IRequestVersion,
|
||||
IRouteRowVersion,
|
||||
rowEvents,
|
||||
submitEvents,
|
||||
} from './historyModel';
|
||||
|
||||
function rv(over: Partial<IRouteRowVersion>): IRouteRowVersion {
|
||||
return {
|
||||
time: '2026-09-24T10:00:00Z',
|
||||
status: 'Ждёт очереди',
|
||||
approverName: 'Анна Ким',
|
||||
editorName: 'Антон Огай',
|
||||
stepTitle: 'Начальник расчётной группы',
|
||||
comment: '',
|
||||
date: '',
|
||||
delegatedByName: '',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function req(time: string, status: string, editorName = 'Антон Огай'): IRequestVersion {
|
||||
return { time, status, editorName };
|
||||
}
|
||||
|
||||
describe('rowEvents', () => {
|
||||
it('решение даёт событие с комментарием и временем, сколько шаг был у человека', () => {
|
||||
const events = rowEvents([
|
||||
rv({ time: '2026-09-24T10:00:00Z', status: 'Ждёт очереди' }),
|
||||
rv({ time: '2026-09-24T10:00:05Z', status: 'На согласовании', date: '2026-09-24T10:00:05Z' }),
|
||||
rv({ time: '2026-09-24T13:00:05Z', status: 'Возвращена на доработку', date: '2026-09-24T13:00:05Z', comment: 'Уточните оклад' }),
|
||||
]);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
kind: 'returned',
|
||||
time: '2026-09-24T13:00:05Z',
|
||||
actor: 'Анна Ким',
|
||||
stepTitle: 'Начальник расчётной группы',
|
||||
comment: 'Уточните оклад',
|
||||
durationMs: 3 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('после пересохранения патчем в той же строке видны оба круга', () => {
|
||||
const events = rowEvents([
|
||||
rv({ status: 'На согласовании', date: '2026-09-24T10:00:00Z' }),
|
||||
rv({ status: 'Возвращена на доработку', date: '2026-09-25T10:00:00Z', comment: 'Исправьте' }),
|
||||
rv({ time: '2026-09-26T09:00:00Z', status: 'Ждёт очереди' }),
|
||||
rv({ status: 'На согласовании', date: '2026-09-26T09:00:00Z' }),
|
||||
rv({ status: 'Согласовано', date: '2026-09-26T10:00:00Z' }),
|
||||
]);
|
||||
expect(events.map((e) => e.kind)).toEqual(['returned', 'approved']);
|
||||
expect(events[1].durationMs).toBe(60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('первая версия с «Кем переназначено» — событие делегирования', () => {
|
||||
const events = rowEvents([
|
||||
rv({ time: '2026-09-25T09:00:00Z', approverName: 'Асель Самаева', delegatedByName: 'Анна Ким' }),
|
||||
]);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
kind: 'delegated',
|
||||
time: '2026-09-25T09:00:00Z',
|
||||
actor: 'Анна Ким',
|
||||
target: 'Асель Самаева',
|
||||
stepTitle: 'Начальник расчётной группы',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('смена человека без смены статуса — замена исполнителя', () => {
|
||||
const events = rowEvents([
|
||||
rv({ status: 'На исполнении', approverName: 'Петров Пётр' }),
|
||||
rv({ time: '2026-09-26T12:00:00Z', status: 'На исполнении', approverName: 'Сидорова Анна', editorName: 'Алтынай Бултурукова' }),
|
||||
]);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
kind: 'replaced',
|
||||
time: '2026-09-26T12:00:00Z',
|
||||
actor: 'Алтынай Бултурукова',
|
||||
target: 'Сидорова Анна',
|
||||
previous: 'Петров Пётр',
|
||||
stepTitle: 'Начальник расчётной группы',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitEvents', () => {
|
||||
it('отправка из черновика и повторная после возврата; ход по маршруту отправкой не считается', () => {
|
||||
const events = submitEvents([
|
||||
req('2026-09-24T09:00:00Z', 'Черновик'),
|
||||
req('2026-09-24T10:00:00Z', 'На согласовании'),
|
||||
req('2026-09-25T10:00:00Z', 'Возвращена на доработку', 'Анна Ким'),
|
||||
req('2026-09-26T09:00:00Z', 'На согласовании'),
|
||||
req('2026-09-26T10:00:00Z', 'Выполнение', 'Анна Ким'),
|
||||
]);
|
||||
expect(events.map((e) => [e.kind, e.time])).toEqual([
|
||||
['submitted', '2026-09-24T10:00:00Z'],
|
||||
['resubmitted', '2026-09-26T09:00:00Z'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildApprovalHistory', () => {
|
||||
it('разбивает ленту на отправки, новые сверху, и показывает, у кого заявка сейчас', () => {
|
||||
const history = buildApprovalHistory(
|
||||
[
|
||||
[
|
||||
rv({ status: 'На согласовании', date: '2026-09-24T10:00:00Z' }),
|
||||
rv({ status: 'Возвращена на доработку', date: '2026-09-25T10:00:00Z' }),
|
||||
rv({ time: '2026-09-26T09:00:00Z', status: 'На согласовании', date: '2026-09-26T09:00:00Z' }),
|
||||
],
|
||||
],
|
||||
[req('2026-09-24T10:00:00Z', 'На согласовании'), req('2026-09-25T10:00:00Z', 'Возвращена на доработку'), req('2026-09-26T09:00:00Z', 'На согласовании')]
|
||||
);
|
||||
|
||||
expect(history.rounds.map((r) => r.number)).toEqual([2, 1]);
|
||||
expect(history.rounds[0].events.map((e) => e.kind)).toEqual(['resubmitted']);
|
||||
expect(history.rounds[1].events.map((e) => e.kind)).toEqual(['returned', 'submitted']);
|
||||
expect(history.current).toEqual([
|
||||
{ person: 'Анна Ким', stepTitle: 'Начальник расчётной группы', since: '2026-09-26T09:00:00Z' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('без версий — пусто', () => {
|
||||
expect(buildApprovalHistory([], [])).toEqual({ rounds: [], current: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('минуты, часы, дни', () => {
|
||||
expect(formatDuration(30 * 1000)).toBe('1 мин');
|
||||
expect(formatDuration(15 * 60 * 1000)).toBe('15 мин');
|
||||
expect(formatDuration(3 * 60 * 60 * 1000)).toBe('3 ч');
|
||||
expect(formatDuration(2 * 24 * 60 * 60 * 1000)).toBe('2 дн');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { REQUEST_STATUS } from '../models/statuses';
|
||||
|
||||
/**
|
||||
* «История согласования» заявки — лента событий, собранная из истории версий
|
||||
* SharePoint: строк маршрута (решения, делегирование, замены) и самой заявки
|
||||
* (отправки). Чистые функции, без SharePoint — загрузка в approvalHistory.service.ts.
|
||||
*
|
||||
* Прошлые отправки видны, потому что маршрут пересохраняется патчем (см.
|
||||
* writeRoute): строки остаются, и их прошлые решения лежат в версиях. Решения
|
||||
* из отправок ДО включения патча (2026-09-29) были удалены вместе со строками —
|
||||
* у старых заявок видна только их последняя отправка.
|
||||
*/
|
||||
|
||||
/** Одна версия строки маршрута — поля, нужные для ленты. */
|
||||
export interface IRouteRowVersion {
|
||||
/** Когда создана версия (ISO). */
|
||||
time: string;
|
||||
status: string;
|
||||
/** Человек в строке («Согласующий»). */
|
||||
approverName: string;
|
||||
/** Кто сохранил эту версию (Editor). */
|
||||
editorName: string;
|
||||
/** Должность шага — Title строки. */
|
||||
stepTitle: string;
|
||||
comment: string;
|
||||
/** Колонка «Дата»: момент активации или решения (ISO), '' — пусто. */
|
||||
date: string;
|
||||
/** «Кем переназначено» — у строк делегатов. */
|
||||
delegatedByName: string;
|
||||
}
|
||||
|
||||
/** Одна версия заявки — для событий «отправлено». */
|
||||
export interface IRequestVersion {
|
||||
time: string;
|
||||
status: string;
|
||||
editorName: string;
|
||||
}
|
||||
|
||||
export type HistoryEventKind =
|
||||
| 'submitted'
|
||||
| 'resubmitted'
|
||||
| 'approved'
|
||||
| 'executed'
|
||||
| 'returned'
|
||||
| 'rejected'
|
||||
| 'delegated'
|
||||
| 'replaced';
|
||||
|
||||
export interface IHistoryEvent {
|
||||
kind: HistoryEventKind;
|
||||
time: string;
|
||||
/** Кто сделал. */
|
||||
actor: string;
|
||||
/** Над кем: кому передали / кого поставили вместо. */
|
||||
target?: string;
|
||||
/** Кого заменили (для 'replaced'). */
|
||||
previous?: string;
|
||||
stepTitle?: string;
|
||||
comment?: string;
|
||||
/** Сколько шаг был у человека до решения, мс. */
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
/** Шаг, который сейчас у человека. */
|
||||
export interface ICurrentHolder {
|
||||
person: string;
|
||||
stepTitle: string;
|
||||
/** С какого момента у него (ISO), '' — неизвестно. */
|
||||
since: string;
|
||||
}
|
||||
|
||||
export interface IHistoryRound {
|
||||
/** Номер отправки, с 1. */
|
||||
number: number;
|
||||
/** События отправки, НОВЫЕ СВЕРХУ. */
|
||||
events: IHistoryEvent[];
|
||||
}
|
||||
|
||||
export interface IApprovalHistory {
|
||||
/** Отправки, НОВЫЕ СВЕРХУ. */
|
||||
rounds: IHistoryRound[];
|
||||
current: ICurrentHolder[];
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
|
||||
|
||||
const DECISION_KINDS: Record<string, HistoryEventKind> = {
|
||||
[REQUEST_STATUS.approved]: 'approved',
|
||||
[REQUEST_STATUS.executed]: 'executed',
|
||||
[REQUEST_STATUS.returned]: 'returned',
|
||||
[REQUEST_STATUS.rejected]: 'rejected',
|
||||
};
|
||||
|
||||
// Статусы заявки, ИЗ которых переход в работу — это отправка автором, а не
|
||||
// движение по маршруту (На согласовании → Выполнение отправкой не считается).
|
||||
const BEFORE_SUBMIT_STATUSES: string[] = ['', REQUEST_STATUS.draft, REQUEST_STATUS.returned, REQUEST_STATUS.rejected];
|
||||
const SUBMITTED_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution];
|
||||
|
||||
function msBetween(fromIso: string, toIso: string): number | undefined {
|
||||
const diff = new Date(toIso).getTime() - new Date(fromIso).getTime();
|
||||
return isNaN(diff) || diff < 0 ? undefined : diff;
|
||||
}
|
||||
|
||||
/** События одной строки маршрута по её версиям (от старой к новой). */
|
||||
export function rowEvents(versions: IRouteRowVersion[]): IHistoryEvent[] {
|
||||
const events: IHistoryEvent[] = [];
|
||||
let arrivedAt = '';
|
||||
|
||||
versions.forEach((v, i) => {
|
||||
const prev = i > 0 ? versions[i - 1] : undefined;
|
||||
const time = v.date || v.time;
|
||||
|
||||
if (!prev && v.delegatedByName) {
|
||||
events.push({ kind: 'delegated', time: v.time, actor: v.delegatedByName, target: v.approverName, stepTitle: v.stepTitle });
|
||||
}
|
||||
// Тот же шаг, другой человек — замена (не пересохранение: статус не менялся).
|
||||
if (prev && prev.approverName !== v.approverName && prev.status === v.status) {
|
||||
events.push({ kind: 'replaced', time: v.time, actor: v.editorName, target: v.approverName, previous: prev.approverName, stepTitle: v.stepTitle });
|
||||
}
|
||||
if (ACTIVE_STATUSES.indexOf(v.status) >= 0 && (!prev || ACTIVE_STATUSES.indexOf(prev.status) < 0)) {
|
||||
arrivedAt = time;
|
||||
}
|
||||
const decision = DECISION_KINDS[v.status];
|
||||
if (decision && (!prev || prev.status !== v.status)) {
|
||||
events.push({
|
||||
kind: decision,
|
||||
time,
|
||||
actor: v.approverName,
|
||||
stepTitle: v.stepTitle,
|
||||
comment: v.comment || undefined,
|
||||
durationMs: arrivedAt ? msBetween(arrivedAt, time) : undefined,
|
||||
});
|
||||
arrivedAt = '';
|
||||
}
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Отправки заявки автором — по версиям заявки (от старой к новой). */
|
||||
export function submitEvents(versions: IRequestVersion[]): IHistoryEvent[] {
|
||||
const events: IHistoryEvent[] = [];
|
||||
versions.forEach((v, i) => {
|
||||
const prevStatus = i > 0 ? versions[i - 1].status : '';
|
||||
if (SUBMITTED_STATUSES.indexOf(v.status) < 0 || BEFORE_SUBMIT_STATUSES.indexOf(prevStatus) < 0) return;
|
||||
const kind: HistoryEventKind = events.length === 0 ? 'submitted' : 'resubmitted';
|
||||
events.push({ kind, time: v.time, actor: v.editorName });
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Кто держит заявку сейчас — по последним версиям строк. */
|
||||
function currentHolders(rows: IRouteRowVersion[][]): ICurrentHolder[] {
|
||||
return rows
|
||||
.filter((versions) => versions.length > 0 && ACTIVE_STATUSES.indexOf(versions[versions.length - 1].status) >= 0)
|
||||
.map((versions) => {
|
||||
const last = versions[versions.length - 1];
|
||||
return { person: last.approverName, stepTitle: last.stepTitle, since: last.date };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Лента целиком: события по времени, разбитые на отправки (каждая отправка
|
||||
* автором открывает новую), новые сверху.
|
||||
*/
|
||||
export function buildApprovalHistory(rows: IRouteRowVersion[][], requestVersions: IRequestVersion[]): IApprovalHistory {
|
||||
const all = rows
|
||||
.reduce<IHistoryEvent[]>((acc, versions) => acc.concat(rowEvents(versions)), [])
|
||||
.concat(submitEvents(requestVersions))
|
||||
.sort((a, b) => a.time.localeCompare(b.time));
|
||||
|
||||
const rounds: IHistoryRound[] = [];
|
||||
all.forEach((event) => {
|
||||
const startsRound = event.kind === 'submitted' || event.kind === 'resubmitted';
|
||||
if (startsRound || rounds.length === 0) rounds.push({ number: rounds.length + 1, events: [] });
|
||||
rounds[rounds.length - 1].events.push(event);
|
||||
});
|
||||
|
||||
return {
|
||||
rounds: rounds.map((r) => ({ ...r, events: [...r.events].reverse() })).reverse(),
|
||||
current: currentHolders(rows),
|
||||
};
|
||||
}
|
||||
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const HOUR_MS = 60 * MINUTE_MS;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
/** «15 мин», «3 ч», «2 дн» — грубо, для подписи «сколько держал шаг». */
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < HOUR_MS) return `${Math.max(1, Math.round(ms / MINUTE_MS))} мин`;
|
||||
if (ms < DAY_MS) return `${Math.round(ms / HOUR_MS)} ч`;
|
||||
return `${Math.round(ms / DAY_MS)} дн`;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { HistoryEventKind, IHistoryEvent } from './historyModel';
|
||||
|
||||
// Тексты «Истории согласования» — в одном месте (задел под локализацию).
|
||||
// Формулировки безличные («согласовано», а не «согласовала»): пол по имени не
|
||||
// угадываем.
|
||||
export const HISTORY_TEXT = {
|
||||
title: 'История согласования',
|
||||
loading: 'Загрузка истории…',
|
||||
empty: 'Пока нет событий: заявка ещё не отправлялась.',
|
||||
errorPrefix: 'Не удалось загрузить историю:',
|
||||
round: (n: number): string => `Отправка ${n}`,
|
||||
now: 'сейчас',
|
||||
waiting: (duration: string): string => `ждёт ${duration}`,
|
||||
took: (duration: string): string => `за ${duration}`,
|
||||
partialNote:
|
||||
'Показаны события, сохранённые в SharePoint. Решения из отправок до включения истории могли не сохраниться.',
|
||||
};
|
||||
|
||||
const ACTION: Record<HistoryEventKind, string> = {
|
||||
submitted: 'отправлено на согласование',
|
||||
resubmitted: 'отправлено повторно',
|
||||
approved: 'согласовано',
|
||||
executed: 'выполнено',
|
||||
returned: 'возвращено на доработку',
|
||||
rejected: 'отклонено',
|
||||
delegated: 'передано',
|
||||
replaced: 'заменён исполнитель',
|
||||
};
|
||||
|
||||
/** Что произошло — «согласовано», «передано: Асель Самаева», «заменён исполнитель: А → Б». */
|
||||
export function eventAction(event: IHistoryEvent): string {
|
||||
const action = ACTION[event.kind];
|
||||
if (event.kind === 'delegated' && event.target) return `${action}: ${event.target}`;
|
||||
if (event.kind === 'replaced') return `${action}: ${event.previous || '—'} → ${event.target || '—'}`;
|
||||
return action;
|
||||
}
|
||||
@@ -68,6 +68,9 @@ export type { IApproverOrgInfo } from './approverOrgInfo';
|
||||
export { canEditRoute } from './routeEditRights';
|
||||
export { canEditAllFields } from './fullEditRights';
|
||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||
export { ApprovalHistory } from './history/ApprovalHistory';
|
||||
export { ApprovalHistoryFeed } from './history/ApprovalHistoryFeed';
|
||||
export { HISTORY_TEXT } from './history/historyTexts';
|
||||
export { UserPicker } from './ui/UserPicker';
|
||||
export { DecisionPanel } from './ui/DecisionPanel';
|
||||
export { StatusBadge } from './ui/StatusBadge';
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { IExistingRouteRow, planRoutePatch } from './routePatchPlan';
|
||||
|
||||
const APPROVAL = 'Согласование';
|
||||
const EXECUTION = 'Исполнение';
|
||||
|
||||
function row(id: number, approverId: number, phase = APPROVAL, delegatedById = 0): IExistingRouteRow {
|
||||
return { id, approverId, phase, delegatedById };
|
||||
}
|
||||
|
||||
describe('planRoutePatch', () => {
|
||||
it('маршрут не изменился — все строки обновляются на месте, ничего не создаётся и не удаляется', () => {
|
||||
const plan = planRoutePatch(
|
||||
[row(10, 1), row(11, 2), row(12, 3)],
|
||||
[
|
||||
{ phase: APPROVAL, approverId: 1 },
|
||||
{ phase: APPROVAL, approverId: 2 },
|
||||
{ phase: APPROVAL, approverId: 3 },
|
||||
]
|
||||
);
|
||||
expect(plan.update).toEqual([
|
||||
{ rowId: 10, stepIndex: 0 },
|
||||
{ rowId: 11, stepIndex: 1 },
|
||||
{ rowId: 12, stepIndex: 2 },
|
||||
]);
|
||||
expect(plan.create).toEqual([]);
|
||||
expect(plan.remove).toEqual([]);
|
||||
});
|
||||
|
||||
it('согласующий сменился — старая строка убирается, новая создаётся', () => {
|
||||
const plan = planRoutePatch(
|
||||
[row(10, 1), row(11, 2)],
|
||||
[
|
||||
{ phase: APPROVAL, approverId: 1 },
|
||||
{ phase: APPROVAL, approverId: 9 },
|
||||
]
|
||||
);
|
||||
expect(plan.update).toEqual([{ rowId: 10, stepIndex: 0 }]);
|
||||
expect(plan.create).toEqual([1]);
|
||||
expect(plan.remove).toEqual([11]);
|
||||
});
|
||||
|
||||
it('порядок шагов поменялся — строки сопоставляются по человеку, а не по позиции', () => {
|
||||
const plan = planRoutePatch(
|
||||
[row(10, 1), row(11, 2)],
|
||||
[
|
||||
{ phase: APPROVAL, approverId: 2 },
|
||||
{ phase: APPROVAL, approverId: 1 },
|
||||
]
|
||||
);
|
||||
expect(plan.update).toEqual([
|
||||
{ rowId: 11, stepIndex: 0 },
|
||||
{ rowId: 10, stepIndex: 1 },
|
||||
]);
|
||||
expect(plan.create).toEqual([]);
|
||||
expect(plan.remove).toEqual([]);
|
||||
});
|
||||
|
||||
it('один человек дважды в фазе — пары по порядку, лишняя строка убирается', () => {
|
||||
const plan = planRoutePatch([row(10, 1), row(11, 1), row(12, 1)], [
|
||||
{ phase: APPROVAL, approverId: 1 },
|
||||
{ phase: APPROVAL, approverId: 1 },
|
||||
]);
|
||||
expect(plan.update).toEqual([
|
||||
{ rowId: 10, stepIndex: 0 },
|
||||
{ rowId: 11, stepIndex: 1 },
|
||||
]);
|
||||
expect(plan.remove).toEqual([12]);
|
||||
});
|
||||
|
||||
it('фаза важна — тот же человек в другой фазе считается другим шагом', () => {
|
||||
const plan = planRoutePatch([row(10, 1, APPROVAL)], [{ phase: EXECUTION, approverId: 1 }]);
|
||||
expect(plan.update).toEqual([]);
|
||||
expect(plan.create).toEqual([0]);
|
||||
expect(plan.remove).toEqual([10]);
|
||||
});
|
||||
|
||||
it('строка делегата не сопоставляется и убирается, даже если человек есть в новом маршруте', () => {
|
||||
const plan = planRoutePatch([row(10, 1), row(11, 5, APPROVAL, 1)], [
|
||||
{ phase: APPROVAL, approverId: 1 },
|
||||
{ phase: APPROVAL, approverId: 5 },
|
||||
]);
|
||||
expect(plan.update).toEqual([{ rowId: 10, stepIndex: 0 }]);
|
||||
expect(plan.create).toEqual([1]);
|
||||
expect(plan.remove).toEqual([11]);
|
||||
});
|
||||
|
||||
it('первая отправка (строк ещё нет) — всё создаётся', () => {
|
||||
const plan = planRoutePatch([], [
|
||||
{ phase: APPROVAL, approverId: 1 },
|
||||
{ phase: EXECUTION, approverId: 2 },
|
||||
]);
|
||||
expect(plan).toEqual({ update: [], create: [0, 1], remove: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* План пересохранения маршрута «патчем»: какие существующие строки обновить,
|
||||
* какие шаги создать заново и какие строки убрать. Чистая функция, без
|
||||
* SharePoint — её выполняет ApprovalListService.patchSteps.
|
||||
*
|
||||
* Зачем патч, а не «удалить всё и создать заново» (см. writeRoute): строка,
|
||||
* которая остаётся в маршруте, обновляется на месте, и SharePoint сохраняет её
|
||||
* прошлые значения в истории версий — кто, когда и с каким комментарием решал
|
||||
* в прошлом круге (напр. вернул на доработку). При удалении это терялось.
|
||||
*
|
||||
* Строка и шаг считаются «одним и тем же», если совпадают фаза и согласующий.
|
||||
* Если один человек стоит в фазе несколько раз, пары сопоставляются по порядку.
|
||||
* Строки делегатов (delegatedById > 0) добавлены переназначением, а не
|
||||
* маршрутом — новому маршруту они не соответствуют и убираются.
|
||||
*/
|
||||
|
||||
/** Существующая строка маршрута — то, что нужно для сопоставления. */
|
||||
export interface IExistingRouteRow {
|
||||
id: number;
|
||||
phase: string;
|
||||
approverId: number;
|
||||
/** Кто переназначил — у строк делегатов; 0 у обычных строк маршрута. */
|
||||
delegatedById: number;
|
||||
}
|
||||
|
||||
/** Шаг нового маршрута. index — его позиция во входном массиве. */
|
||||
export interface IPlannedRouteStep {
|
||||
phase: string;
|
||||
approverId: number;
|
||||
}
|
||||
|
||||
export interface IRoutePatchPlan {
|
||||
/** Обновить строку rowId значениями шага stepIndex. */
|
||||
update: Array<{ rowId: number; stepIndex: number }>;
|
||||
/** Индексы шагов, для которых подходящей строки нет, — создать. */
|
||||
create: number[];
|
||||
/** Строки, которым в новом маршруте ничего не соответствует, — убрать. */
|
||||
remove: number[];
|
||||
}
|
||||
|
||||
function matchKey(phase: string, approverId: number): string {
|
||||
return `${phase}|${approverId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* existing — в порядке маршрута (как отдаёт getForRequest, по StepOrder):
|
||||
* от этого зависит, какая строка достанется какому шагу, если один человек
|
||||
* стоит в фазе дважды.
|
||||
*/
|
||||
export function planRoutePatch(existing: IExistingRouteRow[], planned: IPlannedRouteStep[]): IRoutePatchPlan {
|
||||
// Свободные строки по ключу, в порядке маршрута — первый шаг забирает первую.
|
||||
const freeRows = new Map<string, number[]>();
|
||||
existing
|
||||
.filter((row) => row.delegatedById === 0)
|
||||
.forEach((row) => {
|
||||
const key = matchKey(row.phase, row.approverId);
|
||||
freeRows.set(key, (freeRows.get(key) ?? []).concat(row.id));
|
||||
});
|
||||
|
||||
const plan: IRoutePatchPlan = { update: [], create: [], remove: [] };
|
||||
const usedRowIds = new Set<number>();
|
||||
|
||||
planned.forEach((step, stepIndex) => {
|
||||
const candidates = freeRows.get(matchKey(step.phase, step.approverId)) ?? [];
|
||||
const rowId = candidates.shift();
|
||||
if (rowId === undefined) {
|
||||
plan.create.push(stepIndex);
|
||||
return;
|
||||
}
|
||||
usedRowIds.add(rowId);
|
||||
plan.update.push({ rowId, stepIndex });
|
||||
});
|
||||
|
||||
plan.remove = existing.filter((row) => !usedRowIds.has(row.id)).map((row) => row.id);
|
||||
return plan;
|
||||
}
|
||||
@@ -165,6 +165,11 @@ describe('statusForNewRow', () => {
|
||||
expect(statusForNewRow(rows)).toBe('—');
|
||||
});
|
||||
|
||||
it('решение согласующего не копируется, даже если оно у всех одинаковое', () => {
|
||||
expect(statusForNewRow([row({ status: 'Возвращена на доработку' })])).toBe('—');
|
||||
expect(statusForNewRow([row({ status: 'Согласовано' }), row({ status: 'Согласовано' })])).toBe('—');
|
||||
});
|
||||
|
||||
it('пустой статус приравнивается к прочерку', () => {
|
||||
expect(statusForNewRow([row({ status: '' }), row({ status: '—' })])).toBe('—');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { friendlyErrorMessage } from '../diagnostics';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
import { IApproverOrgInfo } from './approverOrgInfo';
|
||||
import { REQUEST_STATUS } from './models/statuses';
|
||||
|
||||
export interface IUseMatrixEditor {
|
||||
rows: IMatrixRow[];
|
||||
@@ -79,9 +80,22 @@ export function withOrders(rows: IMatrixRow[]): IMatrixRow[] {
|
||||
// строкам общий статус, и показывать «—» рядом с «Черновик» было бы враньём.
|
||||
// Если статусы разные (возвращённая заявка, часть шагов уже с решениями) —
|
||||
// честнее оставить «—»: в какое состояние попадёт новый шаг, ещё не решено.
|
||||
//
|
||||
// Решение согласующего (Согласовано / Возвращена / Отклонено / Выполнено) не
|
||||
// копируем никогда, даже если оно у всех строк одинаковое: у только что
|
||||
// добавленного человека решения нет. Иначе после возврата заявки с одним
|
||||
// согласующим каждый новый шаг показывался «Возвращена на доработку».
|
||||
const DECISION_STATUSES: string[] = [
|
||||
REQUEST_STATUS.approved,
|
||||
REQUEST_STATUS.returned,
|
||||
REQUEST_STATUS.rejected,
|
||||
REQUEST_STATUS.executed,
|
||||
];
|
||||
|
||||
export function statusForNewRow(rows: IMatrixRow[]): string {
|
||||
if (rows.length === 0) return '—';
|
||||
const first = rows[0].status || '—';
|
||||
if (DECISION_STATUSES.indexOf(first) >= 0) return '—';
|
||||
return rows.every((r) => (r.status || '—') === first) ? first : '—';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { forcedDownloadUrl } from './downloadAll';
|
||||
|
||||
const SITE = 'https://tnsukz.sharepoint.com/sites/requests';
|
||||
|
||||
describe('forcedDownloadUrl', () => {
|
||||
it('серверный относительный путь — через download.aspx с абсолютным SourceUrl', () => {
|
||||
expect(forcedDownloadUrl('/sites/requests/Lists/List4/Attachments/5/скан.pdf', SITE)).toBe(
|
||||
`${SITE}/_layouts/15/download.aspx?SourceUrl=${encodeURIComponent(
|
||||
'https://tnsukz.sharepoint.com/sites/requests/Lists/List4/Attachments/5/скан.pdf'
|
||||
)}`
|
||||
);
|
||||
});
|
||||
|
||||
it('абсолютный url остаётся как есть внутри SourceUrl', () => {
|
||||
const file = 'https://tnsukz.sharepoint.com/sites/requests/Shared Documents/a.docx';
|
||||
expect(forcedDownloadUrl(file, SITE)).toBe(
|
||||
`${SITE}/_layouts/15/download.aspx?SourceUrl=${encodeURIComponent(file)}`
|
||||
);
|
||||
});
|
||||
|
||||
it('слеш на конце адреса сайта не удваивается', () => {
|
||||
expect(forcedDownloadUrl('/x.pdf', `${SITE}/`)).toContain(`${SITE}/_layouts/15/download.aspx`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { DATA_SITE_URL } from '../config';
|
||||
|
||||
/** Вложение в том виде, в каком его показывает AttachmentList. */
|
||||
export interface IDownloadableFile {
|
||||
fileName: string;
|
||||
/** Серверный относительный (как у вложений элементов) или абсолютный url. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Пауза между загрузками. Если запустить все сразу, браузер часть загрузок
|
||||
// молча отбрасывает; полсекунды хватает, чтобы каждая успела стартовать.
|
||||
const DOWNLOAD_INTERVAL_MS = 500;
|
||||
|
||||
function absoluteUrl(url: string, siteUrl: string): string {
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
return `${new URL(siteUrl).origin}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ссылка, по которой SharePoint ОТДАЁТ файл загрузкой, а не открывает его:
|
||||
* прямая ссылка на PDF или картинку открылась бы в просмотре браузера.
|
||||
* download.aspx — стандартная страница SharePoint, отдаёт файл с
|
||||
* Content-Disposition: attachment.
|
||||
*/
|
||||
export function forcedDownloadUrl(url: string, siteUrl: string = DATA_SITE_URL): string {
|
||||
const site = siteUrl.replace(/\/+$/, '');
|
||||
return `${site}/_layouts/15/download.aspx?SourceUrl=${encodeURIComponent(absoluteUrl(url, siteUrl))}`;
|
||||
}
|
||||
|
||||
function startDownload(file: IDownloadableFile): void {
|
||||
const link = document.createElement('a');
|
||||
link.href = forcedDownloadUrl(file.url);
|
||||
link.download = file.fileName;
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачать все файлы по очереди — отдельными загрузками, без архива. При первом
|
||||
* использовании браузер спросит, разрешить ли сайту скачивать несколько файлов:
|
||||
* это его защита, обойти её нельзя, достаточно один раз разрешить.
|
||||
* Промис завершается, когда запущена последняя загрузка.
|
||||
*/
|
||||
export function downloadAllFiles(files: IDownloadableFile[]): Promise<void> {
|
||||
const withUrl = files.filter((f) => !!f.url);
|
||||
return new Promise((resolve) => {
|
||||
withUrl.forEach((file, i) => {
|
||||
window.setTimeout(() => {
|
||||
startDownload(file);
|
||||
if (i === withUrl.length - 1) resolve();
|
||||
}, i * DOWNLOAD_INTERVAL_MS);
|
||||
});
|
||||
if (withUrl.length === 0) resolve();
|
||||
});
|
||||
}
|
||||
@@ -34,7 +34,8 @@ export const ChatMessage: React.FC<IChatMessageProps> = ({ message }) => (
|
||||
{message.text && <div className={styles.text}>{message.text}</div>}
|
||||
{message.attachments.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
<AttachmentList items={message.attachments} />
|
||||
{/* В сообщении чата «Скачать все» ни к чему — там обычно один-два файла. */}
|
||||
<AttachmentList items={message.attachments} downloadAll={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -74,6 +74,21 @@ export function getNotifyOnlyTo(): string {
|
||||
*/
|
||||
export const DICTIONARY_API_BASE_URL = 'https://api.tnsu.kz';
|
||||
|
||||
/**
|
||||
* Проходы СКУД (Hikvision) текущего пользователя — тот же gateway и AAD-app,
|
||||
* что у справочников. Сотрудника API определяет по токену, e-mail не передаём.
|
||||
* Используется формой «Объяснительная по СКУД» (forms/SkudExplanation).
|
||||
*/
|
||||
export const SKUD_EVENTS_PATH = '/Hikvision/Events/CurrentUser';
|
||||
|
||||
/**
|
||||
* Производственный календарь (праздничные выходные) — SP-список на корневом
|
||||
* сайте, где живёт главная страница портала (portal-main-page читает его же).
|
||||
* Недоступен — календарь СКУД просто не красит праздники.
|
||||
*/
|
||||
export const HOLIDAYS_SITE_URL = 'https://tnsukz.sharepoint.com';
|
||||
export const HOLIDAYS_LIST_TITLE = 'Производственный Календарь';
|
||||
|
||||
/**
|
||||
* Power Automate: поток оповещения согласующего/исполнителя.
|
||||
* Триггер «When an HTTP request is received» защищён Entra («любой пользователь
|
||||
|
||||
@@ -32,6 +32,7 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
|
||||
'hr-transfer': 'Заявка на перемещение сотрудника',
|
||||
'hr-vacation': 'Заявка на отпуск',
|
||||
'hr-unpaid-leave': 'Отпуск без содержания',
|
||||
'hr-skud-explanation': 'Объяснительная по СКУД',
|
||||
'it-1c-ticket': 'Заявка в отдел 1С',
|
||||
'it-workplace-setup': 'Организация рабочего места',
|
||||
'recruiting-hire': 'Прием на работу',
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Кнопка над списком, прижата вправо — не спорит с именами файлов.
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user