feat: changed get time logic to approvers

This commit is contained in:
Тимур Городецкий
2026-08-13 10:01:08 +05:00
parent 2e6e138e8d
commit 8578653c16
55 changed files with 2249 additions and 46 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@microsoft/spfx-web-build-rig/profiles/default/config/jest.config.json",
"collectCoverage": false
}
+105
View File
@@ -0,0 +1,105 @@
# План: обсуждение задачи
_Создано: 2026-08-12_
## Контекст
Пишем сюда идею, договорённости и шаги, чтобы ничего не забыть.
## Идея (со слов пользователя)
> Данные заявок лежат в списках SP Online. Т.к. рекомендуемый предел ~5000, а заявки
> копятся бесконечно, придумать механизм «архивации»/шардинга: при достижении 5000
> элементов заводить новый список с тем же именем + числовой постфикс (`— данные_2`),
> и фронт начинает писать новые заявки туда.
## Важный факт (уточнение)
- **5000 = List View Threshold** — лимит на строки, сканируемые ОДНИМ запросом, а НЕ на хранение.
- Список SPO хранит **до 30 млн** элементов. «Переполнения» по хранению не будет.
- Реальная проблема = запросы без индекса, сканирующие >5000 строк → throttling.
Лечится индексами + фильтрацией, а не разбиением списка.
## Цена шардинга по счётчику (что ломается в нашем коде)
- `myRequests.service` → чтение становится UNION по N шардам на каждый тип.
- `requestCode.ts` → код `type-YYMM-itemId`, itemId в новом списке снова с 1 → дубли кодов.
- Списки согласования растут ещё быстрее (строки маршрута) — их тоже шардить?
- Провижининг схемы/индексов/прав (Sites.Selected) для каждого нового списка на лету.
- Отчётность/поиск «за период» становятся кросс-списочными.
## Варианты (развилка)
- **A. Индексы + пагинация (рекомендую как базу)** — индексируем RequestStatus/Created/Author,
читаем через `.top()`+paging. Закрывает throughput-проблему полностью. Кода минимум.
- **B. Архивация по статусу/дате** — закрытые заявки переносим в `...— архив`, активный список
остаётся маленьким. Это «правильная» версия интуиции пользователя. Разгрузка без ломки кодов.
- **C. Шардинг по 5000 (исходная идея)** — крайний вариант, только если реально упрёмся в десятки млн.
## ВЫБРАННОЕ НАПРАВЛЕНИЕ (после обсуждения)
Причина тормозов подтверждена пользователем: слабый интернет (Казахстан) + список зависает.
Есть мандат «держать ≤5000 элементов в списке, плодить новые списки».
Решение = **C (шардинг) + обязательная пагинация**. Обе части нужны:
- **Шардинг** ≤5000 на список — выполняет мандат / снимает threshold-throttling (ops).
- **Пагинация + фильтр по индексу** — фронт НИКОГДА не качает весь список, только маленькую
страницу (`.top(50)`, фильтр по Author/RequestStatus). Именно это убирает зависания на
слабом канале. Без этой части шардинг сам по себе скорость НЕ спасёт (4999 строк грузятся
так же долго, как 5000).
## Кто что создаёт (решено)
- Создание шардов — **бэкенд-скрипт по расписанию** (НЕ фронт). Варианты хостинга:
Azure Function (timer) / Azure Automation runbook (PnP.PowerShell) / Power Automate /
cron на своём сервере (есть git.tnsu.kz → своя инфра). Auth: app-only (сертификат), Sites.Selected.
- Плюсы: права на создание списков не на фронте; тяжёлый провижининг мимо слабого канала юзера;
нет гонок (шард готовится заранее); переиспользуем существующий PnP-провижининг.
## Контракт бэк ↔ фронт: РЕЕСТР ШАРДОВ
Маленький SP-список / config-элемент. На каждый тип заявки:
`базовое имя`, `номер активного шарда`, `счётчик`, (для approval — свой активный/счётчик).
- Бэкенд-джоб ОБНОВЛЯЕТ (создал шард → новый активный номер).
- Фронт ЧИТАЕТ (активный список для записи + полный набор для чтения).
## Роли в коде фронта (обёртки, детали позже)
- `resolveActiveList(type)` → активный список для записи; все `submit.ts` зовут её вместо
хардкода `DATA_LIST_TITLE`.
- `resolveAllShards(type)` → все шарды для чтения; запрос по каждому с фильтром + `.top()`,
склейка маленьких кусков (быстро на слабом канале).
- Порог переключения — 4900 (запас до 5000).
- Коды: внести номер шарда (`type-YYMM-s2-itemId`), иначе itemId в новом списке снова с 1 → дубли.
- Списки согласования шардить по СВОЕМУ счётчику (растут в 3-5 раз быстрее) — проектируем после данных.
## Открытые вопросы (для следующего обсуждения)
1. Хостинг бэкенд-джоба: Azure Function / Automation / Power Automate / свой cron (git.tnsu.kz)? Что доступно и кто админит?
2. Реестр шардов — отдельный SP-список или один JSON config-элемент? (склоняюсь к маленькому списку — виден в админке)
3. ~~Формат кода с шардом?~~ РЕШЕНО: offset-схема (см. ниже).
4. Согласование: шардим сразу вместе с данными или сначала только данные, approval потом?
5. ~~Старые данные >5000 сейчас?~~ РЕШЕНО: таких списков нет, гринфилд. Разовая миграция НЕ нужна.
Текущие списки = «шард №1». Настоящая архивация (прятать старые из выдачи) — опционально, потом.
6. Прогноз объёма: заявок/месяц по каждому типу — чтобы понять реальную частоту переключений.
## Решения (зафиксировано)
- Направление: шардинг + обязательная пагинация (обе части).
- Создание шардов: бэкенд-скрипт по расписанию (app-only), НЕ фронт.
- Стык бэк↔фронт: реестр шардов.
- РЕЖИМ РАБОТЫ: сейчас ТОЛЬКО план и обсуждение. Код НЕ пишем без явного подтверждения пользователя.
### Код заявки: offset-схема (сквозная нумерация)
- ВАЖНО: SP-шный `ID` мы НЕ контролируем — в каждом новом списке он всегда с 1. Нельзя заставить
SP стартовать с 5001. Поэтому сквозную нумерацию делаем в НАШЕМ коде, не в SP ID.
- Код = `type-YYMM-{offset + itemId}`, offset = сумма финальных счётчиков всех предыдущих шардов.
- Шард 1: offset 0 → `hr-2608-1` … (совпадает с текущим форматом → обратная совместимость!)
- Шард 2: offset 5000 → `hr-2608-5001` …
- Уникальность гарантирована. Непрерывность не идеальна (удаления → дырки в SP ID), но это ок —
важна уникальность, а не отсутствие пропусков.
- Финальный счётчик закрытого шарда пишет бэкенд-джоб в реестр при переключении.
### «Мои заявки» — из ВСЕХ шардов
- `resolveAllShards(type)`: опрос всех шардов, каждый с фильтром по Author + `.top()`, склейка+сортировка.
- Трафик мал (с каждого шарда пара строк) → быстро на слабом канале.
- Оптимизация на потом: при большом числе шардов читать по умолчанию только свежие, старые — по кнопке «Показать ещё».
## Шаги (черновик, не выполняем)
- [ ] Закрыть открытые вопросы 1-6 (обсуждение)
- [ ] Спроектировать схему реестра шардов
- [ ] Спроектировать интерфейс `resolveActiveList` / `resolveAllShards` (без реализации)
- [ ] Спроектировать бэкенд-джоб (псевдокод/алгоритм)
- [ ] Только после «го» — реализация
## Заметки
- Паттерн доступа сегодня: у каждой формы свой `DATA_LIST_TITLE`, доступ через
`getSP().web.lists.getByTitle(...)` (см. `forms/*/submit.ts`, `load.ts`, `myRequests.service`).
@@ -2,6 +2,7 @@ import * as React from 'react';
import styles from './RequestPortal.module.scss'; import styles from './RequestPortal.module.scss';
import type { IRequestPortalProps } from './IRequestPortalProps'; import type { IRequestPortalProps } from './IRequestPortalProps';
import { IconSprite } from '../shared/ui/Icon/IconSprite'; import { IconSprite } from '../shared/ui/Icon/IconSprite';
import { ConfirmHost } from '../shared/ui';
import { import {
navigate, navigate,
persistOwnTab, persistOwnTab,
@@ -112,9 +113,11 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
// Смена категории закрывает открытую форму (SubmitFeature сбрасывает выбор, // Смена категории закрывает открытую форму (SubmitFeature сбрасывает выбор,
// когда категория перестаёт совпадать) — значит введённое пропадёт. // когда категория перестаёт совпадать) — значит введённое пропадёт.
const changeCategory = (categoryId: string): void => { const changeCategory = (categoryId: string): void => {
if (!confirmDiscardFormChanges()) return; confirmDiscardFormChanges().then((ok) => {
markFormClean(); if (!ok) return;
setActiveCategoryId(categoryId); markFormClean();
setActiveCategoryId(categoryId);
}).catch(() => undefined);
}; };
const goto = (nav: NavSection): void => { const goto = (nav: NavSection): void => {
@@ -239,14 +242,17 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
// Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт // Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт
// только в памяти — уходя без сохранения, пользователь теряет правки. // только в памяти — уходя без сохранения, пользователь теряет правки.
const closeEditDrawer = (): void => { const closeEditDrawer = (): void => {
if (!confirmDiscardFormChanges()) return; confirmDiscardFormChanges().then((ok) => {
markFormClean(); if (!ok) return;
closeDrawer(); markFormClean();
closeDrawer();
}).catch(() => undefined);
}; };
return ( return (
<section className={rootClasses}> <section className={rootClasses}>
<IconSprite /> <IconSprite />
<ConfirmHost />
<div className={styles.layout}> <div className={styles.layout}>
<div className={styles.side}> <div className={styles.side}>
<SideNav <SideNav
@@ -8,7 +8,8 @@ export const CATEGORIES: ICategory[] = [
{ id: 'recruiting', title: 'Отдел рекрутинга', icon: 'people' }, { id: 'recruiting', title: 'Отдел рекрутинга', icon: 'people' },
{ id: 'it', title: 'IT', icon: 'monitor' }, { id: 'it', title: 'IT', icon: 'monitor' },
{ id: 'accounting', title: 'Бухгалтерия', icon: 'calculator' }, { id: 'accounting', title: 'Бухгалтерия', icon: 'calculator' },
{ id: 'hr', title: 'Отдел кадров', icon: 'clipboard' } { id: 'hr', title: 'Отдел кадров', icon: 'clipboard' },
{ id: 'warehouse', title: 'Склад / Снабжение', icon: 'building' }
]; ];
/** Категория, активная при открытии портала. */ /** Категория, активная при открытии портала. */
+4 -1
View File
@@ -15,7 +15,10 @@ export const REQUEST_TYPES: IRequestType[] = [
// Отдел кадров // Отдел кадров
{ id: 'hr-vacation', categoryId: 'hr', title: 'Заявка на отпуск', icon: 'calendar' }, { id: 'hr-vacation', categoryId: 'hr', title: 'Заявка на отпуск', icon: 'calendar' },
{ id: 'hr-transfer', categoryId: 'hr', title: 'на перемещение сотрудников', icon: 'send' } { id: 'hr-transfer', categoryId: 'hr', title: 'на перемещение сотрудников', icon: 'send' },
// Склад / Снабжение
{ id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send' }
]; ];
/** Заявки, относящиеся к указанной категории. */ /** Заявки, относящиеся к указанной категории. */
@@ -57,9 +57,11 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
// если правки есть и они не сохранены; после согласия флаг снимаем — терять // если правки есть и они не сохранены; после согласия флаг снимаем — терять
// уже нечего. // уже нечего.
const handleBack = (): void => { const handleBack = (): void => {
if (!confirmDiscardFormChanges()) return; confirmDiscardFormChanges().then((ok) => {
markFormClean(); if (!ok) return;
setSelectedRequest(null); markFormClean();
setSelectedRequest(null);
}).catch(() => undefined);
}; };
const renderSelected = (request: IRequestType): React.ReactNode => { const renderSelected = (request: IRequestType): React.ReactNode => {
@@ -4,6 +4,7 @@ import styles from './RecruitingHireForm.module.scss';
import { import {
Alert, Alert,
FormCard, FormCard,
InitiatorLine,
FormModeProvider, FormModeProvider,
FileDropzone, FileDropzone,
AttachmentList, AttachmentList,
@@ -219,6 +220,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: Отдел рекрутинга" subtitle="Категория: Отдел рекрутинга"
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={requestId} />}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RecruitingHireView.module.scss'; import styles from './RecruitingHireView.module.scss';
import { Alert, Button, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList, Icon, PrintButton } from '../../shared/ui'; import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, CollapseSection, FormField, AttachmentList, Icon, PrintButton } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -129,8 +129,10 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
// Раздел «Новая заявка» остаётся смонтированным при переключении // Раздел «Новая заявка» остаётся смонтированным при переключении
// вкладок, но переход «на основе» пересоздаёт форму — если там // вкладок, но переход «на основе» пересоздаёт форму — если там
// остались несохранённые правки, спрашиваем перед их потерей. // остались несохранённые правки, спрашиваем перед их потерей.
if (!confirmDiscardFormChanges()) return; confirmDiscardFormChanges().then((ok) => {
navigate({ mode: 'create', nav: 'new', formKey: WORKPLACE_FORM_KEY, sourceId: itemId }); if (!ok) return;
navigate({ mode: 'create', nav: 'new', formKey: WORKPLACE_FORM_KEY, sourceId: itemId });
}).catch(() => undefined);
}} }}
> >
<Icon name="copy" size="sm" /> <Icon name="copy" size="sm" />
@@ -143,6 +145,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<FormCard <FormCard
title="Заявка на прием" title="Заявка на прием"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={itemId} />}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -4,6 +4,7 @@ import styles from './RecruitingSelectionForm.module.scss';
import { import {
Alert, Alert,
FormCard, FormCard,
InitiatorLine,
FormModeProvider, FormModeProvider,
FileDropzone, FileDropzone,
AttachmentList, AttachmentList,
@@ -184,6 +185,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: Отдел рекрутинга" subtitle="Категория: Отдел рекрутинга"
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={requestId} />}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RecruitingSelectionView.module.scss'; import styles from './RecruitingSelectionView.module.scss';
import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui'; import { Alert, FormCard, InitiatorLine, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -111,6 +111,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
<FormCard <FormCard
title="Заявка на подбор" title="Заявка на подбор"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={itemId} />}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -4,6 +4,7 @@ import styles from './Ticket1CForm.module.scss';
import { import {
Alert, Alert,
FormCard, FormCard,
InitiatorLine,
FormModeProvider, FormModeProvider,
FileDropzone, FileDropzone,
AttachmentList, AttachmentList,
@@ -268,6 +269,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: IT" subtitle="Категория: IT"
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={requestId} />}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -6,6 +6,7 @@ import {
Alert, Alert,
Button, Button,
FormCard, FormCard,
InitiatorLine,
FormModeProvider, FormModeProvider,
CollapseSection, CollapseSection,
FormField, FormField,
@@ -190,6 +191,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId }) => {
<FormCard <FormCard
title="Заявка в отдел 1С" title="Заявка в отдел 1С"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={itemId} />}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -10,6 +10,10 @@ export const APPROVAL_LIST_TITLE = 'Заявка в отдел 1С — согл
// name "Заявка в отдел 1С" (не путать с «Маршрут #8» — это id самого маршрута // name "Заявка в отдел 1С" (не путать с «Маршрут #8» — это id самого маршрута
// в админке, отдельная сущность от id типа заявки). // в админке, отдельная сущность от id типа заявки).
export const TICKET1C_REQUEST_TYPE_ID = 10; export const TICKET1C_REQUEST_TYPE_ID = 10;
// Префикс уникального кода заявки — буквенная метка «1С» вместо числового id
// типа (10). Только для отображения/идентификации; на резолв маршрута и
// уведомления (там нужен числовой TICKET1C_REQUEST_TYPE_ID) не влияет.
export const TICKET1C_CODE_PREFIX = '1С';
/** Экземпляр сервиса списка согласования этой формы. */ /** Экземпляр сервиса списка согласования этой формы. */
export function ticket1cApprovalService(): ApprovalListService { export function ticket1cApprovalService(): ApprovalListService {
@@ -59,7 +63,7 @@ export async function saveTicketRequest(
const added = await list.items.add(item); const added = await list.items.add(item);
id = (added as { Id: number }).Id; id = (added as { Id: number }).Id;
// Код известен только после создания элемента (нужен его Id). // Код известен только после создания элемента (нужен его Id).
code = buildRequestUniqueCode(TICKET1C_REQUEST_TYPE_ID, id, new Date()); code = buildRequestUniqueCode(TICKET1C_CODE_PREFIX, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code }); await list.items.getById(id).update({ RequestUniqueCode: code });
} }
@@ -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,302 @@
import * as React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import styles from './TmcTransferForm.module.scss';
import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
FileDropzone,
AttachmentList,
CollapseSection,
FormField,
Icon,
RequestNumber,
SubmitActions,
useRequestSubmit
} from '../../shared/ui';
import {
ApprovalMatrix,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
notifyActiveStep,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite,
APPROVAL_PHASE,
findUnassignedStep
} from '../../shared/approval';
import {
uploadAttachments,
getAttachments,
deleteAttachment,
IAttachmentInfo
} from '../../shared/attachments/attachments.service';
import { IRequestFormProps } from '../../models/types';
import { IFormData, EMPTY_DATA, SetField } from './model';
import {
saveTmcTransferRequest,
tmcTransferApprovalService,
TMC_TRANSFER_REQUEST_TYPE_ID,
DATA_LIST_TITLE,
APPROVAL_LIST_TITLE
} from './submit';
import { loadTmcTransferRequest } from './load';
import { HeaderSection } from './sections/HeaderSection';
import { ItemsSection } from './sections/ItemsSection';
const TmcTransferForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit();
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Новая заявка — предзаполняем «От» текущим пользователем и дату сегодняшней.
useEffect(() => {
if (editId) {
return;
}
const user = context.pageContext.user;
const today = new Date().toISOString().slice(0, 10);
setData((prev) => ({
...prev,
RequesterEmail: user.email || '',
AuthorName: user.displayName || '',
RequestDate: prev.RequestDate || today
}));
}, [editId, context]);
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и вложения.
useEffect(() => {
if (!editId) {
return;
}
let alive = true;
Promise.all([loadTmcTransferRequest(editId), getAttachments(DATA_LIST_TITLE, editId)])
.then(([loaded, atts]) => {
if (alive) {
setData(loaded.data);
setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
setExistingAttachments(atts);
}
})
.catch((e) => {
if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${(e as Error).message}`);
}
});
return () => {
alive = false;
};
}, [editId]);
const approval = useMemo(() => tmcTransferApprovalService(), []);
const userEmail = context.pageContext.user.email;
// У новой заявки маршрут ПЛАНИРУЕТСЯ (resolve по внешнему API), у открытой на
// редактирование — читается из уже записанных строк.
// projectId = oid проекта-источника: по нему бэкенд подставляет согласующего
// «руководитель проекта-источника». Меняется проект → перепланируем маршрут.
const projectId = data.SourceProjectId || null;
const targetProjectId = data.TargetProjectId || null;
// Маршрут согласования зависит от проекта-источника («Откуда»): пока он не
// выбран, resolve вернёт 404 — не грузим, показываем заглушку. У открытой на
// редактирование заявки маршрут уже записан, источник не нужен.
const matrixReady = !!editId || !!projectId;
// Исполнитель определяется проектом-получателем («Куда»): показываем блок с
// заглушкой, пока «Куда» не выбрано. Источник тоже нужен, иначе resolve 404.
const executorsReady = !!editId || (!!projectId && !!targetProjectId);
const loadMatrix = useCallback(
() =>
editId
? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
: getPlannedMatrix(context, TMC_TRANSFER_REQUEST_TYPE_ID, userEmail, approval, 'Approval', null, undefined, projectId),
[context, userEmail, approval, editId, projectId]
);
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
approval.getJobTitle(login)
);
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
const loadExecutors = useCallback(
() =>
editId
? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
: getPlannedMatrix(context, TMC_TRANSFER_REQUEST_TYPE_ID, userEmail, approval, 'Execution', null, undefined, projectId),
[context, userEmail, approval, editId, projectId]
);
const executors = useMatrixEditor(
loadExecutors,
`${userEmail}-${editId ?? 'new'}-${projectId}-${targetProjectId}`,
false,
executorsReady,
(login) => approval.getJobTitle(login)
);
const hasItems = data.Items.some((i) => i.name.trim().length > 0);
const isValid =
data.SourceProject.trim().length > 0 && data.TargetProject.trim().length > 0 && hasItems;
const validate = (): string | undefined =>
isValid
? undefined
: 'Заполните обязательные поля: проект-источник, проект-получатель и хотя бы одну позицию ТМЦ.';
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
order: r.order
}));
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
const handleRemoveAttachment = (fileName: string): void => {
if (requestId === undefined) {
return;
}
deleteAttachment(DATA_LIST_TITLE, requestId, fileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch(() => undefined);
};
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
const { id, code } = await saveTmcTransferRequest(
requestId,
data,
toApprovers(matrix.rows),
toApprovers(executors.rows),
status
);
setRequestId(id);
setRequestStatus(status);
if (code) {
setRequestCode(code);
}
if (files.length > 0) {
await uploadAttachments(DATA_LIST_TITLE, id, files);
setFiles([]);
setExistingAttachments(await getAttachments(DATA_LIST_TITLE, id));
}
if (onSaved) {
onSaved();
}
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: TMC_TRANSFER_REQUEST_TYPE_ID,
dataListName: DATA_LIST_TITLE,
approvalListName: APPROVAL_LIST_TITLE
});
}
};
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
const unassigned = findUnassignedStep(matrix.rows);
if (unassigned) {
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
}
const unassignedExecutor = findUnassignedStep(executors.rows);
if (unassignedExecutor) {
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`;
}
return undefined;
};
const handleSaveDraft = (): Promise<void> =>
submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> =>
submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
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={requestType.title}
subtitle="Категория: Склад / Снабжение"
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} 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}>
<HeaderSection {...sectionProps} />
<ItemsSection {...sectionProps} />
</FormModeProvider>
{!readOnly && (
<CollapseSection title="Вложения" icon="upload">
{existingAttachments.length > 0 && (
<FormField label="Прикреплённые файлы" full>
<AttachmentList items={existingAttachments} onRemove={handleRemoveAttachment} />
</FormField>
)}
<FormField label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full>
<FileDropzone files={files} onChange={setFiles} />
</FormField>
</CollapseSection>
)}
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
waiting={!matrixReady}
waitingText="Выберите проект-источник («Откуда»), чтобы построить маршрут согласования."
/>
{/* Блок исполнителя показываем, только когда по маршруту реально есть
исполнители (грузится по проектам, см. executorsReady). */}
{executorsReady && !executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту."
/>
)}
<SubmitActions
submit={submit}
isValid={isValid}
readOnly={readOnly}
onCancel={onCancel}
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmit}
/>
</FormCard>
</div>
);
};
export default TmcTransferForm;
@@ -0,0 +1,15 @@
@import '../../../../styles/design-tokens';
.state {
padding: $sp-8;
color: $gray-500;
font-size: $fs-sm;
text-align: center;
}
.toolbar {
display: flex;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
}
@@ -0,0 +1,200 @@
import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './TmcTransferView.module.scss';
import {
Alert,
Button,
FormCard,
InitiatorLine,
FormModeProvider,
CollapseSection,
FormField,
AttachmentList,
PrintButton,
Icon
} from '../../shared/ui';
import { navigate } from '../../shared/routing';
import {
ApproverService,
DecisionPanel,
StatusBadge,
ApprovalMatrix,
useMatrixEditor,
notifyActiveStep,
isEditableStatus,
APPROVAL_PHASE
} from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { IFormData, EMPTY_DATA, SetField } from './model';
import { loadTmcTransferRequest } from './load';
import {
tmcTransferApprovalService,
TMC_TRANSFER_REQUEST_TYPE_ID,
DATA_LIST_TITLE,
APPROVAL_LIST_TITLE
} from './submit';
import { HeaderSection } from './sections/HeaderSection';
import { ItemsSection } from './sections/ItemsSection';
interface ITmcTransferViewProps {
context: WebPartContext;
itemId: number;
}
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// В просмотре форма read-only, но секции ждут set — даём no-op.
const set: SetField = () => undefined;
const approverService = useMemo(
() => new ApproverService(tmcTransferApprovalService(), DATA_LIST_TITLE),
[]
);
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const approval = useMemo(() => tmcTransferApprovalService(), []);
const loadApprovers = useCallback(
() =>
approval
.getMatrixForRequest(itemId)
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
[approval, itemId]
);
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l)
);
const loadExecutors = useCallback(
() =>
approval
.getMatrixForRequest(itemId)
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
[approval, itemId]
);
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l)
);
useEffect(() => {
let alive = true;
setLoading(true);
Promise.all([loadTmcTransferRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)])
.then(([loaded, files]) => {
if (alive) {
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
setAttachments(files);
setError(null);
setLoading(false);
}
})
.catch((e) => {
if (alive) {
setError(`Не удалось загрузить заявку №${itemId}: ${(e as Error).message}`);
setLoading(false);
}
});
return () => {
alive = false;
};
}, [itemId]);
if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>;
}
if (error) {
return (
<div className={styles.state}>
<Alert variant="err">{error}</Alert>
</div>
);
}
const sectionProps = { data, set, context };
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
{isEditableStatus(status) && (
<Button
variant="secondary"
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey: 'wh-tmc-transfer', itemId })}
>
<Icon name="edit" size="sm" />
Редактировать
</Button>
)}
<PrintButton />
</div>
<div className="rp-print-root">
<FormCard
title="Служебная записка на перемещение ТМЦ"
subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={itemId} />}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<FormModeProvider readOnly>
<HeaderSection {...sectionProps} />
<ItemsSection {...sectionProps} />
</FormModeProvider>
<div className="rp-no-print">
<CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full>
<AttachmentList items={attachments} />
</FormField>
</CollapseSection>
</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>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notifyActiveStep(context, approval, itemId, {
requestTypeId: TMC_TRANSFER_REQUEST_TYPE_ID,
dataListName: DATA_LIST_TITLE,
approvalListName: APPROVAL_LIST_TITLE
}).catch(() => undefined);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
notifyActiveStep(context, approval, itemId, {
requestTypeId: TMC_TRANSFER_REQUEST_TYPE_ID,
dataListName: DATA_LIST_TITLE,
approvalListName: APPROVAL_LIST_TITLE
}).catch(() => undefined);
}}
/>
</div>
</div>
);
};
export default TmcTransferView;
@@ -0,0 +1,14 @@
{
"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": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
]
}
@@ -0,0 +1,74 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ILineItem } from '../../shared/ui';
import { IFormData, EMPTY_DATA } from './model';
import { DATA_LIST_TITLE } from './submit';
export interface ILoadedRequest {
data: IFormData;
status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
}
/** Безопасный разбор ItemsJson в массив позиций (пустой массив при ошибке/пустом значении). */
function parseItems(raw: unknown): ILineItem[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as ILineItem[]) : [];
} catch {
return [];
}
}
/** Прочитать заявку «СЗ на перемещение ТМЦ» по id: модель формы + статус + код. */
export async function loadTmcTransferRequest(itemId: number): Promise<ILoadedRequest> {
const item = (await getSP()
.web.lists.getByTitle(DATA_LIST_TITLE)
.items.getById(itemId)
.select(
'Id',
'Recipient',
'RequesterEmail',
'AuthorName',
'Subject',
'RequestDate',
'SourceProject',
'SourceProjectId',
'SourceProjectManager',
'TargetProject',
'TargetProjectId',
'Reason',
'ContactPerson',
'ContactPhone',
'ItemsJson',
'RequestStatus',
'RequestUniqueCode'
)()) as Record<string, unknown>;
const requestDate = (item.RequestDate as string) || '';
const data: IFormData = {
...EMPTY_DATA,
Recipient: (item.Recipient as string) || '',
RequesterEmail: (item.RequesterEmail as string) || '',
AuthorName: (item.AuthorName as string) || '',
Subject: (item.Subject as string) || '',
RequestDate: requestDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
SourceProject: (item.SourceProject as string) || '',
SourceProjectId: (item.SourceProjectId as string) || '',
SourceProjectManager: (item.SourceProjectManager as string) || '',
TargetProject: (item.TargetProject as string) || '',
TargetProjectId: (item.TargetProjectId as string) || '',
Reason: (item.Reason as string) || '',
ContactPerson: (item.ContactPerson as string) || '',
ContactPhone: (item.ContactPhone as string) || '',
Items: parseItems(item.ItemsJson)
};
return {
data,
status: (item.RequestStatus as string) || '',
code: (item.RequestUniqueCode as string) || ''
};
}
@@ -0,0 +1,65 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ILineItem } from '../../shared/ui';
/**
* Данные формы «Служебная записка на перемещение ТМЦ».
* Ключи шапки = internalName колонок SP-списка «СЗ на перемещение ТМЦ — данные».
* Табличная часть (Items) в SP не разворачивается в колонки, а сериализуется
* в JSON-колонку ItemsJson (см. submit.ts / load.ts).
*/
export interface IFormData {
// Шапка служебной записки
Recipient: string; // Кому (по умолч. «Директору Самаевой А.А.»)
// NB: не «Author» — это зарезервированное системное имя колонки SP (Created By).
RequesterEmail: string; // От — email заявителя (текущий пользователь)
AuthorName: string; // От — Ф.И.О. заявителя (для печати)
Subject: string; // Тема (по умолч. «Перемещение ТМЦ»)
RequestDate: string; // Дата (YYYY-MM-DD)
// Проект-источник (откуда забираем) — выбор из справочника /Project
SourceProject: string;
SourceProjectId: string; // oid проекта ('' — если введён вручную)
/** Снимок email руководителя проекта-источника — для будущего маршрута согласования. */
SourceProjectManager: string;
// Проект-получатель (куда перемещаем)
TargetProject: string;
TargetProjectId: string;
Reason: string; // Обоснование / вступительный текст
ContactPerson: string; // Исполнитель («Исп. …»), Ф.И.О.
ContactPhone: string; // Телефон исполнителя
// Табличная часть — позиции ТМЦ (сериализуется в ItemsJson)
Items: ILineItem[];
}
/** Обновление одного поля формы с сохранением типа значения. */
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
/** Пропсы каждой секции формы. */
export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
}
export const DEFAULT_RECIPIENT = 'Директору Самаевой А.А.';
export const DEFAULT_SUBJECT = 'Перемещение ТМЦ';
export const EMPTY_DATA: IFormData = {
Recipient: DEFAULT_RECIPIENT,
RequesterEmail: '',
AuthorName: '',
Subject: DEFAULT_SUBJECT,
RequestDate: '',
SourceProject: '',
SourceProjectId: '',
SourceProjectManager: '',
TargetProject: '',
TargetProjectId: '',
Reason: '',
ContactPerson: '',
ContactPhone: '',
Items: []
};
@@ -0,0 +1,25 @@
{
"dataListTitle": "СЗ на перемещение ТМЦ — данные",
"columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "Recipient", "title": "Кому", "type": "Text" },
{ "internalName": "RequesterEmail", "title": "От (email заявителя)", "type": "Text" },
{ "internalName": "AuthorName", "title": "От (Ф.И.О.)", "type": "Text" },
{ "internalName": "Subject", "title": "Тема", "type": "Text" },
{ "internalName": "RequestDate", "title": "Дата", "type": "DateTime", "dateOnly": true },
{ "internalName": "SourceProject", "title": "Проект-источник (откуда)", "type": "Text", "required": true },
{ "internalName": "SourceProjectId", "title": "Id проекта-источника", "type": "Text" },
{ "internalName": "SourceProjectManager", "title": "Руководитель проекта-источника (email)", "type": "Text" },
{ "internalName": "TargetProject", "title": "Проект-получатель (куда)", "type": "Text", "required": true },
{ "internalName": "TargetProjectId", "title": "Id проекта-получателя", "type": "Text" },
{ "internalName": "Reason", "title": "Обоснование / текст", "type": "Note" },
{ "internalName": "ContactPerson", "title": "Исполнитель (Ф.И.О.)", "type": "Text" },
{ "internalName": "ContactPhone", "title": "Телефон исполнителя", "type": "Text" },
{ "internalName": "ItemsJson", "title": "Позиции ТМЦ (JSON)", "type": "Note" }
]
}
@@ -0,0 +1,52 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, TextArea } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model';
/**
* Шапка служебной записки: заявитель, проекты «откуда → куда», дата, обоснование.
* «Кому» и «Тема» — константы служебки (пишутся из модели по умолчанию), в форме не показываются.
*/
export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<FormGrid>
<FormField label="Откуда (проект-источник)" htmlFor="SourceProject" required>
<ProjectPicker
id="SourceProject"
context={context}
value={data.SourceProject}
placeholder="Начните вводить название проекта…"
onChange={(name, id, project) => {
set('SourceProject', name);
set('SourceProjectId', id);
// Снимок руководителя проекта-источника — пригодится для маршрута согласования.
set('SourceProjectManager', project ? project.projectManager || '' : '');
}}
/>
</FormField>
<FormField label="Куда (проект-получатель)" htmlFor="TargetProject" required>
<ProjectPicker
id="TargetProject"
context={context}
value={data.TargetProject}
placeholder="Начните вводить название проекта…"
onChange={(name, id) => {
set('TargetProject', name);
set('TargetProjectId', id);
}}
/>
</FormField>
<FormField label="Дата" htmlFor="RequestDate">
<TextInput id="RequestDate" type="date" value={data.RequestDate} onChange={(v) => set('RequestDate', v)} />
</FormField>
<FormField label="Обоснование / текст" htmlFor="Reason" full>
<TextArea
id="Reason"
value={data.Reason}
onChange={(v) => set('Reason', v)}
placeholder="В связи с производственной необходимостью прошу согласовать перемещение ТМЦ…"
/>
</FormField>
</FormGrid>
);
@@ -0,0 +1,4 @@
// Тело CollapseSection — грид 1fr 1fr; таблица должна занимать обе колонки.
.full {
grid-column: 1 / -1;
}
@@ -0,0 +1,13 @@
import * as React from 'react';
import styles from './ItemsSection.module.scss';
import { CollapseSection, ItemsTable } from '../../../shared/ui';
import { ISectionProps } from '../model';
/** Табличная часть СЗ: позиции ТМЦ (ручной ввод + вставка из Excel). */
export const ItemsSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Перечень ТМЦ" icon="clipboard">
<div className={styles.full}>
<ItemsTable value={data.Items} onChange={(rows) => set('Items', rows)} />
</div>
</CollapseSection>
);
@@ -0,0 +1,78 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
import { buildRequestUniqueCode } from '../../shared/requestCode';
import { ILineItem } from '../../shared/ui';
import { IFormData } from './model';
export const DATA_LIST_TITLE = 'СЗ на перемещение ТМЦ — данные';
export const APPROVAL_LIST_TITLE = 'СЗ на перемещение ТМЦ — согласование';
// Тип заявки во внешнем approval-route API — подтверждён через GET /api/request-types:
// id 12, name «Заявка на перемещение», pageUrl «wh-tmc-transfer», section «Снабжение».
export const TMC_TRANSFER_REQUEST_TYPE_ID = 12;
/** Экземпляр сервиса списка согласования этой формы. */
export function tmcTransferApprovalService(): ApprovalListService {
return new ApprovalListService(APPROVAL_LIST_TITLE);
}
/** Осмысленный заголовок элемента (системная колонка Title). */
function buildTitle(data: IFormData): string {
const route = [data.SourceProject, data.TargetProject].filter(Boolean).join(' → ');
return ['Перемещение ТМЦ', route].filter(Boolean).join(': ');
}
/** Поля элемента списка данных из модели формы. Items → JSON в ItemsJson. */
function buildItem(data: IFormData, status: string): Record<string, unknown> {
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
if (key === 'Items') {
// Табличная часть не разворачивается в колонки — сериализуем в Note-колонку.
const items = data.Items as ILineItem[];
item.ItemsJson = items.length > 0 ? JSON.stringify(items) : null;
return;
}
const value = data[key];
item[key] = value === '' ? null : value;
});
return item;
}
export interface ISaveResult {
id: number;
/** Задан только при создании новой заявки (существующий код не меняется). */
code?: string;
}
/**
* Сохранить заявку «СЗ на перемещение ТМЦ»: контент + маршрут (согласующие + исполнители).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Возвращает Id заявки (+ уникальный код при создании).
*/
export async function saveTmcTransferRequest(
existingId: number | undefined,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string
): Promise<ISaveResult> {
const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE);
const item = buildItem(data, status);
let id: number;
let code: string | undefined;
if (existingId) {
await list.items.getById(existingId).update(item);
id = existingId;
} else {
const added = await list.items.add(item);
id = (added as { Id: number }).Id;
// Код известен только после создания элемента (нужен его Id).
code = buildRequestUniqueCode(TMC_TRANSFER_REQUEST_TYPE_ID, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(tmcTransferApprovalService(), id, approvers, executors, status);
return { id, code };
}
@@ -4,6 +4,7 @@ import styles from './VacationForm.module.scss';
import { import {
Alert, Alert,
FormCard, FormCard,
InitiatorLine,
FormModeProvider, FormModeProvider,
Icon, Icon,
RequestNumber, RequestNumber,
@@ -191,6 +192,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: Отдел кадров" subtitle="Категория: Отдел кадров"
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={requestId} />}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './VacationView.module.scss'; import styles from './VacationView.module.scss';
import { Alert, FormCard, FormModeProvider, PrintButton } from '../../shared/ui'; import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -105,6 +105,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId }) => {
<FormCard <FormCard
title="Заявка на отпуск" title="Заявка на отпуск"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={itemId} />}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -4,6 +4,7 @@ import styles from './WorkplaceSetupForm.module.scss';
import { import {
Alert, Alert,
FormCard, FormCard,
InitiatorLine,
FormModeProvider, FormModeProvider,
FileDropzone, FileDropzone,
AttachmentList, AttachmentList,
@@ -243,6 +244,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
<FormCard <FormCard
title={requestType.title} title={requestType.title}
subtitle="Категория: IT" subtitle="Категория: IT"
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={requestId} />}
headerRight={ headerRight={
requestId || requestStatus ? ( requestId || requestStatus ? (
<> <>
@@ -6,6 +6,7 @@ import {
Alert, Alert,
Button, Button,
FormCard, FormCard,
InitiatorLine,
FormModeProvider, FormModeProvider,
CollapseSection, CollapseSection,
FormField, FormField,
@@ -205,6 +206,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
<FormCard <FormCard
title="Организация рабочего места" title="Организация рабочего места"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={itemId} />}
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -27,6 +27,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
), ),
'hr-vacation': React.lazy( 'hr-vacation': React.lazy(
() => import(/* webpackChunkName: 'form-vacation' */ './Vacation/VacationForm') () => import(/* webpackChunkName: 'form-vacation' */ './Vacation/VacationForm')
),
'wh-tmc-transfer': React.lazy(
() => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm')
) )
}; };
@@ -27,6 +27,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
), ),
'hr-vacation': React.lazy( 'hr-vacation': React.lazy(
() => import(/* webpackChunkName: 'view-vacation' */ './Vacation/VacationView') () => import(/* webpackChunkName: 'view-vacation' */ './Vacation/VacationView')
),
'wh-tmc-transfer': React.lazy(
() => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView')
) )
}; };
@@ -130,7 +130,7 @@ export class ApprovalListService {
if (queuedRows.length === 0) return; if (queuedRows.length === 0) return;
const minOrder = Math.min(...queuedRows.map((r) => r.order)); const minOrder = Math.min(...queuedRows.map((r) => r.order));
await Promise.all( await Promise.all(
queuedRows.filter((r) => r.order === minOrder).map((r) => this.setStatus(r.id, activeStatus)) queuedRows.filter((r) => r.order === minOrder).map((r) => this.setActive(r.id, activeStatus))
); );
}; };
@@ -146,6 +146,17 @@ export class ApprovalListService {
await this.list().items.getById(rowId).update({ [FIELDS.status]: status }); await this.list().items.getById(rowId).update({ [FIELDS.status]: status });
} }
// Активация строки: статус + отметка времени, КОГДА строка стала текущей
// («получил на согласование/исполнение»). Отличается от setStatus именно
// датой — использовать только на переходе в активный статус, не для отката в
// «Ждёт очереди». Дату решения позже перезапишет setDecision. См.
// activateNextQueued (единая точка активации: отправка, одобрение, делегат).
private async setActive(rowId: number, status: string): Promise<void> {
await this.list()
.items.getById(rowId)
.update({ [FIELDS.status]: status, [FIELDS.date]: new Date().toISOString() });
}
// Добавляет одну строку согласования с заданным Order (для делегата). // Добавляет одну строку согласования с заданным Order (для делегата).
// delegatedById — Id пользователя, который выполнил переназначение (не строки // delegatedById — Id пользователя, который выполнил переназначение (не строки
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту // из маршрута — у них делегата нет). Он сохраняет право переназначить эту
@@ -262,10 +273,14 @@ export class ApprovalListService {
} }
// Должность пользователя из профиля SharePoint (SPS-JobTitle). '' если недоступно. // Должность пользователя из профиля SharePoint (SPS-JobTitle). '' если недоступно.
// Принимает и клеймовый логин (i:0#.f|membership|…), и голый e-mail: пикер
// сотрудника отдаёт именно e-mail (см. pickedLogin), а getPropertiesFor ждёт
// account name — приводим e-mail к клеймовому виду так же, как ensure().
public async getJobTitle(loginName: string): Promise<string> { public async getJobTitle(loginName: string): Promise<string> {
if (!loginName) return ''; if (!loginName) return '';
const account = loginName.indexOf('|') >= 0 ? loginName : `i:0#.f|membership|${loginName}`;
try { try {
const props = (await getSP().profiles.getPropertiesFor(loginName)) as { const props = (await getSP().profiles.getPropertiesFor(account)) as {
UserProfileProperties?: unknown; UserProfileProperties?: unknown;
}; };
const raw = props.UserProfileProperties; const raw = props.UserProfileProperties;
@@ -326,11 +341,17 @@ export class ApprovalListService {
return getSP().web.ensureUser(login); return getSP().web.ensureUser(login);
} }
// Формат «дд.мм.гггг чч:мм» в казахстанском поясе (UTC+5) — жёстко, а не по
// поясу браузера: время должно совпадать у всех участников. В SharePoint дата
// хранится в UTC (setDecision/setActive пишут toISOString); Казахстан без
// перехода на летнее время, поэтому достаточно прибавить 5 часов и читать
// UTC-компоненты (getUTC*), не завися от локального пояса среды.
private formatDate(iso: string): string { private formatDate(iso: string): string {
if (!iso) return ''; if (!iso) return '';
const d = new Date(iso); const KZ_OFFSET_MS = 5 * 60 * 60 * 1000;
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`; const d = new Date(new Date(iso).getTime() + KZ_OFFSET_MS);
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`; const p = (n: number): string => (n < 10 ? `0${n}` : `${n}`);
return `${dd}.${mm}.${d.getFullYear()}`; const date = `${p(d.getUTCDate())}.${p(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`;
return `${date} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
} }
} }
@@ -41,12 +41,16 @@ export async function resolveRoute(
userEmail: string, userEmail: string,
status: ApprovalStatus = 'Approval', status: ApprovalStatus = 'Approval',
departmentId: string | null = null, departmentId: string | null = null,
fieldValues: IResolveFieldValue[] = [] fieldValues: IResolveFieldValue[] = [],
projectId: string | null = null
): Promise<IResolvedRoute> { ): Promise<IResolvedRoute> {
const c = await client(context); const c = await client(context);
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`; const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
// Эндпоинт сменился с GET (query) на POST (JSON-тело). // Эндпоинт сменился с GET (query) на POST (JSON-тело).
// projectId (uuid проекта) — для форм, где согласующий зависит от проекта
// (напр. «руководитель проекта-источника» в СЗ на перемещение ТМЦ): бэкенд по
// нему подставляет исполнителя роли. Формы без привязки к проекту шлют null.
const response: HttpClientResponse = await c.post(url, AadHttpClient.configurations.v1, { const response: HttpClientResponse = await c.post(url, AadHttpClient.configurations.v1, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -55,7 +59,7 @@ export async function resolveRoute(
body: JSON.stringify({ body: JSON.stringify({
requestTypeId, requestTypeId,
userEmail, userEmail,
projectId: null, projectId: projectId || null,
status, status,
fieldValues, fieldValues,
departmentId: departmentId || null departmentId: departmentId || null
@@ -16,10 +16,11 @@ export async function getPlannedMatrix(
approval: ApprovalListService, approval: ApprovalListService,
status: ApprovalStatus = 'Approval', status: ApprovalStatus = 'Approval',
departmentId?: string | null, departmentId?: string | null,
fieldValues?: IResolveFieldValue[] fieldValues?: IResolveFieldValue[],
projectId?: string | null
): Promise<IMatrixRow[]> { ): Promise<IMatrixRow[]> {
if (!userEmail) return []; if (!userEmail) return [];
const route = await resolveRoute(context, requestTypeId, userEmail, status, departmentId, fieldValues); const route = await resolveRoute(context, requestTypeId, userEmail, status, departmentId, fieldValues, projectId);
const phase = status === 'Execution' ? APPROVAL_PHASE.execution : APPROVAL_PHASE.approval; const phase = status === 'Execution' ? APPROVAL_PHASE.execution : APPROVAL_PHASE.approval;
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo); const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
return Promise.all( return Promise.all(
@@ -18,6 +18,15 @@ export interface IApprovalMatrixProps {
emptyText?: string; emptyText?: string;
/** Плейсхолдер пикера добавления (только если editor.editable). */ /** Плейсхолдер пикера добавления (только если editor.editable). */
pickerPlaceholder?: string; pickerPlaceholder?: string;
/**
* Режим ожидания: маршрут ещё нельзя построить (не выбрана зависимость,
* напр. проект). Показываем только заголовок и информационную заглушку —
* без обращения к API, таблицы и пикера. Состояние loading/error/rows
* редактора при этом игнорируется.
*/
waiting?: boolean;
/** Текст заглушки в режиме waiting. */
waitingText?: string;
} }
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */ /** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
@@ -28,6 +37,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
roleLabel = 'Согласующий', roleLabel = 'Согласующий',
emptyText = 'Согласующие не заданы.', emptyText = 'Согласующие не заданы.',
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…', pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…',
waiting = false,
waitingText = '',
}) => { }) => {
const { rows, loading, error, editable, blocks } = editor; const { rows, loading, error, editable, blocks } = editor;
@@ -86,12 +97,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
return ( return (
<div className={styles.matrixWrap}> <div className={styles.matrixWrap}>
<div className={styles.matrixTitle}>{title}</div> <div className={styles.matrixTitle}>{title}</div>
{loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>} {waiting && <div className={styles.matrixNote}>{waitingText}</div>}
{!loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>} {!waiting && loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
{!loading && !error && rows.length === 0 && ( {!waiting && !loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>}
{!waiting && !loading && !error && rows.length === 0 && (
<div className={styles.matrixNote}>{emptyText}</div> <div className={styles.matrixNote}>{emptyText}</div>
)} )}
{!loading && !error && rows.length > 0 && ( {!waiting && !loading && !error && rows.length > 0 && (
<div className={styles.matrixScroll}> <div className={styles.matrixScroll}>
<table className={styles.matrix}> <table className={styles.matrix}>
<thead> <thead>
@@ -193,7 +205,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
</table> </table>
</div> </div>
)} )}
{editable && !loading && ( {editable && !waiting && !loading && (
<div className={styles.matrixPicker}> <div className={styles.matrixPicker}>
<UserPicker search={search} onPick={addApprover} placeholder={pickerPlaceholder} /> <UserPicker search={search} onPick={addApprover} placeholder={pickerPlaceholder} />
</div> </div>
@@ -0,0 +1,28 @@
import { getSP } from './pnp/pnpjs';
/** Инициатор заявки — из системного поля «Автор» (Created By) элемента списка. */
export interface IInitiator {
name: string;
email: string;
}
/**
* Прочитать инициатора (создателя) элемента списка по его Id.
* Источник — системное поле Author (Created By), заполняется SharePoint
* автоматически при создании, работает для любой формы без доп. колонок.
* Возвращает undefined, если элемент/автор недоступен (не роняем шапку).
*/
export async function getCreatedBy(listTitle: string, itemId: number): Promise<IInitiator | undefined> {
try {
const item = (await getSP()
.web.lists.getByTitle(listTitle)
.items.getById(itemId)
.select('Author/Title', 'Author/EMail')
.expand('Author')()) as { Author?: { Title?: string; EMail?: string } };
const author = item.Author;
if (!author) return undefined;
return { name: author.Title || '', email: author.EMail || '' };
} catch {
return undefined;
}
}
@@ -2,3 +2,7 @@ export { getDepartments } from './departments.service';
export type { IDepartment } from './models/IDepartment'; export type { IDepartment } from './models/IDepartment';
export { DepartmentPicker } from './ui/DepartmentPicker'; export { DepartmentPicker } from './ui/DepartmentPicker';
export type { IDepartmentPickerProps } from './ui/DepartmentPicker'; export type { IDepartmentPickerProps } from './ui/DepartmentPicker';
export { getProjects } from './projects.service';
export type { IProject } from './models/IProject';
export { ProjectPicker } from './ui/ProjectPicker';
export type { IProjectPickerProps } from './ui/ProjectPicker';
@@ -0,0 +1,31 @@
// GET https://api.tnsu.kz/Project (пагинация ?page&pageSize&includeDeleted) →
// { items: IProject[] }. Ниже — только поля, которые реально используем в портале.
// В ответе есть и другие (latitude/longitude, contractType, managerHSE, security,
// longName, id1c и т.д.) — при необходимости добавить сюда.
export interface IProject {
/** Числовой Id проекта. */
id: number;
/** GUID проекта — стабильный идентификатор, храним его как SourceProjectId. */
oid: string;
/** Наименование проекта (то, что видит пользователь). */
name: string;
/** Код проекта (напр. «2026»). */
code: string;
/** Адрес / регион объекта. */
address: string;
/** Заказчик. */
customerName: string;
/** Руководитель проекта — email. Пригодится для маршрута согласования. */
projectManager: string;
/** Планировщик — email. */
planner: string;
/** Статус проекта (напр. «Текущий»). */
status: string;
/** Признак удалённого проекта. */
deleted: boolean;
}
/** Обёртка ответа /Project. */
export interface IProjectListResponse {
items: IProject[];
}
@@ -0,0 +1,44 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
import { IProject, IProjectListResponse } from './models/IProject';
// GET /Project — справочник проектов. Ответ обёрнут в { items: [...] } и
// ПАГИНИРОВАН, поэтому тянем ВСЕ страницы (иначе проекты за пределами первой
// страницы не находятся в поиске пикера). Авторизация та же (AadHttpClient +
// APPROVAL_API_CLIENT_ID, SSO текущего пользователя).
const PAGE_SIZE = 500;
const MAX_PAGES = 50; // страховка от бесконечного цикла (до 25 000 проектов)
async function fetchProjects(context: WebPartContext): Promise<IProject[]> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const all: IProject[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const response: HttpClientResponse = await client.get(
`${DICTIONARY_API_BASE_URL}/Project?page=${page}&pageSize=${PAGE_SIZE}&includeDeleted=false`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`Project (page ${page}): ${response.status} ${response.statusText}`);
}
const body: IProjectListResponse = await response.json();
const items = body.items ?? [];
all.push(...items);
// Последняя страница — вернулось меньше запрошенного размера.
if (items.length < PAGE_SIZE) break;
}
return all;
}
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
// промис повторным вызовам (как в departments.service).
let cache: Promise<IProject[]> | undefined;
export function getProjects(context: WebPartContext): Promise<IProject[]> {
if (!cache) {
cache = fetchProjects(context).catch((e) => {
cache = undefined;
throw e;
});
}
return cache;
}
@@ -65,7 +65,13 @@ export const DepartmentPicker: React.FC<IDepartmentPickerProps> = ({
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
readOnly={effectiveReadOnly} readOnly={effectiveReadOnly}
onChange={(e) => onChange(e.target.value, '')} // setOpen(true) и при вводе, а не только по фокусу: после выбора список
// закрывается (handlePick), но фокус остаётся на поле — без этого
// повторный ввод в том же фокусе не открывал бы выпадашку заново.
onChange={(e) => {
onChange(e.target.value, '');
setOpen(true);
}}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)} onBlur={() => setOpen(false)}
/> />
@@ -0,0 +1,90 @@
@import '../../../../../styles/design-tokens';
.wrap {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
// справа место под крестик-очистку
padding: 11px 36px 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
}
}
.clear {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-400;
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-100;
color: $gray-700;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 260px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.meta {
font-size: $fs-caption;
color: $gray-500;
}
@@ -0,0 +1,126 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ProjectPicker.module.scss';
import { useFormReadOnly } from '../../ui/FormMode';
import { getProjects } from '../projects.service';
import { IProject } from '../models/IProject';
export interface IProjectPickerProps {
context: WebPartContext;
id?: string;
/** Текущее название проекта (то, что видно в поле). */
value: string;
/**
* name — введённый/выбранный текст; id — oid проекта ('' если текст введён
* вручную и не выбран из списка); project — выбранная запись (для снимка
* руководителя/планировщика на будущий маршрут). При ручном вводе project не передаётся.
*/
onChange: (name: string, id: string, project?: IProject) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const MAX_SUGGESTIONS = 30;
/** Поле выбора проекта со справочником (GET /Project): ввод текста фильтрует список, выбор проставляет oid. */
export const ProjectPicker: React.FC<IProjectPickerProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [projects, setProjects] = React.useState<IProject[]>([]);
const [open, setOpen] = React.useState<boolean>(false);
React.useEffect(() => {
let alive = true;
getProjects(context)
.then((list) => {
if (alive) setProjects(list);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
const suggestions = React.useMemo(() => {
const query = value.trim().toLowerCase();
if (!query) return [];
return projects
.filter(
(p) =>
p.name.toLowerCase().indexOf(query) !== -1 ||
(p.code && p.code.toLowerCase().indexOf(query) !== -1)
)
.slice(0, MAX_SUGGESTIONS);
}, [projects, value]);
const handlePick = (project: IProject): void => {
onChange(project.name, project.oid, project);
setOpen(false);
};
return (
<div className={styles.wrap}>
<input
id={id}
className={styles.input}
value={value}
placeholder={placeholder}
readOnly={effectiveReadOnly}
// setOpen(true) и при вводе, а не только по фокусу: после выбора список
// закрывается (handlePick), но фокус остаётся на поле — без этого
// повторный ввод в том же фокусе не открывал бы выпадашку заново.
onChange={(e) => {
onChange(e.target.value, '');
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{/* Быстрая очистка: сбрасываем и текст, и oid проекта. preventDefault на
mousedown — чтобы клик не «съел» фокус до onClick (как в списке ниже). */}
{!effectiveReadOnly && value.length > 0 && (
<button
type="button"
className={styles.clear}
aria-label="Очистить"
title="Очистить"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange('', '');
setOpen(false);
}}
>
×
</button>
)}
{!effectiveReadOnly && open && suggestions.length > 0 && (
<div className={styles.list}>
{suggestions.map((p) => (
<button
key={p.oid}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick, и список закрывается до выбора.
onMouseDown={(e) => e.preventDefault()}
onClick={() => handlePick(p)}
>
{p.name}
<span className={styles.meta}>
{[p.code, p.customerName].filter(Boolean).join(' · ')}
</span>
</button>
))}
</div>
)}
</div>
);
};
@@ -1,11 +1,13 @@
/** /**
* Уникальный код заявки: {requestTypeId}-{YY}{MM}-{itemId}. * Уникальный код заявки: {prefix}-{YY}{MM}-{itemId}.
* requestTypeId — id типа заявки во внешнем approval-route API (GET /api/request-types). * prefix — по умолчанию числовой id типа заявки (approval-route API,
* YY/MM — год и месяц подачи, itemId — Id элемента в списке данных. Пример: 1-2608-12. * GET /api/request-types), но может быть и строкой-меткой формы (напр. «1С»
* для заявок в отдел 1С). YY/MM — год и месяц подачи, itemId — Id элемента.
* Примеры: 1-2608-12, 1С-2608-12.
*/ */
export function buildRequestUniqueCode(requestTypeId: number, itemId: number, submittedAt: Date): string { export function buildRequestUniqueCode(prefix: number | string, itemId: number, submittedAt: Date): string {
const yy = String(submittedAt.getFullYear()).slice(-2); const yy = String(submittedAt.getFullYear()).slice(-2);
const month = submittedAt.getMonth() + 1; const month = submittedAt.getMonth() + 1;
const mm = month < 10 ? `0${month}` : String(month); const mm = month < 10 ? `0${month}` : String(month);
return `${requestTypeId}-${yy}${mm}-${itemId}`; return `${prefix}-${yy}${mm}-${itemId}`;
} }
@@ -0,0 +1,98 @@
@import '../../../../../styles/design-tokens';
// Поверх всего, включая SPFx-хром вебпарта.
.overlay {
position: fixed;
inset: 0;
z-index: 999997;
display: flex;
align-items: center;
justify-content: center;
padding: $sp-6;
background: rgba($navy-950, .55);
font-family: $font-sans;
// Плавное появление подложки.
animation: overlayIn .14s ease-out;
}
.card {
box-sizing: border-box;
width: 100%;
max-width: 440px;
padding: $sp-5 $sp-5 $sp-4;
border-radius: $r-lg;
background: $white;
box-shadow: $sh-modal;
animation: cardIn .16s ease-out;
}
.title {
font-family: $font-serif;
font-size: $fs-form-title;
font-weight: 600;
color: $navy-950;
}
.message {
margin-top: $sp-2;
font-size: $fs-sm;
line-height: 1.5;
color: $gray-700;
}
.actions {
display: flex;
justify-content: flex-end;
gap: $sp-2;
margin-top: $sp-5;
}
.cancelBtn {
padding: 9px 18px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
color: $navy-800;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: .15s;
&:hover {
border-color: $gold-400;
}
}
.confirmBtn {
@include btn-accent;
padding: 9px 18px;
border-radius: $r-sm;
font-size: $fs-sm;
cursor: pointer;
}
.confirmDanger {
padding: 9px 18px;
border: 0;
border-radius: $r-sm;
background: $red-500;
color: $white;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: .15s;
&:hover {
filter: brightness(.94);
}
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(8px) scale(.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@@ -0,0 +1,82 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import styles from './ConfirmDialog.module.scss';
import { IConfirmOptions, registerConfirmHost } from './confirmModal';
interface IConfirmDialogProps extends IConfirmOptions {
onConfirm: () => void;
onCancel: () => void;
}
/** Само окно подтверждения. Рендерится в document.body поверх всего (portal). */
const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
title,
message,
confirmText = 'Продолжить',
cancelText = 'Отмена',
danger,
onConfirm,
onCancel,
}) => {
// Esc — отмена. Слушаем на всё окно, пока модалка открыта.
React.useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onCancel();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onCancel]);
return createPortal(
// Клик по фону = отмена; клик внутри карточки не всплывает до фона.
<div className={styles.overlay} onMouseDown={onCancel}>
<div
className={styles.card}
role="dialog"
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
{title && <div className={styles.title}>{title}</div>}
<div className={styles.message}>{message}</div>
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onCancel}>
{cancelText}
</button>
<button
type="button"
className={danger ? styles.confirmDanger : styles.confirmBtn}
onClick={onConfirm}
autoFocus
>
{confirmText}
</button>
</div>
</div>
</div>,
document.body
);
};
/**
* Хост модалки: монтируется ОДИН раз в портале. Слушает императивные вызовы
* confirmModal(...) и показывает окно; ответ пользователя резолвит их промис.
*/
export const ConfirmHost: React.FC = () => {
const [state, setState] = React.useState<{
opts: IConfirmOptions;
resolve: (ok: boolean) => void;
} | undefined>(undefined);
React.useEffect(() => registerConfirmHost((opts, resolve) => setState({ opts, resolve })), []);
if (!state) return null;
const close = (ok: boolean): void => {
state.resolve(ok);
setState(undefined);
};
return (
<ConfirmDialog {...state.opts} onConfirm={() => close(true)} onCancel={() => close(false)} />
);
};
@@ -0,0 +1,44 @@
// Императивный вызов модалки подтверждения из любого места (в т.ч. из обычных
// модулей вроде shared/unsavedChanges.ts, где нет React-контекста).
//
// Модалка живёт в React-дереве (ConfirmHost, монтируется один раз в
// RequestPortal). Здесь — только «мостик»: confirmModal(...) отдаёт промис,
// который резолвится, когда пользователь нажмёт «Продолжить»/«Отмена».
export interface IConfirmOptions {
/** Заголовок окна. */
title?: string;
/** Основной текст-вопрос. */
message: string;
/** Подпись подтверждающей кнопки. По умолчанию — «Продолжить». */
confirmText?: string;
/** Подпись кнопки отмены. По умолчанию — «Отмена». */
cancelText?: string;
/** Опасное действие (потеря данных) — подтверждающая кнопка красная. */
danger?: boolean;
}
type ConfirmHandler = (opts: IConfirmOptions, resolve: (ok: boolean) => void) => void;
// Единственный подписчик — смонтированный ConfirmHost. Больше одного окна
// подтверждения одновременно нам не нужно.
let handler: ConfirmHandler | undefined;
export function registerConfirmHost(fn: ConfirmHandler): () => void {
handler = fn;
return () => {
if (handler === fn) handler = undefined;
};
}
/**
* Показать модальное подтверждение. Промис резолвится в true («Продолжить») или
* false («Отмена»/Esc/клик по фону). Если хост почему-то не смонтирован —
* падаем на нативный confirm, чтобы не потерять защиту от потери данных.
*/
export function confirmModal(opts: IConfirmOptions): Promise<boolean> {
if (!handler) {
return Promise.resolve(window.confirm(opts.message));
}
return new Promise<boolean>((resolve) => handler!(opts, resolve));
}
@@ -6,17 +6,20 @@ interface IFormCardProps {
subtitle?: string; subtitle?: string;
/** Контент в правом верхнем углу шапки (например, номер заявки). */ /** Контент в правом верхнем углу шапки (например, номер заявки). */
headerRight?: React.ReactNode; headerRight?: React.ReactNode;
/** Строка под подзаголовком (например, инициатор заявки). */
belowSubtitle?: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
} }
/** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */ /** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */
export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, headerRight, children }) => { export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, headerRight, belowSubtitle, children }) => {
return ( return (
<div className={styles.card}> <div className={styles.card}>
<div className={styles.header}> <div className={styles.header}>
<div> <div>
<div className={styles.title}>{title}</div> <div className={styles.title}>{title}</div>
{subtitle && <div className={styles.subtitle}>{subtitle}</div>} {subtitle && <div className={styles.subtitle}>{subtitle}</div>}
{belowSubtitle}
</div> </div>
{headerRight && <div className={styles.headerRight}>{headerRight}</div>} {headerRight && <div className={styles.headerRight}>{headerRight}</div>}
</div> </div>
@@ -0,0 +1,34 @@
@import '../../../../../styles/design-tokens';
.line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 4px;
font-size: 12px;
color: $gray-500;
}
.label {
color: $gray-400;
}
.name {
color: $gray-700;
font-weight: 500;
}
.sep {
color: $gray-400;
}
.email {
color: $navy-700;
text-decoration: none;
&:hover {
color: $gold-600;
text-decoration: underline;
}
}
@@ -0,0 +1,51 @@
import * as React from 'react';
import styles from './InitiatorLine.module.scss';
import { getCreatedBy, IInitiator } from '../../createdBy';
export interface IInitiatorLineProps {
/** Заголовок списка данных заявки. */
listTitle: string;
/** Id элемента заявки. undefined — заявка ещё не сохранена (ничего не показываем). */
itemId?: number;
}
/**
* Строка «Инициатор: Ф.И.О. · email» под номером заявки. Сама подгружает
* создателя (Created By) по listTitle+itemId. Пока не сохранено / нет данных — не рендерится.
*/
export const InitiatorLine: React.FC<IInitiatorLineProps> = ({ listTitle, itemId }) => {
const [initiator, setInitiator] = React.useState<IInitiator | undefined>(undefined);
React.useEffect(() => {
if (!itemId) {
setInitiator(undefined);
return;
}
let alive = true;
getCreatedBy(listTitle, itemId)
.then((info) => {
if (alive) setInitiator(info);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [listTitle, itemId]);
if (!initiator || (!initiator.name && !initiator.email)) {
return null;
}
return (
<div className={styles.line}>
<span className={styles.label}>Инициатор:</span>
{initiator.name && <span className={styles.name}>{initiator.name}</span>}
{initiator.name && initiator.email && <span className={styles.sep}>·</span>}
{initiator.email && (
<a className={styles.email} href={`mailto:${initiator.email}`}>
{initiator.email}
</a>
)}
</div>
);
};
@@ -0,0 +1,18 @@
/**
* Строка табличной части (позиция ТМЦ). Все поля — строки: значения приходят из
* текстовых полей ввода и вставки из Excel; количество не считаем арифметически,
* поэтому храним как есть. Массив таких строк сериализуется в JSON (колонка ItemsJson).
*/
export interface ILineItem {
/** Наименование. */
name: string;
/** Инвентарный номер. */
invNumber: string;
/** Единица измерения (по умолчанию «шт»). */
unit: string;
/** Количество. */
qty: string;
}
/** Пустая строка позиции (для «Добавить строку»). Ед.изм по умолчанию «шт». */
export const EMPTY_LINE_ITEM: ILineItem = { name: '', invNumber: '', unit: 'шт', qty: '' };
@@ -0,0 +1,166 @@
@import '../../../../../styles/design-tokens';
.tableWrap {
width: 100%;
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
th,
td {
border: 1px solid $gray-200;
padding: 0;
text-align: left;
vertical-align: middle;
}
th {
padding: 7px 9px;
background: $navy-50;
font-weight: 600;
font-size: $fs-caption;
color: $gray-700;
white-space: nowrap;
}
td {
padding: 4px 6px;
}
}
// Ширины служебных колонок.
.num {
width: 44px;
text-align: center;
}
.inv {
width: 150px;
}
.unit {
width: 78px;
text-align: center;
}
.qty {
width: 84px;
text-align: right;
}
.act {
width: 40px;
text-align: center;
}
// Ячейки без рамки — рамку рисует td, поле занимает всю ячейку.
.cell {
box-sizing: border-box;
width: 100%;
padding: 6px 7px;
border: 1px solid transparent;
border-radius: $r-sm;
background: transparent;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
&:focus {
outline: none;
border-color: $gold-400;
background: $white;
}
}
.cellCenter {
text-align: center;
}
.cellRight {
text-align: right;
}
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $red-50;
color: $red-500;
}
}
.empty {
padding: 14px !important;
text-align: center;
color: $gray-500;
}
.toolbar {
display: flex;
align-items: center;
gap: $sp-2;
margin-top: $sp-3;
}
.count {
font-size: $fs-caption;
color: $gray-500;
}
.paste {
margin-top: $sp-3;
padding: $sp-3;
border: 1px solid $gray-200;
border-radius: $r-md;
background: $gray-50;
}
.pasteHint {
margin: 0 0 $sp-2;
font-size: $fs-caption;
color: $gray-500;
}
.pasteArea {
box-sizing: border-box;
width: 100%;
padding: 9px 11px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: 'Consolas', 'Courier New', monospace;
font-size: $fs-caption;
color: $gray-900;
resize: vertical;
@include input-focus-gold;
}
.pasteActions {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
margin-top: $sp-2;
flex-wrap: wrap;
}
.pasteButtons {
display: flex;
gap: $sp-2;
}
@@ -0,0 +1,207 @@
import * as React from 'react';
import styles from './ItemsTable.module.scss';
import { Button } from '../Button/Button';
import { Icon } from '../Icon/Icon';
import { useFormReadOnly } from '../FormMode';
import { ILineItem, EMPTY_LINE_ITEM } from './ILineItem';
import { parseExcelRows } from './parseExcelRows';
export interface IItemsTableProps {
value: ILineItem[];
onChange: (rows: ILineItem[]) => void;
/** Только чтение (просмотр/печать). Если не задан — берётся из FormMode. */
readOnly?: boolean;
}
/**
* Редактируемая табличная часть (позиции ТМЦ): № (авто) / Наименование /
* Инв. номер / Ед.изм / Кол-во. Ручной ввод — основной способ (добавить/удалить/
* править строки), плюс массовая вставка из Excel (кнопка → textarea → превью).
*/
export const ItemsTable: React.FC<IItemsTableProps> = ({ value, onChange, readOnly }) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [pasteOpen, setPasteOpen] = React.useState<boolean>(false);
const [pasteText, setPasteText] = React.useState<string>('');
const setCell = (index: number, key: keyof ILineItem, cellValue: string): void => {
const next = value.map((row, i) => (i === index ? { ...row, [key]: cellValue } : row));
onChange(next);
};
const addRow = (): void => onChange([...value, { ...EMPTY_LINE_ITEM }]);
const removeRow = (index: number): void => onChange(value.filter((_, i) => i !== index));
const parsed = React.useMemo(() => parseExcelRows(pasteText), [pasteText]);
const applyPaste = (mode: 'append' | 'replace'): void => {
onChange(mode === 'append' ? [...value, ...parsed] : parsed);
setPasteText('');
setPasteOpen(false);
};
// Просмотр/печать — статичная таблица без полей ввода.
if (effectiveReadOnly) {
return (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.num}>№</th>
<th>Наименование</th>
<th className={styles.inv}>Инв. номер</th>
<th className={styles.unit}>Ед.изм</th>
<th className={styles.qty}>Кол-во</th>
</tr>
</thead>
<tbody>
{value.length === 0 ? (
<tr>
<td colSpan={5} className={styles.empty}>Позиции не добавлены</td>
</tr>
) : (
value.map((row, i) => (
<tr key={i}>
<td className={styles.num}>{i + 1}</td>
<td>{row.name}</td>
<td>{row.invNumber}</td>
<td className={styles.unit}>{row.unit}</td>
<td className={styles.qty}>{row.qty}</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}
return (
<div>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.num}>№</th>
<th>Наименование</th>
<th className={styles.inv}>Инв. номер</th>
<th className={styles.unit}>Ед.изм</th>
<th className={styles.qty}>Кол-во</th>
<th className={styles.act} aria-label="Действия" />
</tr>
</thead>
<tbody>
{value.length === 0 ? (
<tr>
<td colSpan={6} className={styles.empty}>
Нет позиций. Добавьте строку или вставьте из Excel.
</td>
</tr>
) : (
value.map((row, i) => (
<tr key={i}>
<td className={styles.num}>{i + 1}</td>
<td>
<input
className={styles.cell}
value={row.name}
onChange={(e) => setCell(i, 'name', e.target.value)}
/>
</td>
<td>
<input
className={styles.cell}
value={row.invNumber}
onChange={(e) => setCell(i, 'invNumber', e.target.value)}
/>
</td>
<td>
<input
className={`${styles.cell} ${styles.cellCenter}`}
value={row.unit}
onChange={(e) => setCell(i, 'unit', e.target.value)}
/>
</td>
<td>
<input
className={`${styles.cell} ${styles.cellRight}`}
value={row.qty}
inputMode="decimal"
onChange={(e) => setCell(i, 'qty', e.target.value)}
/>
</td>
<td className={styles.act}>
<button
type="button"
className={styles.remove}
title="Удалить строку"
onClick={() => removeRow(i)}
>
<Icon name="trash" size="sm" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className={styles.toolbar}>
<Button variant="secondary" size="sm" onClick={addRow}>
<Icon name="plus" size="sm" />
Добавить строку
</Button>
<Button variant="ghost" size="sm" onClick={() => setPasteOpen((v) => !v)}>
<Icon name="upload" size="sm" />
Вставить из Excel
</Button>
{value.length > 0 && <span className={styles.count}>Позиций: {value.length}</span>}
</div>
{pasteOpen && (
<div className={styles.paste}>
<p className={styles.pasteHint}>
Скопируйте строки из Excel (колонки Наименование, Инв. номер, Ед.изм, Кол-во —
с «№» или без) и вставьте сюда (Ctrl+V).
</p>
<textarea
className={styles.pasteArea}
value={pasteText}
placeholder="Вставьте ячейки из Excel…"
onChange={(e) => setPasteText(e.target.value)}
rows={6}
/>
<div className={styles.pasteActions}>
<span className={styles.count}>
{parsed.length > 0 ? `Распознано строк: ${parsed.length}` : 'Пока ничего не распознано'}
</span>
<div className={styles.pasteButtons}>
<Button
variant="secondary"
size="sm"
disabled={parsed.length === 0}
onClick={() => applyPaste('append')}
>
Добавить {parsed.length > 0 ? `(${parsed.length})` : ''}
</Button>
<Button
variant="ghost"
size="sm"
disabled={parsed.length === 0}
onClick={() => applyPaste('replace')}
>
Заменить все
</Button>
<Button variant="ghost" size="sm" onClick={() => { setPasteText(''); setPasteOpen(false); }}>
Отмена
</Button>
</div>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,100 @@
import { ILineItem, EMPTY_LINE_ITEM } from './ILineItem';
/**
* Разбор вставленного из Excel/таблицы текста (TSV) в строки позиций.
*
* Excel кладёт в буфер обмена текст с табами: колонки разделены '\t', строки —
* переводом строки. Ячейки, содержащие таб/перенос/кавычку, оборачиваются в
* двойные кавычки, внутри кавычка экранируется удвоением ("") — это учитываем,
* иначе многострочное наименование разорвётся на несколько позиций.
*
* Отображение колонок по их числу в строке:
* 5 колонок → первая это «№» (генерим сами), берём 2..5;
* 4 колонки → Наименование / Инв.номер / Ед.изм / Кол-во как есть;
* иначе → берём по первым доступным (Наименование обязательно).
* Ед.изм по умолчанию «шт». Строка-заголовок и пустые строки отбрасываются.
*/
export function parseExcelRows(text: string): ILineItem[] {
if (!text || !text.trim()) return [];
return splitRecords(text)
.filter((cells) => cells.some((c) => c.trim() !== '')) // пустые строки убрать
.filter((cells) => !isHeaderRow(cells)) // строку-заголовок отбросить
.map(mapColumns);
}
/** Похожа ли строка на шапку таблицы (её вставлять как позицию не нужно). */
function isHeaderRow(cells: string[]): boolean {
const joined = cells.join(' ').toLowerCase();
return (
joined.indexOf('наимен') !== -1 ||
(joined.indexOf('инв') !== -1 && joined.indexOf('кол') !== -1)
);
}
/** Ячейки строки → позиция с учётом числа колонок. */
function mapColumns(cells: string[]): ILineItem {
// 5+ колонок: первая — порядковый номер из источника, отбрасываем.
const c = cells.length >= 5 ? cells.slice(1) : cells;
const get = (i: number): string => (c[i] || '').trim();
return {
...EMPTY_LINE_ITEM,
name: get(0),
invNumber: get(1),
unit: get(2) || 'шт',
qty: get(3)
};
}
/**
* Разбить TSV-текст на записи (строки) с учётом кавычек Excel.
* Возвращает массив строк, каждая — массив ячеек.
*/
function splitRecords(text: string): string[][] {
const records: string[][] = [];
let row: string[] = [];
let field = '';
let inQuotes = false;
const pushField = (): void => {
row.push(field);
field = '';
};
const pushRow = (): void => {
pushField();
records.push(row);
row = [];
};
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') {
field += '"'; // экранированная кавычка ""
i++;
} else {
inQuotes = false; // конец кавычек
}
} else {
field += ch;
}
continue;
}
if (ch === '"' && field === '') {
inQuotes = true; // кавычки только в начале ячейки
} else if (ch === '\t') {
pushField();
} else if (ch === '\n') {
pushRow();
} else if (ch === '\r') {
// игнорируем — часть '\r\n'
} else {
field += ch;
}
}
// последняя запись без завершающего перевода строки
if (field !== '' || row.length > 0) {
pushRow();
}
return records;
}
@@ -15,13 +15,23 @@ export { CollapseSection } from './CollapseSection/CollapseSection';
export { FormActions } from './FormActions/FormActions'; export { FormActions } from './FormActions/FormActions';
export { FileDropzone } from './FileDropzone/FileDropzone'; export { FileDropzone } from './FileDropzone/FileDropzone';
export { AttachmentList } from './AttachmentList/AttachmentList'; export { AttachmentList } from './AttachmentList/AttachmentList';
export { ItemsTable } from './ItemsTable/ItemsTable';
export type { IItemsTableProps } from './ItemsTable/ItemsTable';
export type { ILineItem } from './ItemsTable/ILineItem';
export { EMPTY_LINE_ITEM } from './ItemsTable/ILineItem';
export { parseExcelRows } from './ItemsTable/parseExcelRows';
export { PeopleField } from './PeopleField/PeopleField'; export { PeopleField } from './PeopleField/PeopleField';
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti'; export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { FormModeProvider, useFormReadOnly } from './FormMode'; export { FormModeProvider, useFormReadOnly } from './FormMode';
export { Icon } from './Icon/Icon'; export { Icon } from './Icon/Icon';
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay'; export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
export { ConfirmHost } from './ConfirmDialog/ConfirmDialog';
export { confirmModal } from './ConfirmDialog/confirmModal';
export type { IConfirmOptions } from './ConfirmDialog/confirmModal';
export { SubmitActions } from './SubmitActions/SubmitActions'; export { SubmitActions } from './SubmitActions/SubmitActions';
export { RequestNumber } from './RequestNumber/RequestNumber'; export { RequestNumber } from './RequestNumber/RequestNumber';
export { InitiatorLine } from './InitiatorLine/InitiatorLine';
export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine';
export { PrintButton } from './PrintButton/PrintButton'; export { PrintButton } from './PrintButton/PrintButton';
export { useRequestSubmit } from './useRequestSubmit'; export { useRequestSubmit } from './useRequestSubmit';
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit'; export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';
@@ -1,3 +1,5 @@
import { confirmModal } from './ui/ConfirmDialog/confirmModal';
// Есть ли в открытой форме «Новая заявка» правки, которые ещё не сохранены. // Есть ли в открытой форме «Новая заявка» правки, которые ещё не сохранены.
// //
// Нужно, потому что раздел «Новая заявка» держится смонтированным при // Нужно, потому что раздел «Новая заявка» держится смонтированным при
@@ -23,11 +25,18 @@ export function hasUnsavedFormChanges(): boolean {
return dirty; return dirty;
} }
// Спросить подтверждение, если несохранённые правки есть. true — можно // Спросить подтверждение, если несохранённые правки есть. Промис резолвится в
// продолжать (правок нет либо пользователь согласился их потерять). // true — можно продолжать (правок нет либо пользователь согласился их потерять),
export function confirmDiscardFormChanges(): boolean { // false — отменить действие. Асинхронно, потому что показываем модалку в стиле
if (!dirty) return true; // портала (см. shared/ui/ConfirmDialog) вместо нативного window.confirm.
return window.confirm( export function confirmDiscardFormChanges(): Promise<boolean> {
'В открытой форме «Новая заявка» есть несохранённые изменения — они будут потеряны. Продолжить?' if (!dirty) return Promise.resolve(true);
); return confirmModal({
title: 'Несохранённые изменения',
message:
'В открытой форме «Новая заявка» есть несохранённые изменения — они будут потеряны. Продолжить?',
confirmText: 'Да, продолжить',
cancelText: 'Остаться',
danger: true,
});
} }