Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
@import '../../../styles/design-tokens';
|
||||
|
||||
.approvalAdmin {
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
.stateBox {
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
color: $gray-500;
|
||||
font-size: 13.5px;
|
||||
background: $gray-50;
|
||||
border-radius: $r-lg;
|
||||
border: 1px solid $gray-200;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ApprovalAdmin.module.scss';
|
||||
import type { IApprovalAdminProps } from './IApprovalAdminProps';
|
||||
import { AdminApp } from './app/AdminApp';
|
||||
import { ApprovalRouteApi } from '../services/api';
|
||||
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
|
||||
import { PortalDictionaryClient } from '../services/portalDictionary';
|
||||
import { texts } from '../texts';
|
||||
|
||||
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
|
||||
|
||||
function ensureFontsLoaded(): void {
|
||||
if (typeof document === 'undefined' || document.getElementById(FONTS_LINK_ID)) return;
|
||||
const link = document.createElement('link');
|
||||
link.id = FONTS_LINK_ID;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@500;600;700&display=swap';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
async function createHttpClient(props: IApprovalAdminProps): Promise<IHttpClient> {
|
||||
if (props.useDevAuth) {
|
||||
return new DevApiHttpClient(props.apiBaseUrl, props.devUserEmail || 'dev@tnsu.kz');
|
||||
}
|
||||
const aadClient = await props.context.aadHttpClientFactory.getClient(props.aadResourceId);
|
||||
return new AadApiHttpClient(aadClient, props.apiBaseUrl);
|
||||
}
|
||||
|
||||
const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
||||
const [api, setApi] = React.useState<ApprovalRouteApi | undefined>();
|
||||
const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>();
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
React.useEffect(() => {
|
||||
ensureFontsLoaded();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setApi(undefined);
|
||||
setError(undefined);
|
||||
|
||||
createHttpClient(props)
|
||||
.then(client => { if (!cancelled) setApi(new ApprovalRouteApi(client)); })
|
||||
.catch((e: Error) => { if (!cancelled) setError(e.message); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.useDevAuth, props.apiBaseUrl, props.devUserEmail, props.aadResourceId]);
|
||||
|
||||
// Справочники всегда идут через AAD, независимо от Dev-режима нашего API:
|
||||
// api.tnsu.kz принимает только настоящий токен, заголовок X-Dev-User-Email
|
||||
// ему ничего не говорит.
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDictionary(undefined);
|
||||
|
||||
props.context.aadHttpClientFactory
|
||||
.getClient(props.aadResourceId)
|
||||
.then(client => {
|
||||
if (!cancelled) setDictionary(new PortalDictionaryClient(client, props.portalBaseUrl));
|
||||
})
|
||||
.catch(() => { /* экран маршрутов покажет ошибку загрузки справочника */ });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.aadResourceId, props.portalBaseUrl]);
|
||||
|
||||
return (
|
||||
<section className={styles.approvalAdmin}>
|
||||
{error && (
|
||||
<div className={styles.stateBox}>
|
||||
{texts.app.connectFailed(error)}
|
||||
<br />
|
||||
{texts.app.connectHint}
|
||||
</div>
|
||||
)}
|
||||
{!error && !api && <div className={styles.stateBox}>{texts.app.connecting}</div>}
|
||||
{api && (
|
||||
<AdminApp
|
||||
api={api}
|
||||
dictionary={dictionary}
|
||||
currentUserEmail={props.context.pageContext.user.email}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalAdmin;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
|
||||
export interface IApprovalAdminProps {
|
||||
context: WebPartContext;
|
||||
apiBaseUrl: string;
|
||||
useDevAuth: boolean;
|
||||
devUserEmail: string;
|
||||
aadResourceId: string;
|
||||
portalBaseUrl: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.shell {
|
||||
background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%);
|
||||
border-radius: $r-lg;
|
||||
border: 1px solid $gray-200;
|
||||
overflow: hidden;
|
||||
box-shadow: $sh-1;
|
||||
color: $gray-900;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-body;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 28px 30px 36px;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.centerState {
|
||||
padding: 60px 30px;
|
||||
text-align: center;
|
||||
color: $gray-400;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react';
|
||||
import styles from './AdminApp.module.scss';
|
||||
import { TopNav } from './TopNav';
|
||||
import { ScreenKey } from './types';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { TemplateRoute } from '../../models';
|
||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||
import { RoutesScreen } from '../screens/RoutesScreen';
|
||||
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||
|
||||
export interface IAdminAppProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
currentUserEmail?: string;
|
||||
}
|
||||
|
||||
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUserEmail }) => {
|
||||
const [screen, setScreen] = React.useState<ScreenKey>('dashboard');
|
||||
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
||||
const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState('');
|
||||
|
||||
const navigate = (key: ScreenKey): void => {
|
||||
setScreen(key);
|
||||
};
|
||||
|
||||
const openSteps = (route: TemplateRoute): void => {
|
||||
setSelectedRoute(route);
|
||||
api.requestTypes.get(route.requestTypeId)
|
||||
.then(rt => setSelectedRequestTypeName(rt.name))
|
||||
.catch(() => setSelectedRequestTypeName(`#${route.requestTypeId}`));
|
||||
setScreen('routeSteps');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<TopNav active={screen} onSelect={navigate} />
|
||||
<div className={styles.content}>
|
||||
{screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />}
|
||||
{screen === 'requestTypes' && <RequestTypesScreen api={api} />}
|
||||
{screen === 'routes' && <RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />}
|
||||
{screen === 'routeSteps' && selectedRoute && (
|
||||
<RouteStepsScreen
|
||||
api={api}
|
||||
route={selectedRoute}
|
||||
requestTypeName={selectedRequestTypeName}
|
||||
dictionary={dictionary}
|
||||
onBack={() => setScreen('routes')}
|
||||
/>
|
||||
)}
|
||||
{screen === 'resolve' && (
|
||||
<ResolveScreen api={api} dictionary={dictionary} currentUserEmail={currentUserEmail} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.topnav {
|
||||
background: $grad-header;
|
||||
padding: 0 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 54px;
|
||||
box-shadow: 0 1px 0 rgba(212,160,30,.35);
|
||||
border-radius: $r-md $r-md 0 0;
|
||||
// Без переноса: высота шапки фиксирована (54px), поэтому перенесённая
|
||||
// строка вкладок вылезала бы за её пределы вместе с подчёркиванием.
|
||||
// Вместо переноса вкладки скроллятся по горизонтали.
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-right: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
background: $grad-gold-v;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $navy-950;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
font-family: $font-serif;
|
||||
}
|
||||
|
||||
.brandText {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
letter-spacing: .4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-wrap: nowrap;
|
||||
// min-width: 0 обязателен — иначе flex-элемент не сожмётся уже своего
|
||||
// содержимого и overflow-x никогда не сработает.
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
// Делает контейнер offsetParent'ом для вкладок: автопрокрутка в TopNav.tsx
|
||||
// считает offsetLeft относительно него. Без этого offsetLeft отсчитывался
|
||||
// бы от body и прокрутка уезжала бы не туда.
|
||||
position: relative;
|
||||
|
||||
// Полоса прокрутки на тёмной шапке выглядит инородно, скрываем её —
|
||||
// вкладки остаются доступны свайпом/колесом.
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
background: 0;
|
||||
color: rgba(255,255,255,.5);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: .18s;
|
||||
height: 54px;
|
||||
border-bottom: 2px solid transparent;
|
||||
letter-spacing: .2px;
|
||||
|
||||
&:hover { color: rgba(255,255,255,.85); }
|
||||
&.on { color: $gold-300; border-bottom-color: $gold-400; }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from 'react';
|
||||
import styles from './TopNav.module.scss';
|
||||
import { ScreenKey } from './types';
|
||||
import { texts } from '../../texts';
|
||||
|
||||
interface ITab {
|
||||
key: ScreenKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TABS: ITab[] = [
|
||||
{ key: 'dashboard', label: texts.nav.dashboard },
|
||||
{ key: 'requestTypes', label: texts.nav.requestTypes },
|
||||
{ key: 'routes', label: texts.nav.routes },
|
||||
{ key: 'resolve', label: texts.nav.resolve }
|
||||
];
|
||||
|
||||
export interface ITopNavProps {
|
||||
active: ScreenKey;
|
||||
onSelect: (key: ScreenKey) => void;
|
||||
}
|
||||
|
||||
function isTabActive(active: ScreenKey, tabKey: ScreenKey): boolean {
|
||||
// Экран шагов маршрута — вложенный, подсвечиваем вкладку «Маршруты».
|
||||
return active === tabKey || (active === 'routeSteps' && tabKey === 'routes');
|
||||
}
|
||||
|
||||
export const TopNav: React.FC<ITopNavProps> = ({ active, onSelect }) => {
|
||||
const tabsRef = React.useRef<HTMLDivElement>(null);
|
||||
const activeTabRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Вкладки скроллятся по горизонтали, поэтому активная может оказаться за
|
||||
// краем. Подкручиваем контейнер вручную (не scrollIntoView — тот способен
|
||||
// утащить за собой прокрутку всей страницы SharePoint).
|
||||
React.useEffect(() => {
|
||||
const reveal = (): void => {
|
||||
const container = tabsRef.current;
|
||||
const tab = activeTabRef.current;
|
||||
if (!container || !tab) return;
|
||||
|
||||
const left = tab.offsetLeft;
|
||||
const width = tab.offsetWidth;
|
||||
|
||||
if (width > container.clientWidth) {
|
||||
// Вкладка шире видимой полосы — показываем её начало, чтобы подпись
|
||||
// читалась с первого слова.
|
||||
container.scrollLeft = left;
|
||||
} else if (left < container.scrollLeft) {
|
||||
container.scrollLeft = left;
|
||||
} else if (left + width > container.scrollLeft + container.clientWidth) {
|
||||
container.scrollLeft = left + width - container.clientWidth;
|
||||
}
|
||||
};
|
||||
|
||||
reveal();
|
||||
|
||||
// Шрифты (Lora/Inter) подключаются рантаймом: пока они не применились,
|
||||
// ширины вкладок другие и позиция прокрутки получается устаревшей.
|
||||
const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
|
||||
let cancelled = false;
|
||||
if (fonts?.ready) {
|
||||
fonts.ready.then(() => { if (!cancelled) reveal(); }).catch(() => { /* не критично */ });
|
||||
}
|
||||
|
||||
window.addEventListener('resize', reveal);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('resize', reveal);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<nav className={styles.topnav}>
|
||||
<div className={styles.brand}>
|
||||
<div className={styles.mark}>{texts.nav.markLetter}</div>
|
||||
<div className={styles.brandText}>{texts.nav.title}</div>
|
||||
</div>
|
||||
<div className={styles.tabs} ref={tabsRef}>
|
||||
{TABS.map(tab => {
|
||||
const on = isTabActive(active, tab.key);
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
ref={on ? activeTabRef : undefined}
|
||||
className={`${styles.tab} ${on ? styles.on : ''}`}
|
||||
onClick={() => onSelect(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import styles from './DashboardScreen.module.scss';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
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';
|
||||
|
||||
export interface IDashboardScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
onNavigate: (key: ScreenKey) => void;
|
||||
}
|
||||
|
||||
// Пока список грузится или запрос упал — показываем прочерк, а не «0»,
|
||||
// чтобы не соврать пользователю про реальное количество записей.
|
||||
function statValue(list: AsyncList<unknown>): React.ReactNode {
|
||||
if (list.isLoading || list.error) return texts.common.empty;
|
||||
return list.items.length;
|
||||
}
|
||||
|
||||
export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNavigate }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const routes = useTemplateRoutes(api);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StatRow>
|
||||
<StatCard label={texts.dashboard.requestTypesStat} value={statValue(requestTypes)} />
|
||||
<StatCard label={texts.dashboard.routesStat} value={statValue(routes)} />
|
||||
</StatRow>
|
||||
|
||||
<div className={styles.sectionsLabel}>{texts.dashboard.sections}</div>
|
||||
<hr className={styles.sectionsDivider} />
|
||||
<CardGrid>
|
||||
<NavCard
|
||||
icon="clipboard"
|
||||
title={texts.dashboard.requestTypesCard.title}
|
||||
description={texts.dashboard.requestTypesCard.description}
|
||||
onClick={() => onNavigate('requestTypes')}
|
||||
/>
|
||||
<NavCard
|
||||
icon="route"
|
||||
title={texts.dashboard.routesCard.title}
|
||||
description={texts.dashboard.routesCard.description}
|
||||
onClick={() => onNavigate('routes')}
|
||||
/>
|
||||
<NavCard
|
||||
icon="search"
|
||||
title={texts.dashboard.resolveCard.title}
|
||||
description={texts.dashboard.resolveCard.description}
|
||||
onClick={() => onNavigate('resolve')}
|
||||
/>
|
||||
</CardGrid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import * as React from 'react';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { RequestType } from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
Field,
|
||||
FormGrid,
|
||||
FormModal,
|
||||
IconButton,
|
||||
PageHeader,
|
||||
PrimaryCell,
|
||||
TextInput
|
||||
} from '../ui';
|
||||
|
||||
export interface IRequestTypesScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
}
|
||||
|
||||
interface IFormState {
|
||||
name: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: IFormState = { name: '', pageUrl: '' };
|
||||
|
||||
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api }) => {
|
||||
const list = useRequestTypes(api);
|
||||
|
||||
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
||||
const submit = useAsyncSubmit();
|
||||
|
||||
const deleteTarget = useDeleteTarget<RequestType>(item => api.requestTypes.remove(item.id), list.reload);
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(undefined);
|
||||
setForm(EMPTY_FORM);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: RequestType): void => {
|
||||
setEditing(item);
|
||||
setForm({ name: item.name, pageUrl: item.pageUrl });
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const submitForm = (): void => {
|
||||
if (!form.name.trim() || !form.pageUrl.trim()) {
|
||||
submit.fail(texts.requestTypes.validationRequired);
|
||||
return;
|
||||
}
|
||||
const input = { name: form.name.trim(), pageUrl: form.pageUrl.trim() };
|
||||
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
|
||||
|
||||
submit.run(() =>
|
||||
request.then(() => {
|
||||
setShowForm(false);
|
||||
list.reload();
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} />
|
||||
<PageHeader
|
||||
icon="clipboard"
|
||||
subtitle={texts.requestTypes.subtitle}
|
||||
title={texts.requestTypes.title}
|
||||
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.requestTypes.add}</Button>}
|
||||
/>
|
||||
|
||||
{list.error && <Alert kind="err">{list.error}</Alert>}
|
||||
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
|
||||
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: '100px',
|
||||
align: 'right',
|
||||
sticky: true,
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||
</ActionsCell>
|
||||
)
|
||||
}
|
||||
]}
|
||||
rows={list.items}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<FormModal
|
||||
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
|
||||
error={submit.error}
|
||||
isSaving={submit.isBusy}
|
||||
onSubmit={submitForm}
|
||||
onClose={() => setShowForm(false)}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label={texts.requestTypes.nameLabel} required full>
|
||||
<TextInput
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={texts.requestTypes.namePlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.requestTypes.pageUrlLabel} required full hint={texts.requestTypes.pageUrlHint}>
|
||||
<TextInput
|
||||
value={form.pageUrl}
|
||||
onChange={e => setForm({ ...form, pageUrl: e.target.value })}
|
||||
placeholder={texts.requestTypes.pageUrlPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
)}
|
||||
|
||||
{deleteTarget.target && (
|
||||
<ConfirmDialog
|
||||
title={texts.requestTypes.deleteTitle}
|
||||
text={texts.requestTypes.deleteText(deleteTarget.target.name)}
|
||||
confirmLabel={texts.common.remove}
|
||||
busy={deleteTarget.isBusy}
|
||||
onCancel={deleteTarget.cancel}
|
||||
onConfirm={deleteTarget.confirm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
.runButtonRow {
|
||||
margin-top: 18px;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ResolveScreen.module.scss';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { Project, RouteStepStatus } from '../../models';
|
||||
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useResolveRoute } from '../../hooks/useResolveRoute';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||
import {
|
||||
Alert,
|
||||
AsyncSearchableSelect,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
CellNote,
|
||||
DataTable,
|
||||
Field,
|
||||
FormGrid,
|
||||
PageHeader,
|
||||
Panel,
|
||||
PrimaryCell,
|
||||
SearchableSelect,
|
||||
Select
|
||||
} from '../ui';
|
||||
|
||||
export interface IResolveScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
currentUserEmail?: string;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
||||
|
||||
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, currentUserEmail }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const [requestTypeId, setRequestTypeId] = React.useState('');
|
||||
const [userEmail, setUserEmail] = React.useState('');
|
||||
const [projectId, setProjectId] = React.useState('');
|
||||
const [projectLabel, setProjectLabel] = React.useState('');
|
||||
const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval);
|
||||
|
||||
const resolved = useResolveRoute(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
|
||||
const searchProjects = React.useCallback(
|
||||
(query: string, page: number) => {
|
||||
if (!dictionary) {
|
||||
return Promise.reject(new Error(texts.resolve.projectDictionaryLoading));
|
||||
}
|
||||
return dictionary.searchProjects(query, page);
|
||||
},
|
||||
[dictionary]
|
||||
);
|
||||
|
||||
const run = (): void => {
|
||||
if (!requestTypeId) {
|
||||
resolved.fail(texts.resolve.validationRequestType);
|
||||
return;
|
||||
}
|
||||
resolved.resolve({
|
||||
requestTypeId: Number(requestTypeId),
|
||||
userEmail: userEmail.trim() || undefined,
|
||||
projectId: projectId.trim() || undefined,
|
||||
status
|
||||
});
|
||||
};
|
||||
|
||||
const result = resolved.result;
|
||||
const unresolvedCount = result ? result.steps.filter(s => !s.executorEmail).length : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.resolve.breadcrumb }]} />
|
||||
<PageHeader icon="search" subtitle={texts.resolve.subtitle} title={texts.resolve.title} />
|
||||
|
||||
<Panel>
|
||||
<FormGrid>
|
||||
<Field label={texts.resolve.requestTypeLabel} required full>
|
||||
<Select
|
||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder={texts.resolve.requestTypePlaceholder}
|
||||
value={requestTypeId}
|
||||
onChange={e => setRequestTypeId(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
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 label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}>
|
||||
<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 label={texts.resolve.statusLabel} required>
|
||||
<Select options={STATUS_OPTIONS} value={status} onChange={e => setStatus(e.target.value as RouteStepStatus)} />
|
||||
</Field>
|
||||
</FormGrid>
|
||||
<div className={styles.runButtonRow}>
|
||||
<Button variant="accent" icon="search" onClick={run} disabled={resolved.isBusy}>
|
||||
{resolved.isBusy ? texts.resolve.running : texts.resolve.run}
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{resolved.error && <Alert kind="err">{resolved.error}</Alert>}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<Alert kind="ok">
|
||||
{texts.resolve.summary(result.routeId, routeTypeLabels[result.routeType], result.requiresControl)}
|
||||
</Alert>
|
||||
{unresolvedCount > 0 && <Alert kind="warn">{texts.resolve.unresolved(unresolvedCount)}</Alert>}
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'order', header: texts.resolve.columnOrderNo, width: '50px', render: r => r.orderNo },
|
||||
{
|
||||
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',
|
||||
header: texts.resolve.columnReassign,
|
||||
align: 'center',
|
||||
render: r => (r.allowReassignToOtherApprovers ? texts.resolve.reassignAllowed : texts.common.no)
|
||||
}
|
||||
]}
|
||||
rows={result.steps}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={texts.resolve.emptySteps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import * as React from 'react';
|
||||
import { RouteStepFormModal } from './RouteStepFormModal';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
|
||||
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
|
||||
import { describeStepExecutor } from '../../utils/stepExecutor';
|
||||
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
CellNote,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
IconButton,
|
||||
PageHeader,
|
||||
PrimaryCell
|
||||
} from '../ui';
|
||||
|
||||
export interface IRouteStepsScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
route: TemplateRoute;
|
||||
requestTypeName: string;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, dictionary, onBack }) => {
|
||||
const steps = useRouteSteps(api, route.id);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
|
||||
|
||||
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
|
||||
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
|
||||
item => api.templateRouteSteps.remove(route.id, item.id),
|
||||
steps.reload
|
||||
);
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(undefined);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: TemplateRouteStep): void => {
|
||||
setEditing(item);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSaved = (savedStep: TemplateRouteStep): void => {
|
||||
setSavedWarning(savedStep.warning ?? undefined);
|
||||
steps.reload();
|
||||
};
|
||||
|
||||
const nextOrderNo = steps.items.length > 0 ? Math.max(...steps.items.map(s => s.orderNo)) + 1 : 1;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: texts.common.home },
|
||||
{ label: texts.routes.breadcrumb, onClick: onBack },
|
||||
{ label: texts.routeSteps.breadcrumb(route.id) }
|
||||
]}
|
||||
/>
|
||||
<PageHeader
|
||||
icon="flag-check"
|
||||
subtitle={texts.routeSteps.subtitle(requestTypeName, routeTypeLabels[route.routeType])}
|
||||
title={texts.routeSteps.title(route.id)}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" icon="arrow-left" onClick={onBack}>{texts.routeSteps.back}</Button>
|
||||
<Button variant="accent" icon="plus" onClick={openCreate}>{texts.routeSteps.add}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{savedWarning && <Alert kind="warn">{savedWarning}</Alert>}
|
||||
{steps.error && <Alert kind="err">{steps.error}</Alert>}
|
||||
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'order', header: texts.routeSteps.columnOrderNo, width: '50px', render: r => r.orderNo },
|
||||
{
|
||||
key: 'executor',
|
||||
header: texts.routeSteps.columnExecutor,
|
||||
render: r => {
|
||||
const executor = describeStepExecutor(r, employees.items);
|
||||
return (
|
||||
<div>
|
||||
<PrimaryCell>{executor.primary}</PrimaryCell>
|
||||
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: texts.routeSteps.columnStatus,
|
||||
render: r => (
|
||||
<Badge variant={r.status === RouteStepStatus.Approval ? 'gold' : 'blue'}>
|
||||
{routeStepStatusLabels[r.status]}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'reassign',
|
||||
header: texts.routeSteps.columnReassign,
|
||||
align: 'center',
|
||||
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: '100px',
|
||||
align: 'right',
|
||||
sticky: true,
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||
</ActionsCell>
|
||||
)
|
||||
}
|
||||
]}
|
||||
rows={steps.items}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<RouteStepFormModal
|
||||
api={api}
|
||||
route={route}
|
||||
editing={editing}
|
||||
nextOrderNo={nextOrderNo}
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
onSaved={handleSaved}
|
||||
onClose={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget.target && (
|
||||
<ConfirmDialog
|
||||
title={texts.routeSteps.deleteTitle}
|
||||
text={texts.routeSteps.deleteText(deleteTarget.target.orderNo)}
|
||||
confirmLabel={texts.common.remove}
|
||||
busy={deleteTarget.isBusy}
|
||||
onCancel={deleteTarget.cancel}
|
||||
onConfirm={deleteTarget.confirm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
.filter {
|
||||
margin-bottom: 18px;
|
||||
max-width: 320px;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RoutesScreen.module.scss';
|
||||
import { RouteFormModal } from './RouteFormModal';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
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 {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
Field,
|
||||
IconButton,
|
||||
PageHeader,
|
||||
PrimaryCell,
|
||||
Select
|
||||
} from '../ui';
|
||||
|
||||
export interface IRoutesScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
dictionary?: PortalDictionaryClient;
|
||||
onOpenSteps: (route: TemplateRoute) => void;
|
||||
}
|
||||
|
||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, onOpenSteps }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const portalDictionaries = usePortalDictionaries(dictionary);
|
||||
|
||||
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState('');
|
||||
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
||||
|
||||
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
|
||||
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
||||
|
||||
const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
|
||||
|
||||
// Пока справочник не загрузился (или маршрут ссылается на удалённую запись),
|
||||
// показываем усечённый Guid — чтобы строка таблицы не выглядела пустой.
|
||||
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 openCreate = (): void => {
|
||||
setEditing(undefined);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: TemplateRoute): void => {
|
||||
setEditing(item);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routes.breadcrumb }]} />
|
||||
<PageHeader
|
||||
icon="route"
|
||||
subtitle={texts.routes.subtitle}
|
||||
title={texts.routes.title}
|
||||
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.routes.add}</Button>}
|
||||
/>
|
||||
|
||||
<div className={styles.filter}>
|
||||
<Field label={texts.routes.filterLabel}>
|
||||
<Select
|
||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder={texts.routes.filterAll}
|
||||
value={filterRequestTypeId}
|
||||
onChange={e => setFilterRequestTypeId(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{portalDictionaries.error && <Alert kind="warn">{texts.routes.dictionaryFailed(portalDictionaries.error)}</Alert>}
|
||||
{routes.error && <Alert kind="err">{routes.error}</Alert>}
|
||||
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'requestType',
|
||||
header: texts.routes.columnRequestType,
|
||||
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
|
||||
},
|
||||
{
|
||||
key: 'routeType',
|
||||
header: texts.routes.columnRouteType,
|
||||
render: r => <Badge variant={r.routeType === RouteType.Parallel ? 'blue' : 'gold'}>{routeTypeLabels[r.routeType]}</Badge>
|
||||
},
|
||||
{ key: 'company', header: texts.routes.columnCompany, render: r => companyName(r.companyId) },
|
||||
{ key: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) },
|
||||
{
|
||||
key: 'byProject',
|
||||
header: texts.routes.columnByProject,
|
||||
align: 'center',
|
||||
render: r => (r.byProject ? <Badge variant="ok">{texts.common.yes}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||
},
|
||||
{
|
||||
key: 'control',
|
||||
header: texts.routes.columnControl,
|
||||
align: 'center',
|
||||
render: r => (r.requiresControl ? <Badge variant="gold">{texts.routes.controlRequired}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: '160px',
|
||||
align: 'right',
|
||||
sticky: true,
|
||||
render: r => (
|
||||
<ActionsCell>
|
||||
<Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>{texts.routes.openSteps}</Button>
|
||||
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
|
||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||
</ActionsCell>
|
||||
)
|
||||
}
|
||||
]}
|
||||
rows={routes.items}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<RouteFormModal
|
||||
api={api}
|
||||
editing={editing}
|
||||
initialRequestTypeId={filterRequestTypeId}
|
||||
requestTypes={requestTypes.items}
|
||||
companies={portalDictionaries.companies}
|
||||
departments={portalDictionaries.departments}
|
||||
dictionaryLoading={portalDictionaries.isLoading}
|
||||
onSaved={routes.reload}
|
||||
onClose={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget.target && (
|
||||
<ConfirmDialog
|
||||
title={texts.routes.deleteTitle}
|
||||
text={texts.routes.deleteText(deleteTarget.target.id)}
|
||||
confirmLabel={texts.common.remove}
|
||||
busy={deleteTarget.isBusy}
|
||||
onCancel={deleteTarget.cancel}
|
||||
onConfirm={deleteTarget.confirm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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,17 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: $r-md;
|
||||
font-size: 12.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.warn { background: $gold-50; color: $amber-700; border-color: $gold-300; }
|
||||
.ok { background: $green-50; color: $green-600; border-color: #BBF7D0; }
|
||||
.err { background: $red-50; color: $red-500; border-color: #FECACA; }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Alert.module.scss';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export type AlertKind = 'warn' | 'ok' | 'err';
|
||||
|
||||
export interface IAlertProps {
|
||||
kind: AlertKind;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ICONS = { warn: 'warning', ok: 'check', err: 'close' } as const;
|
||||
|
||||
export const Alert: React.FC<IAlertProps> = ({ kind, children }) => (
|
||||
<div className={`${styles.alert} ${styles[kind]}`}>
|
||||
<Icon name={ICONS[kind]} size="sm" />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -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,41 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 11px;
|
||||
border-radius: $r-full;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .2px;
|
||||
white-space: nowrap;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.ok { background: $green-50; color: $green-600; }
|
||||
.gold { background: $gold-100; color: $amber-700; }
|
||||
.neutral { background: $gray-100; color: $gray-500; }
|
||||
.blue { background: $blue-50; color: $blue-600; }
|
||||
.red { background: $red-50; color: $red-500; }
|
||||
|
||||
.count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
background: $navy-900;
|
||||
color: $gold-300;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 0 6px;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Badge.module.scss';
|
||||
|
||||
export type BadgeVariant = 'ok' | 'gold' | 'neutral' | 'blue' | 'red';
|
||||
|
||||
export interface IBadgeProps {
|
||||
variant?: BadgeVariant;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Badge: React.FC<IBadgeProps> = ({ variant = 'neutral', children }) => (
|
||||
<span className={`${styles.badge} ${styles[variant]}`}>{children}</span>
|
||||
);
|
||||
|
||||
export const CountBadge: React.FC<{ count: number }> = ({ count }) => (
|
||||
<span className={styles.count}>{count}</span>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px 20px;
|
||||
border-radius: $r-sm;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: $font-sans;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: .18s;
|
||||
white-space: nowrap;
|
||||
letter-spacing: .15px;
|
||||
text-decoration: none;
|
||||
|
||||
&:disabled {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: $navy-800;
|
||||
color: #fff;
|
||||
border-color: $navy-800;
|
||||
&:hover:not(:disabled) { background: $navy-700; box-shadow: $sh-2; }
|
||||
}
|
||||
|
||||
.accent {
|
||||
@include btn-accent;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: #fff;
|
||||
color: $navy-800;
|
||||
border-color: $gray-300;
|
||||
&:hover:not(:disabled) { border-color: $gold-500; color: $navy-900; }
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: $navy-700;
|
||||
border-color: transparent;
|
||||
&:hover:not(:disabled) { background: $navy-100; }
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: #fff;
|
||||
color: $red-500;
|
||||
border-color: $gray-300;
|
||||
&:hover:not(:disabled) { border-color: $red-500; background: $red-50; }
|
||||
}
|
||||
|
||||
.sm {
|
||||
padding: 7px 14px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $r-sm;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
color: $gray-400;
|
||||
transition: .18s;
|
||||
|
||||
&:hover:not(:disabled) { color: $navy-800; background: $navy-50; border-color: $gray-200; }
|
||||
&.danger:hover:not(:disabled) { color: $red-500; background: $red-50; }
|
||||
&:disabled { opacity: .4; cursor: not-allowed; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Button.module.scss';
|
||||
import { Icon, IconName } from './Icon';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'accent' | 'secondary' | 'ghost' | 'danger';
|
||||
|
||||
export interface IButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: 'md' | 'sm';
|
||||
icon?: IconName;
|
||||
}
|
||||
|
||||
export const Button: React.FC<IButtonProps> = ({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
const classes = [styles.btn, styles[variant], size === 'sm' ? styles.sm : '', className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<button type="button" className={classes} {...rest}>
|
||||
{icon && <Icon name={icon} size="sm" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.navCard {
|
||||
background: #fff;
|
||||
border-radius: $r-md;
|
||||
padding: 22px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: $sh-1;
|
||||
cursor: pointer;
|
||||
@include card-hover-gold;
|
||||
border: 1px solid $gray-200;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
background: $grad-gold-v;
|
||||
opacity: 0;
|
||||
transition: .22s;
|
||||
}
|
||||
&:hover::before { opacity: 1; }
|
||||
}
|
||||
|
||||
.navIcon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: $r-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: $navy-50;
|
||||
color: $navy-700;
|
||||
border: 1px solid $navy-100;
|
||||
transition: .22s;
|
||||
|
||||
.navCard:hover & { background: $gold-50; color: $gold-600; border-color: $gold-300; }
|
||||
}
|
||||
|
||||
.navTitle { font-size: 14.5px; font-weight: 600; color: $navy-900; letter-spacing: .1px; }
|
||||
.navDesc { font-size: 12px; color: $gray-400; margin-top: 2px; }
|
||||
|
||||
.navArrow {
|
||||
color: $gray-300;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
transition: .22s;
|
||||
.navCard:hover & { color: $gold-500; transform: translateX(3px); }
|
||||
}
|
||||
|
||||
.statRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.statCard {
|
||||
background: #fff;
|
||||
border-radius: $r-md;
|
||||
padding: 18px 20px;
|
||||
box-shadow: $sh-1;
|
||||
border: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
color: $gray-400;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-family: $font-serif;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,100 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.wrap {
|
||||
background: #fff;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $sh-1;
|
||||
overflow: hidden;
|
||||
border: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
// Внутренний скролл-контейнер: если колонок больше, чем помещается в узкую
|
||||
// колонку страницы (например, в редакторе SharePoint), таблица должна
|
||||
// скроллиться по горизонтали, а не обрезаться по overflow:hidden у .wrap.
|
||||
.scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
// min-width, не width: при большом числе колонок таблица должна иметь
|
||||
// право стать шире контейнера — тогда её подхватит .scroll (overflow-x).
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
text-align: left;
|
||||
padding: 13px 18px;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
color: $gray-400;
|
||||
font-weight: 600;
|
||||
background: $gray-50;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
padding: 15px 18px;
|
||||
border-bottom: 1px solid $gray-100;
|
||||
color: $gray-700;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table tbody tr:last-child td { border-bottom: 0; }
|
||||
.table tbody tr { transition: .12s; }
|
||||
.table tbody tr:hover { background: $gold-50; }
|
||||
|
||||
.stickyCell {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.table thead th.stickyCell { z-index: 2; }
|
||||
|
||||
.table tbody td.stickyCell {
|
||||
background: #fff;
|
||||
box-shadow: -6px 0 6px -6px rgba(15, 36, 64, .12);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.table tbody tr:hover td.stickyCell { background: $gold-50; }
|
||||
|
||||
.primaryCell {
|
||||
font-weight: 500;
|
||||
color: $navy-900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
color: $gray-400;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react';
|
||||
import styles from './DataTable.module.scss';
|
||||
|
||||
export interface IColumn<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render: (row: T) => React.ReactNode;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
width?: string;
|
||||
// Держит колонку у правого края видимой области при горизонтальной
|
||||
// прокрутке — чтобы кнопки действий не уезжали за край на узких экранах.
|
||||
sticky?: boolean;
|
||||
}
|
||||
|
||||
export interface IDataTableProps<T> {
|
||||
columns: IColumn<T>[];
|
||||
rows: T[];
|
||||
keyOf: (row: T) => string | number;
|
||||
emptyMessage: string;
|
||||
}
|
||||
|
||||
export function DataTable<T>({ columns, rows, keyOf, emptyMessage }: IDataTableProps<T>): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.scroll}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={col.sticky ? styles.stickyCell : undefined}
|
||||
style={{ width: col.width, textAlign: col.align ?? 'left' }}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={keyOf(row)}>
|
||||
{columns.map(col => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={col.sticky ? styles.stickyCell : undefined}
|
||||
style={{ textAlign: col.align ?? 'left' }}
|
||||
>
|
||||
{col.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{rows.length === 0 && <div className={styles.empty}>{emptyMessage}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px 22px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
// Grid-элементы по умолчанию не сжимаются уже своего min-content
|
||||
// (min-width: auto) — из-за этого длинный текст/плейсхолдер в инпуте
|
||||
// мог распирать колонку и вызывать горизонтальный скролл модалки.
|
||||
min-width: 0;
|
||||
}
|
||||
.full { grid-column: 1 / -1; }
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $gray-700;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
|
||||
.req { color: $gold-600; }
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
// border-box обязателен: width:100% + padding/border в content-box
|
||||
// (браузерный дефолт) делает элемент шире контейнера на padding+border
|
||||
// и распирает модалку в горизонтальный скролл.
|
||||
box-sizing: border-box;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
font-size: 13.5px;
|
||||
font-family: $font-sans;
|
||||
color: $gray-900;
|
||||
transition: .18s;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
|
||||
@include input-focus-gold;
|
||||
|
||||
&::placeholder { color: $gray-400; }
|
||||
&:disabled { background: $gray-50; color: $gray-400; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.textarea { resize: vertical; min-height: 80px; }
|
||||
|
||||
.select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 11.5px;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11.5px;
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.checkboxRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 13px;
|
||||
color: $gray-700;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
accent-color: $gold-500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.radioRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 13px;
|
||||
color: $gray-700;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&.disabled { opacity: .45; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: $gold-500;
|
||||
cursor: pointer;
|
||||
&:disabled { cursor: not-allowed; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Icon.module.scss';
|
||||
|
||||
// Единый набор иконок в стиле референса (stroke, round caps, viewBox 24x24).
|
||||
export type IconName =
|
||||
| 'route'
|
||||
| 'clipboard'
|
||||
| 'flag-check'
|
||||
| 'people'
|
||||
| 'warning'
|
||||
| 'check'
|
||||
| 'close'
|
||||
| 'plus'
|
||||
| 'edit'
|
||||
| 'trash'
|
||||
| 'more'
|
||||
| 'chevron-right'
|
||||
| 'chevron-down'
|
||||
| 'chevron-left'
|
||||
| 'arrow-left'
|
||||
| 'search'
|
||||
| 'sort'
|
||||
| 'mail'
|
||||
| 'briefcase'
|
||||
| 'flag'
|
||||
| 'crown'
|
||||
| 'refresh';
|
||||
|
||||
let spriteInjected = false;
|
||||
|
||||
const SPRITE_SVG = `
|
||||
<symbol id="i-route" viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M9 6h7a3 3 0 0 1 3 3v0a3 3 0 0 1-3 3H8a3 3 0 0 0-3 3v0a3 3 0 0 0 3 3h7"/></symbol>
|
||||
<symbol id="i-clipboard" viewBox="0 0 24 24"><path d="M9 3h6v2a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z"/><rect x="5" y="5" width="14" height="16" rx="2"/><path d="M9 12h6M9 16h4"/></symbol>
|
||||
<symbol id="i-flag-check" viewBox="0 0 24 24"><path d="M4 3v18"/><path d="M4 4h13l-2 4 2 4H4"/></symbol>
|
||||
<symbol id="i-people" viewBox="0 0 24 24"><circle cx="9" cy="7" r="3"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/><circle cx="17" cy="8" r="2.5"/><path d="M17 13.5a3.5 3.5 0 0 1 3.5 3.5v1"/></symbol>
|
||||
<symbol id="i-warning" viewBox="0 0 24 24"><path d="M12 2 1 21h22z" stroke-linejoin="round"/><path d="M12 9v5M12 17h.01"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="M5 12l5 5L20 7"/></symbol>
|
||||
<symbol id="i-close" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
|
||||
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M10 11v6M14 11v6"/><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12"/><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"/></symbol>
|
||||
<symbol id="i-more" viewBox="0 0 24 24"><circle cx="12" cy="5" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="i-chevron-right" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></symbol>
|
||||
<symbol id="i-chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
|
||||
<symbol id="i-chevron-left" viewBox="0 0 24 24"><path d="m15 6-6 6 6 6"/></symbol>
|
||||
<symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="M19 12H5m0 0 5-5m-5 5 5 5"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></symbol>
|
||||
<symbol id="i-sort" viewBox="0 0 24 24"><path d="M7 3v18M7 3l-4 4m4-4 4 4M17 21V3m0 18-4-4m4 4 4-4"/></symbol>
|
||||
<symbol id="i-mail" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m4 6 8 7 8-7"/></symbol>
|
||||
<symbol id="i-briefcase" viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M3 12h18"/></symbol>
|
||||
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 3v18"/><path d="M5 4h13l-3 4 3 4H5"/></symbol>
|
||||
<symbol id="i-crown" viewBox="0 0 24 24"><path d="m3 8 4 3 5-6 5 6 4-3-2 11H5z"/></symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></symbol>
|
||||
`;
|
||||
|
||||
function ensureSpriteInjected(): void {
|
||||
if (spriteInjected || typeof document === 'undefined') return;
|
||||
const holder = document.createElement('div');
|
||||
holder.style.display = 'none';
|
||||
holder.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">${SPRITE_SVG}</svg>`;
|
||||
document.body.appendChild(holder);
|
||||
spriteInjected = true;
|
||||
}
|
||||
|
||||
export type IconSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface IIconProps {
|
||||
name: IconName;
|
||||
size?: IconSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SIZE_PX: Record<IconSize, number> = { sm: 16, md: 20, lg: 24 };
|
||||
|
||||
export const Icon: React.FC<IIconProps> = ({ name, size = 'md', className }) => {
|
||||
React.useEffect(() => {
|
||||
ensureSpriteInjected();
|
||||
}, []);
|
||||
|
||||
const px = SIZE_PX[size];
|
||||
return (
|
||||
<svg width={px} height={px} className={[styles.icon, className].filter(Boolean).join(' ')}>
|
||||
<use href={`#i-${name}`} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10,27,48,.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 300;
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.box {
|
||||
background: #fff;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $sh-modal;
|
||||
width: 480px;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
border-top: 3px solid $gold-500;
|
||||
}
|
||||
|
||||
.narrow { width: 420px; text-align: center; padding: 30px; }
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 22px 26px 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: $font-serif;
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.body { padding: 18px 26px 26px; }
|
||||
|
||||
.narrowBody { padding: 0; }
|
||||
|
||||
.text {
|
||||
font-size: 13px;
|
||||
color: $gray-500;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 26px 22px;
|
||||
}
|
||||
|
||||
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Modal.module.scss';
|
||||
import { IconButton } from './IconButton';
|
||||
import { texts } from '../../texts';
|
||||
|
||||
export interface IModalProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
footer?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
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.box}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<IconButton icon="close" label={texts.common.close} onClick={onClose} />
|
||||
</div>
|
||||
<div className={styles.body}>{children}</div>
|
||||
{footer && <div className={styles.footer}>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Card.module.scss';
|
||||
import { Icon, IconName } from './Icon';
|
||||
|
||||
export interface INavCardProps {
|
||||
icon: IconName;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** Карточка-ссылка на раздел админки. */
|
||||
export const NavCard: React.FC<INavCardProps> = ({ icon, title, description, onClick }) => (
|
||||
<a className={styles.navCard} onClick={e => { e.preventDefault(); onClick(); }} href="#">
|
||||
<div className={styles.navIcon}><Icon name={icon} /></div>
|
||||
<div>
|
||||
<div className={styles.navTitle}>{title}</div>
|
||||
<div className={styles.navDesc}>{description}</div>
|
||||
</div>
|
||||
<div className={styles.navArrow}><Icon name="chevron-right" size="sm" /></div>
|
||||
</a>
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.bread {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: $gray-400;
|
||||
margin-bottom: 14px;
|
||||
letter-spacing: .2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.breadLink {
|
||||
color: $navy-700;
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
&:hover { color: $gold-600; }
|
||||
}
|
||||
|
||||
.sep { color: $gold-400; font-size: 10px; }
|
||||
|
||||
.ph {
|
||||
@include page-header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: $sh-2;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -60px;
|
||||
top: -60px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(212,160,30,.14), transparent 65%);
|
||||
}
|
||||
}
|
||||
|
||||
.phLeft { display: flex; align-items: center; gap: 18px; position: relative; z-index: 1; }
|
||||
|
||||
.phIcon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: $r-md;
|
||||
background: rgba(212,160,30,.14);
|
||||
border: 1px solid rgba(212,160,30,.3);
|
||||
color: $gold-300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.phSub {
|
||||
font-size: 10.5px;
|
||||
color: rgba(255,255,255,.55);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.phTitle {
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-page-title;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-top: 3px;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
|
||||
.phRight {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import styles from './PageChrome.module.scss';
|
||||
import { Icon, IconName } from './Icon';
|
||||
|
||||
export interface IPageHeaderProps {
|
||||
icon: IconName;
|
||||
subtitle: string;
|
||||
title: string;
|
||||
/** Кнопки действий страницы — выравниваются по правому краю заголовка. */
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<IPageHeaderProps> = ({ icon, subtitle, title, actions }) => (
|
||||
<div className={styles.ph}>
|
||||
<div className={styles.phLeft}>
|
||||
<div className={styles.phIcon}><Icon name={icon} /></div>
|
||||
<div>
|
||||
<div className={styles.phSub}>{subtitle}</div>
|
||||
<div className={styles.phTitle}>{title}</div>
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className={styles.phRight}>{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: #fff;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: .18s;
|
||||
|
||||
@include input-focus-gold;
|
||||
|
||||
&:disabled { background: $gray-50; color: $gray-400; cursor: not-allowed; }
|
||||
&.open { border-color: $gold-500; box-shadow: 0 0 0 3px rgba(212,160,30,.12); }
|
||||
}
|
||||
|
||||
.value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.placeholder { color: $gray-400; }
|
||||
|
||||
.chevron { color: $gray-400; flex-shrink: 0; }
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border-radius: $r-md;
|
||||
box-shadow: $sh-modal;
|
||||
border: 1px solid $gray-200;
|
||||
padding: 6px;
|
||||
z-index: 400;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
outline: 0;
|
||||
margin-bottom: 6px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::placeholder { color: $gray-400; }
|
||||
}
|
||||
|
||||
.list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 9px 12px;
|
||||
border-radius: $r-sm;
|
||||
cursor: pointer;
|
||||
font-size: 13.5px;
|
||||
color: $gray-700;
|
||||
transition: .1s;
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-family: $font-sans;
|
||||
|
||||
&:hover, &.active { background: $gold-50; color: $navy-900; }
|
||||
&.selected { font-weight: 600; color: $navy-900; }
|
||||
}
|
||||
|
||||
.itemSub {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: $gray-400;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
color: $gray-400;
|
||||
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 {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid $gray-100;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import * as React from 'react';
|
||||
import styles from './SearchableSelect.module.scss';
|
||||
import { Icon } from './Icon';
|
||||
import { texts } from '../../texts';
|
||||
|
||||
export interface ISearchableOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Второстепенная строка под названием — например, компания у отдела. */
|
||||
sublabel?: string;
|
||||
}
|
||||
|
||||
export interface ISearchableSelectProps {
|
||||
options: ISearchableOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
/** Показывать пункт «очистить» — для необязательных полей. */
|
||||
allowClear?: boolean;
|
||||
clearLabel?: string;
|
||||
/** Текст, когда список опций пуст (не результат поиска, а вообще нечего выбирать). */
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = texts.select.placeholder,
|
||||
searchPlaceholder = texts.select.searchPlaceholder,
|
||||
disabled,
|
||||
loading,
|
||||
allowClear,
|
||||
clearLabel = texts.select.notSelected,
|
||||
emptyMessage = texts.select.nothingFound
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 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 —
|
||||
// фильтровать в памяти дешевле, чем ходить на сервер на каждый символ.
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return effectiveOptions;
|
||||
return effectiveOptions.filter(o =>
|
||||
o.label.toLowerCase().indexOf(q) !== -1 ||
|
||||
(o.sublabel ? o.sublabel.toLowerCase().indexOf(q) !== -1 : false)
|
||||
);
|
||||
}, [effectiveOptions, 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]);
|
||||
|
||||
const toggle = (): void => {
|
||||
if (disabled) return;
|
||||
setQuery('');
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const pick = (next: string): void => {
|
||||
onChange(next);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.trigger} ${open ? styles.open : ''}`}
|
||||
onClick={toggle}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<span className={`${styles.value} ${selected ? '' : styles.placeholder}`}>
|
||||
{loading ? texts.select.loadingDictionary : (selected ? selected.label : 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}>
|
||||
{filtered.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
{effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map(opt => (
|
||||
<button
|
||||
type="button"
|
||||
key={opt.value}
|
||||
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
||||
onClick={() => pick(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.sublabel && <span className={styles.itemSub}>{opt.sublabel}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{allowClear && value && (
|
||||
<div className={styles.clear}>
|
||||
<button type="button" className={styles.item} onClick={() => pick('')}>
|
||||
{clearLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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} />
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
export * from './Icon';
|
||||
export * from './Button';
|
||||
export * from './IconButton';
|
||||
export * from './Badge';
|
||||
export * from './Alert';
|
||||
export * from './Panel';
|
||||
export * from './Breadcrumbs';
|
||||
export * from './PageHeader';
|
||||
export * from './CardGrid';
|
||||
export * from './NavCard';
|
||||
export * from './StatRow';
|
||||
export * from './StatCard';
|
||||
export * from './DataTable';
|
||||
export * from './PrimaryCell';
|
||||
export * from './ActionsCell';
|
||||
export * from './CellNote';
|
||||
export * from './Modal';
|
||||
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 './AsyncSearchableSelect';
|
||||
Reference in New Issue
Block a user