Фронтенд для сервиса (#5)
Tests / test (push) Successful in 2m26s

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:
2026-07-30 06:11:24 +00:00
parent 5476c1bab5
commit da158cfd84
114 changed files with 26045 additions and 2 deletions
@@ -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}>&#9670;</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';