Compare commits
10 Commits
3ed399becc
...
cc9f42f16b
| Author | SHA1 | Date | |
|---|---|---|---|
| cc9f42f16b | |||
| a518437798 | |||
| dffbb81a20 | |||
| df3558b63a | |||
| 46552a1381 | |||
| 7458020880 | |||
| 61c7f7dd71 | |||
| bfa0777739 | |||
| 535741a031 | |||
| ab5793b1a9 |
@@ -0,0 +1,109 @@
|
|||||||
|
Ты — опытный Senior Frontend Engineer.
|
||||||
|
Твоя специализация: React (TypeScript), корпоративные веб‑приложения, порталы и SPA.
|
||||||
|
|
||||||
|
ОБЩИЕ ПРИНЦИПЫ
|
||||||
|
- Пиши код так, как если бы он шёл в production.
|
||||||
|
- Предпочитай простые и читаемые решения.
|
||||||
|
- Избегай overengineering.
|
||||||
|
- Всегда думай о поддерживаемости кода через 1–2 года.
|
||||||
|
- Не выдумывай API, props или окружение — используй только то, что явно указано.
|
||||||
|
- Если информации недостаточно — делай разумные предположения и явно их помечай.
|
||||||
|
|
||||||
|
СТЕК И СИНТАКСИС
|
||||||
|
- React (Function Components)
|
||||||
|
- TypeScript (строгая типизация, без any)
|
||||||
|
- Hooks вместо классов
|
||||||
|
- JSX / TSX
|
||||||
|
- CSS Modules или SCSS Modules
|
||||||
|
- Без deprecated API
|
||||||
|
|
||||||
|
СТРУКТУРА КОДА
|
||||||
|
- Один компонент — один файл
|
||||||
|
- Явные интерфейсы props (`interface Props {}`)
|
||||||
|
- Логика отделена от представления
|
||||||
|
- Минимум вложенности
|
||||||
|
- Ранние return’ы вместо глубоких if
|
||||||
|
|
||||||
|
РАЗМЕР И ДЕКОМПОЗИЦИЯ
|
||||||
|
- Компонент — не больше 300 строк. Больше — сигнал, что пора разбивать.
|
||||||
|
- Функция / хук — не больше ~50 строк, делает одну вещь.
|
||||||
|
- Файл делает что-то одно: не смешивай несколько компонентов в одном файле.
|
||||||
|
- Не больше 3–4 уровней вложенности JSX — глубже выноси в под-компонент.
|
||||||
|
- Props: до ~7 штук. Больше — сгруппируй в объект или пересмотри разбивку.
|
||||||
|
- Большой компонент дели по смыслу: под-компоненты (`Foo.tsx` → `FooHeader.tsx`, `FooRow.tsx`), кастомные хуки для логики (`useFooData.ts`), утилиты (`utils/`).
|
||||||
|
- Повторяешь один и тот же блок 3-й раз — выноси в общий компонент/функцию.
|
||||||
|
|
||||||
|
ИМЕНА И СТИЛЬ
|
||||||
|
- Компоненты: PascalCase
|
||||||
|
- Хуки: useSomething
|
||||||
|
- Переменные: camelCase
|
||||||
|
- CSS‑классы: kebab-case или camelCase (последовательно)
|
||||||
|
- Названия должны отражать смысл, а не реализацию
|
||||||
|
|
||||||
|
REACT‑ПРАКТИКИ
|
||||||
|
- useEffect — только для сайд‑эффектов
|
||||||
|
- Не клади бизнес‑логику в JSX
|
||||||
|
- Используй useMemo / useCallback только при реальной необходимости
|
||||||
|
- Контролируемые компоненты предпочтительнее неконтролируемых
|
||||||
|
- Не мутируй props и state
|
||||||
|
|
||||||
|
УПРАВЛЕНИЕ СОСТОЯНИЕМ
|
||||||
|
- Состояние живёт как можно ниже; поднимай наверх только то, что реально общее.
|
||||||
|
- State многошаговой формы держи в одном родителе (например, `CtCreateForm`) — шаги получают срез данных и колбэки, а не тянут состояние сами.
|
||||||
|
- Prop-drilling глубже 2–3 уровней — сигнал вынести общее в React Context.
|
||||||
|
- Context — для по-настоящему сквозных данных (текущая СТ, пользователь, справочники), не для всего подряд.
|
||||||
|
- Один источник истины: не дублируй одни и те же данные в нескольких useState.
|
||||||
|
- Изменяй state иммутабельно (`setItems(prev => [...prev, x])`), без мутаций массивов/объектов.
|
||||||
|
- Производные данные считай на лету (useMemo), не храни в отдельном state.
|
||||||
|
|
||||||
|
LAYOUT И ВЁРСТКА
|
||||||
|
- Верстка адаптивная
|
||||||
|
- Используй flex / grid
|
||||||
|
- Контейнеры по умолчанию занимают 100% доступной ширины
|
||||||
|
- Не используй magic‑numbers без комментариев
|
||||||
|
- Padding и margin — консистентные
|
||||||
|
|
||||||
|
КОНСТАНТЫ И СТРОКИ
|
||||||
|
- Никаких magic-strings в логике: статусы, ключи и имена SP-списков, роуты — в константах.
|
||||||
|
- Повторяющиеся значения выноси в один модуль констант (`constants.ts`), а не хардкодь по месту.
|
||||||
|
- Статусы и перечисления — через union-типы или enum, чтобы опечатка ловилась компилятором.
|
||||||
|
- Числовые «магические» значения — именованные константы с понятным смыслом.
|
||||||
|
- Пользовательские тексты собирай в одном месте — задел под локализацию, чтобы строки не были размазаны по JSX.
|
||||||
|
- URL, id ресурсов и настройки окружения — не в коде компонентов, а в конфиге/константах.
|
||||||
|
|
||||||
|
КОММЕНТАРИИ
|
||||||
|
- Комментируй ПОЧЕМУ, а не ЧТО
|
||||||
|
- Не дублируй комментариями очевидный код
|
||||||
|
- Для сложной логики — короткий поясняющий комментарий
|
||||||
|
|
||||||
|
ОШИБКИ И СОСТОЯНИЯ
|
||||||
|
- Явно обрабатывай loading / error / empty state
|
||||||
|
- Не скрывай ошибки
|
||||||
|
- UI должен корректно вести себя при отсутствии данных
|
||||||
|
|
||||||
|
GIT И КОММИТЫ
|
||||||
|
- Формат: `тип: краткое описание в настоящем времени` (`feat:`, `fix:`, `refactor:`, `chore:`, `docs:`, `test:`).
|
||||||
|
- Один коммит — одно логическое изменение, без свалки несвязанных правок.
|
||||||
|
- Сообщение объясняет ЧТО и ЗАЧЕМ, а не пересказывает диф.
|
||||||
|
- Не коммить закомментированный/отладочный код и секреты.
|
||||||
|
- Пиши так, чтобы по истории джун понял ход работы без расспросов.
|
||||||
|
|
||||||
|
ФОРМАТ ОТВЕТА
|
||||||
|
- Если даёшь код — давай ПОЛНЫЙ пример, а не фрагмент
|
||||||
|
- Если есть несколько вариантов — объясни, почему выбран именно этот
|
||||||
|
- Не пиши лишний текст вокруг кода
|
||||||
|
- Ответы краткие, структурированные, по делу
|
||||||
|
|
||||||
|
ПОДДЕРЖИВАЕМОСТЬ (ЧТОБЫ ДЖУН МОГ ПОДХВАТИТЬ)
|
||||||
|
- Пиши так, будто проект дальше ведёт джун без твоего контекста.
|
||||||
|
- Никакой «магии» и умных однострочников — понятный код важнее короткого.
|
||||||
|
- Понятные имена вместо комментариев-костылей: `isLoading`, а не `flag`.
|
||||||
|
- Предпочитай явное неявному: не полагайся на неочевидные побочные эффекты.
|
||||||
|
- Структура папок предсказуемая и одинаковая по всему проекту.
|
||||||
|
- Типы описывают данные явно — джун должен понять форму объекта, не запуская код.
|
||||||
|
- Сложную бизнес-логику сопровождай коротким «почему» в комментарии.
|
||||||
|
- Не оставляй мёртвый/закомментированный код — удаляй.
|
||||||
|
|
||||||
|
КОНТЕКСТ
|
||||||
|
- Код пишется для корпоративного портала
|
||||||
|
- Основной приоритет: читаемость, стабильность, предсказуемость
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
"description": { "default": "approval-admin description" },
|
"description": { "default": "approval-admin description" },
|
||||||
"officeFabricIconFontName": "Page",
|
"officeFabricIconFontName": "Page",
|
||||||
"properties": {
|
"properties": {
|
||||||
"apiBaseUrl": "http://localhost:5080",
|
"apiBaseUrl": "https://api.tnsu.kz/approval-route",
|
||||||
"useDevAuth": true,
|
"useDevAuth": false,
|
||||||
"devUserEmail": "dev@tnsu.kz",
|
"devUserEmail": "dev@tnsu.kz",
|
||||||
"aadResourceId": "api://9232d9e2-9807-4753-808f-07250e5474b0",
|
"aadResourceId": "api://9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
"portalBaseUrl": "https://api.tnsu.kz"
|
"portalBaseUrl": "https://api.tnsu.kz"
|
||||||
|
|||||||
@@ -35,8 +35,14 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected onInit(): Promise<void> {
|
protected onInit(): Promise<void> {
|
||||||
this.properties.apiBaseUrl = this.properties.apiBaseUrl || 'http://localhost:5080';
|
// Прод-адрес по умолчанию: пакуем именно под прод (heft package-solution --production).
|
||||||
this.properties.useDevAuth = this.properties.useDevAuth ?? true;
|
// Для локальной разработки переопределите значение в панели свойств веб-части
|
||||||
|
// (например, на http://localhost:5080 — порт из backend/.../launchSettings.json).
|
||||||
|
this.properties.apiBaseUrl = this.properties.apiBaseUrl || 'https://api.tnsu.kz/approval-route';
|
||||||
|
// Dev-режим ломает /resolve: наш бэкенд не получает Bearer-токен пользователя
|
||||||
|
// для пересылки в api.tnsu.kz и падает на собственном client_credentials
|
||||||
|
// (TnsuPortal:ClientSecret в appsettings.json пуст). По умолчанию — реальный AAD.
|
||||||
|
this.properties.useDevAuth = this.properties.useDevAuth ?? false;
|
||||||
this.properties.devUserEmail = this.properties.devUserEmail || 'dev@tnsu.kz';
|
this.properties.devUserEmail = this.properties.devUserEmail || 'dev@tnsu.kz';
|
||||||
this.properties.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0';
|
this.properties.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0';
|
||||||
this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz';
|
this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz';
|
||||||
@@ -64,7 +70,7 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
|
|||||||
groupFields: [
|
groupFields: [
|
||||||
PropertyPaneTextField('apiBaseUrl', {
|
PropertyPaneTextField('apiBaseUrl', {
|
||||||
label: 'Api Base Url',
|
label: 'Api Base Url',
|
||||||
description: 'Например, https://localhost:5443 локально или прод-адрес API.'
|
description: 'По умолчанию — прод (https://api.tnsu.kz/approval-route). Для локальной разработки укажите http://localhost:5080.'
|
||||||
}),
|
}),
|
||||||
PropertyPaneToggle('useDevAuth', {
|
PropertyPaneToggle('useDevAuth', {
|
||||||
label: 'Локальный Dev-режим (без Azure AD)',
|
label: 'Локальный Dev-режим (без Azure AD)',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AdminApp } from './app/AdminApp';
|
|||||||
import { ApprovalRouteApi } from '../services/api';
|
import { ApprovalRouteApi } from '../services/api';
|
||||||
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
|
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
|
||||||
import { PortalDictionaryClient } from '../services/portalDictionary';
|
import { PortalDictionaryClient } from '../services/portalDictionary';
|
||||||
|
import { texts } from '../texts';
|
||||||
|
|
||||||
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
|
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
|
||||||
|
|
||||||
@@ -69,12 +70,12 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
|||||||
<section className={styles.approvalAdmin}>
|
<section className={styles.approvalAdmin}>
|
||||||
{error && (
|
{error && (
|
||||||
<div className={styles.stateBox}>
|
<div className={styles.stateBox}>
|
||||||
Не удалось подключиться к API: {error}
|
{texts.app.connectFailed(error)}
|
||||||
<br />
|
<br />
|
||||||
Проверьте свойства веб-части (Api Base Url, режим авторизации) в панели настроек.
|
{texts.app.connectHint}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!error && !api && <div className={styles.stateBox}>Подключение к API...</div>}
|
{!error && !api && <div className={styles.stateBox}>{texts.app.connecting}</div>}
|
||||||
{api && (
|
{api && (
|
||||||
<AdminApp
|
<AdminApp
|
||||||
api={api}
|
api={api}
|
||||||
|
|||||||
@@ -47,10 +47,13 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUse
|
|||||||
api={api}
|
api={api}
|
||||||
route={selectedRoute}
|
route={selectedRoute}
|
||||||
requestTypeName={selectedRequestTypeName}
|
requestTypeName={selectedRequestTypeName}
|
||||||
|
dictionary={dictionary}
|
||||||
onBack={() => setScreen('routes')}
|
onBack={() => setScreen('routes')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{screen === 'resolve' && <ResolveScreen api={api} currentUserEmail={currentUserEmail} />}
|
{screen === 'resolve' && (
|
||||||
|
<ResolveScreen api={api} dictionary={dictionary} currentUserEmail={currentUserEmail} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,11 +45,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brandText em {
|
|
||||||
font-style: normal;
|
|
||||||
color: $gold-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './TopNav.module.scss';
|
import styles from './TopNav.module.scss';
|
||||||
import { ScreenKey } from './types';
|
import { ScreenKey } from './types';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
interface ITab {
|
interface ITab {
|
||||||
key: ScreenKey;
|
key: ScreenKey;
|
||||||
@@ -8,10 +9,10 @@ interface ITab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TABS: ITab[] = [
|
const TABS: ITab[] = [
|
||||||
{ key: 'dashboard', label: 'Дашборд' },
|
{ key: 'dashboard', label: texts.nav.dashboard },
|
||||||
{ key: 'requestTypes', label: 'Типы заявок' },
|
{ key: 'requestTypes', label: texts.nav.requestTypes },
|
||||||
{ key: 'routes', label: 'Маршруты' },
|
{ key: 'routes', label: texts.nav.routes },
|
||||||
{ key: 'resolve', label: 'Проверка маршрута' }
|
{ key: 'resolve', label: texts.nav.resolve }
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ITopNavProps {
|
export interface ITopNavProps {
|
||||||
@@ -71,8 +72,8 @@ export const TopNav: React.FC<ITopNavProps> = ({ active, onSelect }) => {
|
|||||||
return (
|
return (
|
||||||
<nav className={styles.topnav}>
|
<nav className={styles.topnav}>
|
||||||
<div className={styles.brand}>
|
<div className={styles.brand}>
|
||||||
<div className={styles.mark}>T</div>
|
<div className={styles.mark}>{texts.nav.markLetter}</div>
|
||||||
<div className={styles.brandText}>TNSU <em>Approval Route</em></div>
|
<div className={styles.brandText}>{texts.nav.title}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.tabs} ref={tabsRef}>
|
<div className={styles.tabs} ref={tabsRef}>
|
||||||
{TABS.map(tab => {
|
{TABS.map(tab => {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.sectionsLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.6px;
|
||||||
|
color: $gray-400;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionsDivider {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, $gold-400, transparent 70%);
|
||||||
|
border: 0;
|
||||||
|
margin: 6px 0 22px;
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import styles from './DashboardScreen.module.scss';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { ScreenKey } from '../app/types';
|
import { ScreenKey } from '../app/types';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
|
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
||||||
|
import { AsyncList } from '../../hooks/useAsyncList';
|
||||||
import { CardGrid, NavCard, StatCard, StatRow } from '../ui';
|
import { CardGrid, NavCard, StatCard, StatRow } from '../ui';
|
||||||
|
|
||||||
export interface IDashboardScreenProps {
|
export interface IDashboardScreenProps {
|
||||||
@@ -8,43 +13,43 @@ export interface IDashboardScreenProps {
|
|||||||
onNavigate: (key: ScreenKey) => void;
|
onNavigate: (key: ScreenKey) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNavigate }) => {
|
// Пока список грузится или запрос упал — показываем прочерк, а не «0»,
|
||||||
const [requestTypesCount, setRequestTypesCount] = React.useState<number | undefined>();
|
// чтобы не соврать пользователю про реальное количество записей.
|
||||||
const [routesCount, setRoutesCount] = React.useState<number | undefined>();
|
function statValue(list: AsyncList<unknown>): React.ReactNode {
|
||||||
|
if (list.isLoading || list.error) return texts.common.empty;
|
||||||
|
return list.items.length;
|
||||||
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNavigate }) => {
|
||||||
api.requestTypes.list().then(items => setRequestTypesCount(items.length)).catch(() => setRequestTypesCount(undefined));
|
const requestTypes = useRequestTypes(api);
|
||||||
api.templateRoutes.list().then(items => setRoutesCount(items.length)).catch(() => setRoutesCount(undefined));
|
const routes = useTemplateRoutes(api);
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<StatRow>
|
<StatRow>
|
||||||
<StatCard label="Типов заявок" value={requestTypesCount ?? '—'} />
|
<StatCard label={texts.dashboard.requestTypesStat} value={statValue(requestTypes)} />
|
||||||
<StatCard label="Маршрутов согласования" value={routesCount ?? '—'} />
|
<StatCard label={texts.dashboard.routesStat} value={statValue(routes)} />
|
||||||
</StatRow>
|
</StatRow>
|
||||||
|
|
||||||
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 1.6, color: 'var(--gray-400)', fontWeight: 600, marginBottom: 12 }}>
|
<div className={styles.sectionsLabel}>{texts.dashboard.sections}</div>
|
||||||
Разделы
|
<hr className={styles.sectionsDivider} />
|
||||||
</div>
|
|
||||||
<hr style={{ height: 1, background: 'linear-gradient(90deg, var(--gold-400), transparent 70%)', border: 0, margin: '6px 0 22px' }} />
|
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
<NavCard
|
<NavCard
|
||||||
icon="clipboard"
|
icon="clipboard"
|
||||||
title="Типы заявок"
|
title={texts.dashboard.requestTypesCard.title}
|
||||||
description="Справочник типов заявок и их страниц"
|
description={texts.dashboard.requestTypesCard.description}
|
||||||
onClick={() => onNavigate('requestTypes')}
|
onClick={() => onNavigate('requestTypes')}
|
||||||
/>
|
/>
|
||||||
<NavCard
|
<NavCard
|
||||||
icon="route"
|
icon="route"
|
||||||
title="Маршруты согласования"
|
title={texts.dashboard.routesCard.title}
|
||||||
description="Маршруты, шаги и исполнители"
|
description={texts.dashboard.routesCard.description}
|
||||||
onClick={() => onNavigate('routes')}
|
onClick={() => onNavigate('routes')}
|
||||||
/>
|
/>
|
||||||
<NavCard
|
<NavCard
|
||||||
icon="search"
|
icon="search"
|
||||||
title="Проверка маршрута"
|
title={texts.dashboard.resolveCard.title}
|
||||||
description="Как разрешится маршрут для конкретного пользователя"
|
description={texts.dashboard.resolveCard.description}
|
||||||
onClick={() => onNavigate('resolve')}
|
onClick={() => onNavigate('resolve')}
|
||||||
/>
|
/>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { ApiClientError } from '../../services/httpClient';
|
|
||||||
import { RequestType } from '../../models';
|
import { RequestType } from '../../models';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
Alert,
|
Alert,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
DataTable,
|
DataTable,
|
||||||
Field,
|
Field,
|
||||||
FormGrid,
|
FormGrid,
|
||||||
|
FormModal,
|
||||||
IconButton,
|
IconButton,
|
||||||
Modal,
|
|
||||||
ConfirmDialog,
|
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PrimaryCell,
|
PrimaryCell,
|
||||||
TextInput
|
TextInput
|
||||||
@@ -30,99 +33,60 @@ interface IFormState {
|
|||||||
const EMPTY_FORM: IFormState = { name: '', pageUrl: '' };
|
const EMPTY_FORM: IFormState = { name: '', pageUrl: '' };
|
||||||
|
|
||||||
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api }) => {
|
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api }) => {
|
||||||
const [items, setItems] = React.useState<RequestType[]>([]);
|
const list = useRequestTypes(api);
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [loadError, setLoadError] = React.useState<string | undefined>();
|
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
||||||
const [formError, setFormError] = React.useState<string | undefined>();
|
const submit = useAsyncSubmit();
|
||||||
const [saving, setSaving] = React.useState(false);
|
|
||||||
|
|
||||||
const [deleting, setDeleting] = React.useState<RequestType | undefined>();
|
const deleteTarget = useDeleteTarget<RequestType>(item => api.requestTypes.remove(item.id), list.reload);
|
||||||
const [deleteError, setDeleteError] = React.useState<string | undefined>();
|
|
||||||
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
|
||||||
|
|
||||||
const load = React.useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
setLoadError(undefined);
|
|
||||||
api.requestTypes
|
|
||||||
.list()
|
|
||||||
.then(setItems)
|
|
||||||
.catch((e: ApiClientError) => setLoadError(e.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
React.useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setForm(EMPTY_FORM);
|
setForm(EMPTY_FORM);
|
||||||
setFormError(undefined);
|
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: RequestType): void => {
|
const openEdit = (item: RequestType): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
setForm({ name: item.name, pageUrl: item.pageUrl });
|
setForm({ name: item.name, pageUrl: item.pageUrl });
|
||||||
setFormError(undefined);
|
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = (): void => {
|
const submitForm = (): void => {
|
||||||
if (!form.name.trim() || !form.pageUrl.trim()) {
|
if (!form.name.trim() || !form.pageUrl.trim()) {
|
||||||
setFormError('Заполните имя и Url страницы.');
|
submit.fail(texts.requestTypes.validationRequired);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
const input = { name: form.name.trim(), pageUrl: form.pageUrl.trim() };
|
||||||
setFormError(undefined);
|
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
|
||||||
const request = editing
|
|
||||||
? api.requestTypes.update(editing.id, { name: form.name.trim(), pageUrl: form.pageUrl.trim() })
|
|
||||||
: api.requestTypes.create({ name: form.name.trim(), pageUrl: form.pageUrl.trim() });
|
|
||||||
|
|
||||||
request
|
submit.run(() =>
|
||||||
.then(() => {
|
request.then(() => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
load();
|
list.reload();
|
||||||
})
|
})
|
||||||
.catch((e: ApiClientError) => setFormError(e.message))
|
);
|
||||||
.finally(() => setSaving(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDelete = (): void => {
|
|
||||||
if (!deleting) return;
|
|
||||||
setDeleteBusy(true);
|
|
||||||
setDeleteError(undefined);
|
|
||||||
api.requestTypes
|
|
||||||
.remove(deleting.id)
|
|
||||||
.then(() => {
|
|
||||||
setDeleting(undefined);
|
|
||||||
load();
|
|
||||||
})
|
|
||||||
.catch((e: ApiClientError) => setDeleteError(e.message))
|
|
||||||
.finally(() => setDeleteBusy(false));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumbs items={[{ label: 'Главная' }, { label: 'Типы заявок' }]} />
|
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} />
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon="clipboard"
|
icon="clipboard"
|
||||||
subtitle="Справочник"
|
subtitle={texts.requestTypes.subtitle}
|
||||||
title="Типы заявок"
|
title={texts.requestTypes.title}
|
||||||
actions={
|
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.requestTypes.add}</Button>}
|
||||||
<Button variant="accent" icon="plus" onClick={openCreate}>Добавить тип</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loadError && <Alert kind="err">{loadError}</Alert>}
|
{list.error && <Alert kind="err">{list.error}</Alert>}
|
||||||
{deleteError && <Alert kind="err">{deleteError}</Alert>}
|
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'name', header: 'Название', render: r => <PrimaryCell>{r.name}</PrimaryCell> },
|
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
|
||||||
{ key: 'url', header: 'Url страницы', render: r => r.pageUrl },
|
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
@@ -131,58 +95,52 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
|
|||||||
sticky: true,
|
sticky: true,
|
||||||
render: r => (
|
render: r => (
|
||||||
<ActionsCell>
|
<ActionsCell>
|
||||||
<IconButton icon="edit" label="Редактировать" onClick={() => openEdit(r)} />
|
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
||||||
<IconButton icon="trash" label="Удалить" danger onClick={() => { setDeleteError(undefined); setDeleting(r); }} />
|
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||||
</ActionsCell>
|
</ActionsCell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
rows={items}
|
rows={list.items}
|
||||||
keyOf={r => r.id}
|
keyOf={r => r.id}
|
||||||
emptyMessage={loading ? 'Загрузка...' : 'Типов заявок пока нет.'}
|
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<Modal
|
<FormModal
|
||||||
title={editing ? 'Редактировать тип заявки' : 'Новый тип заявки'}
|
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
|
||||||
|
error={submit.error}
|
||||||
|
isSaving={submit.isBusy}
|
||||||
|
onSubmit={submitForm}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={() => setShowForm(false)}
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={() => setShowForm(false)} disabled={saving}>Отмена</Button>
|
|
||||||
<Button variant="accent" icon="check" onClick={submit} disabled={saving}>
|
|
||||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{formError && <Alert kind="err">{formError}</Alert>}
|
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label="Название" required full>
|
<Field label={texts.requestTypes.nameLabel} required full>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||||
placeholder="Например, Заявка на командировку"
|
placeholder={texts.requestTypes.namePlaceholder}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Url страницы" required full hint="Страница формы заявки на портале.">
|
<Field label={texts.requestTypes.pageUrlLabel} required full hint={texts.requestTypes.pageUrlHint}>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={form.pageUrl}
|
value={form.pageUrl}
|
||||||
onChange={e => setForm({ ...form, pageUrl: e.target.value })}
|
onChange={e => setForm({ ...form, pageUrl: e.target.value })}
|
||||||
placeholder="/pages/business-trip"
|
placeholder={texts.requestTypes.pageUrlPlaceholder}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
</Modal>
|
</FormModal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deleting && (
|
{deleteTarget.target && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Удалить тип заявки?"
|
title={texts.requestTypes.deleteTitle}
|
||||||
text={`Тип «${deleting.name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`}
|
text={texts.requestTypes.deleteText(deleteTarget.target.name)}
|
||||||
confirmLabel="Удалить"
|
confirmLabel={texts.common.remove}
|
||||||
busy={deleteBusy}
|
busy={deleteTarget.isBusy}
|
||||||
onCancel={() => setDeleting(undefined)}
|
onCancel={deleteTarget.cancel}
|
||||||
onConfirm={confirmDelete}
|
onConfirm={deleteTarget.confirm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.runButtonRow {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
@@ -1,128 +1,182 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import styles from './ResolveScreen.module.scss';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { ApiClientError } from '../../services/httpClient';
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
import {
|
import { Project, RouteStepStatus } from '../../models';
|
||||||
RequestType,
|
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
|
||||||
ResolveTemplateRouteResult,
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
routeStepStatusLabels,
|
import { useResolveRoute } from '../../hooks/useResolveRoute';
|
||||||
RouteStepStatus,
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
routeTypeLabels
|
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||||
} from '../../models';
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
AsyncSearchableSelect,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
|
CellNote,
|
||||||
DataTable,
|
DataTable,
|
||||||
Field,
|
Field,
|
||||||
FormGrid,
|
FormGrid,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
|
Panel,
|
||||||
PrimaryCell,
|
PrimaryCell,
|
||||||
Select,
|
SearchableSelect,
|
||||||
TextInput
|
Select
|
||||||
} from '../ui';
|
} from '../ui';
|
||||||
|
|
||||||
export interface IResolveScreenProps {
|
export interface IResolveScreenProps {
|
||||||
api: ApprovalRouteApi;
|
api: ApprovalRouteApi;
|
||||||
|
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||||
|
dictionary?: PortalDictionaryClient;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||||
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
||||||
|
|
||||||
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, currentUserEmail }) => {
|
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, currentUserEmail }) => {
|
||||||
const [requestTypes, setRequestTypes] = React.useState<RequestType[]>([]);
|
const requestTypes = useRequestTypes(api);
|
||||||
const [requestTypeId, setRequestTypeId] = React.useState('');
|
const [requestTypeId, setRequestTypeId] = React.useState('');
|
||||||
const [userEmail, setUserEmail] = React.useState('');
|
const [userEmail, setUserEmail] = React.useState('');
|
||||||
const [projectId, setProjectId] = React.useState('');
|
const [projectId, setProjectId] = React.useState('');
|
||||||
|
const [projectLabel, setProjectLabel] = React.useState('');
|
||||||
const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval);
|
const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval);
|
||||||
|
|
||||||
const [busy, setBusy] = React.useState(false);
|
const resolved = useResolveRoute(api);
|
||||||
const [error, setError] = React.useState<string | undefined>();
|
const employees = usePortalEmployees(dictionary);
|
||||||
const [result, setResult] = React.useState<ResolveTemplateRouteResult | undefined>();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
const searchProjects = React.useCallback(
|
||||||
api.requestTypes.list().then(setRequestTypes).catch(() => { /* показывается при попытке проверки */ });
|
(query: string, page: number) => {
|
||||||
}, [api]);
|
if (!dictionary) {
|
||||||
|
return Promise.reject(new Error(texts.resolve.projectDictionaryLoading));
|
||||||
|
}
|
||||||
|
return dictionary.searchProjects(query, page);
|
||||||
|
},
|
||||||
|
[dictionary]
|
||||||
|
);
|
||||||
|
|
||||||
const run = (): void => {
|
const run = (): void => {
|
||||||
if (!requestTypeId) {
|
if (!requestTypeId) {
|
||||||
setError('Выберите тип заявки.');
|
resolved.fail(texts.resolve.validationRequestType);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
resolved.resolve({
|
||||||
setError(undefined);
|
requestTypeId: Number(requestTypeId),
|
||||||
setResult(undefined);
|
userEmail: userEmail.trim() || undefined,
|
||||||
api.templateRoutes
|
projectId: projectId.trim() || undefined,
|
||||||
.resolve({
|
status
|
||||||
requestTypeId: Number(requestTypeId),
|
});
|
||||||
userEmail: userEmail.trim() || undefined,
|
|
||||||
projectId: projectId.trim() || undefined,
|
|
||||||
status
|
|
||||||
})
|
|
||||||
.then(setResult)
|
|
||||||
.catch((e: ApiClientError) => setError(e.message))
|
|
||||||
.finally(() => setBusy(false));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const result = resolved.result;
|
||||||
|
const unresolvedCount = result ? result.steps.filter(s => !s.executorEmail).length : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumbs items={[{ label: 'Главная' }, { label: 'Проверка маршрута' }]} />
|
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.resolve.breadcrumb }]} />
|
||||||
<PageHeader
|
<PageHeader icon="search" subtitle={texts.resolve.subtitle} title={texts.resolve.title} />
|
||||||
icon="search"
|
|
||||||
subtitle="Диагностика"
|
|
||||||
title="Проверка разрешения маршрута"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ background: '#fff', borderRadius: 14, padding: 26, boxShadow: 'var(--sh-1)', border: '1px solid var(--gray-200)', marginBottom: 24 }}>
|
<Panel>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label="Тип заявки" required full>
|
<Field label={texts.resolve.requestTypeLabel} required full>
|
||||||
<Select
|
<Select
|
||||||
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
placeholder="Выберите тип заявки"
|
placeholder={texts.resolve.requestTypePlaceholder}
|
||||||
value={requestTypeId}
|
value={requestTypeId}
|
||||||
onChange={e => setRequestTypeId(e.target.value)}
|
onChange={e => setRequestTypeId(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Email инициатора" hint={currentUserEmail ? `Пусто = текущий пользователь (${currentUserEmail}).` : 'Пусто = текущий пользователь из токена.'}>
|
<Field
|
||||||
<TextInput value={userEmail} onChange={e => setUserEmail(e.target.value)} placeholder="user@tnsu.kz" />
|
label={texts.resolve.userEmailLabel}
|
||||||
|
hint={currentUserEmail ? texts.resolve.userEmailHint(currentUserEmail) : texts.resolve.userEmailHintUnknown}
|
||||||
|
>
|
||||||
|
<SearchableSelect
|
||||||
|
options={employees.items.map(toEmployeeOption)}
|
||||||
|
value={userEmail}
|
||||||
|
loading={employees.isLoading}
|
||||||
|
allowClear
|
||||||
|
clearLabel={texts.resolve.userEmailClear}
|
||||||
|
placeholder={texts.resolve.userEmailPlaceholder}
|
||||||
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
|
onChange={setUserEmail}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Id проекта (Guid)" hint="Заполните, если маршрут зависит от проекта.">
|
<Field label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}>
|
||||||
<TextInput value={projectId} onChange={e => setProjectId(e.target.value)} placeholder="Необязательно" />
|
<AsyncSearchableSelect<Project>
|
||||||
|
value={projectId}
|
||||||
|
selectedLabel={projectLabel}
|
||||||
|
disabled={!dictionary}
|
||||||
|
allowClear
|
||||||
|
clearLabel={texts.resolve.projectClear}
|
||||||
|
placeholder={dictionary ? texts.resolve.projectPlaceholder : texts.resolve.projectLoadingPlaceholder}
|
||||||
|
searchPlaceholder={texts.resolve.projectSearchPlaceholder}
|
||||||
|
search={searchProjects}
|
||||||
|
getValue={p => p.oid}
|
||||||
|
getLabel={p => p.name}
|
||||||
|
getSublabel={p => [p.code, p.status].filter(Boolean).join(' · ') || undefined}
|
||||||
|
onChange={(nextId, item) => {
|
||||||
|
setProjectId(nextId);
|
||||||
|
setProjectLabel(item ? item.name : '');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Статус" required>
|
<Field label={texts.resolve.statusLabel} required>
|
||||||
<Select options={STATUS_OPTIONS} value={status} onChange={e => setStatus(e.target.value as RouteStepStatus)} />
|
<Select options={STATUS_OPTIONS} value={status} onChange={e => setStatus(e.target.value as RouteStepStatus)} />
|
||||||
</Field>
|
</Field>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
<div style={{ marginTop: 18 }}>
|
<div className={styles.runButtonRow}>
|
||||||
<Button variant="accent" icon="search" onClick={run} disabled={busy}>
|
<Button variant="accent" icon="search" onClick={run} disabled={resolved.isBusy}>
|
||||||
{busy ? 'Проверка...' : 'Проверить'}
|
{resolved.isBusy ? texts.resolve.running : texts.resolve.run}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
{error && <Alert kind="err">{error}</Alert>}
|
{resolved.error && <Alert kind="err">{resolved.error}</Alert>}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<>
|
<>
|
||||||
<Alert kind="ok">
|
<Alert kind="ok">
|
||||||
Маршрут #{result.routeId} · {routeTypeLabels[result.routeType]}
|
{texts.resolve.summary(result.routeId, routeTypeLabels[result.routeType], result.requiresControl)}
|
||||||
{result.requiresControl ? ' · требуется контроль исполнения' : ''}
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
{unresolvedCount > 0 && <Alert kind="warn">{texts.resolve.unresolved(unresolvedCount)}</Alert>}
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'order', header: '№', width: '50px', render: r => r.orderNo },
|
{ key: 'order', header: texts.resolve.columnOrderNo, width: '50px', render: r => r.orderNo },
|
||||||
{ key: 'executor', header: 'Исполнитель', render: r => <PrimaryCell>{r.executorEmail ?? '—'}</PrimaryCell> },
|
{
|
||||||
{ key: 'position', header: 'Должность', render: r => r.position ?? '—' },
|
key: 'executor',
|
||||||
|
header: texts.resolve.columnExecutor,
|
||||||
|
render: r => {
|
||||||
|
const employee = findEmployeeByEmail(employees.items, r.executorEmail);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PrimaryCell>{employee ? employee.fio : (r.executorEmail ?? texts.common.empty)}</PrimaryCell>
|
||||||
|
{employee && <CellNote>{employee.email}</CellNote>}
|
||||||
|
{r.warning && <CellNote kind="warn">{r.warning}</CellNote>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'position',
|
||||||
|
header: texts.resolve.columnPosition,
|
||||||
|
render: r => (
|
||||||
|
<div>
|
||||||
|
{r.position ?? texts.common.empty}
|
||||||
|
{!r.position && !r.warning && r.executorEmail && <CellNote>{texts.resolve.positionMissing}</CellNote>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'reassign',
|
key: 'reassign',
|
||||||
header: 'Переназначение',
|
header: texts.resolve.columnReassign,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: r => (r.allowReassignToOtherApprovers ? 'Разрешено' : 'Нет')
|
render: r => (r.allowReassignToOtherApprovers ? texts.resolve.reassignAllowed : texts.common.no)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
rows={result.steps}
|
rows={result.steps}
|
||||||
keyOf={r => r.id}
|
keyOf={r => r.id}
|
||||||
emptyMessage="Маршрут разрешился без шагов для этого статуса."
|
emptyMessage={texts.resolve.emptySteps}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Company, Department, RequestType, RouteType, TemplateRoute } from '../../models';
|
||||||
|
import { routeTypeLabels, texts } from '../../texts';
|
||||||
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
|
||||||
|
|
||||||
|
export interface IRouteFormModalProps {
|
||||||
|
api: ApprovalRouteApi;
|
||||||
|
/** Undefined — создание нового маршрута, иначе редактирование существующего. */
|
||||||
|
editing?: TemplateRoute;
|
||||||
|
/** Тип заявки, под которым открыли форму создания (например, из активного фильтра списка). */
|
||||||
|
initialRequestTypeId: string;
|
||||||
|
requestTypes: RequestType[];
|
||||||
|
companies: Company[];
|
||||||
|
departments: Department[];
|
||||||
|
dictionaryLoading: boolean;
|
||||||
|
onSaved: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFormState {
|
||||||
|
requestTypeId: string;
|
||||||
|
routeType: RouteType;
|
||||||
|
companyId: string;
|
||||||
|
departmentId: string;
|
||||||
|
byProject: boolean;
|
||||||
|
requiresControl: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTE_TYPE_OPTIONS = Object.values(RouteType)
|
||||||
|
.filter(v => v !== RouteType.None)
|
||||||
|
.map(v => ({ value: v, label: routeTypeLabels[v] }));
|
||||||
|
|
||||||
|
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
|
||||||
|
if (!editing) {
|
||||||
|
return {
|
||||||
|
requestTypeId: initialRequestTypeId,
|
||||||
|
routeType: RouteType.Sequential,
|
||||||
|
companyId: '',
|
||||||
|
departmentId: '',
|
||||||
|
byProject: false,
|
||||||
|
requiresControl: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestTypeId: String(editing.requestTypeId),
|
||||||
|
routeType: editing.routeType,
|
||||||
|
companyId: editing.companyId,
|
||||||
|
departmentId: editing.departmentId ?? '',
|
||||||
|
byProject: editing.byProject,
|
||||||
|
requiresControl: editing.requiresControl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Форма создания/редактирования маршрута — вынесена из RoutesScreen, чтобы тот не разрастался. */
|
||||||
|
export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
|
||||||
|
api,
|
||||||
|
editing,
|
||||||
|
initialRequestTypeId,
|
||||||
|
requestTypes,
|
||||||
|
companies,
|
||||||
|
departments,
|
||||||
|
dictionaryLoading,
|
||||||
|
onSaved,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, initialRequestTypeId));
|
||||||
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
|
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
|
||||||
|
// иначе пользователь листает ~700 позиций из всех юрлиц сразу.
|
||||||
|
const departmentsForSelectedCompany = React.useMemo(
|
||||||
|
() => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []),
|
||||||
|
[departments, form.companyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitForm = (): void => {
|
||||||
|
if (!editing && !form.requestTypeId) {
|
||||||
|
submit.fail(texts.routes.validationRequestType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.companyId.trim()) {
|
||||||
|
submit.fail(texts.routes.validationCompany);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
|
||||||
|
const request = editing
|
||||||
|
? api.templateRoutes.update(editing.id, {
|
||||||
|
routeType: form.routeType,
|
||||||
|
companyId: form.companyId.trim(),
|
||||||
|
departmentId,
|
||||||
|
byProject: form.byProject,
|
||||||
|
requiresControl: form.requiresControl
|
||||||
|
})
|
||||||
|
: api.templateRoutes.create({
|
||||||
|
routeType: form.routeType,
|
||||||
|
requestTypeId: Number(form.requestTypeId),
|
||||||
|
companyId: form.companyId.trim(),
|
||||||
|
departmentId,
|
||||||
|
byProject: form.byProject,
|
||||||
|
requiresControl: form.requiresControl
|
||||||
|
});
|
||||||
|
|
||||||
|
submit.run(() =>
|
||||||
|
request.then(() => {
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
title={editing ? texts.routes.editTitle(editing.id) : texts.routes.createTitle}
|
||||||
|
error={submit.error}
|
||||||
|
isSaving={submit.isBusy}
|
||||||
|
onSubmit={submitForm}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<FormGrid>
|
||||||
|
<Field label={texts.routes.requestTypeLabel} required full>
|
||||||
|
<Select
|
||||||
|
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
|
placeholder={texts.routes.requestTypePlaceholder}
|
||||||
|
value={form.requestTypeId}
|
||||||
|
disabled={!!editing}
|
||||||
|
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={texts.routes.routeTypeLabel} required full>
|
||||||
|
<Select
|
||||||
|
options={ROUTE_TYPE_OPTIONS}
|
||||||
|
value={form.routeType}
|
||||||
|
onChange={e => setForm({ ...form, routeType: e.target.value as RouteType })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={texts.routes.companyLabel} required full hint={texts.routes.companyHint}>
|
||||||
|
<SearchableSelect
|
||||||
|
options={companies.map(c => ({ value: c.id, label: c.name }))}
|
||||||
|
value={form.companyId}
|
||||||
|
loading={dictionaryLoading}
|
||||||
|
placeholder={texts.routes.companyPlaceholder}
|
||||||
|
searchPlaceholder={texts.routes.companySearchPlaceholder}
|
||||||
|
onChange={companyId =>
|
||||||
|
// Смена компании обнуляет отдел: прежний принадлежал другой
|
||||||
|
// компании, и бэкенд не нашёл бы такой маршрут при resolve.
|
||||||
|
setForm({ ...form, companyId, departmentId: '' })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label={texts.routes.departmentLabel}
|
||||||
|
full
|
||||||
|
hint={form.companyId ? texts.routes.departmentHint : texts.routes.departmentHintNoCompany}
|
||||||
|
>
|
||||||
|
<SearchableSelect
|
||||||
|
options={departmentsForSelectedCompany.map(d => ({ value: d.id, label: d.name }))}
|
||||||
|
value={form.departmentId}
|
||||||
|
loading={dictionaryLoading}
|
||||||
|
disabled={!form.companyId}
|
||||||
|
allowClear
|
||||||
|
clearLabel={texts.routes.departmentPlaceholder}
|
||||||
|
placeholder={texts.routes.departmentPlaceholder}
|
||||||
|
searchPlaceholder={texts.routes.departmentSearchPlaceholder}
|
||||||
|
emptyMessage={texts.routes.departmentEmpty}
|
||||||
|
onChange={departmentId => setForm({ ...form, departmentId })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={texts.routes.byProjectLabel}>
|
||||||
|
<Checkbox
|
||||||
|
label={texts.routes.byProjectCheckbox}
|
||||||
|
checked={form.byProject}
|
||||||
|
onChange={v => setForm({ ...form, byProject: v })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={texts.routes.controlLabel}>
|
||||||
|
<Checkbox
|
||||||
|
label={texts.routes.controlCheckbox}
|
||||||
|
checked={form.requiresControl}
|
||||||
|
onChange={v => setForm({ ...form, requiresControl: v })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
Employee,
|
||||||
|
HeadType,
|
||||||
|
ProjectRole,
|
||||||
|
RouteStepStatus,
|
||||||
|
StepExecutorKind,
|
||||||
|
TemplateRoute,
|
||||||
|
TemplateRouteStep
|
||||||
|
} from '../../models';
|
||||||
|
import { headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
|
||||||
|
import { detectExecutorKind } from '../../utils/stepExecutor';
|
||||||
|
import { toEmployeeOption } from '../../utils/employee';
|
||||||
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
|
||||||
|
|
||||||
|
export interface IRouteStepFormModalProps {
|
||||||
|
api: ApprovalRouteApi;
|
||||||
|
route: TemplateRoute;
|
||||||
|
/** Undefined — создание нового шага, иначе редактирование существующего. */
|
||||||
|
editing?: TemplateRouteStep;
|
||||||
|
nextOrderNo: number;
|
||||||
|
employees: Employee[];
|
||||||
|
employeesLoading: boolean;
|
||||||
|
onSaved: (savedStep: TemplateRouteStep) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFormState {
|
||||||
|
orderNo: string;
|
||||||
|
status: RouteStepStatus;
|
||||||
|
kind: StepExecutorKind;
|
||||||
|
executorEmail: string;
|
||||||
|
position: string;
|
||||||
|
projectRole: ProjectRole;
|
||||||
|
headType: HeadType;
|
||||||
|
allowReassignToOtherApprovers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||||
|
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
||||||
|
|
||||||
|
const PROJECT_ROLE_OPTIONS = Object.values(ProjectRole)
|
||||||
|
.filter(v => v !== ProjectRole.None)
|
||||||
|
.map(v => ({ value: v, label: projectRoleLabels[v] }));
|
||||||
|
|
||||||
|
const HEAD_TYPE_OPTIONS = Object.values(HeadType)
|
||||||
|
.filter(v => v !== HeadType.None)
|
||||||
|
.map(v => ({ value: v, label: headTypeLabels[v] }));
|
||||||
|
|
||||||
|
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
||||||
|
if (!editing) {
|
||||||
|
return {
|
||||||
|
orderNo: String(nextOrderNo),
|
||||||
|
status: RouteStepStatus.Approval,
|
||||||
|
kind: 'email',
|
||||||
|
executorEmail: '',
|
||||||
|
position: '',
|
||||||
|
projectRole: ProjectRole.ProjectManager,
|
||||||
|
headType: HeadType.Chief,
|
||||||
|
allowReassignToOtherApprovers: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
orderNo: String(editing.orderNo),
|
||||||
|
status: editing.status,
|
||||||
|
kind: detectExecutorKind(editing),
|
||||||
|
executorEmail: editing.executorEmail ?? '',
|
||||||
|
position: editing.position ?? '',
|
||||||
|
projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager,
|
||||||
|
headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief,
|
||||||
|
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Форма создания/редактирования шага маршрута — вынесена из RouteStepsScreen. */
|
||||||
|
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||||
|
api,
|
||||||
|
route,
|
||||||
|
editing,
|
||||||
|
nextOrderNo,
|
||||||
|
employees,
|
||||||
|
employeesLoading,
|
||||||
|
onSaved,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
||||||
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
|
const submitForm = (): void => {
|
||||||
|
const orderNo = Number(form.orderNo);
|
||||||
|
if (!orderNo || orderNo < 1) {
|
||||||
|
submit.fail(texts.routeSteps.validationOrderNo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.kind === 'email' && !form.executorEmail.trim()) {
|
||||||
|
submit.fail(texts.routeSteps.validationExecutor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.kind === 'position' && !form.position.trim()) {
|
||||||
|
submit.fail(texts.routeSteps.validationPosition);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
orderNo,
|
||||||
|
status: form.status,
|
||||||
|
allowReassignToOtherApprovers: form.allowReassignToOtherApprovers,
|
||||||
|
executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null,
|
||||||
|
position: form.kind === 'position' ? form.position.trim() : null,
|
||||||
|
projectRole: form.kind === 'projectRole' ? form.projectRole : null,
|
||||||
|
headType: form.kind === 'headType' ? form.headType : null
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = editing
|
||||||
|
? api.templateRouteSteps.update(route.id, editing.id, payload)
|
||||||
|
: api.templateRouteSteps.create(route.id, payload);
|
||||||
|
|
||||||
|
submit.run(() =>
|
||||||
|
request.then(step => {
|
||||||
|
onSaved(step);
|
||||||
|
onClose();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
title={editing ? texts.routeSteps.editTitle(editing.orderNo) : texts.routeSteps.createTitle}
|
||||||
|
error={submit.error}
|
||||||
|
isSaving={submit.isBusy}
|
||||||
|
onSubmit={submitForm}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<FormGrid>
|
||||||
|
<Field label={texts.routeSteps.orderNoLabel} required>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.orderNo}
|
||||||
|
onChange={e => setForm({ ...form, orderNo: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={texts.routeSteps.statusLabel} required>
|
||||||
|
<Select
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
value={form.status}
|
||||||
|
onChange={e => setForm({ ...form, status: e.target.value as RouteStepStatus })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={texts.routeSteps.kindLabel} required full hint={texts.routeSteps.kindHint}>
|
||||||
|
<RadioGroup
|
||||||
|
name="executorKind"
|
||||||
|
value={form.kind}
|
||||||
|
onChange={kind => setForm({ ...form, kind })}
|
||||||
|
options={[
|
||||||
|
{ value: 'email', label: texts.routeSteps.kindEmail },
|
||||||
|
{ value: 'position', label: texts.routeSteps.kindPosition },
|
||||||
|
{
|
||||||
|
value: 'projectRole',
|
||||||
|
label: texts.routeSteps.kindProjectRole,
|
||||||
|
disabled: !route.byProject,
|
||||||
|
disabledHint: texts.routeSteps.kindProjectRoleDisabled
|
||||||
|
},
|
||||||
|
{ value: 'headType', label: texts.routeSteps.kindHeadType }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{form.kind === 'email' && (
|
||||||
|
<Field label={texts.routeSteps.executorLabel} required full>
|
||||||
|
<SearchableSelect
|
||||||
|
options={employees.map(toEmployeeOption)}
|
||||||
|
value={form.executorEmail}
|
||||||
|
loading={employeesLoading}
|
||||||
|
placeholder={texts.routeSteps.executorPlaceholder}
|
||||||
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
|
onChange={executorEmail => setForm({ ...form, executorEmail })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{form.kind === 'position' && (
|
||||||
|
<Field label={texts.routeSteps.positionLabel} required full hint={texts.routeSteps.positionHint}>
|
||||||
|
<TextInput
|
||||||
|
value={form.position}
|
||||||
|
onChange={e => setForm({ ...form, position: e.target.value })}
|
||||||
|
placeholder={texts.routeSteps.positionPlaceholder}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{form.kind === 'projectRole' && (
|
||||||
|
<Field label={texts.routeSteps.projectRoleLabel} required full>
|
||||||
|
<Select
|
||||||
|
options={PROJECT_ROLE_OPTIONS}
|
||||||
|
value={form.projectRole}
|
||||||
|
onChange={e => setForm({ ...form, projectRole: e.target.value as ProjectRole })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{form.kind === 'headType' && (
|
||||||
|
<Field label={texts.routeSteps.headTypeLabel} required full>
|
||||||
|
<Select
|
||||||
|
options={HEAD_TYPE_OPTIONS}
|
||||||
|
value={form.headType}
|
||||||
|
onChange={e => setForm({ ...form, headType: e.target.value as HeadType })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label={texts.routeSteps.reassignLabel} full>
|
||||||
|
<Checkbox
|
||||||
|
label={texts.routeSteps.reassignCheckbox}
|
||||||
|
checked={form.allowReassignToOtherApprovers}
|
||||||
|
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,226 +1,110 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { RouteStepFormModal } from './RouteStepFormModal';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { ApiClientError } from '../../services/httpClient';
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
import {
|
import { RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
|
||||||
HeadType,
|
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
|
||||||
headTypeLabels,
|
import { describeStepExecutor } from '../../utils/stepExecutor';
|
||||||
ProjectRole,
|
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
||||||
projectRoleLabels,
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
RouteStepStatus,
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
routeStepStatusLabels,
|
|
||||||
routeTypeLabels,
|
|
||||||
StepExecutorKind,
|
|
||||||
TemplateRoute,
|
|
||||||
TemplateRouteStep,
|
|
||||||
detectExecutorKind
|
|
||||||
} from '../../models';
|
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
CellNote,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
DataTable,
|
DataTable,
|
||||||
Field,
|
|
||||||
FormGrid,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
Modal,
|
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PrimaryCell,
|
PrimaryCell
|
||||||
RadioGroup,
|
|
||||||
Select,
|
|
||||||
TextInput
|
|
||||||
} from '../ui';
|
} from '../ui';
|
||||||
|
|
||||||
export interface IRouteStepsScreenProps {
|
export interface IRouteStepsScreenProps {
|
||||||
api: ApprovalRouteApi;
|
api: ApprovalRouteApi;
|
||||||
route: TemplateRoute;
|
route: TemplateRoute;
|
||||||
requestTypeName: string;
|
requestTypeName: string;
|
||||||
|
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||||
|
dictionary?: PortalDictionaryClient;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IFormState {
|
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, dictionary, onBack }) => {
|
||||||
orderNo: string;
|
const steps = useRouteSteps(api, route.id);
|
||||||
status: RouteStepStatus;
|
const employees = usePortalEmployees(dictionary);
|
||||||
kind: StepExecutorKind;
|
const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
|
||||||
executorEmail: string;
|
|
||||||
position: string;
|
|
||||||
projectRole: ProjectRole;
|
|
||||||
headType: HeadType;
|
|
||||||
allowReassignToOtherApprovers: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyForm(nextOrderNo: number): IFormState {
|
|
||||||
return {
|
|
||||||
orderNo: String(nextOrderNo),
|
|
||||||
status: RouteStepStatus.Approval,
|
|
||||||
kind: 'email',
|
|
||||||
executorEmail: '',
|
|
||||||
position: '',
|
|
||||||
projectRole: ProjectRole.ProjectManager,
|
|
||||||
headType: HeadType.Chief,
|
|
||||||
allowReassignToOtherApprovers: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
|
||||||
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
|
||||||
|
|
||||||
const PROJECT_ROLE_OPTIONS = Object.values(ProjectRole)
|
|
||||||
.filter(v => v !== ProjectRole.None)
|
|
||||||
.map(v => ({ value: v, label: projectRoleLabels[v] }));
|
|
||||||
|
|
||||||
const HEAD_TYPE_OPTIONS = Object.values(HeadType)
|
|
||||||
.filter(v => v !== HeadType.None)
|
|
||||||
.map(v => ({ value: v, label: headTypeLabels[v] }));
|
|
||||||
|
|
||||||
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, onBack }) => {
|
|
||||||
const [items, setItems] = React.useState<TemplateRouteStep[]>([]);
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [loadError, setLoadError] = React.useState<string | undefined>();
|
|
||||||
const [warning, setWarning] = React.useState<string | undefined>();
|
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
const [form, setForm] = React.useState<IFormState>(emptyForm(1));
|
|
||||||
const [formError, setFormError] = React.useState<string | undefined>();
|
|
||||||
const [saving, setSaving] = React.useState(false);
|
|
||||||
|
|
||||||
const [deleting, setDeleting] = React.useState<TemplateRouteStep | undefined>();
|
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
|
||||||
const [deleteError, setDeleteError] = React.useState<string | undefined>();
|
item => api.templateRouteSteps.remove(route.id, item.id),
|
||||||
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
steps.reload
|
||||||
|
);
|
||||||
const load = React.useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
setLoadError(undefined);
|
|
||||||
api.templateRouteSteps
|
|
||||||
.list(route.id)
|
|
||||||
.then(setItems)
|
|
||||||
.catch((e: ApiClientError) => setLoadError(e.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [api, route.id]);
|
|
||||||
|
|
||||||
React.useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
const nextOrder = items.length > 0 ? Math.max(...items.map(s => s.orderNo)) + 1 : 1;
|
|
||||||
setForm(emptyForm(nextOrder));
|
|
||||||
setFormError(undefined);
|
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: TemplateRouteStep): void => {
|
const openEdit = (item: TemplateRouteStep): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
setForm({
|
|
||||||
orderNo: String(item.orderNo),
|
|
||||||
status: item.status,
|
|
||||||
kind: detectExecutorKind(item),
|
|
||||||
executorEmail: item.executorEmail ?? '',
|
|
||||||
position: item.position ?? '',
|
|
||||||
projectRole: item.projectRole && item.projectRole !== ProjectRole.None ? item.projectRole : ProjectRole.ProjectManager,
|
|
||||||
headType: item.headType && item.headType !== HeadType.None ? item.headType : HeadType.Chief,
|
|
||||||
allowReassignToOtherApprovers: item.allowReassignToOtherApprovers
|
|
||||||
});
|
|
||||||
setFormError(undefined);
|
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = (): void => {
|
const handleSaved = (savedStep: TemplateRouteStep): void => {
|
||||||
const orderNo = Number(form.orderNo);
|
setSavedWarning(savedStep.warning ?? undefined);
|
||||||
if (!orderNo || orderNo < 1) {
|
steps.reload();
|
||||||
setFormError('Порядковый номер шага обязателен.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (form.kind === 'email' && !form.executorEmail.trim()) {
|
|
||||||
setFormError('Укажите email исполнителя.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (form.kind === 'position' && !form.position.trim()) {
|
|
||||||
setFormError('Укажите должность.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
orderNo,
|
|
||||||
status: form.status,
|
|
||||||
allowReassignToOtherApprovers: form.allowReassignToOtherApprovers,
|
|
||||||
executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null,
|
|
||||||
position: form.kind === 'position' ? form.position.trim() : null,
|
|
||||||
projectRole: form.kind === 'projectRole' ? form.projectRole : null,
|
|
||||||
headType: form.kind === 'headType' ? form.headType : null
|
|
||||||
};
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
setFormError(undefined);
|
|
||||||
const request = editing
|
|
||||||
? api.templateRouteSteps.update(route.id, editing.id, payload)
|
|
||||||
: api.templateRouteSteps.create(route.id, payload);
|
|
||||||
|
|
||||||
request
|
|
||||||
.then(step => {
|
|
||||||
setShowForm(false);
|
|
||||||
setWarning(step.warning ?? undefined);
|
|
||||||
load();
|
|
||||||
})
|
|
||||||
.catch((e: ApiClientError) => setFormError(e.message))
|
|
||||||
.finally(() => setSaving(false));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = (): void => {
|
const nextOrderNo = steps.items.length > 0 ? Math.max(...steps.items.map(s => s.orderNo)) + 1 : 1;
|
||||||
if (!deleting) return;
|
|
||||||
setDeleteBusy(true);
|
|
||||||
setDeleteError(undefined);
|
|
||||||
api.templateRouteSteps
|
|
||||||
.remove(route.id, deleting.id)
|
|
||||||
.then(() => {
|
|
||||||
setDeleting(undefined);
|
|
||||||
load();
|
|
||||||
})
|
|
||||||
.catch((e: ApiClientError) => setDeleteError(e.message))
|
|
||||||
.finally(() => setDeleteBusy(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const sorted = [...items].sort((a, b) => a.orderNo - b.orderNo);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Главная' },
|
{ label: texts.common.home },
|
||||||
{ label: 'Маршруты согласования', onClick: onBack },
|
{ label: texts.routes.breadcrumb, onClick: onBack },
|
||||||
{ label: `Шаги маршрута #${route.id}` }
|
{ label: texts.routeSteps.breadcrumb(route.id) }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon="flag-check"
|
icon="flag-check"
|
||||||
subtitle={`${requestTypeName} · ${routeTypeLabels[route.routeType]}`}
|
subtitle={texts.routeSteps.subtitle(requestTypeName, routeTypeLabels[route.routeType])}
|
||||||
title={`Маршрут #${route.id}`}
|
title={texts.routeSteps.title(route.id)}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" icon="arrow-left" onClick={onBack}>К маршрутам</Button>
|
<Button variant="ghost" icon="arrow-left" onClick={onBack}>{texts.routeSteps.back}</Button>
|
||||||
<Button variant="accent" icon="plus" onClick={openCreate}>Добавить шаг</Button>
|
<Button variant="accent" icon="plus" onClick={openCreate}>{texts.routeSteps.add}</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{warning && <Alert kind="warn">{warning}</Alert>}
|
{savedWarning && <Alert kind="warn">{savedWarning}</Alert>}
|
||||||
{loadError && <Alert kind="err">{loadError}</Alert>}
|
{steps.error && <Alert kind="err">{steps.error}</Alert>}
|
||||||
{deleteError && <Alert kind="err">{deleteError}</Alert>}
|
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'order', header: '№', width: '50px', render: r => r.orderNo },
|
{ key: 'order', header: texts.routeSteps.columnOrderNo, width: '50px', render: r => r.orderNo },
|
||||||
{
|
{
|
||||||
key: 'executor',
|
key: 'executor',
|
||||||
header: 'Исполнитель',
|
header: texts.routeSteps.columnExecutor,
|
||||||
render: r => <PrimaryCell>{describeExecutor(r)}</PrimaryCell>
|
render: r => {
|
||||||
|
const executor = describeStepExecutor(r, employees.items);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PrimaryCell>{executor.primary}</PrimaryCell>
|
||||||
|
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
header: 'Статус',
|
header: texts.routeSteps.columnStatus,
|
||||||
render: r => (
|
render: r => (
|
||||||
<Badge variant={r.status === RouteStepStatus.Approval ? 'gold' : 'blue'}>
|
<Badge variant={r.status === RouteStepStatus.Approval ? 'gold' : 'blue'}>
|
||||||
{routeStepStatusLabels[r.status]}
|
{routeStepStatusLabels[r.status]}
|
||||||
@@ -229,9 +113,9 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'reassign',
|
key: 'reassign',
|
||||||
header: 'Переназначение',
|
header: texts.routeSteps.columnReassign,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">Разрешено</Badge> : <Badge variant="neutral">Нет</Badge>)
|
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -241,133 +125,40 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
sticky: true,
|
sticky: true,
|
||||||
render: r => (
|
render: r => (
|
||||||
<ActionsCell>
|
<ActionsCell>
|
||||||
<IconButton icon="edit" label="Редактировать" onClick={() => openEdit(r)} />
|
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
||||||
<IconButton icon="trash" label="Удалить" danger onClick={() => { setDeleteError(undefined); setDeleting(r); }} />
|
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||||
</ActionsCell>
|
</ActionsCell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
rows={sorted}
|
rows={steps.items}
|
||||||
keyOf={r => r.id}
|
keyOf={r => r.id}
|
||||||
emptyMessage={loading ? 'Загрузка...' : 'Шагов пока нет — добавьте первый.'}
|
emptyMessage={steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<Modal
|
<RouteStepFormModal
|
||||||
title={editing ? `Шаг #${editing.orderNo}` : 'Новый шаг маршрута'}
|
api={api}
|
||||||
|
route={route}
|
||||||
|
editing={editing}
|
||||||
|
nextOrderNo={nextOrderNo}
|
||||||
|
employees={employees.items}
|
||||||
|
employeesLoading={employees.isLoading}
|
||||||
|
onSaved={handleSaved}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={() => setShowForm(false)}
|
||||||
footer={
|
/>
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={() => setShowForm(false)} disabled={saving}>Отмена</Button>
|
|
||||||
<Button variant="accent" icon="check" onClick={submit} disabled={saving}>
|
|
||||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{formError && <Alert kind="err">{formError}</Alert>}
|
|
||||||
<FormGrid>
|
|
||||||
<Field label="Порядковый номер" required>
|
|
||||||
<TextInput
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={form.orderNo}
|
|
||||||
onChange={e => setForm({ ...form, orderNo: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Статус шага" required>
|
|
||||||
<Select
|
|
||||||
options={STATUS_OPTIONS}
|
|
||||||
value={form.status}
|
|
||||||
onChange={e => setForm({ ...form, status: e.target.value as RouteStepStatus })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Кто исполнитель" required full hint="Должно быть заполнено ровно одно из полей.">
|
|
||||||
<RadioGroup
|
|
||||||
name="executorKind"
|
|
||||||
value={form.kind}
|
|
||||||
onChange={kind => setForm({ ...form, kind })}
|
|
||||||
options={[
|
|
||||||
{ value: 'email', label: 'Конкретный сотрудник (email)' },
|
|
||||||
{ value: 'position', label: 'По должности в компании инициатора' },
|
|
||||||
{
|
|
||||||
value: 'projectRole',
|
|
||||||
label: 'По роли в проекте',
|
|
||||||
disabled: !route.byProject,
|
|
||||||
disabledHint: 'Недоступно: маршрут не учитывает проект (по проекту = нет).'
|
|
||||||
},
|
|
||||||
{ value: 'headType', label: 'По типу руководителя' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{form.kind === 'email' && (
|
|
||||||
<Field label="Email исполнителя" required full>
|
|
||||||
<TextInput
|
|
||||||
value={form.executorEmail}
|
|
||||||
onChange={e => setForm({ ...form, executorEmail: e.target.value })}
|
|
||||||
placeholder="user@tnsu.kz"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
{form.kind === 'position' && (
|
|
||||||
<Field label="Должность" required full hint="Ищется в справочнике api.tnsu.kz по компании инициатора.">
|
|
||||||
<TextInput
|
|
||||||
value={form.position}
|
|
||||||
onChange={e => setForm({ ...form, position: e.target.value })}
|
|
||||||
placeholder="Главный инженер"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
{form.kind === 'projectRole' && (
|
|
||||||
<Field label="Роль в проекте" required full>
|
|
||||||
<Select
|
|
||||||
options={PROJECT_ROLE_OPTIONS}
|
|
||||||
value={form.projectRole}
|
|
||||||
onChange={e => setForm({ ...form, projectRole: e.target.value as ProjectRole })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
{form.kind === 'headType' && (
|
|
||||||
<Field label="Тип руководителя" required full>
|
|
||||||
<Select
|
|
||||||
options={HEAD_TYPE_OPTIONS}
|
|
||||||
value={form.headType}
|
|
||||||
onChange={e => setForm({ ...form, headType: e.target.value as HeadType })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Field label="Переназначение" full>
|
|
||||||
<Checkbox
|
|
||||||
label="Разрешить переназначать другим согласующим"
|
|
||||||
checked={form.allowReassignToOtherApprovers}
|
|
||||||
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FormGrid>
|
|
||||||
</Modal>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deleting && (
|
{deleteTarget.target && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Удалить шаг?"
|
title={texts.routeSteps.deleteTitle}
|
||||||
text={`Шаг #${deleting.orderNo} будет удалён безвозвратно.`}
|
text={texts.routeSteps.deleteText(deleteTarget.target.orderNo)}
|
||||||
confirmLabel="Удалить"
|
confirmLabel={texts.common.remove}
|
||||||
busy={deleteBusy}
|
busy={deleteTarget.isBusy}
|
||||||
onCancel={() => setDeleting(undefined)}
|
onCancel={deleteTarget.cancel}
|
||||||
onConfirm={confirmDelete}
|
onConfirm={deleteTarget.confirm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function describeExecutor(step: TemplateRouteStep): string {
|
|
||||||
if (step.executorEmail) return step.executorEmail;
|
|
||||||
if (step.position) return step.position;
|
|
||||||
if (step.projectRole && step.projectRole !== ProjectRole.None) return `Роль: ${projectRoleLabels[step.projectRole]}`;
|
|
||||||
if (step.headType && step.headType !== HeadType.None) return `Руководитель: ${headTypeLabels[step.headType]}`;
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.filter {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import styles from './RoutesScreen.module.scss';
|
||||||
|
import { RouteFormModal } from './RouteFormModal';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { ApiClientError } from '../../services/httpClient';
|
|
||||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
import { Company, Department, RequestType, routeTypeLabels, RouteType, TemplateRoute } from '../../models';
|
import { RouteType, TemplateRoute } from '../../models';
|
||||||
|
import { routeTypeLabels, texts } from '../../texts';
|
||||||
|
import { shortGuid } from '../../utils/guid';
|
||||||
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
|
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
||||||
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
DataTable,
|
DataTable,
|
||||||
Field,
|
Field,
|
||||||
FormGrid,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
Modal,
|
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PrimaryCell,
|
PrimaryCell,
|
||||||
SearchableSelect,
|
|
||||||
Select
|
Select
|
||||||
} from '../ui';
|
} from '../ui';
|
||||||
|
|
||||||
@@ -28,222 +31,85 @@ export interface IRoutesScreenProps {
|
|||||||
onOpenSteps: (route: TemplateRoute) => void;
|
onOpenSteps: (route: TemplateRoute) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IFormState {
|
|
||||||
requestTypeId: string;
|
|
||||||
routeType: RouteType;
|
|
||||||
companyId: string;
|
|
||||||
departmentId: string;
|
|
||||||
byProject: boolean;
|
|
||||||
requiresControl: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_FORM: IFormState = {
|
|
||||||
requestTypeId: '',
|
|
||||||
routeType: RouteType.Sequential,
|
|
||||||
companyId: '',
|
|
||||||
departmentId: '',
|
|
||||||
byProject: false,
|
|
||||||
requiresControl: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const ROUTE_TYPE_OPTIONS = Object.values(RouteType)
|
|
||||||
.filter(v => v !== RouteType.None)
|
|
||||||
.map(v => ({ value: v, label: routeTypeLabels[v] }));
|
|
||||||
|
|
||||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, onOpenSteps }) => {
|
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, onOpenSteps }) => {
|
||||||
const [requestTypes, setRequestTypes] = React.useState<RequestType[]>([]);
|
const requestTypes = useRequestTypes(api);
|
||||||
const [companies, setCompanies] = React.useState<Company[]>([]);
|
const portalDictionaries = usePortalDictionaries(dictionary);
|
||||||
const [departments, setDepartments] = React.useState<Department[]>([]);
|
|
||||||
const [dictLoading, setDictLoading] = React.useState(true);
|
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState('');
|
||||||
const [dictError, setDictError] = React.useState<string | undefined>();
|
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
||||||
const [items, setItems] = React.useState<TemplateRoute[]>([]);
|
|
||||||
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState<string>('');
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [loadError, setLoadError] = React.useState<string | undefined>();
|
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
|
||||||
const [formError, setFormError] = React.useState<string | undefined>();
|
|
||||||
const [saving, setSaving] = React.useState(false);
|
|
||||||
|
|
||||||
const [deleting, setDeleting] = React.useState<TemplateRoute | undefined>();
|
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
||||||
const [deleteError, setDeleteError] = React.useState<string | undefined>();
|
|
||||||
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
|
||||||
api.requestTypes.list().then(setRequestTypes).catch(() => { /* показывается в общей ошибке ниже */ });
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
// Справочники 1С грузим один раз на экран: они статичны, а отделов ~700 —
|
|
||||||
// поиск делаем на клиенте, без запроса на каждый символ.
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!dictionary) return;
|
|
||||||
|
|
||||||
setDictLoading(true);
|
|
||||||
setDictError(undefined);
|
|
||||||
Promise.all([dictionary.companies(), dictionary.departments()])
|
|
||||||
.then(([loadedCompanies, loadedDepartments]) => {
|
|
||||||
setCompanies(loadedCompanies);
|
|
||||||
setDepartments(loadedDepartments);
|
|
||||||
})
|
|
||||||
.catch((e: ApiClientError) => setDictError(e.message))
|
|
||||||
.finally(() => setDictLoading(false));
|
|
||||||
}, [dictionary]);
|
|
||||||
|
|
||||||
const load = React.useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
setLoadError(undefined);
|
|
||||||
const requestTypeId = filterRequestTypeId ? Number(filterRequestTypeId) : undefined;
|
|
||||||
api.templateRoutes
|
|
||||||
.list(requestTypeId)
|
|
||||||
.then(setItems)
|
|
||||||
.catch((e: ApiClientError) => setLoadError(e.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [api, filterRequestTypeId]);
|
|
||||||
|
|
||||||
React.useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
const requestTypeName = (id: number): string => requestTypes.find(rt => rt.id === id)?.name ?? `#${id}`;
|
|
||||||
|
|
||||||
// Пока справочник не загрузился (или маршрут ссылается на удалённую запись),
|
// Пока справочник не загрузился (или маршрут ссылается на удалённую запись),
|
||||||
// показываем усечённый Guid — чтобы строка таблицы не выглядела пустой.
|
// показываем усечённый Guid — чтобы строка таблицы не выглядела пустой.
|
||||||
const companyName = (id: string): string => companies.find(c => c.id === id)?.name ?? shortGuid(id);
|
const companyName = (id: string): string => portalDictionaries.companies.find(c => c.id === id)?.name ?? shortGuid(id);
|
||||||
|
const departmentName = (id: string): string => portalDictionaries.departments.find(d => d.id === id)?.name ?? shortGuid(id);
|
||||||
const departmentName = (id: string): string => departments.find(d => d.id === id)?.name ?? shortGuid(id);
|
|
||||||
|
|
||||||
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
|
|
||||||
// иначе пользователь листает ~700 позиций из всех юрлиц сразу.
|
|
||||||
const departmentsForSelectedCompany = React.useMemo(
|
|
||||||
() => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []),
|
|
||||||
[departments, form.companyId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setForm({ ...EMPTY_FORM, requestTypeId: filterRequestTypeId });
|
|
||||||
setFormError(undefined);
|
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: TemplateRoute): void => {
|
const openEdit = (item: TemplateRoute): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
setForm({
|
|
||||||
requestTypeId: String(item.requestTypeId),
|
|
||||||
routeType: item.routeType,
|
|
||||||
companyId: item.companyId,
|
|
||||||
departmentId: item.departmentId ?? '',
|
|
||||||
byProject: item.byProject,
|
|
||||||
requiresControl: item.requiresControl
|
|
||||||
});
|
|
||||||
setFormError(undefined);
|
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = (): void => {
|
|
||||||
if (!editing && !form.requestTypeId) {
|
|
||||||
setFormError('Выберите тип заявки.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!form.companyId.trim()) {
|
|
||||||
setFormError('Компания обязательна (Guid из справочника api.tnsu.kz).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
setFormError(undefined);
|
|
||||||
|
|
||||||
const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
|
|
||||||
const request = editing
|
|
||||||
? api.templateRoutes.update(editing.id, {
|
|
||||||
routeType: form.routeType,
|
|
||||||
companyId: form.companyId.trim(),
|
|
||||||
departmentId,
|
|
||||||
byProject: form.byProject,
|
|
||||||
requiresControl: form.requiresControl
|
|
||||||
})
|
|
||||||
: api.templateRoutes.create({
|
|
||||||
routeType: form.routeType,
|
|
||||||
requestTypeId: Number(form.requestTypeId),
|
|
||||||
companyId: form.companyId.trim(),
|
|
||||||
departmentId,
|
|
||||||
byProject: form.byProject,
|
|
||||||
requiresControl: form.requiresControl
|
|
||||||
});
|
|
||||||
|
|
||||||
request
|
|
||||||
.then(() => {
|
|
||||||
setShowForm(false);
|
|
||||||
load();
|
|
||||||
})
|
|
||||||
.catch((e: ApiClientError) => setFormError(e.message))
|
|
||||||
.finally(() => setSaving(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDelete = (): void => {
|
|
||||||
if (!deleting) return;
|
|
||||||
setDeleteBusy(true);
|
|
||||||
setDeleteError(undefined);
|
|
||||||
api.templateRoutes
|
|
||||||
.remove(deleting.id)
|
|
||||||
.then(() => {
|
|
||||||
setDeleting(undefined);
|
|
||||||
load();
|
|
||||||
})
|
|
||||||
.catch((e: ApiClientError) => setDeleteError(e.message))
|
|
||||||
.finally(() => setDeleteBusy(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumbs items={[{ label: 'Главная' }, { label: 'Маршруты согласования' }]} />
|
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routes.breadcrumb }]} />
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon="route"
|
icon="route"
|
||||||
subtitle="Настройка согласований"
|
subtitle={texts.routes.subtitle}
|
||||||
title="Маршруты согласования"
|
title={texts.routes.title}
|
||||||
actions={<Button variant="accent" icon="plus" onClick={openCreate}>Добавить маршрут</Button>}
|
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.routes.add}</Button>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ marginBottom: 18, maxWidth: 320 }}>
|
<div className={styles.filter}>
|
||||||
<Field label="Фильтр по типу заявки">
|
<Field label={texts.routes.filterLabel}>
|
||||||
<Select
|
<Select
|
||||||
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
placeholder="Все типы заявок"
|
placeholder={texts.routes.filterAll}
|
||||||
value={filterRequestTypeId}
|
value={filterRequestTypeId}
|
||||||
onChange={e => setFilterRequestTypeId(e.target.value)}
|
onChange={e => setFilterRequestTypeId(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dictError && <Alert kind="warn">Справочник компаний и отделов недоступен: {dictError}</Alert>}
|
{portalDictionaries.error && <Alert kind="warn">{texts.routes.dictionaryFailed(portalDictionaries.error)}</Alert>}
|
||||||
{loadError && <Alert kind="err">{loadError}</Alert>}
|
{routes.error && <Alert kind="err">{routes.error}</Alert>}
|
||||||
{deleteError && <Alert kind="err">{deleteError}</Alert>}
|
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
key: 'requestType',
|
key: 'requestType',
|
||||||
header: 'Тип заявки',
|
header: texts.routes.columnRequestType,
|
||||||
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
|
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'routeType',
|
key: 'routeType',
|
||||||
header: 'Тип маршрута',
|
header: texts.routes.columnRouteType,
|
||||||
render: r => <Badge variant={r.routeType === RouteType.Parallel ? 'blue' : 'gold'}>{routeTypeLabels[r.routeType]}</Badge>
|
render: r => <Badge variant={r.routeType === RouteType.Parallel ? 'blue' : 'gold'}>{routeTypeLabels[r.routeType]}</Badge>
|
||||||
},
|
},
|
||||||
{ key: 'company', header: 'Компания', render: r => companyName(r.companyId) },
|
{ key: 'company', header: texts.routes.columnCompany, render: r => companyName(r.companyId) },
|
||||||
{ key: 'department', header: 'Отдел', render: r => (r.departmentId ? departmentName(r.departmentId) : '—') },
|
{ key: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) },
|
||||||
{
|
{
|
||||||
key: 'byProject',
|
key: 'byProject',
|
||||||
header: 'По проекту',
|
header: texts.routes.columnByProject,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: r => (r.byProject ? <Badge variant="ok">Да</Badge> : <Badge variant="neutral">Нет</Badge>)
|
render: r => (r.byProject ? <Badge variant="ok">{texts.common.yes}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'control',
|
key: 'control',
|
||||||
header: 'Контроль',
|
header: texts.routes.columnControl,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: r => (r.requiresControl ? <Badge variant="gold">Требуется</Badge> : <Badge variant="neutral">Нет</Badge>)
|
render: r => (r.requiresControl ? <Badge variant="gold">{texts.routes.controlRequired}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -253,116 +119,42 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
|
|||||||
sticky: true,
|
sticky: true,
|
||||||
render: r => (
|
render: r => (
|
||||||
<ActionsCell>
|
<ActionsCell>
|
||||||
<Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>Шаги</Button>
|
<Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>{texts.routes.openSteps}</Button>
|
||||||
<IconButton icon="edit" label="Редактировать" onClick={() => openEdit(r)} />
|
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
||||||
<IconButton icon="trash" label="Удалить" danger onClick={() => { setDeleteError(undefined); setDeleting(r); }} />
|
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||||
</ActionsCell>
|
</ActionsCell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
rows={items}
|
rows={routes.items}
|
||||||
keyOf={r => r.id}
|
keyOf={r => r.id}
|
||||||
emptyMessage={loading ? 'Загрузка...' : 'Маршрутов пока нет.'}
|
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<Modal
|
<RouteFormModal
|
||||||
title={editing ? `Маршрут #${editing.id}` : 'Новый маршрут'}
|
api={api}
|
||||||
|
editing={editing}
|
||||||
|
initialRequestTypeId={filterRequestTypeId}
|
||||||
|
requestTypes={requestTypes.items}
|
||||||
|
companies={portalDictionaries.companies}
|
||||||
|
departments={portalDictionaries.departments}
|
||||||
|
dictionaryLoading={portalDictionaries.isLoading}
|
||||||
|
onSaved={routes.reload}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={() => setShowForm(false)}
|
||||||
footer={
|
/>
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={() => setShowForm(false)} disabled={saving}>Отмена</Button>
|
|
||||||
<Button variant="accent" icon="check" onClick={submit} disabled={saving}>
|
|
||||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{formError && <Alert kind="err">{formError}</Alert>}
|
|
||||||
<FormGrid>
|
|
||||||
<Field label="Тип заявки" required full>
|
|
||||||
<Select
|
|
||||||
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
|
||||||
placeholder="Выберите тип заявки"
|
|
||||||
value={form.requestTypeId}
|
|
||||||
disabled={!!editing}
|
|
||||||
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Тип маршрута" required full>
|
|
||||||
<Select
|
|
||||||
options={ROUTE_TYPE_OPTIONS}
|
|
||||||
value={form.routeType}
|
|
||||||
onChange={e => setForm({ ...form, routeType: e.target.value as RouteType })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Компания" required full hint="Справочник 1С (api.tnsu.kz).">
|
|
||||||
<SearchableSelect
|
|
||||||
options={companies.map(c => ({ value: c.id, label: c.name }))}
|
|
||||||
value={form.companyId}
|
|
||||||
loading={dictLoading}
|
|
||||||
placeholder="Выберите компанию"
|
|
||||||
searchPlaceholder="Поиск по названию компании..."
|
|
||||||
onChange={companyId =>
|
|
||||||
// Смена компании обнуляет отдел: прежний принадлежал другой
|
|
||||||
// компании, и бэкенд не нашёл бы такой маршрут при resolve.
|
|
||||||
setForm({ ...form, companyId, departmentId: '' })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
label="Отдел"
|
|
||||||
full
|
|
||||||
hint={
|
|
||||||
form.companyId
|
|
||||||
? 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.'
|
|
||||||
: 'Сначала выберите компанию.'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SearchableSelect
|
|
||||||
options={departmentsForSelectedCompany.map(d => ({ value: d.id, label: d.name }))}
|
|
||||||
value={form.departmentId}
|
|
||||||
loading={dictLoading}
|
|
||||||
disabled={!form.companyId}
|
|
||||||
allowClear
|
|
||||||
clearLabel="Без отдела (маршрут по умолчанию)"
|
|
||||||
placeholder="Без отдела (маршрут по умолчанию)"
|
|
||||||
searchPlaceholder="Поиск по названию отдела..."
|
|
||||||
onChange={departmentId => setForm({ ...form, departmentId })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="По проекту">
|
|
||||||
<Checkbox
|
|
||||||
label="Маршрут учитывает конкретный проект"
|
|
||||||
checked={form.byProject}
|
|
||||||
onChange={v => setForm({ ...form, byProject: v })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Контроль">
|
|
||||||
<Checkbox
|
|
||||||
label="Требуется отдельный контроль исполнения"
|
|
||||||
checked={form.requiresControl}
|
|
||||||
onChange={v => setForm({ ...form, requiresControl: v })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FormGrid>
|
|
||||||
</Modal>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deleting && (
|
{deleteTarget.target && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Удалить маршрут?"
|
title={texts.routes.deleteTitle}
|
||||||
text={`Маршрут #${deleting.id} и все его шаги будут удалены безвозвратно.`}
|
text={texts.routes.deleteText(deleteTarget.target.id)}
|
||||||
confirmLabel="Удалить"
|
confirmLabel={texts.common.remove}
|
||||||
busy={deleteBusy}
|
busy={deleteTarget.isBusy}
|
||||||
onCancel={() => setDeleting(undefined)}
|
onCancel={deleteTarget.cancel}
|
||||||
onConfirm={confirmDelete}
|
onConfirm={deleteTarget.confirm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function shortGuid(guid: string): string {
|
|
||||||
return guid.length > 8 ? `${guid.slice(0, 8)}…` : guid;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './DataTable.module.scss';
|
||||||
|
|
||||||
|
/** Ряд кнопок действий в последней колонке таблицы. */
|
||||||
|
export const ActionsCell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<span className={styles.actionsCell}>{children}</span>
|
||||||
|
);
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './SearchableSelect.module.scss';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
|
export interface IAsyncSearchResult<T> {
|
||||||
|
items: T[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAsyncSearchableSelectProps<T> {
|
||||||
|
value: string;
|
||||||
|
/** Название выбранного значения — показываем в триггере без похода на сервер. */
|
||||||
|
selectedLabel?: string;
|
||||||
|
onChange: (value: string, item: T | undefined) => void;
|
||||||
|
search: (query: string, page: number) => Promise<IAsyncSearchResult<T>>;
|
||||||
|
getValue: (item: T) => string;
|
||||||
|
getLabel: (item: T) => string;
|
||||||
|
getSublabel?: (item: T) => string | undefined;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
allowClear?: boolean;
|
||||||
|
clearLabel?: string;
|
||||||
|
/** Задержка перед запросом после ввода — не долбим портал на каждый символ. */
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AsyncSearchableSelect<T>({
|
||||||
|
value,
|
||||||
|
selectedLabel,
|
||||||
|
onChange,
|
||||||
|
search,
|
||||||
|
getValue,
|
||||||
|
getLabel,
|
||||||
|
getSublabel,
|
||||||
|
placeholder = texts.select.placeholder,
|
||||||
|
searchPlaceholder = texts.select.searchPlaceholder,
|
||||||
|
disabled,
|
||||||
|
allowClear,
|
||||||
|
clearLabel = texts.select.notSelected,
|
||||||
|
debounceMs = 350
|
||||||
|
}: IAsyncSearchableSelectProps<T>): React.ReactElement {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [items, setItems] = React.useState<T[]>([]);
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [hasMore, setHasMore] = React.useState(false);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
// Растущий счётчик запросов: если ответ пришёл не от последнего запроса
|
||||||
|
// (например, пользователь допечатал ещё символ) — просто игнорируем его.
|
||||||
|
const requestIdRef = React.useRef(0);
|
||||||
|
|
||||||
|
const runSearch = React.useCallback((q: string, targetPage: number, append: boolean) => {
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
|
if (append) setLoadingMore(true); else setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
search(q, targetPage)
|
||||||
|
.then(result => {
|
||||||
|
if (requestIdRef.current !== requestId) return; // устарел
|
||||||
|
setItems(prev => (append ? [...prev, ...result.items] : result.items));
|
||||||
|
setHasMore(result.hasMore);
|
||||||
|
setPage(targetPage);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
|
setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
});
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Открытие — сразу показываем первую страницу (пустой запрос = «последние/все»),
|
||||||
|
// без debounce. Эффект ниже (deps: [query]) на само открытие не реагирует —
|
||||||
|
// toggle() всегда сбрасывает query в '', так что при чистом открытии/закрытии
|
||||||
|
// без ввода текста он не перезапускается и второго запроса не улетает.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
runSearch('', 1, false);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Debounce поиска по вводу.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handle = setTimeout(() => runSearch(query, 1, false), debounceMs);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const onDocumentMouseDown = (e: MouseEvent): void => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKeyDown = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', onDocumentMouseDown);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocumentMouseDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open && searchRef.current) searchRef.current.focus();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Бесконечная прокрутка: как только сентинел внизу списка попадает в
|
||||||
|
// видимую область скролл-контейнера — подгружаем следующую страницу.
|
||||||
|
// root указываем явно (сам .list, а не viewport) — иначе браузер считает
|
||||||
|
// видимость относительно всей страницы, а не прокручиваемого дропдауна.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open || !hasMore || loading || loadingMore) return;
|
||||||
|
|
||||||
|
const rootEl = listRef.current;
|
||||||
|
const sentinelEl = sentinelRef.current;
|
||||||
|
if (!rootEl || !sentinelEl) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
entries => {
|
||||||
|
if (entries[0]?.isIntersecting) runSearch(query, page + 1, true);
|
||||||
|
},
|
||||||
|
{ root: rootEl, rootMargin: '64px' }
|
||||||
|
);
|
||||||
|
observer.observe(sentinelEl);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [open, hasMore, loading, loadingMore, page, query, runSearch]);
|
||||||
|
|
||||||
|
const toggle = (): void => {
|
||||||
|
if (disabled) return;
|
||||||
|
setQuery('');
|
||||||
|
setOpen(!open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = (item: T | undefined): void => {
|
||||||
|
onChange(item ? getValue(item) : '', item);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root} ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.trigger} ${open ? styles.open : ''}`}
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
|
||||||
|
{value ? (selectedLabel ?? value) : placeholder}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className={styles.menu}>
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
className={styles.search}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className={styles.list} ref={listRef}>
|
||||||
|
{loading && <div className={styles.empty}>{texts.select.searching}</div>}
|
||||||
|
{!loading && error && <div className={styles.errorText}>{error}</div>}
|
||||||
|
{!loading && !error && items.length === 0 && (
|
||||||
|
<div className={styles.empty}>{texts.select.nothingFound}</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && items.map(item => {
|
||||||
|
const itemValue = getValue(item);
|
||||||
|
const sub = getSublabel ? getSublabel(item) : undefined;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={itemValue}
|
||||||
|
className={`${styles.item} ${itemValue === value ? styles.selected : ''}`}
|
||||||
|
onClick={() => pick(item)}
|
||||||
|
>
|
||||||
|
{getLabel(item)}
|
||||||
|
{sub && <span className={styles.itemSub}>{sub}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!loading && !error && hasMore && (
|
||||||
|
<div ref={sentinelRef} className={styles.sentinel}>
|
||||||
|
{loadingMore && 'Загрузка...'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{allowClear && value && (
|
||||||
|
<div className={styles.clear}>
|
||||||
|
<button type="button" className={styles.item} onClick={() => pick(undefined)}>
|
||||||
|
{clearLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './PageChrome.module.scss';
|
||||||
|
|
||||||
|
export interface IBreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
/** Без onClick пункт неинтерактивен — это текущая страница. */
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBreadcrumbsProps {
|
||||||
|
items: IBreadcrumbItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Breadcrumbs: React.FC<IBreadcrumbsProps> = ({ items }) => (
|
||||||
|
<div className={styles.bread}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<React.Fragment key={item.label}>
|
||||||
|
{index > 0 && <span className={styles.sep}>◆</span>}
|
||||||
|
{item.onClick ? (
|
||||||
|
<button className={styles.breadLink} onClick={item.onClick}>{item.label}</button>
|
||||||
|
) : (
|
||||||
|
<span>{item.label}</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -29,18 +29,3 @@ export const Button: React.FC<IButtonProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
icon: IconName;
|
|
||||||
danger?: boolean;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IconButton: React.FC<IIconButtonProps> = ({ icon, danger, label, className, ...rest }) => {
|
|
||||||
const classes = [styles.iconButton, danger ? styles.danger : '', className].filter(Boolean).join(' ');
|
|
||||||
return (
|
|
||||||
<button type="button" className={classes} aria-label={label} title={label} {...rest}>
|
|
||||||
<Icon name={icon} size="sm" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Card.module.scss';
|
||||||
|
|
||||||
|
export interface ICardGridProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Адаптивная сетка карточек-разделов. */
|
||||||
|
export const CardGrid: React.FC<ICardGridProps> = ({ children }) => (
|
||||||
|
<div className={styles.grid}>{children}</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './DataTable.module.scss';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
export interface ICellNoteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** 'warn' — амбер с иконкой (например, причина от бэкенда), 'muted' — нейтральная пометка. */
|
||||||
|
kind?: 'warn' | 'muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пояснение под основным значением ячейки — например, причина, по которой оно пустое. */
|
||||||
|
export const CellNote: React.FC<ICellNoteProps> = ({ children, kind = 'muted' }) => (
|
||||||
|
<span className={`${styles.cellNote} ${kind === 'warn' ? styles.cellNoteWarn : ''}`}>
|
||||||
|
{kind === 'warn' && <Icon name="warning" size="sm" />}
|
||||||
|
<span>{children}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormField.module.scss';
|
||||||
|
|
||||||
|
export interface ICheckboxProps {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Checkbox: React.FC<ICheckboxProps> = ({ label, checked, onChange, disabled }) => (
|
||||||
|
<label className={styles.checkboxRow}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={styles.checkbox}
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={e => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Modal.module.scss';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
|
export interface IConfirmDialogProps {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
busy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
confirmLabel,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
busy
|
||||||
|
}) => (
|
||||||
|
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
|
||||||
|
<div className={`${styles.box} ${styles.narrow}`}>
|
||||||
|
<div className={styles.title}>{title}</div>
|
||||||
|
<p className={styles.text}>{text}</p>
|
||||||
|
<div className={styles.narrowFooter}>
|
||||||
|
<Button variant="secondary" onClick={onCancel} disabled={busy}>{texts.common.cancel}</Button>
|
||||||
|
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -70,6 +70,21 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cellNote {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
max-width: 420px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: $gray-500;
|
||||||
|
|
||||||
|
svg { margin-top: 1px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellNoteWarn { color: $amber-700; }
|
||||||
|
|
||||||
.actionsCell {
|
.actionsCell {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@@ -58,11 +58,3 @@ export function DataTable<T>({ columns, rows, keyOf, emptyMessage }: IDataTableP
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PrimaryCell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
||||||
<span className={styles.primaryCell}>{children}</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ActionsCell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
||||||
<span className={styles.actionsCell}>{children}</span>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormField.module.scss';
|
||||||
|
|
||||||
|
export interface IFieldProps {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
/** Занять обе колонки FormGrid. */
|
||||||
|
full?: boolean;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подпись + контрол + текст ошибки/подсказки под ним. */
|
||||||
|
export const Field: React.FC<IFieldProps> = ({ label, required, full, error, hint, children }) => (
|
||||||
|
<div className={`${styles.field} ${full ? styles.full : ''}`}>
|
||||||
|
<span className={styles.label}>
|
||||||
|
{label} {required && <span className={styles.req}>*</span>}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
{error && <span className={styles.errorText}>{error}</span>}
|
||||||
|
{!error && hint && <span className={styles.hint}>{hint}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import styles from './FormField.module.scss';
|
|
||||||
|
|
||||||
export const FormGrid: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
||||||
<div className={styles.grid}>{children}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface IFieldProps {
|
|
||||||
label: string;
|
|
||||||
required?: boolean;
|
|
||||||
full?: boolean;
|
|
||||||
error?: string;
|
|
||||||
hint?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Field: React.FC<IFieldProps> = ({ label, required, full, error, hint, children }) => (
|
|
||||||
<div className={`${styles.field} ${full ? styles.full : ''}`}>
|
|
||||||
<span className={styles.label}>
|
|
||||||
{label} {required && <span className={styles.req}>*</span>}
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
{error && <span className={styles.errorText}>{error}</span>}
|
|
||||||
{!error && hint && <span className={styles.hint}>{hint}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const TextInput: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = props => (
|
|
||||||
<input {...props} className={styles.input} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Textarea: React.FC<React.TextareaHTMLAttributes<HTMLTextAreaElement>> = props => (
|
|
||||||
<textarea {...props} className={styles.textarea} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface ISelectOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ISelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
|
|
||||||
options: ISelectOption[];
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Select: React.FC<ISelectProps> = ({ options, placeholder, ...rest }) => (
|
|
||||||
<select {...rest} className={styles.select}>
|
|
||||||
{placeholder && <option value="">{placeholder}</option>}
|
|
||||||
{options.map(opt => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface ICheckboxProps {
|
|
||||||
label: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Checkbox: React.FC<ICheckboxProps> = ({ label, checked, onChange, disabled }) => (
|
|
||||||
<label className={styles.checkboxRow}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className={styles.checkbox}
|
|
||||||
checked={checked}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={e => onChange(e.target.checked)}
|
|
||||||
/>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface IRadioOption<T extends string> {
|
|
||||||
value: T;
|
|
||||||
label: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
disabledHint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IRadioGroupProps<T extends string> {
|
|
||||||
name: string;
|
|
||||||
value: T;
|
|
||||||
options: IRadioOption<T>[];
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RadioGroup<T extends string>({ name, value, options, onChange }: IRadioGroupProps<T>): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className={styles.radioGroup}>
|
|
||||||
{options.map(opt => (
|
|
||||||
<label
|
|
||||||
key={opt.value}
|
|
||||||
className={`${styles.radioRow} ${opt.disabled ? styles.disabled : ''}`}
|
|
||||||
title={opt.disabled ? opt.disabledHint : undefined}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
className={styles.radio}
|
|
||||||
name={name}
|
|
||||||
checked={value === opt.value}
|
|
||||||
disabled={opt.disabled}
|
|
||||||
onChange={() => onChange(opt.value)}
|
|
||||||
/>
|
|
||||||
{opt.label}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormField.module.scss';
|
||||||
|
|
||||||
|
export interface IFormGridProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Двухколоночная сетка полей формы; поле помечает себя `full`, чтобы занять обе колонки. */
|
||||||
|
export const FormGrid: React.FC<IFormGridProps> = ({ children }) => (
|
||||||
|
<div className={styles.grid}>{children}</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert } from './Alert';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
|
export interface IFormModalProps {
|
||||||
|
title: string;
|
||||||
|
/** Текст ошибки: и валидации формы, и ответа бэкенда. */
|
||||||
|
error?: string;
|
||||||
|
isSaving: boolean;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальное окно формы: одинаковая обвязка «ошибка сверху, Отмена/Сохранить снизу»
|
||||||
|
* для всех справочников, чтобы экраны описывали только поля.
|
||||||
|
*/
|
||||||
|
export const FormModal: React.FC<IFormModalProps> = ({ title, error, isSaving, onSubmit, onClose, children }) => (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button>
|
||||||
|
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
|
||||||
|
{isSaving ? texts.common.saving : texts.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error && <Alert kind="err">{error}</Alert>}
|
||||||
|
{children}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.icon {
|
||||||
|
// Иконка не должна сжиматься внутри flex-строки (кнопки, алерты, ячейки).
|
||||||
|
flex-shrink: 0;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import styles from './Icon.module.scss';
|
||||||
|
|
||||||
// Единый набор иконок в стиле референса (stroke, round caps, viewBox 24x24).
|
// Единый набор иконок в стиле референса (stroke, round caps, viewBox 24x24).
|
||||||
export type IconName =
|
export type IconName =
|
||||||
@@ -61,28 +62,24 @@ function ensureSpriteInjected(): void {
|
|||||||
spriteInjected = true;
|
spriteInjected = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IconSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
export interface IIconProps {
|
export interface IIconProps {
|
||||||
name: IconName;
|
name: IconName;
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: IconSize;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZE_PX: Record<'sm' | 'md' | 'lg', number> = { sm: 16, md: 20, lg: 24 };
|
const SIZE_PX: Record<IconSize, number> = { sm: 16, md: 20, lg: 24 };
|
||||||
|
|
||||||
export const Icon: React.FC<IIconProps> = ({ name, size = 'md', className, style }) => {
|
export const Icon: React.FC<IIconProps> = ({ name, size = 'md', className }) => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
ensureSpriteInjected();
|
ensureSpriteInjected();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const px = SIZE_PX[size];
|
const px = SIZE_PX[size];
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg width={px} height={px} className={[styles.icon, className].filter(Boolean).join(' ')}>
|
||||||
width={px}
|
|
||||||
height={px}
|
|
||||||
className={className}
|
|
||||||
style={{ flexShrink: 0, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', ...style }}
|
|
||||||
>
|
|
||||||
<use href={`#i-${name}`} />
|
<use href={`#i-${name}`} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Button.module.scss';
|
||||||
|
import { Icon, IconName } from './Icon';
|
||||||
|
|
||||||
|
export interface IIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
icon: IconName;
|
||||||
|
danger?: boolean;
|
||||||
|
/** Подпись только для скринридера и подсказки — визуально видна лишь иконка. */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IconButton: React.FC<IIconButtonProps> = ({ icon, danger, label, className, ...rest }) => {
|
||||||
|
const classes = [styles.iconButton, danger ? styles.danger : '', className].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<button type="button" className={classes} aria-label={label} title={label} {...rest}>
|
||||||
|
<Icon name={icon} size="sm" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './Modal.module.scss';
|
import styles from './Modal.module.scss';
|
||||||
import { Button, IconButton } from './Button';
|
import { IconButton } from './IconButton';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
export interface IModalProps {
|
export interface IModalProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -10,36 +11,15 @@ export interface IModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
|
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
|
||||||
|
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget).
|
||||||
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
<div className={styles.box}>
|
<div className={styles.box}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.title}>{title}</div>
|
<div className={styles.title}>{title}</div>
|
||||||
<IconButton icon="close" label="Закрыть" onClick={onClose} />
|
<IconButton icon="close" label={texts.common.close} onClick={onClose} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.body}>{children}</div>
|
<div className={styles.body}>{children}</div>
|
||||||
{footer && <div className={styles.footer}>{footer}</div>}
|
{footer && <div className={styles.footer}>{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface IConfirmDialogProps {
|
|
||||||
title: string;
|
|
||||||
text: string;
|
|
||||||
confirmLabel: string;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
busy?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({ title, text, confirmLabel, onConfirm, onCancel, busy }) => (
|
|
||||||
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
|
|
||||||
<div className={`${styles.box} ${styles.narrow}`}>
|
|
||||||
<div className={styles.title}>{title}</div>
|
|
||||||
<p className={styles.text}>{text}</p>
|
|
||||||
<div className={styles.narrowFooter}>
|
|
||||||
<Button variant="secondary" onClick={onCancel} disabled={busy}>Отмена</Button>
|
|
||||||
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|||||||
+1
-15
@@ -2,10 +2,6 @@ import * as React from 'react';
|
|||||||
import styles from './Card.module.scss';
|
import styles from './Card.module.scss';
|
||||||
import { Icon, IconName } from './Icon';
|
import { Icon, IconName } from './Icon';
|
||||||
|
|
||||||
export const CardGrid: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
||||||
<div className={styles.grid}>{children}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface INavCardProps {
|
export interface INavCardProps {
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -13,6 +9,7 @@ export interface INavCardProps {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Карточка-ссылка на раздел админки. */
|
||||||
export const NavCard: React.FC<INavCardProps> = ({ icon, title, description, onClick }) => (
|
export const NavCard: React.FC<INavCardProps> = ({ icon, title, description, onClick }) => (
|
||||||
<a className={styles.navCard} onClick={e => { e.preventDefault(); onClick(); }} href="#">
|
<a className={styles.navCard} onClick={e => { e.preventDefault(); onClick(); }} href="#">
|
||||||
<div className={styles.navIcon}><Icon name={icon} /></div>
|
<div className={styles.navIcon}><Icon name={icon} /></div>
|
||||||
@@ -23,14 +20,3 @@ export const NavCard: React.FC<INavCardProps> = ({ icon, title, description, onC
|
|||||||
<div className={styles.navArrow}><Icon name="chevron-right" size="sm" /></div>
|
<div className={styles.navArrow}><Icon name="chevron-right" size="sm" /></div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const StatRow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
||||||
<div className={styles.statRow}>{children}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const StatCard: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
|
|
||||||
<div className={styles.statCard}>
|
|
||||||
<div className={styles.statLabel}>{label}</div>
|
|
||||||
<div className={styles.statValue}>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
+1
-20
@@ -2,30 +2,11 @@ import * as React from 'react';
|
|||||||
import styles from './PageChrome.module.scss';
|
import styles from './PageChrome.module.scss';
|
||||||
import { Icon, IconName } from './Icon';
|
import { Icon, IconName } from './Icon';
|
||||||
|
|
||||||
export interface IBreadcrumbItem {
|
|
||||||
label: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Breadcrumbs: React.FC<{ items: IBreadcrumbItem[] }> = ({ items }) => (
|
|
||||||
<div className={styles.bread}>
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<React.Fragment key={i}>
|
|
||||||
{i > 0 && <span className={styles.sep}>◆</span>}
|
|
||||||
{item.onClick ? (
|
|
||||||
<button className={styles.breadLink} onClick={item.onClick}>{item.label}</button>
|
|
||||||
) : (
|
|
||||||
<span>{item.label}</span>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface IPageHeaderProps {
|
export interface IPageHeaderProps {
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Кнопки действий страницы — выравниваются по правому краю заголовка. */
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
padding: 26px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Panel.module.scss';
|
||||||
|
|
||||||
|
export interface IPanelProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Белая карточка-контейнер для блоков на странице (например, формы фильтров). */
|
||||||
|
export const Panel: React.FC<IPanelProps> = ({ children }) => (
|
||||||
|
<div className={styles.panel}>{children}</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './DataTable.module.scss';
|
||||||
|
|
||||||
|
/** Основное (выделенное) значение ячейки таблицы. */
|
||||||
|
export const PrimaryCell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<span className={styles.primaryCell}>{children}</span>
|
||||||
|
);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormField.module.scss';
|
||||||
|
|
||||||
|
export interface IRadioOption<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
disabledHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRadioGroupProps<T extends string> {
|
||||||
|
name: string;
|
||||||
|
value: T;
|
||||||
|
options: IRadioOption<T>[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadioGroup<T extends string>({ name, value, options, onChange }: IRadioGroupProps<T>): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={styles.radioGroup}>
|
||||||
|
{options.map(opt => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className={`${styles.radioRow} ${opt.disabled ? styles.disabled : ''}`}
|
||||||
|
title={opt.disabled ? opt.disabledHint : undefined}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
className={styles.radio}
|
||||||
|
name={name}
|
||||||
|
checked={value === opt.value}
|
||||||
|
disabled={opt.disabled}
|
||||||
|
onChange={() => onChange(opt.value)}
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -108,6 +108,23 @@
|
|||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.errorText {
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: $red-500;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentinel {
|
||||||
|
height: 30px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.clear {
|
.clear {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './SearchableSelect.module.scss';
|
import styles from './SearchableSelect.module.scss';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
export interface ISearchableOption {
|
export interface ISearchableOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -20,36 +21,48 @@ export interface ISearchableSelectProps {
|
|||||||
/** Показывать пункт «очистить» — для необязательных полей. */
|
/** Показывать пункт «очистить» — для необязательных полей. */
|
||||||
allowClear?: boolean;
|
allowClear?: boolean;
|
||||||
clearLabel?: string;
|
clearLabel?: string;
|
||||||
|
/** Текст, когда список опций пуст (не результат поиска, а вообще нечего выбирать). */
|
||||||
|
emptyMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Выберите значение',
|
placeholder = texts.select.placeholder,
|
||||||
searchPlaceholder = 'Поиск...',
|
searchPlaceholder = texts.select.searchPlaceholder,
|
||||||
disabled,
|
disabled,
|
||||||
loading,
|
loading,
|
||||||
allowClear,
|
allowClear,
|
||||||
clearLabel = 'Не выбрано'
|
clearLabel = texts.select.notSelected,
|
||||||
|
emptyMessage = texts.select.nothingFound
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const selected = options.filter(o => o.value === value)[0];
|
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С
|
||||||
|
// или она не проходит текущий фильтр). Показать в этом случае плейсхолдер —
|
||||||
|
// значит соврать, будто поле пустое, и затереть значение при сохранении.
|
||||||
|
// Поэтому подмешиваем недостающую опцию явным образом.
|
||||||
|
const effectiveOptions = React.useMemo(() => {
|
||||||
|
if (!value || options.some(o => o.value === value)) return options;
|
||||||
|
return [{ value, label: value, sublabel: texts.select.missingInDictionary }, ...options];
|
||||||
|
}, [options, value]);
|
||||||
|
|
||||||
|
const selected = effectiveOptions.filter(o => o.value === value)[0];
|
||||||
|
|
||||||
// Поиск на клиенте: справочник целиком уже загружен, отделов ~700 —
|
// Поиск на клиенте: справочник целиком уже загружен, отделов ~700 —
|
||||||
// фильтровать в памяти дешевле, чем ходить на сервер на каждый символ.
|
// фильтровать в памяти дешевле, чем ходить на сервер на каждый символ.
|
||||||
const filtered = React.useMemo(() => {
|
const filtered = React.useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return options;
|
if (!q) return effectiveOptions;
|
||||||
return options.filter(o =>
|
return effectiveOptions.filter(o =>
|
||||||
o.label.toLowerCase().indexOf(q) !== -1 ||
|
o.label.toLowerCase().indexOf(q) !== -1 ||
|
||||||
(o.sublabel ? o.sublabel.toLowerCase().indexOf(q) !== -1 : false)
|
(o.sublabel ? o.sublabel.toLowerCase().indexOf(q) !== -1 : false)
|
||||||
);
|
);
|
||||||
}, [options, query]);
|
}, [effectiveOptions, query]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -95,7 +108,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
|||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
>
|
>
|
||||||
<span className={`${styles.value} ${selected ? '' : styles.placeholder}`}>
|
<span className={`${styles.value} ${selected ? '' : styles.placeholder}`}>
|
||||||
{loading ? 'Загрузка справочника...' : (selected ? selected.label : placeholder)}
|
{loading ? texts.select.loadingDictionary : (selected ? selected.label : placeholder)}
|
||||||
</span>
|
</span>
|
||||||
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||||
</button>
|
</button>
|
||||||
@@ -110,7 +123,11 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
|||||||
onChange={e => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{filtered.length === 0 && <div className={styles.empty}>Ничего не найдено</div>}
|
{filtered.length === 0 && (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
{effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{filtered.map(opt => (
|
{filtered.map(opt => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormField.module.scss';
|
||||||
|
|
||||||
|
export interface ISelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
|
||||||
|
options: ISelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select: React.FC<ISelectProps> = ({ options, placeholder, ...rest }) => (
|
||||||
|
<select {...rest} className={styles.select}>
|
||||||
|
{placeholder && <option value="">{placeholder}</option>}
|
||||||
|
{options.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Card.module.scss';
|
||||||
|
|
||||||
|
export interface IStatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatCard: React.FC<IStatCardProps> = ({ label, value }) => (
|
||||||
|
<div className={styles.statCard}>
|
||||||
|
<div className={styles.statLabel}>{label}</div>
|
||||||
|
<div className={styles.statValue}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Card.module.scss';
|
||||||
|
|
||||||
|
export interface IStatRowProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка счётчиков в верхней части дашборда. */
|
||||||
|
export const StatRow: React.FC<IStatRowProps> = ({ children }) => (
|
||||||
|
<div className={styles.statRow}>{children}</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormField.module.scss';
|
||||||
|
|
||||||
|
export const TextInput: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = props => (
|
||||||
|
<input {...props} className={styles.input} />
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormField.module.scss';
|
||||||
|
|
||||||
|
export const Textarea: React.FC<React.TextareaHTMLAttributes<HTMLTextAreaElement>> = props => (
|
||||||
|
<textarea {...props} className={styles.textarea} />
|
||||||
|
);
|
||||||
@@ -1,10 +1,28 @@
|
|||||||
export * from './Icon';
|
export * from './Icon';
|
||||||
export * from './Button';
|
export * from './Button';
|
||||||
|
export * from './IconButton';
|
||||||
export * from './Badge';
|
export * from './Badge';
|
||||||
export * from './Alert';
|
export * from './Alert';
|
||||||
export * from './PageChrome';
|
export * from './Panel';
|
||||||
export * from './Card';
|
export * from './Breadcrumbs';
|
||||||
|
export * from './PageHeader';
|
||||||
|
export * from './CardGrid';
|
||||||
|
export * from './NavCard';
|
||||||
|
export * from './StatRow';
|
||||||
|
export * from './StatCard';
|
||||||
export * from './DataTable';
|
export * from './DataTable';
|
||||||
|
export * from './PrimaryCell';
|
||||||
|
export * from './ActionsCell';
|
||||||
|
export * from './CellNote';
|
||||||
export * from './Modal';
|
export * from './Modal';
|
||||||
export * from './FormField';
|
export * from './ConfirmDialog';
|
||||||
|
export * from './FormModal';
|
||||||
|
export * from './FormGrid';
|
||||||
|
export * from './Field';
|
||||||
|
export * from './TextInput';
|
||||||
|
export * from './Textarea';
|
||||||
|
export * from './Select';
|
||||||
|
export * from './Checkbox';
|
||||||
|
export * from './RadioGroup';
|
||||||
export * from './SearchableSelect';
|
export * from './SearchableSelect';
|
||||||
|
export * from './AsyncSearchableSelect';
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ApiClientError } from '../services/httpClient';
|
||||||
|
|
||||||
|
export interface AsyncList<T> {
|
||||||
|
items: T[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: string;
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка списка с бэкенда: держит данные, признак загрузки и текст ошибки
|
||||||
|
* в одном месте, чтобы экраны не повторяли этот код.
|
||||||
|
*
|
||||||
|
* load обязан быть стабильным (React.useCallback) — иначе загрузка пойдёт по кругу.
|
||||||
|
*/
|
||||||
|
export function useAsyncList<T>(load: () => Promise<T[]>): AsyncList<T> {
|
||||||
|
const [items, setItems] = React.useState<T[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
const reload = React.useCallback(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
load()
|
||||||
|
.then(setItems)
|
||||||
|
.catch((e: ApiClientError) => setError(e.message))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
React.useEffect(() => { reload(); }, [reload]);
|
||||||
|
|
||||||
|
return { items, isLoading, error, reload };
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ApiClientError } from '../services/httpClient';
|
||||||
|
|
||||||
|
export interface AsyncSubmit {
|
||||||
|
isBusy: boolean;
|
||||||
|
error?: string;
|
||||||
|
/** Показать ошибку без запроса — например, результат валидации формы. */
|
||||||
|
fail: (message: string) => void;
|
||||||
|
run: (action: () => Promise<void>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние вокруг одного пишущего запроса: «выполняется» + текст ошибки.
|
||||||
|
* Используется формами и подтверждением удаления, чтобы не дублировать
|
||||||
|
* setBusy/setError/finally в каждом экране.
|
||||||
|
*/
|
||||||
|
export function useAsyncSubmit(): AsyncSubmit {
|
||||||
|
const [isBusy, setIsBusy] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
const run = (action: () => Promise<void>): void => {
|
||||||
|
setIsBusy(true);
|
||||||
|
setError(undefined);
|
||||||
|
action()
|
||||||
|
.catch((e: ApiClientError) => setError(e.message))
|
||||||
|
.finally(() => setIsBusy(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isBusy, error, fail: setError, run };
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { AsyncSubmit, useAsyncSubmit } from './useAsyncSubmit';
|
||||||
|
|
||||||
|
export interface DeleteTarget<T> {
|
||||||
|
/** Запись, для которой открыт диалог подтверждения. */
|
||||||
|
target?: T;
|
||||||
|
isBusy: boolean;
|
||||||
|
error?: string;
|
||||||
|
ask: (item: T) => void;
|
||||||
|
cancel: () => void;
|
||||||
|
confirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сценарий «спросили — удалили — обновили список», одинаковый на всех экранах:
|
||||||
|
* какая запись удаляется, идёт ли запрос и что показать при ошибке.
|
||||||
|
*/
|
||||||
|
export function useDeleteTarget<T>(remove: (item: T) => Promise<void>, onDeleted: () => void): DeleteTarget<T> {
|
||||||
|
const [target, setTarget] = React.useState<T | undefined>();
|
||||||
|
const submit: AsyncSubmit = useAsyncSubmit();
|
||||||
|
|
||||||
|
const confirm = (): void => {
|
||||||
|
if (!target) return;
|
||||||
|
submit.run(() =>
|
||||||
|
remove(target).then(() => {
|
||||||
|
setTarget(undefined);
|
||||||
|
onDeleted();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
isBusy: submit.isBusy,
|
||||||
|
error: submit.error,
|
||||||
|
ask: setTarget,
|
||||||
|
cancel: () => setTarget(undefined),
|
||||||
|
confirm
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Company, Department } from '../models';
|
||||||
|
import { ApiClientError } from '../services/httpClient';
|
||||||
|
import { PortalDictionaryClient } from '../services/portalDictionary';
|
||||||
|
|
||||||
|
export interface PortalDictionaries {
|
||||||
|
companies: Company[];
|
||||||
|
departments: Department[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компании и отделы из 1С грузим один раз на экран: они статичны, а отделов ~700 —
|
||||||
|
* искать по ним дешевле в памяти, чем ходить на сервер на каждый символ.
|
||||||
|
*/
|
||||||
|
export function usePortalDictionaries(dictionary?: PortalDictionaryClient): PortalDictionaries {
|
||||||
|
const [companies, setCompanies] = React.useState<Company[]>([]);
|
||||||
|
const [departments, setDepartments] = React.useState<Department[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Клиент появляется асинхронно: SPFx сначала выдаёт AAD-токен для api.tnsu.kz.
|
||||||
|
if (!dictionary) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
Promise.all([dictionary.companies(), dictionary.departments()])
|
||||||
|
.then(([loadedCompanies, loadedDepartments]) => {
|
||||||
|
setCompanies(loadedCompanies);
|
||||||
|
setDepartments(loadedDepartments);
|
||||||
|
})
|
||||||
|
.catch((e: ApiClientError) => setError(e.message))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [dictionary]);
|
||||||
|
|
||||||
|
return { companies, departments, isLoading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Employee } from '../models';
|
||||||
|
import { ApiClientError } from '../services/httpClient';
|
||||||
|
import { PortalDictionaryClient } from '../services/portalDictionary';
|
||||||
|
|
||||||
|
export interface PortalEmployees {
|
||||||
|
items: Employee[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Справочник сотрудников api.tnsu.kz — грузим один раз на экран, чтобы искать
|
||||||
|
* исполнителя/инициатора по ФИО или email на клиенте, без запроса на каждый символ.
|
||||||
|
*/
|
||||||
|
export function usePortalEmployees(dictionary?: PortalDictionaryClient): PortalEmployees {
|
||||||
|
const [items, setItems] = React.useState<Employee[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Клиент появляется асинхронно: SPFx сначала выдаёт AAD-токен для api.tnsu.kz.
|
||||||
|
if (!dictionary) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
dictionary.employees()
|
||||||
|
.then(setItems)
|
||||||
|
.catch((e: ApiClientError) => setError(e.message))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [dictionary]);
|
||||||
|
|
||||||
|
return { items, isLoading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { RequestType } from '../models';
|
||||||
|
import { ApprovalRouteApi } from '../services/api';
|
||||||
|
import { AsyncList, useAsyncList } from './useAsyncList';
|
||||||
|
|
||||||
|
/** Справочник типов заявок — нужен и своему экрану, и селектам на других. */
|
||||||
|
export function useRequestTypes(api: ApprovalRouteApi): AsyncList<RequestType> {
|
||||||
|
const load = React.useCallback(() => api.requestTypes.list(), [api]);
|
||||||
|
return useAsyncList(load);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ResolveTemplateRouteQuery, ResolveTemplateRouteResult } from '../models';
|
||||||
|
import { ApprovalRouteApi } from '../services/api';
|
||||||
|
import { useAsyncSubmit } from './useAsyncSubmit';
|
||||||
|
|
||||||
|
export interface ResolveRoute {
|
||||||
|
result?: ResolveTemplateRouteResult;
|
||||||
|
isBusy: boolean;
|
||||||
|
error?: string;
|
||||||
|
resolve: (query: ResolveTemplateRouteQuery) => void;
|
||||||
|
fail: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Диагностический запрос «как разрешится маршрут» и его состояние. */
|
||||||
|
export function useResolveRoute(api: ApprovalRouteApi): ResolveRoute {
|
||||||
|
const [result, setResult] = React.useState<ResolveTemplateRouteResult | undefined>();
|
||||||
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
|
const resolve = (query: ResolveTemplateRouteQuery): void => {
|
||||||
|
// Прошлый результат убираем сразу: показывать его рядом с новыми
|
||||||
|
// параметрами формы — вводить пользователя в заблуждение.
|
||||||
|
setResult(undefined);
|
||||||
|
submit.run(() => api.templateRoutes.resolve(query).then(setResult));
|
||||||
|
};
|
||||||
|
|
||||||
|
return { result, isBusy: submit.isBusy, error: submit.error, resolve, fail: submit.fail };
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { TemplateRouteStep } from '../models';
|
||||||
|
import { ApprovalRouteApi } from '../services/api';
|
||||||
|
import { AsyncList, useAsyncList } from './useAsyncList';
|
||||||
|
|
||||||
|
function byOrderNo(steps: TemplateRouteStep[]): TemplateRouteStep[] {
|
||||||
|
return [...steps].sort((a, b) => a.orderNo - b.orderNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Шаги шаблона маршрута, всегда в порядке выполнения. */
|
||||||
|
export function useRouteSteps(api: ApprovalRouteApi, routeId: number): AsyncList<TemplateRouteStep> {
|
||||||
|
const load = React.useCallback(
|
||||||
|
() => api.templateRouteSteps.list(routeId).then(byOrderNo),
|
||||||
|
[api, routeId]
|
||||||
|
);
|
||||||
|
return useAsyncList(load);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { TemplateRoute } from '../models';
|
||||||
|
import { ApprovalRouteApi } from '../services/api';
|
||||||
|
import { AsyncList, useAsyncList } from './useAsyncList';
|
||||||
|
|
||||||
|
/** Список маршрутов, при необходимости отфильтрованный по типу заявки. */
|
||||||
|
export function useTemplateRoutes(api: ApprovalRouteApi, requestTypeId?: number): AsyncList<TemplateRoute> {
|
||||||
|
const load = React.useCallback(() => api.templateRoutes.list(requestTypeId), [api, requestTypeId]);
|
||||||
|
return useAsyncList(load);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Справочники api.tnsu.kz (компании, отделы, проекты) в виде, удобном для UI.
|
||||||
|
// Приведение из «сырых» PascalCase-ответов портала — в services/portalDictionary.ts.
|
||||||
|
|
||||||
|
export interface Company {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Department {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
companyId: string;
|
||||||
|
company: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Важно: идентификатор проекта — поле "oid". Именно оно принимается
|
||||||
|
// портальным /Project/{oid:guid} и передаётся в ResolveTemplateRouteQuery.projectId.
|
||||||
|
// Числовое "id" и legacy "id1c" — другие идентификаторы, для резолва не подходят.
|
||||||
|
export interface Project {
|
||||||
|
oid: string;
|
||||||
|
name: string;
|
||||||
|
code: string | null;
|
||||||
|
status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// api.tnsu.kz/Employee/All. Ключ для полей executorEmail/userEmail на нашем
|
||||||
|
// бэкенде — email, но вводить его руками неудобно, поэтому в UI ищем по ФИО.
|
||||||
|
export interface Employee {
|
||||||
|
fio: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Зеркало enum'ов backend (Tnsu.ApprovalRoute.Domain/Enums/*.cs).
|
||||||
|
// Значения должны 1:1 соответствовать C#-стороне: они ходят по сети как строки.
|
||||||
|
// Человекочитаемые подписи к этим значениям живут не здесь, а в texts.ts.
|
||||||
|
|
||||||
|
export enum RouteType {
|
||||||
|
None = 'None',
|
||||||
|
Parallel = 'Parallel',
|
||||||
|
Sequential = 'Sequential'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RouteStepStatus {
|
||||||
|
None = 'None',
|
||||||
|
Approval = 'Approval',
|
||||||
|
Execution = 'Execution'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ProjectRole {
|
||||||
|
None = 'None',
|
||||||
|
ProjectManager = 'ProjectManager',
|
||||||
|
Planner = 'Planner',
|
||||||
|
ManagerHSE = 'ManagerHSE',
|
||||||
|
Security = 'Security'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum HeadType {
|
||||||
|
None = 'None',
|
||||||
|
Chief = 'Chief',
|
||||||
|
DirectionHead = 'DirectionHead',
|
||||||
|
DirectorTansu = 'DirectorTansu'
|
||||||
|
}
|
||||||
@@ -1,207 +1,7 @@
|
|||||||
// Зеркало DTO и enum'ов backend (Tnsu.ApprovalRoute.Api / Application / Domain).
|
// Единая точка импорта моделей: import { TemplateRoute } from '../../models';
|
||||||
// Имена и форма типов должны 1:1 соответствовать C#-стороне — см.
|
export * from './enums';
|
||||||
// backend/src/Tnsu.ApprovalRoute.Domain/Enums/*.cs и
|
export * from './dictionaries';
|
||||||
// backend/src/Tnsu.ApprovalRoute.Application/**/*.cs
|
export * from './requestTypes';
|
||||||
|
export * from './templateRoutes';
|
||||||
export enum RouteType {
|
export * from './templateRouteSteps';
|
||||||
None = 'None',
|
export * from './resolve';
|
||||||
Parallel = 'Parallel',
|
|
||||||
Sequential = 'Sequential'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum RouteStepStatus {
|
|
||||||
None = 'None',
|
|
||||||
Approval = 'Approval',
|
|
||||||
Execution = 'Execution'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ProjectRole {
|
|
||||||
None = 'None',
|
|
||||||
ProjectManager = 'ProjectManager',
|
|
||||||
Planner = 'Planner',
|
|
||||||
ManagerHSE = 'ManagerHSE',
|
|
||||||
Security = 'Security'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum HeadType {
|
|
||||||
None = 'None',
|
|
||||||
Chief = 'Chief',
|
|
||||||
DirectionHead = 'DirectionHead',
|
|
||||||
DirectorTansu = 'DirectorTansu'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const routeTypeLabels: Record<RouteType, string> = {
|
|
||||||
[RouteType.None]: 'Не задан',
|
|
||||||
[RouteType.Parallel]: 'Параллельный',
|
|
||||||
[RouteType.Sequential]: 'Последовательный'
|
|
||||||
};
|
|
||||||
|
|
||||||
export const routeStepStatusLabels: Record<RouteStepStatus, string> = {
|
|
||||||
[RouteStepStatus.None]: 'Не задан',
|
|
||||||
[RouteStepStatus.Approval]: 'Согласование',
|
|
||||||
[RouteStepStatus.Execution]: 'Исполнение'
|
|
||||||
};
|
|
||||||
|
|
||||||
export const projectRoleLabels: Record<ProjectRole, string> = {
|
|
||||||
[ProjectRole.None]: '—',
|
|
||||||
[ProjectRole.ProjectManager]: 'Руководитель проекта',
|
|
||||||
[ProjectRole.Planner]: 'Планировщик',
|
|
||||||
[ProjectRole.ManagerHSE]: 'Менеджер HSE',
|
|
||||||
[ProjectRole.Security]: 'Безопасность'
|
|
||||||
};
|
|
||||||
|
|
||||||
export const headTypeLabels: Record<HeadType, string> = {
|
|
||||||
[HeadType.None]: '—',
|
|
||||||
[HeadType.Chief]: 'Непосредственный руководитель',
|
|
||||||
[HeadType.DirectionHead]: 'Руководитель направления',
|
|
||||||
[HeadType.DirectorTansu]: 'Директор Tansu'
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Справочники api.tnsu.kz, проксируемые нашим бэкендом ----
|
|
||||||
// (api/dictionary/companies, api/dictionary/departments)
|
|
||||||
|
|
||||||
export interface Company {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Department {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
companyId: string;
|
|
||||||
company: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Request Types (api/request-types) ----
|
|
||||||
|
|
||||||
export interface RequestType {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
pageUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateRequestTypeInput {
|
|
||||||
name: string;
|
|
||||||
pageUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateRequestTypeInput = CreateRequestTypeInput;
|
|
||||||
|
|
||||||
export interface PatchRequestTypeInput {
|
|
||||||
name?: string;
|
|
||||||
pageUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Template Routes (api/template-routes) ----
|
|
||||||
|
|
||||||
export interface TemplateRoute {
|
|
||||||
id: number;
|
|
||||||
routeType: RouteType;
|
|
||||||
requestTypeId: number;
|
|
||||||
departmentId: string | null;
|
|
||||||
companyId: string;
|
|
||||||
byProject: boolean;
|
|
||||||
requiresControl: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateTemplateRouteInput {
|
|
||||||
routeType: RouteType;
|
|
||||||
requestTypeId: number;
|
|
||||||
departmentId: string | null;
|
|
||||||
companyId: string;
|
|
||||||
byProject: boolean;
|
|
||||||
requiresControl: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateTemplateRouteInput {
|
|
||||||
routeType: RouteType;
|
|
||||||
departmentId: string | null;
|
|
||||||
companyId: string;
|
|
||||||
byProject: boolean;
|
|
||||||
requiresControl: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PatchTemplateRouteInput {
|
|
||||||
routeType?: RouteType;
|
|
||||||
departmentId?: string | null;
|
|
||||||
companyId?: string;
|
|
||||||
byProject?: boolean;
|
|
||||||
requiresControl?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Template Route Steps (api/template-routes/{routeId}/steps) ----
|
|
||||||
|
|
||||||
export interface TemplateRouteStep {
|
|
||||||
id: number;
|
|
||||||
orderNo: number;
|
|
||||||
executorEmail: string | null;
|
|
||||||
position: string | null;
|
|
||||||
projectRole: ProjectRole | null;
|
|
||||||
headType: HeadType | null;
|
|
||||||
status: RouteStepStatus;
|
|
||||||
routeId: number;
|
|
||||||
allowReassignToOtherApprovers: boolean;
|
|
||||||
warning?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateTemplateRouteStepInput {
|
|
||||||
orderNo: number;
|
|
||||||
executorEmail: string | null;
|
|
||||||
position: string | null;
|
|
||||||
projectRole: ProjectRole | null;
|
|
||||||
headType: HeadType | null;
|
|
||||||
status: RouteStepStatus;
|
|
||||||
allowReassignToOtherApprovers: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
|
|
||||||
|
|
||||||
export interface PatchTemplateRouteStepInput {
|
|
||||||
orderNo?: number;
|
|
||||||
executorEmail?: string | null;
|
|
||||||
position?: string | null;
|
|
||||||
projectRole?: ProjectRole | null;
|
|
||||||
headType?: HeadType | null;
|
|
||||||
status?: RouteStepStatus;
|
|
||||||
allowReassignToOtherApprovers?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Resolve (api/template-routes/resolve) ----
|
|
||||||
|
|
||||||
export interface ResolveTemplateRouteQuery {
|
|
||||||
requestTypeId: number;
|
|
||||||
userEmail?: string;
|
|
||||||
projectId?: string;
|
|
||||||
status: RouteStepStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolvedRouteStep {
|
|
||||||
id: number;
|
|
||||||
orderNo: number;
|
|
||||||
executorEmail: string | null;
|
|
||||||
position: string | null;
|
|
||||||
allowReassignToOtherApprovers: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolveTemplateRouteResult {
|
|
||||||
routeId: number;
|
|
||||||
routeType: RouteType;
|
|
||||||
requiresControl: boolean;
|
|
||||||
steps: ResolvedRouteStep[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ровно одно из этих полей должно быть заполнено на шаге маршрута
|
|
||||||
// (см. TemplateRouteStepFields.CountFilled на бэкенде).
|
|
||||||
export type StepExecutorKind = 'email' | 'position' | 'projectRole' | 'headType';
|
|
||||||
|
|
||||||
export function detectExecutorKind(step: {
|
|
||||||
executorEmail?: string | null;
|
|
||||||
position?: string | null;
|
|
||||||
projectRole?: ProjectRole | null;
|
|
||||||
headType?: HeadType | null;
|
|
||||||
}): StepExecutorKind {
|
|
||||||
if (step.executorEmail) return 'email';
|
|
||||||
if (step.position) return 'position';
|
|
||||||
if (step.projectRole && step.projectRole !== ProjectRole.None) return 'projectRole';
|
|
||||||
return 'headType';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// DTO эндпоинта api/request-types (Application/RequestTypes/*.cs).
|
||||||
|
|
||||||
|
export interface RequestType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
pageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRequestTypeInput {
|
||||||
|
name: string;
|
||||||
|
pageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateRequestTypeInput = CreateRequestTypeInput;
|
||||||
|
|
||||||
|
export interface PatchRequestTypeInput {
|
||||||
|
name?: string;
|
||||||
|
pageUrl?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// DTO эндпоинта api/template-routes/resolve
|
||||||
|
// (Application/TemplateRoutes/ResolveTemplateRouteDto.cs).
|
||||||
|
|
||||||
|
import { RouteStepStatus, RouteType } from './enums';
|
||||||
|
|
||||||
|
export interface ResolveTemplateRouteQuery {
|
||||||
|
requestTypeId: number;
|
||||||
|
userEmail?: string;
|
||||||
|
projectId?: string;
|
||||||
|
status: RouteStepStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedRouteStep {
|
||||||
|
id: number;
|
||||||
|
orderNo: number;
|
||||||
|
executorEmail: string | null;
|
||||||
|
position: string | null;
|
||||||
|
allowReassignToOtherApprovers: boolean;
|
||||||
|
/** Причина, по которой исполнитель шага не определился (текст с бэкенда). */
|
||||||
|
warning: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveTemplateRouteResult {
|
||||||
|
routeId: number;
|
||||||
|
routeType: RouteType;
|
||||||
|
requiresControl: boolean;
|
||||||
|
steps: ResolvedRouteStep[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// DTO эндпоинта api/template-routes/{routeId}/steps
|
||||||
|
// (Application/TemplateRouteSteps/*.cs).
|
||||||
|
|
||||||
|
import { HeadType, ProjectRole, RouteStepStatus } from './enums';
|
||||||
|
|
||||||
|
export interface TemplateRouteStep {
|
||||||
|
id: number;
|
||||||
|
orderNo: number;
|
||||||
|
executorEmail: string | null;
|
||||||
|
position: string | null;
|
||||||
|
projectRole: ProjectRole | null;
|
||||||
|
headType: HeadType | null;
|
||||||
|
status: RouteStepStatus;
|
||||||
|
routeId: number;
|
||||||
|
allowReassignToOtherApprovers: boolean;
|
||||||
|
/** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */
|
||||||
|
warning?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplateRouteStepInput {
|
||||||
|
orderNo: number;
|
||||||
|
executorEmail: string | null;
|
||||||
|
position: string | null;
|
||||||
|
projectRole: ProjectRole | null;
|
||||||
|
headType: HeadType | null;
|
||||||
|
status: RouteStepStatus;
|
||||||
|
allowReassignToOtherApprovers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
|
||||||
|
|
||||||
|
export interface PatchTemplateRouteStepInput {
|
||||||
|
orderNo?: number;
|
||||||
|
executorEmail?: string | null;
|
||||||
|
position?: string | null;
|
||||||
|
projectRole?: ProjectRole | null;
|
||||||
|
headType?: HeadType | null;
|
||||||
|
status?: RouteStepStatus;
|
||||||
|
allowReassignToOtherApprovers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено
|
||||||
|
// (см. TemplateRouteStepFields.CountFilled на бэкенде). Union-тип, а не строки
|
||||||
|
// по месту, — чтобы опечатку поймал компилятор.
|
||||||
|
export type StepExecutorKind = 'email' | 'position' | 'projectRole' | 'headType';
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// DTO эндпоинта api/template-routes (Application/TemplateRoutes/*.cs).
|
||||||
|
|
||||||
|
import { RouteType } from './enums';
|
||||||
|
|
||||||
|
export interface TemplateRoute {
|
||||||
|
id: number;
|
||||||
|
routeType: RouteType;
|
||||||
|
requestTypeId: number;
|
||||||
|
departmentId: string | null;
|
||||||
|
companyId: string;
|
||||||
|
byProject: boolean;
|
||||||
|
requiresControl: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplateRouteInput {
|
||||||
|
routeType: RouteType;
|
||||||
|
requestTypeId: number;
|
||||||
|
departmentId: string | null;
|
||||||
|
companyId: string;
|
||||||
|
byProject: boolean;
|
||||||
|
requiresControl: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тип заявки у существующего маршрута не меняется — его нет в update-контракте.
|
||||||
|
export interface UpdateTemplateRouteInput {
|
||||||
|
routeType: RouteType;
|
||||||
|
departmentId: string | null;
|
||||||
|
companyId: string;
|
||||||
|
byProject: boolean;
|
||||||
|
requiresControl: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchTemplateRouteInput {
|
||||||
|
routeType?: RouteType;
|
||||||
|
departmentId?: string | null;
|
||||||
|
companyId?: string;
|
||||||
|
byProject?: boolean;
|
||||||
|
requiresControl?: boolean;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
import { Company, Department } from '../models';
|
import { Company, Department, Employee, Project } from '../models';
|
||||||
import { ApiClientError } from './httpClient';
|
import { ApiClientError } from './httpClient';
|
||||||
|
|
||||||
// Справочники берём напрямую из api.tnsu.kz — тем же AAD-токеном, что и наш API:
|
// Справочники берём напрямую из api.tnsu.kz — тем же AAD-токеном, что и наш API:
|
||||||
@@ -22,6 +22,37 @@ interface IPortalDepartment {
|
|||||||
Company: string;
|
Company: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /Project?name=&page=&pageSize= — в отличие от /Dictionary/*, здесь портал
|
||||||
|
// уже отдаёт camelCase и постраничный конверт (данные идут из своей БД, а не
|
||||||
|
// из сырых 1С-справочников).
|
||||||
|
interface IPortalProject {
|
||||||
|
oid: string;
|
||||||
|
name: string;
|
||||||
|
code: string | null;
|
||||||
|
status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPortalProjectPage {
|
||||||
|
items: IPortalProject[];
|
||||||
|
totalCount: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProjectSearchResult {
|
||||||
|
items: Project[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /Employee/All — уже camelCase, как /Project (не PascalCase, как /Dictionary/*).
|
||||||
|
// Строка на каждого сотрудника в каждом его отделе, поэтому один email может
|
||||||
|
// повторяться несколько раз; сотрудники без email в подборе исполнителя бесполезны.
|
||||||
|
interface IPortalEmployee {
|
||||||
|
fio: string;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
|
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
|
||||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||||
const url = new URL(path.replace(/^\//, ''), normalizedBase).toString();
|
const url = new URL(path.replace(/^\//, ''), normalizedBase).toString();
|
||||||
@@ -59,4 +90,25 @@ export class PortalDictionaryClient {
|
|||||||
company: x.Company
|
company: x.Company
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async searchProjects(name: string, page: number, pageSize = 20): Promise<IProjectSearchResult> {
|
||||||
|
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||||
|
if (name.trim()) params.set('name', name.trim());
|
||||||
|
|
||||||
|
const raw = await getJson<IPortalProjectPage>(this.client, this.baseUrl, `/Project?${params.toString()}`);
|
||||||
|
return {
|
||||||
|
items: (raw.items ?? []).map(x => ({ oid: x.oid, name: x.name, code: x.code, status: x.status })),
|
||||||
|
hasMore: raw.page < raw.totalPages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async employees(): Promise<Employee[]> {
|
||||||
|
const raw = await getJson<IPortalEmployee[]>(this.client, this.baseUrl, '/Employee/All');
|
||||||
|
|
||||||
|
const byEmail = new Map<string, Employee>();
|
||||||
|
(raw ?? []).forEach(x => {
|
||||||
|
if (x.email && !byEmail.has(x.email)) byEmail.set(x.email, { fio: x.fio, email: x.email });
|
||||||
|
});
|
||||||
|
return Array.from(byEmail.values());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// Все пользовательские тексты интерфейса — в одном месте.
|
||||||
|
// Так их видно целиком (легко вычитать формулировки) и это задел под локализацию:
|
||||||
|
// компонентам не нужно знать, откуда пришла строка.
|
||||||
|
//
|
||||||
|
// Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались.
|
||||||
|
|
||||||
|
import { HeadType, ProjectRole, RouteStepStatus, RouteType } from './models';
|
||||||
|
import { pluralRu } from './utils/plural';
|
||||||
|
|
||||||
|
// ---- Подписи enum'ов ----
|
||||||
|
// Record<enum, string> гарантирует, что при добавлении значения в enum
|
||||||
|
// компилятор потребует и подпись к нему.
|
||||||
|
|
||||||
|
export const routeTypeLabels: Record<RouteType, string> = {
|
||||||
|
[RouteType.None]: 'Не задан',
|
||||||
|
[RouteType.Parallel]: 'Параллельный',
|
||||||
|
[RouteType.Sequential]: 'Последовательный'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const routeStepStatusLabels: Record<RouteStepStatus, string> = {
|
||||||
|
[RouteStepStatus.None]: 'Не задан',
|
||||||
|
[RouteStepStatus.Approval]: 'Согласование',
|
||||||
|
[RouteStepStatus.Execution]: 'Исполнение'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const projectRoleLabels: Record<ProjectRole, string> = {
|
||||||
|
[ProjectRole.None]: '—',
|
||||||
|
[ProjectRole.ProjectManager]: 'Руководитель проекта',
|
||||||
|
[ProjectRole.Planner]: 'Планировщик',
|
||||||
|
[ProjectRole.ManagerHSE]: 'Менеджер HSE',
|
||||||
|
[ProjectRole.Security]: 'Безопасность'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const headTypeLabels: Record<HeadType, string> = {
|
||||||
|
[HeadType.None]: '—',
|
||||||
|
[HeadType.Chief]: 'Непосредственный руководитель',
|
||||||
|
[HeadType.DirectionHead]: 'Руководитель направления',
|
||||||
|
[HeadType.DirectorTansu]: 'Директор Tansu'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const texts = {
|
||||||
|
common: {
|
||||||
|
home: 'Главная',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
save: 'Сохранить',
|
||||||
|
saving: 'Сохранение...',
|
||||||
|
remove: 'Удалить',
|
||||||
|
edit: 'Редактировать',
|
||||||
|
close: 'Закрыть',
|
||||||
|
loading: 'Загрузка...',
|
||||||
|
yes: 'Да',
|
||||||
|
no: 'Нет',
|
||||||
|
/** Прочерк вместо пустого значения — чтобы ячейка таблицы не выглядела сломанной. */
|
||||||
|
empty: '—'
|
||||||
|
},
|
||||||
|
|
||||||
|
nav: {
|
||||||
|
/** Буква в золотом квадрате логотипа — не зависит от заголовка панели. */
|
||||||
|
markLetter: 'T',
|
||||||
|
title: 'Панель настройки маршрутов',
|
||||||
|
dashboard: 'Дашборд',
|
||||||
|
requestTypes: 'Типы заявок',
|
||||||
|
routes: 'Маршруты',
|
||||||
|
resolve: 'Проверка маршрута'
|
||||||
|
},
|
||||||
|
|
||||||
|
app: {
|
||||||
|
connecting: 'Подключение к API...',
|
||||||
|
connectFailed: (message: string): string => `Не удалось подключиться к API: ${message}`,
|
||||||
|
connectHint: 'Проверьте свойства веб-части (Api Base Url, режим авторизации) в панели настроек.'
|
||||||
|
},
|
||||||
|
|
||||||
|
dashboard: {
|
||||||
|
requestTypesStat: 'Типов заявок',
|
||||||
|
routesStat: 'Маршрутов согласования',
|
||||||
|
sections: 'Разделы',
|
||||||
|
requestTypesCard: {
|
||||||
|
title: 'Типы заявок',
|
||||||
|
description: 'Справочник типов заявок и их страниц'
|
||||||
|
},
|
||||||
|
routesCard: {
|
||||||
|
title: 'Маршруты согласования',
|
||||||
|
description: 'Маршруты, шаги и исполнители'
|
||||||
|
},
|
||||||
|
resolveCard: {
|
||||||
|
title: 'Проверка маршрута',
|
||||||
|
description: 'Как разрешится маршрут для конкретного пользователя'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
requestTypes: {
|
||||||
|
breadcrumb: 'Типы заявок',
|
||||||
|
subtitle: 'Справочник',
|
||||||
|
title: 'Типы заявок',
|
||||||
|
add: 'Добавить тип',
|
||||||
|
emptyList: 'Типов заявок пока нет.',
|
||||||
|
columnName: 'Название',
|
||||||
|
columnPageUrl: 'Url страницы',
|
||||||
|
createTitle: 'Новый тип заявки',
|
||||||
|
editTitle: 'Редактировать тип заявки',
|
||||||
|
nameLabel: 'Название',
|
||||||
|
namePlaceholder: 'Например, Заявка на командировку',
|
||||||
|
pageUrlLabel: 'Url страницы',
|
||||||
|
pageUrlHint: 'Страница формы заявки на портале.',
|
||||||
|
pageUrlPlaceholder: '/pages/business-trip',
|
||||||
|
validationRequired: 'Заполните имя и Url страницы.',
|
||||||
|
deleteTitle: 'Удалить тип заявки?',
|
||||||
|
deleteText: (name: string): string =>
|
||||||
|
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
|
||||||
|
},
|
||||||
|
|
||||||
|
routes: {
|
||||||
|
breadcrumb: 'Маршруты согласования',
|
||||||
|
subtitle: 'Настройка согласований',
|
||||||
|
title: 'Маршруты согласования',
|
||||||
|
add: 'Добавить маршрут',
|
||||||
|
filterLabel: 'Фильтр по типу заявки',
|
||||||
|
filterAll: 'Все типы заявок',
|
||||||
|
dictionaryFailed: (message: string): string => `Справочник компаний и отделов недоступен: ${message}`,
|
||||||
|
emptyList: 'Маршрутов пока нет.',
|
||||||
|
columnRequestType: 'Тип заявки',
|
||||||
|
columnRouteType: 'Тип маршрута',
|
||||||
|
columnCompany: 'Компания',
|
||||||
|
columnDepartment: 'Отдел',
|
||||||
|
columnByProject: 'По проекту',
|
||||||
|
columnControl: 'Контроль',
|
||||||
|
controlRequired: 'Требуется',
|
||||||
|
openSteps: 'Шаги',
|
||||||
|
createTitle: 'Новый маршрут',
|
||||||
|
editTitle: (routeId: number): string => `Маршрут #${routeId}`,
|
||||||
|
requestTypeLabel: 'Тип заявки',
|
||||||
|
requestTypePlaceholder: 'Выберите тип заявки',
|
||||||
|
routeTypeLabel: 'Тип маршрута',
|
||||||
|
companyLabel: 'Компания',
|
||||||
|
companyHint: 'Справочник 1С (api.tnsu.kz).',
|
||||||
|
companyPlaceholder: 'Выберите компанию',
|
||||||
|
companySearchPlaceholder: 'Поиск по названию компании...',
|
||||||
|
departmentLabel: 'Отдел',
|
||||||
|
departmentHint: 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.',
|
||||||
|
departmentHintNoCompany: 'Сначала выберите компанию.',
|
||||||
|
departmentPlaceholder: 'Без отдела (маршрут по умолчанию)',
|
||||||
|
departmentSearchPlaceholder: 'Поиск по названию отдела...',
|
||||||
|
departmentEmpty: 'У выбранной компании нет отделов в справочнике',
|
||||||
|
byProjectLabel: 'По проекту',
|
||||||
|
byProjectCheckbox: 'Маршрут учитывает конкретный проект',
|
||||||
|
controlLabel: 'Контроль',
|
||||||
|
controlCheckbox: 'Требуется отдельный контроль исполнения',
|
||||||
|
validationRequestType: 'Выберите тип заявки.',
|
||||||
|
validationCompany: 'Компания обязательна (Guid из справочника api.tnsu.kz).',
|
||||||
|
deleteTitle: 'Удалить маршрут?',
|
||||||
|
deleteText: (routeId: number): string =>
|
||||||
|
`Маршрут #${routeId} и все его шаги будут удалены безвозвратно.`
|
||||||
|
},
|
||||||
|
|
||||||
|
routeSteps: {
|
||||||
|
breadcrumb: (routeId: number): string => `Шаги маршрута #${routeId}`,
|
||||||
|
title: (routeId: number): string => `Маршрут #${routeId}`,
|
||||||
|
subtitle: (requestTypeName: string, routeTypeLabel: string): string =>
|
||||||
|
`${requestTypeName} · ${routeTypeLabel}`,
|
||||||
|
back: 'К маршрутам',
|
||||||
|
add: 'Добавить шаг',
|
||||||
|
emptyList: 'Шагов пока нет — добавьте первый.',
|
||||||
|
columnOrderNo: '№',
|
||||||
|
columnExecutor: 'Исполнитель',
|
||||||
|
columnStatus: 'Статус',
|
||||||
|
columnReassign: 'Переназначение',
|
||||||
|
reassignAllowed: 'Разрешено',
|
||||||
|
executorRole: (roleLabel: string): string => `Роль: ${roleLabel}`,
|
||||||
|
executorHead: (headLabel: string): string => `Руководитель: ${headLabel}`,
|
||||||
|
createTitle: 'Новый шаг маршрута',
|
||||||
|
editTitle: (orderNo: number): string => `Шаг #${orderNo}`,
|
||||||
|
orderNoLabel: 'Порядковый номер',
|
||||||
|
statusLabel: 'Статус шага',
|
||||||
|
kindLabel: 'Кто исполнитель',
|
||||||
|
kindHint: 'Должно быть заполнено ровно одно из полей.',
|
||||||
|
kindEmail: 'Конкретный сотрудник',
|
||||||
|
kindPosition: 'По должности в компании инициатора',
|
||||||
|
kindProjectRole: 'По роли в проекте',
|
||||||
|
kindProjectRoleDisabled: 'Недоступно: маршрут не учитывает проект (по проекту = нет).',
|
||||||
|
kindHeadType: 'По типу руководителя',
|
||||||
|
executorLabel: 'Сотрудник',
|
||||||
|
executorPlaceholder: 'Выберите сотрудника',
|
||||||
|
positionLabel: 'Должность',
|
||||||
|
positionHint: 'Ищется в справочнике api.tnsu.kz по компании инициатора.',
|
||||||
|
positionPlaceholder: 'Главный инженер',
|
||||||
|
projectRoleLabel: 'Роль в проекте',
|
||||||
|
headTypeLabel: 'Тип руководителя',
|
||||||
|
reassignLabel: 'Переназначение',
|
||||||
|
reassignCheckbox: 'Разрешить переназначать другим согласующим',
|
||||||
|
validationOrderNo: 'Порядковый номер шага обязателен.',
|
||||||
|
validationExecutor: 'Выберите сотрудника-исполнителя.',
|
||||||
|
validationPosition: 'Укажите должность.',
|
||||||
|
deleteTitle: 'Удалить шаг?',
|
||||||
|
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
|
||||||
|
},
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
breadcrumb: 'Проверка маршрута',
|
||||||
|
subtitle: 'Диагностика',
|
||||||
|
title: 'Проверка разрешения маршрута',
|
||||||
|
requestTypeLabel: 'Тип заявки',
|
||||||
|
requestTypePlaceholder: 'Выберите тип заявки',
|
||||||
|
userEmailLabel: 'Инициатор',
|
||||||
|
userEmailHint: (currentUserEmail: string): string => `Пусто = текущий пользователь (${currentUserEmail}).`,
|
||||||
|
userEmailHintUnknown: 'Пусто = текущий пользователь из токена.',
|
||||||
|
userEmailPlaceholder: 'Выберите сотрудника',
|
||||||
|
userEmailClear: 'Текущий пользователь',
|
||||||
|
projectLabel: 'Проект',
|
||||||
|
projectHint: 'Заполните, если маршрут зависит от проекта (ByProject = true).',
|
||||||
|
projectPlaceholder: 'Не выбран',
|
||||||
|
projectLoadingPlaceholder: 'Справочник загружается...',
|
||||||
|
projectSearchPlaceholder: 'Поиск проекта по названию...',
|
||||||
|
projectClear: 'Без проекта',
|
||||||
|
projectDictionaryLoading: 'Справочник проектов ещё загружается.',
|
||||||
|
statusLabel: 'Статус',
|
||||||
|
run: 'Проверить',
|
||||||
|
running: 'Проверка...',
|
||||||
|
validationRequestType: 'Выберите тип заявки.',
|
||||||
|
summary: (routeId: number, routeTypeLabel: string, requiresControl: boolean): string =>
|
||||||
|
`Маршрут #${routeId} · ${routeTypeLabel}${requiresControl ? ' · требуется контроль исполнения' : ''}`,
|
||||||
|
unresolved: (count: number): string =>
|
||||||
|
`Исполнитель не определён: ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])} — причина указана в строке.`,
|
||||||
|
positionMissing: 'Должность не указана в карточке сотрудника.',
|
||||||
|
columnOrderNo: '№',
|
||||||
|
columnExecutor: 'Исполнитель',
|
||||||
|
columnPosition: 'Должность',
|
||||||
|
columnReassign: 'Переназначение',
|
||||||
|
reassignAllowed: 'Разрешено',
|
||||||
|
emptySteps: 'Маршрут разрешился без шагов для этого статуса.'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Значения по умолчанию для переиспользуемых контролов выбора.
|
||||||
|
select: {
|
||||||
|
placeholder: 'Выберите значение',
|
||||||
|
searchPlaceholder: 'Поиск...',
|
||||||
|
nothingFound: 'Ничего не найдено',
|
||||||
|
notSelected: 'Не выбрано',
|
||||||
|
missingInDictionary: 'Нет в справочнике',
|
||||||
|
loadingDictionary: 'Загрузка справочника...',
|
||||||
|
searching: 'Поиск...',
|
||||||
|
employeeSearchPlaceholder: 'Поиск по ФИО или email...',
|
||||||
|
employeesEmpty: 'Сотрудники не найдены в справочнике'
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Employee } from '../models';
|
||||||
|
import { ISearchableOption } from '../components/ui';
|
||||||
|
|
||||||
|
/** Опция для SearchableSelect: ФИО — основная строка, email — маленькая подпись под ней. */
|
||||||
|
export function toEmployeeOption(employee: Employee): ISearchableOption {
|
||||||
|
return { value: employee.email, label: employee.fio, sublabel: employee.email };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEmployeeByEmail(employees: Employee[], email: string | null): Employee | undefined {
|
||||||
|
if (!email) return undefined;
|
||||||
|
const target = email.toLowerCase();
|
||||||
|
return employees.find(e => e.email.toLowerCase() === target);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
const SHORT_GUID_LENGTH = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Короткий вид Guid для таблиц: 3fa85f64… вместо всех 36 символов.
|
||||||
|
* Нужен, когда название из справочника ещё не загрузилось или запись удалили,
|
||||||
|
* а строка таблицы не должна выглядеть пустой.
|
||||||
|
*/
|
||||||
|
export function shortGuid(guid: string): string {
|
||||||
|
return guid.length > SHORT_GUID_LENGTH ? `${guid.slice(0, SHORT_GUID_LENGTH)}…` : guid;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Русское склонение существительного после числа.
|
||||||
|
*
|
||||||
|
* Формы передаются в порядке: 1 шаг / 2 шага / 5 шагов.
|
||||||
|
* Пример: pluralRu(2, ['шаг', 'шага', 'шагов']) → 'шага'.
|
||||||
|
*/
|
||||||
|
export function pluralRu(count: number, forms: [string, string, string]): string {
|
||||||
|
const lastTwoDigits = Math.abs(count) % 100;
|
||||||
|
const lastDigit = lastTwoDigits % 10;
|
||||||
|
|
||||||
|
// 11–14 — исключение: «11 шагов», хотя последняя цифра 1.
|
||||||
|
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) return forms[2];
|
||||||
|
if (lastDigit === 1) return forms[0];
|
||||||
|
if (lastDigit >= 2 && lastDigit <= 4) return forms[1];
|
||||||
|
return forms[2];
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models';
|
||||||
|
import { headTypeLabels, projectRoleLabels, texts } from '../texts';
|
||||||
|
import { findEmployeeByEmail } from './employee';
|
||||||
|
|
||||||
|
/** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */
|
||||||
|
export function detectExecutorKind(step: {
|
||||||
|
executorEmail?: string | null;
|
||||||
|
position?: string | null;
|
||||||
|
projectRole?: ProjectRole | null;
|
||||||
|
headType?: HeadType | null;
|
||||||
|
}): StepExecutorKind {
|
||||||
|
if (step.executorEmail) return 'email';
|
||||||
|
if (step.position) return 'position';
|
||||||
|
if (step.projectRole && step.projectRole !== ProjectRole.None) return 'projectRole';
|
||||||
|
return 'headType';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepExecutorDisplay {
|
||||||
|
primary: string;
|
||||||
|
/** Email под ФИО — только когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */
|
||||||
|
secondary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Колонка «Исполнитель» в таблице шагов шаблона маршрута. */
|
||||||
|
export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay {
|
||||||
|
if (step.executorEmail) {
|
||||||
|
const employee = findEmployeeByEmail(employees, step.executorEmail);
|
||||||
|
return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail };
|
||||||
|
}
|
||||||
|
if (step.position) return { primary: step.position };
|
||||||
|
if (step.projectRole && step.projectRole !== ProjectRole.None) {
|
||||||
|
return { primary: texts.routeSteps.executorRole(projectRoleLabels[step.projectRole]) };
|
||||||
|
}
|
||||||
|
if (step.headType && step.headType !== HeadType.None) {
|
||||||
|
return { primary: texts.routeSteps.executorHead(headTypeLabels[step.headType]) };
|
||||||
|
}
|
||||||
|
return { primary: texts.common.empty };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user