feat: changed get time logic to approvers
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "@microsoft/spfx-web-build-rig/profiles/default/config/jest.config.json",
|
||||||
|
"collectCoverage": false
|
||||||
|
}
|
||||||
+105
@@ -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' }
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Категория, активная при открытии портала. */
|
/** Категория, активная при открытии портала. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user