feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type BreadcrumbItem = {
|
||||
label: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Breadcrumbs({ items, className }: BreadcrumbsProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav aria-label="Хлебные крошки" className={cn('flex flex-wrap items-center gap-1 text-sm text-muted-foreground', className)}>
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
return (
|
||||
<span key={`${item.label}-${index}`} className="inline-flex items-center gap-1">
|
||||
{index > 0 && <ChevronRight className="h-3.5 w-3.5 opacity-60" />}
|
||||
{item.href && !isLast ? (
|
||||
<Link to={item.href} className="hover:text-foreground hover:underline underline-offset-4">
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={cn(isLast && 'font-medium text-foreground')}>{item.label}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { NAV_GROUPS, filterNavGroupsByRole } from '@/config/adminAccess';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type PaletteItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
group: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const items = useMemo<PaletteItem[]>(() => {
|
||||
const groups = filterNavGroupsByRole(NAV_GROUPS, role);
|
||||
const routes = groups.flatMap((g) =>
|
||||
g.items.map((item) => ({
|
||||
id: item.key,
|
||||
label: t(item.label),
|
||||
group: t(g.label),
|
||||
path: item.key,
|
||||
}))
|
||||
);
|
||||
routes.push(
|
||||
{ id: '/profile', label: t('nav.profile'), group: t('navGroup.account'), path: '/profile' },
|
||||
{ id: '/dashboard', label: t('nav.dashboard'), group: t('navGroup.overview'), path: '/dashboard' }
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
return routes.filter((r) => {
|
||||
if (seen.has(r.path)) return false;
|
||||
seen.add(r.path);
|
||||
return true;
|
||||
});
|
||||
}, [role, t]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
item.group.toLowerCase().includes(q) ||
|
||||
item.path.toLowerCase().includes(q)
|
||||
);
|
||||
}, [items, query]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [query, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery('');
|
||||
}, [open]);
|
||||
|
||||
const go = useCallback(
|
||||
(path: string) => {
|
||||
onOpenChange(false);
|
||||
navigate(path);
|
||||
},
|
||||
[navigate, onOpenChange]
|
||||
);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, Math.max(filtered.length - 1, 0)));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter' && filtered[activeIndex]) {
|
||||
e.preventDefault();
|
||||
go(filtered[activeIndex].path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-lg" aria-describedby={undefined}>
|
||||
<DialogTitle className="sr-only">{t('layout.paletteTitle')}</DialogTitle>
|
||||
<div className="flex items-center border-b px-3">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={t('layout.palettePlaceholder')}
|
||||
className="h-12 border-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<ul className="max-h-72 overflow-auto py-2" role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
{t('common.nothingFound')}
|
||||
</li>
|
||||
) : (
|
||||
filtered.map((item, index) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between px-4 py-2 text-left text-sm',
|
||||
index === activeIndex ? 'bg-accent' : 'hover:bg-muted/60'
|
||||
)}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => go(item.path)}
|
||||
>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.group}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
<div className="border-t px-3 py-2 text-[11px] text-muted-foreground">
|
||||
{t('layout.paletteHints')}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Глобальный хоткей Ctrl/⌘+K */
|
||||
export function useCommandPaletteHotkey(onOpen: () => void) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onOpen]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Card, Col } from 'antd';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DayCount } from '../types/api';
|
||||
import AreaTrendChart from './AreaTrendChart';
|
||||
|
||||
@@ -7,10 +7,10 @@ interface Props {
|
||||
title: string;
|
||||
data?: DayCount[];
|
||||
color: string;
|
||||
lg?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DailyLineChartCard: React.FC<Props> = ({ title, data, color, lg = 12 }) => {
|
||||
const DailyLineChartCard: React.FC<Props> = ({ title, data, color, className }) => {
|
||||
const chartData = (data ?? []).map((item) => ({
|
||||
date: item.date,
|
||||
count: item.count,
|
||||
@@ -21,17 +21,20 @@ const DailyLineChartCard: React.FC<Props> = ({ title, data, color, lg = 12 }) =>
|
||||
}
|
||||
|
||||
return (
|
||||
<Col xs={24} lg={lg}>
|
||||
<Card title={title}>
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AreaTrendChart
|
||||
data={chartData}
|
||||
xKey="date"
|
||||
series={[{ key: 'count', color, name: title }]}
|
||||
height={300}
|
||||
height={280}
|
||||
yAllowDecimals={false}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs from 'dayjs';
|
||||
import { useUser } from '@/hooks/useUsers';
|
||||
import { useEvent } from '@/hooks/useEvents';
|
||||
import { useCalendar } from '@/hooks/useCalendars';
|
||||
import { useReport } from '@/hooks/useReports';
|
||||
import { formatDisplayValue } from '@/lib/utils';
|
||||
import { formatStatusLabel } from '@/utils/statusLabels';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Sheet,
|
||||
SheetBody,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
|
||||
export type ExploreEntityType = 'user' | 'event' | 'calendar' | 'report';
|
||||
|
||||
export type ExplorePreviewTarget = {
|
||||
type: ExploreEntityType;
|
||||
id: string;
|
||||
} | null;
|
||||
|
||||
interface EntitySlideOverProps {
|
||||
target: ExplorePreviewTarget;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[110px_1fr] gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 break-words">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserPreview({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useUser(id);
|
||||
if (isLoading) return <PreviewSkeleton />;
|
||||
if (!data) return <p className="text-sm text-muted-foreground">{t('users.notFound')}</p>;
|
||||
return (
|
||||
<dl className="space-y-3">
|
||||
<Field label={t('common.email')}>{formatDisplayValue(data.email)}</Field>
|
||||
<Field label={t('common.nickname')}>{formatDisplayValue(data.nickname)}</Field>
|
||||
<Field label={t('common.role')}>
|
||||
<Badge variant="secondary">{data.role}</Badge>
|
||||
</Field>
|
||||
<Field label={t('common.status')}>
|
||||
<Badge>{formatStatusLabel(data.status)}</Badge>
|
||||
</Field>
|
||||
<Field label={t('common.lastLogin')}>
|
||||
{data.last_login ? dayjs(data.last_login).format('DD.MM.YYYY HH:mm') : t('common.emDash')}
|
||||
</Field>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function EventPreview({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useEvent(id);
|
||||
if (isLoading) return <PreviewSkeleton />;
|
||||
if (!data) return <p className="text-sm text-muted-foreground">{t('events.notFound')}</p>;
|
||||
return (
|
||||
<dl className="space-y-3">
|
||||
<Field label={t('common.title')}>{formatDisplayValue(data.title)}</Field>
|
||||
<Field label={t('common.type')}>{formatDisplayValue(data.event_type)}</Field>
|
||||
<Field label={t('common.status')}>
|
||||
<Badge>{formatDisplayValue(data.status)}</Badge>
|
||||
</Field>
|
||||
<Field label={t('common.start')}>
|
||||
{data.start_time ? dayjs(data.start_time).format('DD.MM.YYYY HH:mm') : t('common.emDash')}
|
||||
</Field>
|
||||
<Field label={t('common.rating')}>
|
||||
{data.rating_avg} ({data.rating_count})
|
||||
</Field>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarPreview({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useCalendar(id);
|
||||
if (isLoading) return <PreviewSkeleton />;
|
||||
if (!data) return <p className="text-sm text-muted-foreground">{t('calendars.notFound')}</p>;
|
||||
return (
|
||||
<dl className="space-y-3">
|
||||
<Field label={t('common.title')}>{formatDisplayValue(data.title)}</Field>
|
||||
<Field label={t('common.type')}>{formatDisplayValue(data.type)}</Field>
|
||||
<Field label={t('common.status')}>
|
||||
<Badge>{formatDisplayValue(data.status)}</Badge>
|
||||
</Field>
|
||||
<Field label={t('common.owner')}>{formatDisplayValue(data.owner_id)}</Field>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportPreview({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useReport(id);
|
||||
if (isLoading) return <PreviewSkeleton />;
|
||||
if (!data) return <p className="text-sm text-muted-foreground">{t('reports.notFound')}</p>;
|
||||
return (
|
||||
<dl className="space-y-3">
|
||||
<Field label={t('common.reason')}>{data.reason}</Field>
|
||||
<Field label={t('common.target')}>
|
||||
{data.target_type} · {data.target_id}
|
||||
</Field>
|
||||
<Field label={t('common.status')}>
|
||||
<Badge>{formatStatusLabel(data.status)}</Badge>
|
||||
</Field>
|
||||
<Field label={t('common.createdAt')}>{dayjs(data.created_at).format('DD.MM.YYYY HH:mm')}</Field>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const detailPath = (type: ExploreEntityType, id: string) => {
|
||||
if (type === 'user') return `/users/${id}`;
|
||||
if (type === 'event') return `/events/${id}`;
|
||||
if (type === 'calendar') return `/calendars/${id}`;
|
||||
return `/reports/${id}`;
|
||||
};
|
||||
|
||||
export function EntitySlideOver({ target, onOpenChange }: EntitySlideOverProps) {
|
||||
const { t } = useTranslation();
|
||||
const open = Boolean(target);
|
||||
|
||||
const titles: Record<ExploreEntityType, string> = {
|
||||
user: t('common.user'),
|
||||
event: t('events.entity'),
|
||||
calendar: t('calendars.entity'),
|
||||
report: t('reports.entity'),
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
{target && (
|
||||
<>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{titles[target.type]}</SheetTitle>
|
||||
<SheetDescription className="font-mono text-xs">{target.id}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetBody>
|
||||
{target.type === 'user' && <UserPreview id={target.id} />}
|
||||
{target.type === 'event' && <EventPreview id={target.id} />}
|
||||
{target.type === 'calendar' && <CalendarPreview id={target.id} />}
|
||||
{target.type === 'report' && <ReportPreview id={target.id} />}
|
||||
</SheetBody>
|
||||
<SheetFooter>
|
||||
<Button asChild>
|
||||
<Link to={detailPath(target.type, target.id)}>{t('common.openFull')}</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import i18n from '@/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
type Props = { children: React.ReactNode };
|
||||
type State = { error: Error | null };
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-6">
|
||||
<Card className="max-w-lg w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>{i18n.t('errors.renderTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{this.state.error.message || i18n.t('errors.renderFallback')}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => this.setState({ error: null })}>
|
||||
{i18n.t('common.tryAgain')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => window.location.assign('/dashboard')}>
|
||||
{i18n.t('common.toDashboard')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs from 'dayjs';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useMetricsStore, type NodeMetric } from '@/store/metricsStore';
|
||||
import MetricIndicator from '@/components/MetricIndicator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ONLINE_MINUTES = 5;
|
||||
const RING_SIZE = 36;
|
||||
|
||||
function latestByNode(allHistory: NodeMetric[], current: NodeMetric | null): Map<string, NodeMetric> {
|
||||
const map = new Map<string, NodeMetric>();
|
||||
if (current) map.set(current.node, current);
|
||||
allHistory.forEach((m) => {
|
||||
const prev = map.get(m.node);
|
||||
if (!prev || dayjs(m.timestamp).isAfter(dayjs(prev.timestamp))) {
|
||||
map.set(m.node, m);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Compact node CPU/RAM rings for the Control Center header. */
|
||||
export function HeaderNodeMetrics({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const allHistory = useMetricsStore((s) => s.allHistory);
|
||||
const current = useMetricsStore((s) => s.current);
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const [canScroll, setCanScroll] = useState({ left: false, right: false });
|
||||
|
||||
const nodes = useMemo(() => {
|
||||
const cutoff = dayjs().subtract(ONLINE_MINUTES, 'minute');
|
||||
const latest = latestByNode(allHistory, current);
|
||||
return Array.from(latest.entries())
|
||||
.filter(([, m]) => dayjs(m.timestamp).isAfter(cutoff))
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [allHistory, current]);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) {
|
||||
setCanScroll({ left: false, right: false });
|
||||
return;
|
||||
}
|
||||
setCanScroll({
|
||||
left: el.scrollLeft > 2,
|
||||
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollState();
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(updateScrollState);
|
||||
ro.observe(el);
|
||||
el.addEventListener('scroll', updateScrollState, { passive: true });
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
el.removeEventListener('scroll', updateScrollState);
|
||||
};
|
||||
}, [nodes.length, updateScrollState]);
|
||||
|
||||
const scrollBy = (dir: -1 | 1) => {
|
||||
scrollerRef.current?.scrollBy({ left: dir * 120, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex items-center text-xs text-muted-foreground', className)}>
|
||||
{t('layout.nodesNoData')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showArrows = canScroll.left || canScroll.right;
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-w-0 max-w-full items-center gap-0.5', className)}>
|
||||
{showArrows && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
disabled={!canScroll.left}
|
||||
onClick={() => scrollBy(-1)}
|
||||
aria-label={t('layout.nodesPrev')}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-2 overflow-x-auto scrollbar-none py-0.5"
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
{nodes.map(([node, stats]) => (
|
||||
<MetricIndicator
|
||||
key={node}
|
||||
node={node}
|
||||
stats={stats}
|
||||
size={RING_SIZE}
|
||||
onClick={() => navigate(`/monitoring?node=${encodeURIComponent(node)}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{showArrows && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
disabled={!canScroll.right}
|
||||
onClick={() => scrollBy(1)}
|
||||
aria-label={t('layout.nodesNext')}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export interface KpiItem {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon?: ReactNode;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
interface ListPageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
kpis?: KpiItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ListPageHeader({ title, description, actions, kpis, className }: ListPageHeaderProps) {
|
||||
const visibleKpis = kpis?.slice(0, 4) ?? [];
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{visibleKpis.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{visibleKpis.map((kpi) => (
|
||||
<Card key={kpi.label}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{kpi.label}</CardTitle>
|
||||
{kpi.icon && <div className="text-muted-foreground">{kpi.icon}</div>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{kpi.value}</div>
|
||||
{kpi.hint && <p className="mt-1 text-xs text-muted-foreground">{kpi.hint}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import ruRU from 'antd/locale/ru_RU';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/ru';
|
||||
import 'dayjs/locale/en';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const antdLocales = { ru: ruRU, en: enUS } as const;
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
/** i18n + dayjs locale sync. */
|
||||
const LocaleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { i18n } = useTranslation();
|
||||
const language = useAuthStore((s) => s.user?.language);
|
||||
@@ -20,11 +16,7 @@ const LocaleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
dayjs.locale(locale);
|
||||
}, [i18n, locale]);
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={antdLocales[locale]}>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
);
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default LocaleProvider;
|
||||
|
||||
@@ -1,14 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Tooltip, Progress } from 'antd';
|
||||
import { NodeMetric } from '../store/metricsStore';
|
||||
import { NodeMetric } from '@/store/metricsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
node: string;
|
||||
stats: NodeMetric;
|
||||
prevStats?: NodeMetric | null;
|
||||
/** Outer ring diameter in px (default 48). */
|
||||
size?: number;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MetricIndicator: React.FC<Props> = ({ node, stats, prevStats }) => {
|
||||
const CircleProgress: React.FC<{
|
||||
percent: number;
|
||||
size: number;
|
||||
strokeWidth: number;
|
||||
color: string;
|
||||
offset?: number;
|
||||
}> = ({ percent, size, strokeWidth, color, offset = 0 }) => {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const clamped = Math.min(100, Math.max(0, percent));
|
||||
const dashOffset = circumference - (clamped / 100) * circumference;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className="absolute -rotate-90"
|
||||
style={{ top: offset, left: offset }}
|
||||
aria-hidden
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="hsl(var(--muted))"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const MetricIndicator: React.FC<Props> = ({
|
||||
node,
|
||||
stats,
|
||||
prevStats,
|
||||
size = 48,
|
||||
onClick,
|
||||
className,
|
||||
}) => {
|
||||
const cpu = stats.cpu_utilization ?? 0;
|
||||
const totalMemory = (stats.memory_total ?? 0) + (stats.memory_available ?? 0);
|
||||
const usedMemory = stats.memory_total ?? 0;
|
||||
@@ -19,48 +73,69 @@ const MetricIndicator: React.FC<Props> = ({ node, stats, prevStats }) => {
|
||||
const prevUsed = prevStats?.memory_total ?? 0;
|
||||
const prevMemoryPercent = prevTotal > 0 ? (prevUsed / prevTotal) * 100 : null;
|
||||
|
||||
const cpuColor = cpu > 80 ? '#ff4d4f' : '#52c41a';
|
||||
const memColor = memoryPercent > 80 ? '#ff4d4f' : '#1890ff';
|
||||
const cpuColor = cpu > 80 ? '#ef4444' : '#22c55e';
|
||||
const memColor = memoryPercent > 80 ? '#ef4444' : '#3b82f6';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
const stroke = Math.max(3, Math.round(size * 0.12));
|
||||
const innerSize = Math.round(size * (32 / 48));
|
||||
const innerOffset = Math.round((size - innerSize) / 2);
|
||||
const fontSize = size <= 36 ? 8 : 10;
|
||||
|
||||
const shellClass = cn(
|
||||
'group relative shrink-0',
|
||||
onClick && 'cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
className
|
||||
);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<CircleProgress percent={memoryPercent} size={size} strokeWidth={stroke} color={memColor} />
|
||||
<CircleProgress
|
||||
percent={cpu}
|
||||
size={innerSize}
|
||||
strokeWidth={stroke}
|
||||
color={cpuColor}
|
||||
offset={innerOffset}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center font-semibold tabular-nums"
|
||||
style={{ fontSize }}
|
||||
>
|
||||
{cpu.toFixed(0)}%
|
||||
</div>
|
||||
<div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 hidden w-max max-w-[240px] -translate-x-1/2 rounded-md border bg-popover px-2 py-1.5 text-left text-xs text-popover-foreground shadow-md group-hover:block group-focus-visible:block">
|
||||
<div className="font-medium">{node}</div>
|
||||
<div>
|
||||
<div>{node}</div>
|
||||
<div>CPU: {cpu.toFixed(1)}% {prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''}</div>
|
||||
<div>
|
||||
Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ ({memoryPercent.toFixed(1)}%)
|
||||
{prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''}
|
||||
</div>
|
||||
CPU: {cpu.toFixed(1)}%{' '}
|
||||
{prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ position: 'relative', width: 48, height: 48, marginRight: 12 }}>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={memoryPercent}
|
||||
format={() => ''}
|
||||
size={48}
|
||||
strokeColor={memColor}
|
||||
railColor="#f0f0f0"
|
||||
strokeWidth={6}
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
/>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={cpu}
|
||||
format={() => ''}
|
||||
size={32}
|
||||
strokeColor={cpuColor}
|
||||
railColor="#f0f0f0"
|
||||
strokeWidth={6}
|
||||
style={{ position: 'absolute', top: 8, left: 8 }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: 10, fontWeight: 600 }}>
|
||||
{cpu.toFixed(0)}%
|
||||
<div>
|
||||
Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ (
|
||||
{memoryPercent.toFixed(1)}%)
|
||||
{prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={shellClass}
|
||||
style={{ width: size, height: size }}
|
||||
onClick={onClick}
|
||||
aria-label={`${node}: CPU ${cpu.toFixed(0)}%, память ${memoryPercent.toFixed(0)}%`}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={shellClass} style={{ width: size, height: size }} title={node}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Breadcrumbs, type BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, breadcrumbs, actions, className }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn('mb-6 space-y-2', className)}>
|
||||
{breadcrumbs && breadcrumbs.length > 0 && <Breadcrumbs items={breadcrumbs} />}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { Spin } from 'antd';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import ForbiddenPage from '../pages/errors/ForbiddenPage';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import ForbiddenPage from '@/pages/errors/ForbiddenPage';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface Props {
|
||||
allowedRoles?: string[];
|
||||
@@ -13,8 +13,9 @@ const ProtectedRoute: React.FC<Props> = ({ allowedRoles }) => {
|
||||
|
||||
if (!isInitialized) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" />
|
||||
<div className="flex min-h-screen items-center justify-center gap-3 p-8">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Card, Statistic } from 'antd';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AreaChart, Area, XAxis, Tooltip } from 'recharts';
|
||||
|
||||
interface SparklinePoint {
|
||||
@@ -10,7 +10,7 @@ interface SparklinePoint {
|
||||
interface Props {
|
||||
title: string;
|
||||
value: number;
|
||||
prefix?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
data?: SparklinePoint[];
|
||||
color?: string;
|
||||
gradientId: string;
|
||||
@@ -19,46 +19,42 @@ interface Props {
|
||||
const StatisticSparklineCard: React.FC<Props> = ({
|
||||
title,
|
||||
value,
|
||||
prefix,
|
||||
icon,
|
||||
data,
|
||||
color = '#1890ff',
|
||||
color = 'hsl(221 83% 53%)',
|
||||
gradientId,
|
||||
}) => (
|
||||
<Card variant="outlined">
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Statistic
|
||||
title={title}
|
||||
value={value}
|
||||
prefix={prefix}
|
||||
style={{ flex: '0 0 auto', marginRight: 16 }}
|
||||
/>
|
||||
{data && data.length > 0 && (
|
||||
<AreaChart
|
||||
width={150}
|
||||
height={50}
|
||||
data={data}
|
||||
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="date" hide />
|
||||
<Tooltip labelFormatter={(label) => `Дата: ${label}`} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
fill={`url(#${gradientId})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, strokeWidth: 0 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-2xl font-bold tabular-nums">{value.toLocaleString('ru-RU')}</div>
|
||||
{data && data.length > 0 && (
|
||||
<AreaChart width={120} height={40} data={data} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="date" hide />
|
||||
<Tooltip labelFormatter={(label) => `Дата: ${label}`} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
fill={`url(#${gradientId})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, strokeWidth: 0 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { TablePaginationBar } from '@/components/explore/TablePaginationBar';
|
||||
import { DEFAULT_TABLE_PAGE_SIZE } from '@/utils/tablePagination';
|
||||
|
||||
export interface ServerTableParams {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
interface DataTableProps<TData, TParams extends ServerTableParams> {
|
||||
columns: ColumnDef<TData, unknown>[];
|
||||
data: TData[];
|
||||
total?: number;
|
||||
isLoading?: boolean;
|
||||
tableKey: string;
|
||||
params: TParams;
|
||||
onParamsChange: (params: TParams) => void;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TParams extends ServerTableParams>({
|
||||
columns,
|
||||
data,
|
||||
total,
|
||||
isLoading,
|
||||
tableKey,
|
||||
params,
|
||||
onParamsChange,
|
||||
emptyMessage,
|
||||
}: DataTableProps<TData, TParams>) {
|
||||
const { t } = useTranslation();
|
||||
const empty = emptyMessage ?? t('common.noData');
|
||||
const sorting: SortingState =
|
||||
params.sort && params.order ? [{ id: params.sort, desc: params.order === 'desc' }] : [];
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
pageCount: total != null ? Math.ceil(total / (params.limit || DEFAULT_TABLE_PAGE_SIZE)) : -1,
|
||||
state: {
|
||||
sorting,
|
||||
pagination: {
|
||||
pageIndex: Math.floor((params.offset || 0) / (params.limit || DEFAULT_TABLE_PAGE_SIZE)),
|
||||
pageSize: params.limit || DEFAULT_TABLE_PAGE_SIZE,
|
||||
},
|
||||
},
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onSortingChange: (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
const first = next[0];
|
||||
onParamsChange({
|
||||
...params,
|
||||
sort: first?.id ?? params.sort,
|
||||
order: first ? (first.desc ? 'desc' : 'asc') : params.order,
|
||||
offset: 0,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
return (
|
||||
<TableHead key={header.id} style={{ width: header.getSize() || undefined }}>
|
||||
{header.isPlaceholder ? null : canSort ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 font-medium hover:text-foreground"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{sorted === 'asc' ? (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
) : sorted === 'desc' ? (
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={`sk-${i}`}>
|
||||
{columns.map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
||||
{empty}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<TablePaginationBar
|
||||
tableKey={tableKey}
|
||||
params={params}
|
||||
total={total}
|
||||
dataLength={data.length}
|
||||
onParamsChange={onParamsChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TablePaginationBar } from '@/components/explore/TablePaginationBar';
|
||||
import type { ServerTableParams } from '@/components/data-table/DataTable';
|
||||
import type { RowAction } from '@/components/explore/RowActionsMenu';
|
||||
import { RowActionsMenu } from '@/components/explore/RowActionsMenu';
|
||||
|
||||
export interface DenseRowModel {
|
||||
id: string;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
badges?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
onActivate?: () => void;
|
||||
actions?: RowAction[];
|
||||
}
|
||||
|
||||
interface DenseRowListProps<TParams extends ServerTableParams> {
|
||||
items: DenseRowModel[];
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
tableKey: string;
|
||||
params: TParams;
|
||||
total?: number;
|
||||
onParamsChange: (params: TParams) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DenseRowList<TParams extends ServerTableParams>({
|
||||
items,
|
||||
isLoading,
|
||||
emptyMessage,
|
||||
tableKey,
|
||||
params,
|
||||
total,
|
||||
onParamsChange,
|
||||
className,
|
||||
}: DenseRowListProps<TParams>) {
|
||||
const { t } = useTranslation();
|
||||
const empty = emptyMessage ?? t('common.noData');
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="divide-y">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">{empty}</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-start gap-3 px-4 py-3 transition-colors',
|
||||
item.onActivate && 'hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'min-w-0 flex-1 text-left',
|
||||
!item.onActivate && 'cursor-default'
|
||||
)}
|
||||
onClick={item.onActivate}
|
||||
disabled={!item.onActivate}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate font-medium text-foreground group-hover:text-primary">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.badges}
|
||||
</div>
|
||||
{(item.subtitle || item.meta) && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{item.subtitle && <span className="truncate">{item.subtitle}</span>}
|
||||
{item.meta && <span className="tabular-nums">{item.meta}</span>}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{item.actions && item.actions.length > 0 && (
|
||||
<div className="shrink-0 pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<RowActionsMenu actions={item.actions} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<TablePaginationBar
|
||||
tableKey={tableKey}
|
||||
params={params}
|
||||
total={total}
|
||||
dataLength={items.length}
|
||||
onParamsChange={onParamsChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { ExploreViewMode } from '@/lib/exploreView';
|
||||
import { DataTable, type ServerTableParams } from '@/components/data-table/DataTable';
|
||||
import { DenseRowList, type DenseRowModel } from '@/components/explore/DenseRowList';
|
||||
|
||||
interface ExploreDataViewProps<TData, TParams extends ServerTableParams> {
|
||||
viewMode: ExploreViewMode;
|
||||
columns: ColumnDef<TData, unknown>[];
|
||||
data: TData[];
|
||||
total?: number;
|
||||
isLoading?: boolean;
|
||||
tableKey: string;
|
||||
params: TParams;
|
||||
onParamsChange: (params: TParams) => void;
|
||||
emptyMessage?: string;
|
||||
denseRows: DenseRowModel[];
|
||||
}
|
||||
|
||||
export function ExploreDataView<TData, TParams extends ServerTableParams>({
|
||||
viewMode,
|
||||
columns,
|
||||
data,
|
||||
total,
|
||||
isLoading,
|
||||
tableKey,
|
||||
params,
|
||||
onParamsChange,
|
||||
emptyMessage,
|
||||
denseRows,
|
||||
}: ExploreDataViewProps<TData, TParams>) {
|
||||
if (viewMode === 'rows') {
|
||||
return (
|
||||
<DenseRowList
|
||||
items={denseRows}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
tableKey={tableKey}
|
||||
params={params}
|
||||
total={total}
|
||||
onParamsChange={onParamsChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
total={total}
|
||||
isLoading={isLoading}
|
||||
tableKey={tableKey}
|
||||
params={params}
|
||||
onParamsChange={onParamsChange}
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ExploreInsightRow {
|
||||
id: string;
|
||||
primary: ReactNode;
|
||||
secondary?: ReactNode;
|
||||
value: ReactNode;
|
||||
}
|
||||
|
||||
export interface ExploreInsightTab {
|
||||
id: string;
|
||||
label: string;
|
||||
rows: ExploreInsightRow[];
|
||||
}
|
||||
|
||||
interface ExploreInsightsCollapseProps {
|
||||
/** Header label when collapsed */
|
||||
title?: string;
|
||||
tabs: ExploreInsightTab[];
|
||||
/** Max rows shown per tab (default 5) */
|
||||
limit?: number;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExploreInsightsCollapse({
|
||||
title,
|
||||
tabs,
|
||||
limit = 5,
|
||||
defaultOpen = false,
|
||||
className,
|
||||
}: ExploreInsightsCollapseProps) {
|
||||
const { t } = useTranslation();
|
||||
const headerTitle = title ?? t('explore.tops');
|
||||
const nonEmpty = useMemo(() => tabs.filter((tab) => tab.rows.length > 0), [tabs]);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
if (nonEmpty.length === 0) return null;
|
||||
|
||||
const active = nonEmpty.find((tab) => tab.id === activeId) ?? nonEmpty[0];
|
||||
const visible = active.rows.slice(0, limit);
|
||||
const totalRows = nonEmpty.reduce((sum, tab) => sum + tab.rows.length, 0);
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-md border border-border/70 bg-muted/20', className)}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn('h-4 w-4 shrink-0 text-muted-foreground transition-transform', open && 'rotate-90')}
|
||||
/>
|
||||
<span className="font-medium text-foreground">{headerTitle}</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
· {nonEmpty.length > 1 ? t('explore.listsCount', { count: nonEmpty.length }) : totalRows}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-border/60">
|
||||
{nonEmpty.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1 border-b border-border/50 px-2 py-1.5">
|
||||
{nonEmpty.map((tab) => {
|
||||
const isActive = tab.id === active.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||
)}
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="ml-1 tabular-nums opacity-60">{tab.rows.length}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-border/50">
|
||||
{visible.map((row) => (
|
||||
<li key={row.id} className="flex items-baseline gap-3 px-3 py-2 text-sm">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{row.primary}</div>
|
||||
{row.secondary && (
|
||||
<div className="truncate text-xs text-muted-foreground">{row.secondary}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 tabular-nums text-muted-foreground">{row.value}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ExploreViewMode } from '@/lib/exploreView';
|
||||
import { ExploreViewToggle } from '@/components/explore/ExploreViewToggle';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface ExploreStat {
|
||||
label: string;
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
interface ExploreListShellProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
stats?: ExploreStat[];
|
||||
actions?: ReactNode;
|
||||
toolbar?: ReactNode;
|
||||
/** Soft CTA strip (e.g. Reports → входящие) */
|
||||
banner?: {
|
||||
text: string;
|
||||
to: string;
|
||||
actionLabel?: string;
|
||||
};
|
||||
viewMode: ExploreViewMode;
|
||||
onViewModeChange: (mode: ExploreViewMode) => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExploreListShell({
|
||||
title,
|
||||
description,
|
||||
stats,
|
||||
actions,
|
||||
toolbar,
|
||||
banner,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
children,
|
||||
className,
|
||||
}: ExploreListShellProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
{stats && stats.length > 0 && (
|
||||
<p className="pt-1 text-sm tabular-nums text-muted-foreground">
|
||||
{stats.map((s, i) => (
|
||||
<span key={s.label}>
|
||||
{i > 0 && <span className="mx-2 text-border">·</span>}
|
||||
<span className="text-foreground/80">{s.label}</span>{' '}
|
||||
<span className="font-medium text-foreground">{s.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
|
||||
{banner && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border/80 bg-muted/30 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">{banner.text}</p>
|
||||
<Button asChild variant="outline" size="sm" className="shrink-0 gap-1.5">
|
||||
<Link to={banner.to}>
|
||||
{banner.actionLabel ?? t('common.openInbox')}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">{toolbar}</div>
|
||||
<ExploreViewToggle value={viewMode} onChange={onViewModeChange} className="shrink-0 self-end sm:self-auto" />
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ExploreSearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ExploreSearch({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
className,
|
||||
children,
|
||||
}: ExploreSearchProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<form
|
||||
className={cn('flex flex-wrap items-center gap-2', className)}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="relative min-w-[200px] max-w-md flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder={placeholder ?? t('common.searchPlaceholder')}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
<Button type="submit" variant="secondary" size="sm" className="h-9">
|
||||
{t('common.find')}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LayoutList, Table2 } from 'lucide-react';
|
||||
import type { ExploreViewMode } from '@/lib/exploreView';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ExploreViewToggleProps {
|
||||
value: ExploreViewMode;
|
||||
onChange: (mode: ExploreViewMode) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExploreViewToggle({ value, onChange, className }: ExploreViewToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn('inline-flex rounded-md border bg-background p-0.5', className)}
|
||||
role="group"
|
||||
aria-label={t('explore.viewAria')}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant={value === 'table' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5"
|
||||
onClick={() => onChange('table')}
|
||||
aria-pressed={value === 'table'}
|
||||
>
|
||||
<Table2 className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t('explore.table')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={value === 'rows' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5"
|
||||
onClick={() => onChange('rows')}
|
||||
aria-pressed={value === 'rows'}
|
||||
>
|
||||
<LayoutList className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t('explore.rows')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface RowAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
separatorBefore?: boolean;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
interface RowActionsMenuProps {
|
||||
actions: RowAction[];
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function RowActionsMenu({ actions, label }: RowActionsMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuLabel = label ?? t('common.actions');
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label={menuLabel} title={menuLabel}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{actions.map((action) => (
|
||||
<Fragment key={action.label}>
|
||||
{action.separatorBefore && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
disabled={action.disabled}
|
||||
className={cn(action.destructive && 'text-destructive focus:text-destructive')}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.icon && <span className="mr-2 inline-flex shrink-0">{action.icon}</span>}
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
DEFAULT_TABLE_PAGE_SIZE,
|
||||
TABLE_PAGE_SIZE_OPTIONS,
|
||||
getTablePagination,
|
||||
mergeTablePagination,
|
||||
} from '@/utils/tablePagination';
|
||||
import type { ServerTableParams } from '@/components/data-table/DataTable';
|
||||
|
||||
interface TablePaginationBarProps<TParams extends ServerTableParams> {
|
||||
tableKey: string;
|
||||
params: TParams;
|
||||
total?: number;
|
||||
dataLength: number;
|
||||
onParamsChange: (params: TParams) => void;
|
||||
}
|
||||
|
||||
export function TablePaginationBar<TParams extends ServerTableParams>({
|
||||
tableKey,
|
||||
params,
|
||||
total,
|
||||
dataLength,
|
||||
onParamsChange,
|
||||
}: TablePaginationBarProps<TParams>) {
|
||||
const { t } = useTranslation();
|
||||
const pagination = getTablePagination(params, total);
|
||||
const pageCount = total != null ? Math.max(1, Math.ceil(total / pagination.pageSize)) : 1;
|
||||
const canPrev = pagination.current > 1;
|
||||
const canNext = total != null ? pagination.current < pageCount : dataLength >= pagination.pageSize;
|
||||
|
||||
const handlePageSizeChange = (size: string) => {
|
||||
const pageSize = Number(size);
|
||||
onParamsChange(
|
||||
mergeTablePagination({ ...params, offset: 0 }, { pageSize, current: 1 }, tableKey)
|
||||
);
|
||||
};
|
||||
|
||||
const handlePageChange = (nextPage: number) => {
|
||||
onParamsChange(
|
||||
mergeTablePagination(params, { current: nextPage, pageSize: pagination.pageSize }, tableKey)
|
||||
);
|
||||
};
|
||||
|
||||
const from = (params.offset ?? 0) + 1;
|
||||
const to = Math.min((params.offset ?? 0) + (params.limit ?? DEFAULT_TABLE_PAGE_SIZE), total ?? 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{total != null ? (
|
||||
t('common.shownRange', { from, to, total })
|
||||
) : (
|
||||
t('common.pageLabel', { current: pagination.current })
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('common.perPage')}</span>
|
||||
<Select value={String(pagination.pageSize)} onValueChange={handlePageSizeChange}>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={!canPrev}
|
||||
onClick={() => handlePageChange(pagination.current - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="min-w-[4rem] text-center text-sm tabular-nums">
|
||||
{pagination.current} / {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={!canNext}
|
||||
onClick={() => handlePageChange(pagination.current + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
info: 'border-primary/30 bg-primary/5 text-foreground [&>svg]:text-primary',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
));
|
||||
Alert.displayName = 'Alert';
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5 ref={ref} className={cn('mb-1 font-medium leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||
)
|
||||
);
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
warning: 'border-transparent bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-4', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-4 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-4 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-black/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
);
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,139 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label ref={ref} className={cn('px-2 py-1.5 text-sm font-semibold', className)} {...props} />
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Sheet = DialogPrimitive.Root;
|
||||
const SheetTrigger = DialogPrimitive.Trigger;
|
||||
const SheetClose = DialogPrimitive.Close;
|
||||
const SheetPortal = DialogPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-black/40', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-y-0 right-0 z-50 flex h-full w-full max-w-md flex-col border-l bg-background shadow-xl transition-transform duration-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 border-b p-4 pr-12', className)} {...props} />
|
||||
);
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
));
|
||||
SheetTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
SheetDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
const SheetBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex-1 overflow-auto p-4', className)} {...props} />
|
||||
);
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-wrap gap-2 border-t bg-muted/30 p-4', className)} {...props} />
|
||||
);
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetBody,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
);
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
'h-10 px-3 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
data-slot="table-cell"
|
||||
className={cn('p-3 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
Reference in New Issue
Block a user