feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
CI / test (push) Failing after 3m9s
CI / push-image (push) Has been skipped
CI / ci-done (push) Failing after 0s

Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
2026-07-14 17:58:33 +03:00
parent de379e28bf
commit fd2c1f10ad
95 changed files with 12699 additions and 4410 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
# Без cache: npm — act_runner отменяет шаг на распаковке ~546MB (exit -1).
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
- run: npm ci
- run: npm run lint
+1
View File
@@ -0,0 +1 @@
22.14.0
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+2 -2
View File
@@ -1,10 +1,10 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>eventhubfrontadmin</title>
<title>EventHub Admin</title>
</head>
<body>
<div id="root"></div>
+2135 -185
View File
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -3,6 +3,9 @@
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=20.19.0"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@@ -11,19 +14,35 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.2.3",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-tooltip": "^1.2.12",
"@tailwindcss/vite": "^4.3.2",
"@tanstack/react-query": "^5.100.10",
"antd": "^6.4.3",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.16.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"i18next": "^26.2.0",
"lucide-react": "^1.24.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.76.0",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.15.1",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2",
"zod": "^4.4.3",
"zustand": "^5.0.13"
},
+17 -6
View File
@@ -1,10 +1,9 @@
import React, { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Spin } from 'antd';
import ProtectedRoute from './components/ProtectedRoute';
import RoleGuard from './components/RoleGuard';
import AdminLayout from './layouts/AdminLayout';
import ControlCenterLayout from './layouts/ControlCenterLayout';
import LoginPage from './pages/auth/LoginPage';
import ProfilePage from './pages/profile/ProfilePage';
import DashboardPage from './pages/dashboard/DashboardPage';
@@ -26,7 +25,10 @@ import SubscriptionListPage from './pages/subscriptions/SubscriptionListPage';
import AdminListPage from './pages/admins/AdminListPage';
import AdminDetailPage from './pages/admins/AdminDetailPage';
import AuditPage from './pages/audit/AuditPage';
import ReportInboxPage from './pages/inbox/ReportInboxPage';
import TicketInboxPage from './pages/inbox/TicketInboxPage';
import { useAuthStore } from './store/authStore';
import { Skeleton } from '@/components/ui/skeleton';
const queryClient = new QueryClient();
@@ -35,12 +37,13 @@ const App: React.FC = () => {
useEffect(() => {
checkAuth();
}, []);
}, [checkAuth]);
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>
);
}
@@ -51,7 +54,7 @@ const App: React.FC = () => {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<AdminLayout />}>
<Route element={<ControlCenterLayout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route element={<RoleGuard />}>
@@ -60,6 +63,14 @@ const App: React.FC = () => {
<Route path="/monitoring" element={<MonitoringPage />} />
</Route>
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'moderator']} />}>
<Route path="/inbox/reports" element={<ReportInboxPage />} />
</Route>
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'support']} />}>
<Route path="/inbox/tickets" element={<TicketInboxPage />} />
</Route>
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin']} />}>
<Route path="/users" element={<UserListPage />} />
<Route path="/users/:id" element={<UserDetailPage />} />
+37
View File
@@ -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>
);
}
+157
View File
@@ -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]);
}
+11 -8
View File
@@ -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>
);
};
+182
View File
@@ -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>
);
}
+41
View File
@@ -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;
}
}
+127
View File
@@ -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>
);
}
+74
View File
@@ -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>
);
}
+4 -12
View File
@@ -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;
+115 -40
View File
@@ -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>
);
};
+26
View File
@@ -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>
);
}
+6 -5
View File
@@ -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>
);
}
+35 -39
View File
@@ -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>
);
+158
View File
@@ -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>
);
}
+112
View File
@@ -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>
);
}
+48
View File
@@ -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>
);
}
+57
View File
@@ -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>
);
}
+43
View File
@@ -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 };
+37
View File
@@ -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 };
+30
View File
@@ -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 };
+48
View File
@@ -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 };
+50
View File
@@ -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 };
+82
View File
@@ -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,
};
+73
View File
@@ -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,
};
+19
View File
@@ -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 };
+17
View File
@@ -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 };
+37
View File
@@ -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 };
+139
View File
@@ -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,
};
+23
View File
@@ -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 };
+87
View File
@@ -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,
};
+7
View File
@@ -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 };
+63
View File
@@ -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 };
+18
View File
@@ -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 };
+85 -18
View File
@@ -5,6 +5,8 @@ export const ROUTE_ACCESS: Record<string, AdminRole[] | undefined> = {
'/dashboard': undefined,
'/profile': undefined,
'/monitoring': undefined,
'/inbox/reports': ['superadmin', 'admin', 'moderator'],
'/inbox/tickets': ['superadmin', 'admin', 'support'],
'/users': ['superadmin', 'admin'],
'/calendars': ['superadmin', 'admin'],
'/events': ['superadmin', 'admin'],
@@ -17,29 +19,75 @@ export const ROUTE_ACCESS: Record<string, AdminRole[] | undefined> = {
'/audit': ['superadmin'],
};
export const MENU_ITEMS: Array<{
export type NavItem = {
key: string;
label: string;
roles?: AdminRole[];
}> = [
{ key: '/dashboard', label: 'Дашборд' },
{ key: '/users', label: 'Пользователи', roles: ['superadmin', 'admin'] },
{ key: '/calendars', label: 'Календари', roles: ['superadmin', 'admin'] },
{ key: '/events', label: 'События', roles: ['superadmin', 'admin'] },
{ key: '/reports', label: 'Жалобы', roles: ['superadmin', 'admin', 'moderator'] },
{ key: '/reviews', label: 'Отзывы', roles: ['superadmin', 'admin', 'moderator'] },
{ key: '/banned-words', label: 'Бан-слова', roles: ['superadmin', 'admin'] },
{ key: '/tickets', label: 'Тикеты', roles: ['superadmin', 'admin', 'support'] },
{ key: '/subscriptions', label: 'Подписки', roles: ['superadmin', 'admin'] },
{ key: '/admins', label: 'Администраторы', roles: ['superadmin'] },
{ key: '/audit', label: 'Аудит', roles: ['superadmin'] },
{ key: '/monitoring', label: 'Мониторинг' },
};
export type NavGroup = {
id: string;
label: string;
items: NavItem[];
};
/** Группы навигации Control Center (label = i18n key). */
export const NAV_GROUPS: NavGroup[] = [
{
id: 'work',
label: 'navGroup.work',
items: [
{ key: '/inbox/reports', label: 'nav.inboxReports', roles: ['superadmin', 'admin', 'moderator'] },
{ key: '/inbox/tickets', label: 'nav.inboxTickets', roles: ['superadmin', 'admin', 'support'] },
],
},
{
id: 'overview',
label: 'navGroup.overview',
items: [
{ key: '/dashboard', label: 'nav.dashboard' },
{ key: '/monitoring', label: 'nav.monitoring' },
],
},
{
id: 'content',
label: 'navGroup.content',
items: [
{ key: '/users', label: 'nav.users', roles: ['superadmin', 'admin'] },
{ key: '/calendars', label: 'nav.calendars', roles: ['superadmin', 'admin'] },
{ key: '/events', label: 'nav.events', roles: ['superadmin', 'admin'] },
{ key: '/subscriptions', label: 'nav.subscriptions', roles: ['superadmin', 'admin'] },
],
},
{
id: 'moderation',
label: 'navGroup.moderation',
items: [
{ key: '/reports', label: 'nav.reports', roles: ['superadmin', 'admin', 'moderator'] },
{ key: '/reviews', label: 'nav.reviews', roles: ['superadmin', 'admin', 'moderator'] },
{ key: '/banned-words', label: 'nav.bannedWords', roles: ['superadmin', 'admin'] },
{ key: '/tickets', label: 'nav.tickets', roles: ['superadmin', 'admin', 'support'] },
],
},
{
id: 'system',
label: 'navGroup.system',
items: [
{ key: '/admins', label: 'nav.admins', roles: ['superadmin'] },
{ key: '/audit', label: 'nav.audit', roles: ['superadmin'] },
],
},
];
/** Плоский список для совместимости и getMenuSelectedKey. */
export const MENU_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
export function canAccessRoute(pathname: string, role: string | undefined): boolean {
if (!role) return false;
const base = pathname.split('/').slice(0, 2).join('/') || '/';
const allowed = ROUTE_ACCESS[base];
const inboxBase = pathname.startsWith('/inbox/') ? pathname.split('/').slice(0, 3).join('/') : null;
const check = inboxBase ?? base;
const allowed = ROUTE_ACCESS[check] ?? ROUTE_ACCESS[base];
if (allowed === undefined) {
return ROUTE_ACCESS[pathname] === undefined || ROUTE_ACCESS[pathname]!.includes(role as AdminRole);
}
@@ -53,11 +101,24 @@ export function filterMenuByRole<T extends { roles?: AdminRole[] }>(
return items.filter((item) => !item.roles || (role && item.roles.includes(role as AdminRole)));
}
export function filterNavGroupsByRole(groups: NavGroup[], role: string | undefined): NavGroup[] {
return groups
.map((group) => ({
...group,
items: filterMenuByRole(group.items, role),
}))
.filter((group) => group.items.length > 0);
}
/** Ключ пункта меню для detail-маршрутов (/users/:id → /users). */
export function getMenuSelectedKey(pathname: string): string {
if (MENU_ITEMS.some((item) => item.key === pathname)) {
return pathname;
}
if (pathname.startsWith('/inbox/')) {
const parts = pathname.split('/').filter(Boolean);
if (parts.length >= 2) return `/inbox/${parts[1]}`;
}
const segment = pathname.split('/').filter(Boolean)[0];
if (segment) {
const parent = `/${segment}`;
@@ -69,14 +130,20 @@ export function getMenuSelectedKey(pathname: string): string {
}
const WS_NOTIFICATION_ROUTES: Record<'report_created' | 'ticket_created', string> = {
report_created: '/reports',
ticket_created: '/tickets',
report_created: '/inbox/reports',
ticket_created: '/inbox/tickets',
};
/** Доступ к WS-уведомлению по роли (согласовано с ROUTE_ACCESS). */
export function canReceiveWsNotification(
type: 'report_created' | 'ticket_created',
role: string | undefined
): boolean {
return canAccessRoute(WS_NOTIFICATION_ROUTES[type], role);
}
/** Стартовая страница по роли после логина. */
export function getDefaultRouteForRole(role: AdminRole | undefined): string {
if (role === 'moderator') return '/inbox/reports';
if (role === 'support') return '/inbox/tickets';
return '/dashboard';
}
+8 -7
View File
@@ -1,7 +1,8 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminsApi } from '../api/adminsApi';
import { AdminListParams, Admin } from '../types/api';
import { message } from 'antd';
import { notify } from '@/lib/notify';
import i18n from '@/i18n';
import { normalizeData } from '../utils/normalize';
export const useAdmins = (params: AdminListParams) => {
@@ -30,9 +31,9 @@ export const useCreateAdmin = () => {
adminsApi.createAdmin(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admins'] });
message.success('Администратор создан');
notify.success(i18n.t('common.entityCreated'));
},
onError: () => message.error('Ошибка создания'),
onError: () => notify.error(i18n.t('common.createError')),
});
};
@@ -43,9 +44,9 @@ export const useUpdateAdmin = () => {
adminsApi.updateAdmin(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admins'] });
message.success('Администратор обновлён');
notify.success(i18n.t('common.entityUpdated'));
},
onError: () => message.error('Ошибка обновления'),
onError: () => notify.error(i18n.t('common.updateError')),
});
};
@@ -55,8 +56,8 @@ export const useDeleteAdmin = () => {
mutationFn: (id: string) => adminsApi.deleteAdmin(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admins'] });
message.success('Администратор удалён');
notify.success(i18n.t('common.entityDeleted'));
},
onError: () => message.error('Ошибка удаления'),
onError: () => notify.error(i18n.t('common.deleteError')),
});
};
+6 -5
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { bannedWordsApi, BannedWordListParams } from '../api/bannedWordsApi';
import { message } from 'antd';
import { notify } from '@/lib/notify';
import i18n from '@/i18n';
import { normalizeData } from '../utils/normalize';
export const useBannedWords = (params?: BannedWordListParams) => {
@@ -19,9 +20,9 @@ export const useAddBannedWord = () => {
mutationFn: (word: string) => bannedWordsApi.addBannedWord(word),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['banned-words'] });
message.success('Слово добавлено');
notify.success(i18n.t('common.entityCreated'));
},
onError: () => message.error('Ошибка добавления'),
onError: () => notify.error(i18n.t('common.createError')),
});
};
@@ -31,8 +32,8 @@ export const useRemoveBannedWord = () => {
mutationFn: (word: string) => bannedWordsApi.removeBannedWord(word),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['banned-words'] });
message.success('Слово удалено');
notify.success(i18n.t('common.entityDeleted'));
},
onError: () => message.error('Ошибка удаления'),
onError: () => notify.error(i18n.t('common.deleteError')),
});
};
+6 -5
View File
@@ -1,7 +1,8 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { calendarsApi } from '../api/calendarsApi';
import { Calendar, CalendarListParams } from '../types/api';
import { message } from 'antd';
import { notify } from '@/lib/notify';
import i18n from '@/i18n';
import { normalizeData } from '../utils/normalize';
export const useCalendars = (params: CalendarListParams) => {
@@ -29,9 +30,9 @@ export const useUpdateCalendar = () => {
calendarsApi.updateCalendar(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendars'] });
message.success('Календарь обновлён');
notify.success(i18n.t('common.entityUpdated'));
},
onError: () => message.error('Ошибка обновления'),
onError: () => notify.error(i18n.t('common.updateError')),
});
};
@@ -41,9 +42,9 @@ export const useDeleteCalendar = () => {
mutationFn: (id: string) => calendarsApi.deleteCalendar(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendars'] });
message.success('Календарь удалён');
notify.success(i18n.t('common.entityDeleted'));
},
onError: () => message.error('Ошибка удаления'),
onError: () => notify.error(i18n.t('common.deleteError')),
});
};
+6 -5
View File
@@ -1,7 +1,8 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsApi } from '../api/eventsApi';
import { Event, EventListParams } from '../types/api';
import { message } from 'antd';
import { notify } from '@/lib/notify';
import i18n from '@/i18n';
import { normalizeData } from '../utils/normalize';
export const useEvents = (params: EventListParams) => {
@@ -29,9 +30,9 @@ export const useUpdateEvent = () => {
eventsApi.updateEvent(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
message.success('Событие обновлено');
notify.success(i18n.t('common.entityUpdated'));
},
onError: () => message.error('Ошибка обновления'),
onError: () => notify.error(i18n.t('common.updateError')),
});
};
@@ -41,9 +42,9 @@ export const useDeleteEvent = () => {
mutationFn: (id: string) => eventsApi.deleteEvent(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
message.success('Событие удалено');
notify.success(i18n.t('common.entityDeleted'));
},
onError: () => message.error('Ошибка удаления'),
onError: () => notify.error(i18n.t('common.deleteError')),
});
};
+13
View File
@@ -0,0 +1,13 @@
import { useCallback, useState } from 'react';
import { getExploreViewMode, setExploreViewMode, type ExploreViewMode } from '@/lib/exploreView';
export function useExploreView() {
const [viewMode, setMode] = useState<ExploreViewMode>(() => getExploreViewMode());
const setViewMode = useCallback((mode: ExploreViewMode) => {
setExploreViewMode(mode);
setMode(mode);
}, []);
return { viewMode, setViewMode } as const;
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect } from 'react';
/**
* Горячие клавиши inbox (когда фокус не в input/textarea/select/contenteditable).
* j/k — соседний элемент, 1/2 — действия, Esc — снять фокус списка (опционально).
*/
export function useInboxHotkeys(options: {
enabled?: boolean;
onNext: () => void;
onPrev: () => void;
onPrimary?: () => void;
onSecondary?: () => void;
onOpen?: () => void;
}) {
const { enabled = true, onNext, onPrev, onPrimary, onSecondary, onOpen } = options;
useEffect(() => {
if (!enabled) return;
const isTypingTarget = (el: EventTarget | null) => {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
};
const handler = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (isTypingTarget(e.target)) return;
const key = e.key.toLowerCase();
if (key === 'j' || key === 'arrowdown') {
e.preventDefault();
onNext();
} else if (key === 'k' || key === 'arrowup') {
e.preventDefault();
onPrev();
} else if (key === 'enter' && onOpen) {
e.preventDefault();
onOpen();
} else if (key === '1' && onPrimary) {
e.preventDefault();
onPrimary();
} else if (key === '2' && onSecondary) {
e.preventDefault();
onSecondary();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [enabled, onNext, onPrev, onPrimary, onSecondary, onOpen]);
}
export function selectNeighborId(
ids: string[],
currentId: string,
direction: 1 | -1
): string | null {
if (ids.length === 0) return null;
const idx = ids.indexOf(currentId);
if (idx < 0) return ids[0] ?? null;
const next = idx + direction;
if (next < 0 || next >= ids.length) return currentId;
return ids[next];
}
/** Следующий после текущего; если конец — предыдущий. */
export function selectAfterRemove(ids: string[], currentId: string): string | null {
const idx = ids.indexOf(currentId);
if (idx < 0) return ids[0] ?? null;
return ids[idx + 1] ?? ids[idx - 1] ?? null;
}
+7 -6
View File
@@ -1,8 +1,9 @@
import { useMutation } from '@tanstack/react-query';
import { message } from 'antd';
import { authApi } from '../api/authApi';
import { useAuthStore } from '../store/authStore';
import { Admin } from '../types/api';
import { authApi } from '@/api/authApi';
import { useAuthStore } from '@/store/authStore';
import { Admin } from '@/types/api';
import { notify } from '@/lib/notify';
import i18n from '@/i18n';
export const useUpdateProfile = () => {
const setUser = useAuthStore((s) => s.setUser);
@@ -11,8 +12,8 @@ export const useUpdateProfile = () => {
mutationFn: (data: Partial<Admin>) => authApi.updateMe(data),
onSuccess: (user) => {
setUser(user);
message.success('Профиль обновлён');
notify.success(i18n.t('profile.saved'));
},
onError: () => message.error('Ошибка обновления профиля'),
onError: () => notify.error(i18n.t('profile.saveError')),
});
};
+4 -3
View File
@@ -1,8 +1,9 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { reportsApi } from '../api/reportsApi';
import { ReportListParams } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
import { notify } from '@/lib/notify';
import i18n from '@/i18n';
export const useReports = (params: ReportListParams) => {
return useQuery({
@@ -29,9 +30,9 @@ export const useUpdateReport = () => {
reportsApi.updateReport(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reports'] });
message.success('Статус обновлён');
notify.success(i18n.t('common.entityUpdated'));
},
onError: () => message.error('Ошибка обновления'),
onError: () => notify.error(i18n.t('common.updateError')),
});
};
+6 -5
View File
@@ -1,7 +1,8 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { reviewsApi } from '../api/reviewsApi';
import { ReviewListParams, Review } from '../types/api';
import { message } from 'antd';
import { notify } from '@/lib/notify';
import i18n from '@/i18n';
import { normalizeData } from '../utils/normalize';
export const useReviews = (params: ReviewListParams) => {
@@ -31,9 +32,9 @@ export const useUpdateReview = () => {
reviewsApi.updateReview(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reviews'] });
message.success('Отзыв обновлён');
notify.success(i18n.t('common.entityUpdated'));
},
onError: () => message.error('Ошибка обновления'),
onError: () => notify.error(i18n.t('common.updateError')),
});
};
@@ -44,10 +45,10 @@ export const useBulkUpdateReviews = () => {
reviewsApi.bulkUpdateReviews(updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reviews'] });
message.success('Статусы обновлены');
notify.success(i18n.t('common.bulkUpdated'));
},
onError: (error) => {
message.error('Ошибка массового обновления');
notify.error(i18n.t('common.bulkUpdateError'));
console.error(error);
},
});
+6 -5
View File
@@ -1,7 +1,8 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { subscriptionsApi } from '../api/subscriptionsApi';
import { SubscriptionListParams, Subscription as SubscriptionType } from '../types/api';
import { message } from 'antd';
import { notify } from '@/lib/notify';
import i18n from '@/i18n';
import { normalizeData } from '../utils/normalize';
export const useSubscriptions = (params: SubscriptionListParams) => {
@@ -29,9 +30,9 @@ export const useUpdateSubscription = () => {
subscriptionsApi.updateSubscription(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
message.success('Подписка обновлена');
notify.success(i18n.t('common.entityUpdated'));
},
onError: () => message.error('Ошибка обновления'),
onError: () => notify.error(i18n.t('common.updateError')),
});
};
@@ -41,9 +42,9 @@ export const useDeleteSubscription = () => {
mutationFn: (id: string) => subscriptionsApi.deleteSubscription(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
message.success('Подписка удалена');
notify.success(i18n.t('common.entityDeleted'));
},
onError: () => message.error('Ошибка удаления'),
onError: () => notify.error(i18n.t('common.deleteError')),
});
};
+6 -5
View File
@@ -1,8 +1,9 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../api/ticketsApi';
import { TicketListParams, Ticket } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
import { notify } from '@/lib/notify';
import i18n from '@/i18n';
export const useTickets = (params: TicketListParams) => {
return useQuery({
@@ -29,9 +30,9 @@ export const useUpdateTicket = () => {
ticketsApi.updateTicket(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
message.success('Тикет обновлён');
notify.success(i18n.t('common.entityUpdated'));
},
onError: () => message.error('Ошибка обновления'),
onError: () => notify.error(i18n.t('common.updateError')),
});
};
@@ -41,9 +42,9 @@ export const useDeleteTicket = () => {
mutationFn: (id: string) => ticketsApi.deleteTicket(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
message.success('Тикет удалён');
notify.success(i18n.t('common.entityDeleted'));
},
onError: () => message.error('Ошибка удаления'),
onError: () => notify.error(i18n.t('common.deleteError')),
});
};
+6 -5
View File
@@ -1,7 +1,8 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usersApi } from '../api/usersApi';
import { UserListParams, User } from '../types/api';
import { message } from 'antd';
import { notify } from '@/lib/notify';
import i18n from '@/i18n';
import { normalizeData } from '../utils/normalize';
export const useUsers = (params: UserListParams) => {
@@ -28,9 +29,9 @@ export const useUpdateUser = () => {
mutationFn: ({ id, data }: { id: string; data: Partial<User> }) => usersApi.updateUser(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
message.success('Пользователь обновлен');
notify.success(i18n.t('common.entityUpdated'));
},
onError: () => message.error('Ошибка обновления'),
onError: () => notify.error(i18n.t('common.updateError')),
});
};
@@ -40,9 +41,9 @@ export const useDeleteUser = () => {
mutationFn: (id: string) => usersApi.deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
message.success('Пользователь удален');
notify.success(i18n.t('common.entityDeleted'));
},
onError: () => message.error('Ошибка удаления'),
onError: () => notify.error(i18n.t('common.deleteError')),
});
};
+97 -94
View File
@@ -1,111 +1,114 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
}
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
--background: 0 0% 100%;
--foreground: 224 71% 4%;
--card: 0 0% 100%;
--card-foreground: 224 71% 4%;
--popover: 0 0% 100%;
--popover-foreground: 224 71% 4%;
--primary: 221 83% 53%;
--primary-foreground: 210 40% 98%;
--secondary: 220 14% 96%;
--secondary-foreground: 220 9% 46%;
--muted: 220 14% 96%;
--muted-foreground: 220 9% 46%;
--accent: 220 14% 96%;
--accent-foreground: 224 71% 4%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 221 83% 53%;
--radius: 0.5rem;
--sidebar: 224 71% 4%;
--sidebar-foreground: 210 40% 98%;
--sidebar-muted: 215 28% 17%;
--sidebar-accent: 217 33% 17%;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
.dark {
--background: 224 71% 4%;
--foreground: 210 40% 98%;
--card: 224 71% 4%;
--card-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 210 40% 98%;
--primary: 217 91% 60%;
--primary-foreground: 222 47% 11%;
--secondary: 217 33% 17%;
--secondary-foreground: 210 40% 98%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--accent: 217 33% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 224 76% 48%;
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
* {
border-color: hsl(var(--border));
}
body {
margin: 0;
margin: 0;
min-height: 100vh;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: 'Segoe UI', 'IBM Plex Sans', 'Source Sans 3', system-ui, sans-serif;
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
#root {
min-height: 100vh;
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
.cc-main-surface {
background:
radial-gradient(ellipse 80% 50% at 100% -10%, hsl(221 83% 53% / 0.06), transparent 55%),
hsl(var(--background));
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
/* Compact table density (toggle in Control Center header) */
html[data-density='compact'] [data-slot='table-head'] {
height: 2rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
font-size: 0.75rem;
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
html[data-density='compact'] [data-slot='table-cell'] {
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
}
-295
View File
@@ -1,295 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Layout, Menu, Button, theme, notification, Avatar, Dropdown, Space, Typography, Badge, Tooltip } from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useAdminWebSocket } from '../hooks/useAdminWebSocket';
import { useMetricsStore, type NodeMetric } from '../store/metricsStore';
import MetricIndicator from '../components/MetricIndicator';
import {
DashboardOutlined,
UserOutlined,
CalendarOutlined,
WarningOutlined,
StarOutlined,
StopOutlined,
BugOutlined,
DollarOutlined,
TeamOutlined,
AuditOutlined,
LogoutOutlined,
SettingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
CloudServerOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { MENU_ITEMS, filterMenuByRole, getMenuSelectedKey, canReceiveWsNotification } from '../config/adminAccess';
import { getEntityDetailPath } from '../utils/entityRoutes';
import { useTranslation } from 'react-i18next';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
const NODE_STATS_THRESHOLD = 5;
const AdminLayout: React.FC = () => {
useAdminWebSocket();
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const { t } = useTranslation();
const { token: { colorBgContainer } } = theme.useToken();
const [collapsed, setCollapsed] = useState(false);
const allHistory = useMetricsStore((s) => s.allHistory);
const previous = useMetricsStore((s) => s.previous);
useEffect(() => {
const handler = (event: CustomEvent) => {
const msg = event.detail;
if (msg.type === 'report_created') {
if (!canReceiveWsNotification('report_created', user?.role)) {
return;
}
const { report_id, target_type, target_id, reason } = msg.data || {};
const targetPath = target_id ? getEntityDetailPath(target_type, target_id) : undefined;
notification.info({
key: report_id ? `report-${report_id}` : `report-${Date.now()}`,
message: t('ws.newReport'),
description: [
report_id ? `Жалоба #${report_id}` : 'Жалоба',
target_type ? `на ${target_type}` : '',
target_id ?? '',
reason ? `(${reason})` : '',
].filter(Boolean).join(' '),
placement: 'topRight',
onClick: () => {
if (report_id) navigate(`/reports/${report_id}`);
else if (targetPath) navigate(targetPath);
},
});
} else if (msg.type === 'ticket_created') {
if (!canReceiveWsNotification('ticket_created', user?.role)) {
return;
}
const { ticket_id } = msg.data || {};
notification.info({
key: ticket_id ? `ticket-${ticket_id}` : `ticket-${Date.now()}`,
message: t('ws.newTicket'),
description: ticket_id ? `Тикет #${ticket_id} создан` : 'Создан новый тикет',
placement: 'topRight',
onClick: () => {
if (ticket_id) navigate(`/tickets/${ticket_id}`);
},
});
}
};
window.addEventListener('admin-ws-message', handler as EventListener);
return () => window.removeEventListener('admin-ws-message', handler as EventListener);
}, [navigate, user?.role, t]);
const onlineNodes = React.useMemo(() => {
const cutoff = dayjs().subtract(NODE_STATS_THRESHOLD, 'minute');
const recentMetrics = allHistory.filter(m => dayjs(m.timestamp).isAfter(cutoff));
const nodes = new Set(recentMetrics.map(m => m.node));
return Array.from(nodes);
}, [allHistory]);
const latestByNode = React.useMemo(() => {
const map = new Map<string, NodeMetric>();
const recent = allHistory.slice(-1000);
for (let i = recent.length - 1; i >= 0; i--) {
const m = recent[i];
if (!map.has(m.node)) {
map.set(m.node, m);
}
}
return map;
}, [allHistory]);
const menuIconByKey: Record<string, React.ReactNode> = {
'/dashboard': <DashboardOutlined />,
'/users': <UserOutlined />,
'/calendars': <CalendarOutlined />,
'/events': <CalendarOutlined />,
'/reports': <WarningOutlined />,
'/reviews': <StarOutlined />,
'/banned-words': <StopOutlined />,
'/tickets': <BugOutlined />,
'/subscriptions': <DollarOutlined />,
'/admins': <TeamOutlined />,
'/audit': <AuditOutlined />,
'/monitoring': <CloudServerOutlined />,
};
const menuItems = MENU_ITEMS.map((item) => ({
key: item.key,
icon: menuIconByKey[item.key],
label: item.label,
roles: item.roles,
}));
const filteredMenu = filterMenuByRole(menuItems, user?.role);
const handleLogout = async () => {
await logout();
navigate('/login');
};
const getDisplayName = () => {
const nick = user?.nickname;
const email = user?.email;
if (nick && nick !== '-' && nick !== 'undefined') return nick;
if (email && email !== '-' && email !== 'undefined') return email;
return user?.id ?? '—';
};
const NodeIndicator: React.FC<{ node: string; stats: NodeMetric; prevStats?: NodeMetric }> = ({
node,
stats,
prevStats,
}) => <MetricIndicator node={node} stats={stats} prevStats={prevStats} />;
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
trigger={null}
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
position: 'sticky',
top: 0,
left: 0,
}}
>
<div style={{ height: 32, margin: 16, color: 'white', textAlign: 'center', fontWeight: 'bold' }}>
{collapsed ? 'EH' : import.meta.env.VITE_APP_TITLE}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[getMenuSelectedKey(location.pathname)]}
items={filteredMenu}
onClick={({ key }) => navigate(key)}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
}}
/>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between',
padding: '12px 24px',
borderTop: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(0,0,0,0.1)',
marginTop: 'auto',
flexShrink: 0,
}}
>
<Dropdown
menu={{
items: [
{
key: 'profile',
icon: <SettingOutlined />,
label: 'Мой профиль',
onClick: () => navigate('/profile'),
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Выйти',
onClick: handleLogout,
},
],
}}
trigger={['click']}
placement="topLeft"
>
<Button
type="text"
style={{
color: 'white',
display: 'flex',
alignItems: 'center',
padding: '4px 0',
border: 'none',
width: collapsed ? 'auto' : '100%',
}}
>
<Avatar icon={<UserOutlined />} src={user?.avatar_url} size="small" />
{!collapsed && (
<Text
style={{
color: 'white',
marginLeft: 8,
maxWidth: 100,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
ellipsis
>
{getDisplayName()}
</Text>
)}
</Button>
</Dropdown>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
color: 'white',
fontSize: '16px',
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
</div>
</Sider>
<Layout>
<Header
style={{
padding: '0 24px',
background: colorBgContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Space size="large">
<Tooltip title={`Онлайн-ноды: ${onlineNodes.join(', ')}`}>
<Badge
status="processing"
text={`Ноды: ${onlineNodes.length}`}
/>
</Tooltip>
{onlineNodes.map(node => {
const stats = latestByNode.get(node);
if (!stats) return null;
const prevStats = previous?.node === node ? previous : undefined;
return <NodeIndicator key={node} node={node} stats={stats} prevStats={prevStats} />;
})}
</Space>
<div>{/* Резерв */}</div>
</Header>
<Content style={{ margin: 24 }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default AdminLayout;
+340
View File
@@ -0,0 +1,340 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
LayoutDashboard,
Inbox,
Users,
Calendar,
CalendarDays,
AlertTriangle,
Star,
Ban,
Ticket,
CreditCard,
Shield,
FileSearch,
Activity,
ChevronLeft,
ChevronRight,
LogOut,
User,
Search,
Rows3,
Check,
Languages,
} from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { useAdminWebSocket } from '@/hooks/useAdminWebSocket';
import { useUpdateProfile } from '@/hooks/useProfile';
import {
filterNavGroupsByRole,
getMenuSelectedKey,
canReceiveWsNotification,
NAV_GROUPS,
type NavItem,
} from '@/config/adminAccess';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { getTableDensity, toggleTableDensity, type TableDensity } from '@/lib/density';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { CommandPalette, useCommandPaletteHotkey } from '@/components/CommandPalette';
import { HeaderNodeMetrics } from '@/components/HeaderNodeMetrics';
const navIconByKey: Record<string, React.ComponentType<{ className?: string }>> = {
'/dashboard': LayoutDashboard,
'/inbox/reports': Inbox,
'/inbox/tickets': Ticket,
'/users': Users,
'/calendars': Calendar,
'/events': CalendarDays,
'/reports': AlertTriangle,
'/reviews': Star,
'/banned-words': Ban,
'/tickets': Ticket,
'/subscriptions': CreditCard,
'/admins': Shield,
'/audit': FileSearch,
'/monitoring': Activity,
};
function NavLinkItem({ item, active, collapsed }: { item: NavItem; active: boolean; collapsed: boolean }) {
const { t } = useTranslation();
const Icon = navIconByKey[item.key] ?? LayoutDashboard;
const label = t(item.label);
return (
<Link
to={item.key}
title={collapsed ? label : undefined}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
collapsed && 'justify-center px-2',
active
? 'bg-sidebar-accent text-white font-medium'
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-white'
)}
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span className="truncate">{label}</span>}
</Link>
);
}
const ControlCenterLayout: React.FC = () => {
useAdminWebSocket();
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const updateProfile = useUpdateProfile();
const [collapsed, setCollapsed] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [density, setDensity] = useState<TableDensity>(() => getTableDensity());
const openPalette = useCallback(() => setPaletteOpen(true), []);
useCommandPaletteHotkey(openPalette);
const selectedKey = getMenuSelectedKey(location.pathname);
const groups = useMemo(() => filterNavGroupsByRole(NAV_GROUPS, user?.role), [user?.role]);
useEffect(() => {
const handler = (event: CustomEvent) => {
const msg = event.detail;
if (msg.type === 'report_created') {
if (!canReceiveWsNotification('report_created', user?.role)) return;
const { report_id, target_type, reason } = msg.data || {};
const inboxPath = report_id
? `/inbox/reports?selected=${report_id}`
: '/inbox/reports';
notify.info(
[
report_id ? t('ws.reportId', { id: report_id }) : t('ws.newReport'),
target_type ? t('ws.onTarget', { type: target_type }) : '',
reason ? `(${reason})` : '',
]
.filter(Boolean)
.join(' '),
{
action: {
label: t('ws.open'),
onClick: () => navigate(inboxPath),
},
}
);
} else if (msg.type === 'ticket_created') {
if (!canReceiveWsNotification('ticket_created', user?.role)) return;
const { ticket_id } = msg.data || {};
const inboxPath = ticket_id
? `/inbox/tickets?selected=${ticket_id}`
: '/inbox/tickets';
notify.info(
ticket_id ? t('ws.ticketCreated', { id: ticket_id }) : t('ws.newTicket'),
{
action: {
label: t('ws.open'),
onClick: () => navigate(inboxPath),
},
}
);
}
};
window.addEventListener('admin-ws-message', handler as EventListener);
return () => window.removeEventListener('admin-ws-message', handler as EventListener);
}, [navigate, user?.role, t]);
const handleLogout = async () => {
await logout();
navigate('/login');
};
const handleDensityToggle = () => {
const next = toggleTableDensity();
setDensity(next);
notify.info(next === 'compact' ? t('layout.densityCompact') : t('layout.densityNormal'));
};
const displayName = (() => {
const nick = user?.nickname;
const email = user?.email;
if (nick && nick !== '-' && nick !== 'undefined') return nick;
if (email && email !== '-' && email !== 'undefined') return email;
return user?.id ?? t('common.emDash');
})();
const initials = displayName.slice(0, 2).toUpperCase();
return (
<div className="flex min-h-screen bg-background">
<aside
className={cn(
'flex flex-col border-r bg-[hsl(var(--sidebar))] text-[hsl(var(--sidebar-foreground))] transition-[width]',
collapsed ? 'w-16' : 'w-60'
)}
>
<div className="flex h-14 items-center justify-between px-3 border-b border-white/10">
{!collapsed && (
<span className="font-semibold text-sm truncate">
{import.meta.env.VITE_APP_TITLE || t('common.appTitle')}
</span>
)}
<Button
variant="ghost"
size="icon"
className="text-sidebar-foreground hover:bg-sidebar-accent shrink-0"
onClick={() => setCollapsed((v) => !v)}
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</Button>
</div>
<ScrollArea className="flex-1 px-2 py-3">
<nav className="space-y-4">
{groups.map((group) => (
<div key={group.id}>
{!collapsed && (
<p className="mb-1 px-3 text-[11px] font-semibold uppercase tracking-wider text-sidebar-foreground/50">
{t(group.label)}
</p>
)}
<div className="space-y-0.5">
{group.items.map((item) => (
<NavLinkItem
key={item.key}
item={item}
collapsed={collapsed}
active={selectedKey === item.key}
/>
))}
</div>
</div>
))}
</nav>
</ScrollArea>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 items-center gap-3 border-b bg-card px-4">
<HeaderNodeMetrics className="min-w-0 flex-1" />
<div className="ml-auto flex shrink-0 items-center gap-1 sm:gap-2">
<Button
variant="outline"
size="sm"
className="hidden sm:inline-flex gap-2 text-muted-foreground"
onClick={openPalette}
>
<Search className="h-3.5 w-3.5" />
{t('layout.search')}
<kbd className="pointer-events-none rounded border bg-muted px-1.5 py-0.5 font-mono text-[10px]">
K
</kbd>
</Button>
<Button
variant="ghost"
size="icon"
className="sm:hidden"
title={t('layout.searchHotkey')}
onClick={openPalette}
>
<Search className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title={density === 'compact' ? t('layout.densityToNormal') : t('layout.densityToCompact')}
onClick={handleDensityToggle}
>
<Rows3 className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 px-2">
<Avatar className="h-8 w-8">
<AvatarImage src={user?.avatar_url ?? undefined} alt={displayName} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className="hidden sm:inline max-w-[120px] truncate text-sm font-medium">
{displayName}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col">
<span>{displayName}</span>
<span className="text-xs font-normal text-muted-foreground">{user?.role}</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="mr-2 h-4 w-4" />
{t('layout.myProfile')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
<Languages className="h-3.5 w-3.5" />
{t('layout.uiLanguage')}
</DropdownMenuLabel>
<DropdownMenuItem
disabled={updateProfile.isPending}
onClick={() => {
if ((user?.language || 'ru') === 'ru') return;
updateProfile.mutate({ language: 'ru' });
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
(user?.language || 'ru') === 'ru' ? 'opacity-100' : 'opacity-0'
)}
/>
{t('common.langRu')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={updateProfile.isPending}
onClick={() => {
if (user?.language === 'en') return;
updateProfile.mutate({ language: 'en' });
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
user?.language === 'en' ? 'opacity-100' : 'opacity-0'
)}
/>
{t('common.langEn')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut className="mr-2 h-4 w-4" />
{t('layout.logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main className="cc-main-surface flex-1 overflow-auto p-4 md:p-6">
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
</main>
</div>
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
</div>
);
};
export default ControlCenterLayout;
+23
View File
@@ -0,0 +1,23 @@
const STORAGE_KEY = 'admin.ui.density';
export type TableDensity = 'comfortable' | 'compact';
export function getTableDensity(): TableDensity {
const raw = localStorage.getItem(STORAGE_KEY);
return raw === 'compact' ? 'compact' : 'comfortable';
}
export function setTableDensity(density: TableDensity): void {
localStorage.setItem(STORAGE_KEY, density);
document.documentElement.dataset.density = density;
}
export function initTableDensity(): void {
document.documentElement.dataset.density = getTableDensity();
}
export function toggleTableDensity(): TableDensity {
const next: TableDensity = getTableDensity() === 'compact' ? 'comfortable' : 'compact';
setTableDensity(next);
return next;
}
+11
View File
@@ -0,0 +1,11 @@
const STORAGE_KEY = 'admin.ui.exploreView';
export type ExploreViewMode = 'table' | 'rows';
export function getExploreViewMode(): ExploreViewMode {
return localStorage.getItem(STORAGE_KEY) === 'rows' ? 'rows' : 'table';
}
export function setExploreViewMode(mode: ExploreViewMode): void {
localStorage.setItem(STORAGE_KEY, mode);
}
+17
View File
@@ -0,0 +1,17 @@
import { toast } from 'sonner';
type NotifyOptions = {
action?: {
label: string;
onClick: () => void;
};
};
export const notify = {
success: (message: string, options?: NotifyOptions) =>
toast.success(message, options?.action ? { action: options.action } : undefined),
error: (message: string, options?: NotifyOptions) =>
toast.error(message, options?.action ? { action: options.action } : undefined),
info: (message: string, options?: NotifyOptions) =>
toast.info(message, options?.action ? { action: options.action } : undefined),
};
+23
View File
@@ -0,0 +1,23 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDisplayValue(val: unknown): string {
if (val === null || val === undefined || val === '' || val === '-' || val === 'undefined') {
return '-';
}
if (Array.isArray(val)) {
return val.length ? val.join(', ') : '-';
}
if (typeof val === 'object') {
try {
return JSON.stringify(val, null, 2);
} catch {
return String(val);
}
}
return String(val);
}
+438 -18
View File
@@ -1,4 +1,442 @@
{
"common": {
"appTitle": "EventHub",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving…",
"delete": "Delete",
"edit": "Edit",
"open": "Open",
"close": "Close",
"create": "Create",
"add": "Add",
"find": "Search",
"search": "Search",
"actions": "Actions",
"backToList": "Back to list",
"tryAgain": "Try again",
"toDashboard": "Go to dashboard",
"yes": "Yes",
"no": "No",
"noData": "No data",
"loading": "Loading…",
"emDash": "—",
"all": "All",
"allStatuses": "All statuses",
"allPlans": "All plans",
"allTypes": "All types",
"total": "Total",
"freeze": "Freeze",
"unfreeze": "Unfreeze",
"quickView": "Quick view",
"show": "Show",
"hide": "Hide",
"irreversible": "This action cannot be undone.",
"pageOf": "{{current}} / {{total}}",
"shownRange": "Showing {{from}}{{to}} of {{total}}",
"pageLabel": "Page {{current}}",
"perPage": "Per page",
"searchPlaceholder": "Search…",
"selectRole": "Select role",
"selectStatus": "Select status",
"selectType": "Select type",
"selectPlan": "Select plan",
"selectLanguage": "Select language",
"selectTimezone": "Select timezone",
"selectAdmin": "Select admin",
"enterReason": "Enter reason for status change",
"unassigned": "Unassigned",
"email": "Email",
"nickname": "Nickname",
"role": "Role",
"status": "Status",
"reason": "Reason",
"phone": "Phone",
"language": "Language",
"timezone": "Timezone",
"lastLogin": "Last login",
"createdAt": "Created",
"updatedAt": "Updated",
"title": "Title",
"description": "Description",
"type": "Type",
"start": "Start",
"rating": "Rating",
"owner": "Owner",
"target": "Target",
"targetType": "Target type",
"sender": "Reporter",
"plan": "Plan",
"comment": "Comment",
"password": "Password",
"error": "Error",
"word": "Word",
"date": "Date",
"dateFrom": "From",
"dateTo": "To",
"assigned": "Assigned",
"expiresAt": "Expires at",
"count": "Count",
"firstSeen": "First seen",
"lastSeen": "Last seen",
"user": "User",
"entityUpdated": "Updated",
"entityDeleted": "Deleted",
"entityCreated": "Created",
"updateError": "Update failed",
"deleteError": "Delete failed",
"createError": "Create failed",
"loadError": "Load failed",
"notFound": "Not found",
"langRu": "Русский",
"langEn": "English",
"reviewed": "Reviewed",
"dismissed": "Dismissed",
"resolved": "Resolved",
"inProgress": "In progress",
"pendingUsers": "Unverified",
"queueEmpty": "Queue is empty",
"openInbox": "Open inbox",
"nothingFound": "Nothing found",
"category": "Category",
"ip": "IP",
"apply": "Apply",
"id": "ID",
"changeStatusTo": "Change status to «{{status}}»",
"confirmMarkAs": "Mark as «{{status}}»?",
"bulkUpdated": "Statuses updated",
"bulkUpdateError": "Bulk update failed",
"openFull": "Open full details",
"socialLinks": "Social links",
"shortName": "Short name",
"color": "Color",
"image": "Image",
"confirmation": "Confirmation",
"settings": "Settings",
"specialist": "Specialist",
"calendar": "Calendar",
"masterEvent": "Master event",
"attachments": "Attachments",
"editHistory": "Edit history",
"recurrence": "Recurrence",
"location": "Location",
"isInstance": "Is instance",
"likes": "Likes",
"dislikes": "Dislikes",
"errorHash": "Error hash",
"errorMessage": "Message",
"stacktrace": "Stack trace",
"context": "Context",
"resolution": "Resolution",
"resolutionNote": "Resolution comment",
"assign": "Assign"
},
"navGroup": {
"work": "Work",
"overview": "Overview",
"content": "Content",
"moderation": "Moderation",
"system": "System",
"account": "Account"
},
"nav": {
"inboxReports": "Inbox · reports",
"inboxTickets": "Inbox · tickets",
"dashboard": "Dashboard",
"monitoring": "Monitoring",
"users": "Users",
"calendars": "Calendars",
"events": "Events",
"subscriptions": "Subscriptions",
"reports": "Reports (list)",
"reviews": "Reviews",
"bannedWords": "Banned words",
"tickets": "Tickets (list)",
"admins": "Admins",
"audit": "Audit",
"profile": "My profile"
},
"layout": {
"search": "Search",
"searchHotkey": "Search (⌘K)",
"myProfile": "My profile",
"uiLanguage": "UI language",
"logout": "Log out",
"densityCompact": "Density: compact",
"densityNormal": "Density: comfortable",
"densityToNormal": "Comfortable density",
"densityToCompact": "Compact density",
"paletteTitle": "Command palette",
"palettePlaceholder": "Go to section…",
"paletteHints": "↑↓ navigate · Enter open · Esc close",
"nodesNoData": "Nodes: no data",
"nodesPrev": "Previous nodes",
"nodesNext": "Next nodes"
},
"explore": {
"viewAria": "List view",
"table": "Table",
"rows": "Rows",
"tops": "Tops",
"listsCount": "{{count}} lists",
"descPreviewName": "Explore · click name for preview, ⋯ for actions",
"descPreviewTitle": "Explore · click title for preview, ⋯ for actions",
"descActions": "Explore · ⋯ for actions",
"descBulk": "Explore · ⋯ for actions, bulk via row selection",
"descRowOpen": "Explore · click row to open, ⋯ for actions",
"searchEvents": "Search events…",
"searchCalendars": "Search calendars…",
"searchUsers": "Search (email, nickname)…",
"searchWord": "Search by word",
"expiringSoon": "Expiring soon",
"paid30d": "Paid · 30 days",
"byRating": "Rating",
"byReviews": "Reviews",
"positive": "Positive",
"negative": "Negative",
"allReviews": "All reviews",
"byReports": "By reports",
"loginAt": "login {{date}}",
"ownerId": "owner {{id}}"
},
"inbox": {
"reportsTitle": "Inbox · Reports",
"ticketsTitle": "Inbox · Tickets",
"reportsHints": "j/k · Enter open · 1 reviewed · 2 dismissed · link → preview",
"ticketsHints": "j/k · Enter open · 1 resolved · 2 in progress",
"pending": "Pending",
"all": "All",
"active": "Active",
"mine": "Mine",
"queue": "Queue ({{count}})",
"selectReport": "Select a report from the queue",
"selectTicket": "Select a ticket from the queue",
"reportCard": "Report {{id}}",
"fullCard": "Full card",
"bannerReports": "Day-to-day report work lives in the inbox",
"bannerTickets": "Day-to-day ticket work lives in the inbox"
},
"dashboard": {
"controlCenter": "Control Center",
"moderation": "Moderation",
"support": "Support",
"subtitleAdmin": "Work · Overview · Investigate",
"subtitleModeration": "Primary mode is the inbox. Shift KPIs only.",
"subtitleSupport": "Focus on assigned tickets — not the full error catalog.",
"reportsInboxTitle": "Reports inbox",
"reportsInboxDesc": "Split-view queue: j/k, 1 reviewed, 2 dismissed",
"ticketsInboxTitle": "Tickets inbox",
"ticketsInboxDesc": "Active / mine · j/k · 1 resolved · 2 in progress",
"reportQueueTitle": "Reports queue",
"reportQueueDesc": "Pending: {{count}}",
"ticketQueueTitle": "Tickets queue",
"ticketQueueDesc": "Open: {{count}}",
"quickOverview": "Quick overview",
"pending": "Pending",
"reviewed": "Reviewed",
"avgTimeH": "Avg time (h)",
"assignedOpen": "Assigned open",
"assignedTotal": "Assigned total",
"avgResolutionH": "Avg resolution (h)",
"eventsByDay": "Events by day",
"registrationsByDay": "Registrations by day",
"reviewsByDay": "Reviews by day",
"reportsByDay": "Reports by day",
"ticketsByDay": "Tickets by day",
"subscriptionsByDay": "Subscriptions by day",
"loadStatsError": "Failed to load stats",
"ticketsTotal": "Tickets (total)",
"ticketsOpen": "Open tickets",
"trialSubscriptions": "Trial subscriptions"
},
"monitoring": {
"title": "Observe · Monitoring",
"subtitle": "Live WS + metrics history · range {{minutes}} min",
"noData": "no data",
"waitingTitle": "Waiting for telemetry",
"waitingDesc": "Metrics appear via WebSocket or REST history for the selected range.",
"noPoints": "No data points yet.",
"node": "Node",
"nodeNamed": "Node {{name}}",
"refreshing": "Refreshing history…",
"m5": "5 min",
"m15": "15 min",
"m30": "30 min",
"h1": "1 hour",
"onlineNodes": "Online nodes",
"cpuPeak": "CPU peak (latest)",
"memoryAvg": "Memory avg",
"cpuMemory": "CPU and memory (MB)",
"memoryByType": "Memory by type (MB)",
"wsQueue": "WebSocket and queue",
"mnesia": "Mnesia",
"mnesiaTables": "Mnesia table sizes (latest)",
"noTableData": "No table data for the selected range",
"table": "Table",
"records": "Records",
"healthy": "{{count}} nodes healthy",
"load": "{{count}} nodes · load"
},
"status": {
"active": "Active",
"frozen": "Frozen",
"deleted": "Deleted",
"pending": "Pending",
"reviewed": "Reviewed",
"dismissed": "Dismissed",
"open": "Open",
"in_progress": "In progress",
"resolved": "Resolved",
"closed": "Closed",
"visible": "Visible",
"hidden": "Hidden",
"cancelled": "Cancelled",
"completed": "Completed",
"expired": "Expired"
},
"ws": {
"newReport": "New report",
"reportId": "Report #{{id}}",
"onTarget": "on {{type}}",
"newTicket": "New ticket created",
"ticketCreated": "Ticket #{{id}} created",
"open": "Open"
},
"errors": {
"forbiddenTitle": "403",
"forbiddenMessage": "You do not have access to «{{section}}».",
"renderTitle": "Render error",
"renderFallback": "Could not render this page."
},
"login": {
"title": "Sign in",
"subtitle": "Control Center · moderation and support",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"invalid_credentials": "Invalid email or password",
"insufficient_permissions": "Insufficient permissions",
"error": "Sign-in error"
},
"profile": {
"title": "My profile",
"description": "Admin account details",
"edit": "Edit",
"saved": "Profile updated",
"saveError": "Failed to update profile",
"avatarUrl": "Avatar URL",
"preferences": "Preferences",
"preferencesJson": "Preferences (JSON)",
"invalidJson": "Invalid JSON",
"preferencesInvalidJson": "Preferences must be valid JSON"
},
"users": {
"title": "Users",
"editTitle": "Edit user",
"deleteTitle": "Delete user?",
"deleteHint": "This cannot be undone. Set a reason via edit first if needed.",
"statusTitle": "Change status to «{{status}}»",
"notFound": "User not found"
},
"events": {
"title": "Events",
"entity": "Event",
"detailTitle": "Event {{name}}",
"editTitle": "Edit event",
"deleteTitle": "Delete event?",
"notFound": "Event not found",
"topsRating": "Top by rating",
"startDatetime": "Start date and time",
"durationMin": "Duration (min)",
"capacity": "Capacity",
"specialistId": "Specialist (ID)",
"calendarId": "Calendar (ID)",
"onlineLink": "Online link",
"tags": "Tags",
"recurrenceInterval": "{{freq}} (interval: {{interval}})",
"ratingWithCount": "{{avg}} ({{count}} ratings)"
},
"calendars": {
"title": "Calendars",
"entity": "Calendar",
"detailTitle": "Calendar {{name}}",
"editTitle": "Edit calendar",
"deleteTitle": "Delete calendar?",
"notFound": "Calendar not found"
},
"reports": {
"title": "Reports",
"entity": "Report",
"notFound": "Report not found",
"detailTitle": "Report {{id}}",
"resolvedAt": "Resolved at",
"resolvedBy": "Resolved by",
"id": "ID"
},
"reviews": {
"title": "Reviews",
"detailTitle": "Review {{id}}",
"editTitle": "Edit review",
"hideSelected": "Hide selected",
"showSelected": "Show selected",
"deleteSelected": "Delete selected",
"notFound": "Review not found",
"score": "Score",
"likesDislikes": "Likes / Dislikes",
"selectAllAria": "Select all on page",
"selectReviewAria": "Select review {{id}}",
"selectReviews": "Select reviews",
"reasonRequired": "Enter a reason for the status change",
"reasonRequiredShort": "Enter a reason",
"bulkStatusTitle": "Change status to «{{status}}» for {{count}} reviews",
"bulkReasonPlaceholder": "Shared reason for selected reviews",
"ratingValue": "Score {{rating}}"
},
"tickets": {
"title": "Tickets",
"detailTitle": "Ticket {{id}}",
"deleteTitle": "Delete ticket?",
"notFound": "Ticket not found",
"statsHint": "Explore · closed {{closed}}, total errors {{errors}}"
},
"subscriptions": {
"title": "Subscriptions",
"editTitle": "Edit subscription",
"deleteTitle": "Delete subscription?",
"trial": "Trial",
"trialUsed": "Trial used",
"trialCount": "Trial",
"expiresUntil": "until {{date}}",
"trialBadge": "trial"
},
"admins": {
"title": "Admins",
"detailTitle": "Admin {{name}}",
"description": "Manage administrator accounts",
"add": "Add admin",
"createTitle": "Create admin",
"editTitle": "Edit admin",
"deleteTitle": "Delete admin?",
"notFound": "Admin not found"
},
"audit": {
"title": "Audit",
"description": "Administrator action log",
"admin": "Admin",
"action": "Action",
"name": "Name",
"searchAdmin": "Search administrator",
"searchAdminPlaceholder": "Email…"
},
"bannedWords": {
"title": "Banned words",
"description": "Manage banned words in content",
"placeholder": "New word",
"deleteTitle": "Delete word?",
"deleteBody": "«{{word}}» will be removed from the banned list.",
"addedBy": "Added by",
"addedAt": "Added at"
},
"menu": {
"dashboard": "Dashboard",
"users": "Users",
@@ -12,23 +450,5 @@
"admins": "Admins",
"audit": "Audit",
"monitoring": "Monitoring"
},
"profile": {
"title": "My profile",
"edit": "Edit",
"saved": "Profile updated"
},
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"invalid_credentials": "Invalid email or password",
"insufficient_permissions": "Insufficient permissions",
"error": "Sign-in error"
},
"ws": {
"newReport": "New report",
"newTicket": "New ticket"
}
}
+438 -18
View File
@@ -1,4 +1,442 @@
{
"common": {
"appTitle": "EventHub",
"cancel": "Отмена",
"save": "Сохранить",
"saving": "Сохранение…",
"delete": "Удалить",
"edit": "Редактировать",
"open": "Открыть",
"close": "Закрыть",
"create": "Создать",
"add": "Добавить",
"find": "Найти",
"search": "Поиск",
"actions": "Действия",
"backToList": "К списку",
"tryAgain": "Попробовать снова",
"toDashboard": "На дашборд",
"yes": "Да",
"no": "Нет",
"noData": "Нет данных",
"loading": "Загрузка…",
"emDash": "—",
"all": "Все",
"allStatuses": "Все статусы",
"allPlans": "Все планы",
"allTypes": "Все типы",
"total": "Всего",
"freeze": "Заморозить",
"unfreeze": "Разморозить",
"quickView": "Быстрый просмотр",
"show": "Показать",
"hide": "Скрыть",
"irreversible": "Это действие нельзя отменить.",
"pageOf": "{{current}} / {{total}}",
"shownRange": "Показано {{from}}{{to}} из {{total}}",
"pageLabel": "Страница {{current}}",
"perPage": "На странице",
"searchPlaceholder": "Поиск…",
"selectRole": "Выберите роль",
"selectStatus": "Выберите статус",
"selectType": "Выберите тип",
"selectPlan": "Выберите план",
"selectLanguage": "Выберите язык",
"selectTimezone": "Выберите пояс",
"selectAdmin": "Выберите администратора",
"enterReason": "Введите причину изменения статуса",
"unassigned": "Не назначен",
"email": "Email",
"nickname": "Ник",
"role": "Роль",
"status": "Статус",
"reason": "Причина",
"phone": "Телефон",
"language": "Язык",
"timezone": "Часовой пояс",
"lastLogin": "Последний вход",
"createdAt": "Создано",
"updatedAt": "Обновлено",
"title": "Название",
"description": "Описание",
"type": "Тип",
"start": "Начало",
"rating": "Рейтинг",
"owner": "Владелец",
"target": "Цель",
"targetType": "Тип цели",
"sender": "Отправитель",
"plan": "План",
"comment": "Комментарий",
"password": "Пароль",
"error": "Ошибка",
"word": "Слово",
"date": "Дата",
"dateFrom": "Дата с",
"dateTo": "Дата по",
"assigned": "Назначен",
"expiresAt": "Дата окончания",
"count": "Повторов",
"firstSeen": "Первый раз",
"lastSeen": "Последний",
"user": "Пользователь",
"entityUpdated": "Обновлено",
"entityDeleted": "Удалено",
"entityCreated": "Создано",
"updateError": "Ошибка обновления",
"deleteError": "Ошибка удаления",
"createError": "Ошибка создания",
"loadError": "Ошибка загрузки",
"notFound": "Не найдено",
"langRu": "Русский",
"langEn": "English",
"reviewed": "Рассмотрено",
"dismissed": "Отклонено",
"resolved": "Решено",
"inProgress": "В работу",
"pendingUsers": "Неподтверждённые",
"queueEmpty": "Очередь пуста",
"openInbox": "Открыть входящие",
"nothingFound": "Ничего не найдено",
"category": "Категория",
"ip": "IP",
"apply": "Применить",
"id": "ID",
"changeStatusTo": "Изменить статус на «{{status}}»",
"confirmMarkAs": "Отметить как «{{status}}»?",
"bulkUpdated": "Статусы обновлены",
"bulkUpdateError": "Ошибка массового обновления",
"openFull": "Открыть полностью",
"socialLinks": "Социальные ссылки",
"shortName": "Короткое имя",
"color": "Цвет",
"image": "Изображение",
"confirmation": "Подтверждение",
"settings": "Настройки",
"specialist": "Специалист",
"calendar": "Календарь",
"masterEvent": "Мастер-событие",
"attachments": "Вложения",
"editHistory": "История изменений",
"recurrence": "Повторение",
"location": "Локация",
"isInstance": "Является экземпляром",
"likes": "Лайки",
"dislikes": "Дизлайки",
"errorHash": "Хеш ошибки",
"errorMessage": "Сообщение",
"stacktrace": "Стектрейс",
"context": "Контекст",
"resolution": "Решение",
"resolutionNote": "Комментарий решения",
"assign": "Назначить"
},
"navGroup": {
"work": "Работа",
"overview": "Обзор",
"content": "Контент",
"moderation": "Модерация",
"system": "Система",
"account": "Аккаунт"
},
"nav": {
"inboxReports": "Входящие · жалобы",
"inboxTickets": "Входящие · тикеты",
"dashboard": "Дашборд",
"monitoring": "Мониторинг",
"users": "Пользователи",
"calendars": "Календари",
"events": "События",
"subscriptions": "Подписки",
"reports": "Жалобы (список)",
"reviews": "Отзывы",
"bannedWords": "Бан-слова",
"tickets": "Тикеты (список)",
"admins": "Администраторы",
"audit": "Аудит",
"profile": "Мой профиль"
},
"layout": {
"search": "Поиск",
"searchHotkey": "Поиск (⌘K)",
"myProfile": "Мой профиль",
"uiLanguage": "Язык интерфейса",
"logout": "Выйти",
"densityCompact": "Плотность: компактная",
"densityNormal": "Плотность: обычная",
"densityToNormal": "Обычная плотность",
"densityToCompact": "Компактная плотность",
"paletteTitle": "Командная палитра",
"palettePlaceholder": "Перейти к разделу…",
"paletteHints": "↑↓ навигация · Enter открыть · Esc закрыть",
"nodesNoData": "Ноды: нет данных",
"nodesPrev": "Предыдущие ноды",
"nodesNext": "Следующие ноды"
},
"explore": {
"viewAria": "Вид списка",
"table": "Таблица",
"rows": "Строки",
"tops": "Топы",
"listsCount": "{{count}} списка",
"descPreviewName": "Explore · клик по имени — preview, ⋯ — действия",
"descPreviewTitle": "Explore · клик по названию — preview, ⋯ — действия",
"descActions": "Explore · ⋯ — действия",
"descBulk": "Explore · ⋯ — действия, массовые операции через выбор строк",
"descRowOpen": "Explore · клик по строке — открыть, ⋯ — действия",
"searchEvents": "Поиск событий…",
"searchCalendars": "Поиск календарей…",
"searchUsers": "Поиск (email, ник)…",
"searchWord": "Поиск по слову",
"expiringSoon": "Скоро истекают",
"paid30d": "Платные · 30 дней",
"byRating": "Рейтинг",
"byReviews": "Отзывы",
"positive": "Позитивные",
"negative": "Негативные",
"allReviews": "Все отзывы",
"byReports": "По жалобам",
"loginAt": "вход {{date}}",
"ownerId": "владелец {{id}}"
},
"inbox": {
"reportsTitle": "Входящие · Жалобы",
"ticketsTitle": "Входящие · Тикеты",
"reportsHints": "j/k · Enter открыть · 1 рассмотрено · 2 отклонено · клик по ссылке → preview",
"ticketsHints": "j/k · Enter открыть · 1 решено · 2 в работу",
"pending": "Ожидают",
"all": "Все",
"active": "Активные",
"mine": "Мои",
"queue": "Очередь ({{count}})",
"selectReport": "Выберите жалобу из очереди",
"selectTicket": "Выберите тикет из очереди",
"reportCard": "Жалоба {{id}}",
"fullCard": "Полная карточка",
"bannerReports": "Оперативная работа с жалобами — во входящих",
"bannerTickets": "Оперативная работа с тикетами — во входящих"
},
"dashboard": {
"controlCenter": "Control Center",
"moderation": "Модерация",
"support": "Поддержка",
"subtitleAdmin": "Работа · Обзор · расследование",
"subtitleModeration": "Главный рабочий режим — входящие. Здесь только KPI смены.",
"subtitleSupport": "Фокус на назначенных тикетах — не на полном каталоге ошибок.",
"reportsInboxTitle": "Входящие жалобы",
"reportsInboxDesc": "Split-view очередь: j/k, 1 рассмотрено, 2 отклонено",
"ticketsInboxTitle": "Входящие тикеты",
"ticketsInboxDesc": "Активные / мои · j/k · 1 решено · 2 в работу",
"reportQueueTitle": "Очередь жалоб",
"reportQueueDesc": "Ожидают: {{count}}",
"ticketQueueTitle": "Очередь тикетов",
"ticketQueueDesc": "Открытых: {{count}}",
"quickOverview": "Быстрый обзор",
"pending": "Ожидают",
"reviewed": "Рассмотрено",
"avgTimeH": "Ср. время (ч)",
"assignedOpen": "Назначено открытых",
"assignedTotal": "Всего назначено",
"avgResolutionH": "Ср. решение (ч)",
"eventsByDay": "События по дням",
"registrationsByDay": "Регистрации по дням",
"reviewsByDay": "Отзывы по дням",
"reportsByDay": "Жалобы по дням",
"ticketsByDay": "Тикеты по дням",
"subscriptionsByDay": "Подписки по дням",
"loadStatsError": "Не удалось загрузить статистику",
"ticketsTotal": "Тикеты (всего)",
"ticketsOpen": "Открытых тикетов",
"trialSubscriptions": "Пробные подписки"
},
"monitoring": {
"title": "Наблюдение · Мониторинг",
"subtitle": "Live WS + история метрик · диапазон {{minutes}} мин",
"noData": "нет данных",
"waitingTitle": "Ожидание телеметрии",
"waitingDesc": "Метрики появятся по WebSocket или после ответа REST history за выбранный интервал.",
"noPoints": "Пока нет точек данных.",
"node": "Нода",
"nodeNamed": "Нода {{name}}",
"refreshing": "Обновление истории…",
"m5": "5 мин",
"m15": "15 мин",
"m30": "30 мин",
"h1": "1 час",
"onlineNodes": "Онлайн-ноды",
"cpuPeak": "Пик CPU (latest)",
"memoryAvg": "Память avg",
"cpuMemory": "CPU и память (МБ)",
"memoryByType": "Память по типам (МБ)",
"wsQueue": "WebSocket и очередь",
"mnesia": "Mnesia",
"mnesiaTables": "Размеры таблиц Mnesia (последняя точка)",
"noTableData": "Нет данных по таблицам за выбранный интервал",
"table": "Таблица",
"records": "Записей",
"healthy": "{{count}} нод в норме",
"load": "{{count}} нод · нагрузка"
},
"status": {
"active": "Активен",
"frozen": "Заморожен",
"deleted": "Удалён",
"pending": "Ожидает",
"reviewed": "Рассмотрено",
"dismissed": "Отклонено",
"open": "Открыт",
"in_progress": "В работе",
"resolved": "Решён",
"closed": "Закрыт",
"visible": "Видимый",
"hidden": "Скрыт",
"cancelled": "Отменён",
"completed": "Завершён",
"expired": "Истёк"
},
"ws": {
"newReport": "Новая жалоба",
"reportId": "Жалоба #{{id}}",
"onTarget": "на {{type}}",
"newTicket": "Создан новый тикет",
"ticketCreated": "Тикет #{{id}} создан",
"open": "Открыть"
},
"errors": {
"forbiddenTitle": "403",
"forbiddenMessage": "У вас нет доступа к разделу «{{section}}».",
"renderTitle": "Ошибка отображения",
"renderFallback": "Не удалось отобразить страницу."
},
"login": {
"title": "Вход",
"subtitle": "Control Center · модерация и поддержка",
"email": "Email",
"password": "Пароль",
"submit": "Войти",
"invalid_credentials": "Неверный email или пароль",
"insufficient_permissions": "Недостаточно прав для входа",
"error": "Ошибка входа"
},
"profile": {
"title": "Мой профиль",
"description": "Данные учётной записи администратора",
"edit": "Редактировать",
"saved": "Профиль обновлён",
"saveError": "Ошибка обновления профиля",
"avatarUrl": "URL аватара",
"preferences": "Настройки",
"preferencesJson": "Настройки (JSON)",
"invalidJson": "Невалидный JSON",
"preferencesInvalidJson": "Поле «Настройки» должно быть валидным JSON"
},
"users": {
"title": "Пользователи",
"editTitle": "Редактировать пользователя",
"deleteTitle": "Удалить пользователя?",
"deleteHint": "Это действие нельзя отменить. При необходимости предварительно укажите причину через редактирование.",
"statusTitle": "Изменить статус на «{{status}}»",
"notFound": "Пользователь не найден"
},
"events": {
"title": "События",
"entity": "Событие",
"detailTitle": "Событие {{name}}",
"editTitle": "Редактировать событие",
"deleteTitle": "Удалить событие?",
"notFound": "Событие не найдено",
"topsRating": "Топ по рейтингу",
"startDatetime": "Дата и время начала",
"durationMin": "Продолжительность (мин)",
"capacity": "Вместимость",
"specialistId": "Специалист (ID)",
"calendarId": "Календарь (ID)",
"onlineLink": "Ссылка онлайн",
"tags": "Теги",
"recurrenceInterval": "{{freq}} (интервал: {{interval}})",
"ratingWithCount": "{{avg}} ({{count}} оценок)"
},
"calendars": {
"title": "Календари",
"entity": "Календарь",
"detailTitle": "Календарь {{name}}",
"editTitle": "Редактировать календарь",
"deleteTitle": "Удалить календарь?",
"notFound": "Календарь не найден"
},
"reports": {
"title": "Жалобы",
"entity": "Жалоба",
"notFound": "Жалоба не найдена",
"detailTitle": "Жалоба {{id}}",
"resolvedAt": "Решено",
"resolvedBy": "Кем решено",
"id": "ID"
},
"reviews": {
"title": "Отзывы",
"detailTitle": "Отзыв {{id}}",
"editTitle": "Редактировать отзыв",
"hideSelected": "Скрыть выбранные",
"showSelected": "Показать выбранные",
"deleteSelected": "Удалить выбранные",
"notFound": "Отзыв не найден",
"score": "Оценка",
"likesDislikes": "Лайки / Дизлайки",
"selectAllAria": "Выбрать все на странице",
"selectReviewAria": "Выбрать отзыв {{id}}",
"selectReviews": "Выберите отзывы",
"reasonRequired": "Укажите причину изменения статуса",
"reasonRequiredShort": "Укажите причину",
"bulkStatusTitle": "Изменить статус на «{{status}}» для {{count}} отзывов",
"bulkReasonPlaceholder": "Общая причина для выбранных отзывов",
"ratingValue": "Оценка {{rating}}"
},
"tickets": {
"title": "Тикеты",
"detailTitle": "Тикет {{id}}",
"deleteTitle": "Удалить тикет?",
"notFound": "Тикет не найден",
"statsHint": "Explore · закрыто {{closed}}, всего ошибок {{errors}}"
},
"subscriptions": {
"title": "Подписки",
"editTitle": "Редактировать подписку",
"deleteTitle": "Удалить подписку?",
"trial": "Пробный",
"trialUsed": "Пробный период использован",
"trialCount": "Пробные",
"expiresUntil": "до {{date}}",
"trialBadge": "пробный"
},
"admins": {
"title": "Администраторы",
"detailTitle": "Администратор {{name}}",
"description": "Управление учётными записями администраторов",
"add": "Добавить администратора",
"createTitle": "Создать администратора",
"editTitle": "Редактировать администратора",
"deleteTitle": "Удалить администратора?",
"notFound": "Администратор не найден"
},
"audit": {
"title": "Аудит",
"description": "Журнал действий администраторов",
"admin": "Администратор",
"action": "Действие",
"name": "Наименование",
"searchAdmin": "Поиск администратора",
"searchAdminPlaceholder": "Email…"
},
"bannedWords": {
"title": "Бан-слова",
"description": "Управление запрещёнными словами в контенте",
"placeholder": "Новое слово",
"deleteTitle": "Удалить слово?",
"deleteBody": "Слово «{{word}}» будет удалено из списка запрещённых.",
"addedBy": "Кем добавлено",
"addedAt": "Дата добавления"
},
"menu": {
"dashboard": "Дашборд",
"users": "Пользователи",
@@ -12,23 +450,5 @@
"admins": "Администраторы",
"audit": "Аудит",
"monitoring": "Мониторинг"
},
"profile": {
"title": "Мой профиль",
"edit": "Редактировать",
"saved": "Профиль обновлён"
},
"login": {
"title": "Вход",
"email": "Email",
"password": "Пароль",
"submit": "Войти",
"invalid_credentials": "Неверный email или пароль",
"insufficient_permissions": "Недостаточно прав для входа",
"error": "Ошибка входа"
},
"ws": {
"newReport": "Новая жалоба",
"newTicket": "Новый тикет"
}
}
+6
View File
@@ -1,13 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Toaster } from 'sonner';
import App from './App';
import './index.css';
import './i18n';
import LocaleProvider from './components/LocaleProvider';
import { initTableDensity } from './lib/density';
initTableDensity();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<LocaleProvider>
<App />
<Toaster richColors position="top-right" />
</LocaleProvider>
</React.StrictMode>
);
+282 -134
View File
@@ -1,159 +1,307 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Modal, Form, Input, Select, message } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import { useAdmin, useUpdateAdmin } from '../../hooks/useAdmins';
import { useTranslation } from 'react-i18next';
import { useForm, Controller } from 'react-hook-form';
import dayjs from 'dayjs';
import { ArrowLeft, Pencil } from 'lucide-react';
import { useAdmin, useUpdateAdmin } from '@/hooks/useAdmins';
import { formatDisplayValue } from '@/lib/utils';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? undefined : val);
const roleBadgeVariant = (role: string) => {
if (role === 'superadmin') return 'destructive' as const;
if (role === 'admin') return 'default' as const;
if (role === 'moderator') return 'secondary' as const;
return 'outline' as const;
};
interface AdminFormValues {
nickname?: string;
email?: string;
role?: string;
status?: string;
timezone?: string;
language?: string;
phone?: string;
}
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
const text = formatDisplayValue(value);
if (text === '-') return <>{emptyLabel}</>;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
}
return <>{text}</>;
};
const AdminDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: admin, isLoading } = useAdmin(id || '');
const updateAdmin = useUpdateAdmin();
const [editModal, setEditModal] = useState(false);
const [form] = Form.useForm();
// Очистка значения
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
// Заполнение формы при открытии
useEffect(() => {
if (editModal && admin) {
setTimeout(() => {
form.setFieldsValue({
nickname: clean(admin.nickname),
email: clean(admin.email),
role: clean(admin.role),
status: clean(admin.status),
timezone: clean(admin.timezone),
language: clean(admin.language),
phone: clean(admin.phone),
});
}, 0);
}
}, [editModal, admin, form]);
const handleSave = () => {
form.validateFields().then((values) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: id!, data: payload },
{
onSuccess: () => {
setEditModal(false);
message.success('Данные обновлены');
},
}
);
});
};
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const form = useForm<AdminFormValues>();
const emDash = t('common.emDash');
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
if (isBadValue(dateStr)) return emDash;
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
useEffect(() => {
if (editModal && admin) {
form.reset({
nickname: clean(admin.nickname) as string | undefined,
email: clean(admin.email) as string | undefined,
role: clean(admin.role) as string | undefined,
status: clean(admin.status) as string | undefined,
timezone: clean(admin.timezone) as string | undefined,
language: clean(admin.language) as string | undefined,
phone: clean(admin.phone) as string | undefined,
});
}
}, [editModal, admin, form]);
const onSave = (values: AdminFormValues) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: id!, data: payload },
{
onSuccess: () => {
setEditModal(false);
},
}
);
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!admin) return <p>Администратор не найден</p>;
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!admin) return <p className="text-muted-foreground">{t('admins.notFound')}</p>;
const titleName = !isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id;
return (
<Card
title={`Администратор ${!isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id}`}
extra={
<Button icon={<EditOutlined />} onClick={() => setEditModal(true)}>
Редактировать
</Button>
}
>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="ID">{admin.id}</Descriptions.Item>
<Descriptions.Item label="Email">{admin.email}</Descriptions.Item>
<Descriptions.Item label="Ник">{isBadValue(admin.nickname) ? '-' : admin.nickname}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag color={roleColors[admin.role] || 'default'}>{admin.role}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={admin.status === 'active' ? 'green' : 'red'}>{admin.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{isBadValue(admin.timezone) ? '-' : admin.timezone}</Descriptions.Item>
<Descriptions.Item label="Язык">{isBadValue(admin.language) ? '-' : admin.language}</Descriptions.Item>
<Descriptions.Item label="Телефон">{isBadValue(admin.phone) ? '-' : admin.phone}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">{isBadValue(admin.avatar_url) ? '-' : admin.avatar_url}</Descriptions.Item>
<Descriptions.Item label="Настройки">{isBadValue(admin.preferences) ? '-' : JSON.stringify(admin.preferences)}</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(admin.last_login)}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(admin.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(admin.updated_at)}</Descriptions.Item>
</Descriptions>
<>
<PageHeader
title={t('admins.detailTitle', { name: titleName })}
breadcrumbs={[
{ label: t('admins.title'), href: '/admins' },
{ label: String(titleName) },
]}
actions={
<>
<Button variant="outline" size="sm" onClick={() => setEditModal(true)}>
<Pencil className="h-4 w-4" />
{t('common.edit')}
</Button>
<Button variant="outline" onClick={() => navigate('/admins')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
</>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{admin.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.email')}</dt>
<dd>{admin.email}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.nickname')}</dt>
<dd>{isBadValue(admin.nickname) ? emDash : admin.nickname}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.role')}</dt>
<dd>
<Badge variant={roleBadgeVariant(admin.role)}>{admin.role}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={admin.status === 'active' ? 'success' : 'destructive'}>{admin.status}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.timezone')}</dt>
<dd>{isBadValue(admin.timezone) ? emDash : admin.timezone}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.language')}</dt>
<dd>{isBadValue(admin.language) ? emDash : admin.language}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.phone')}</dt>
<dd>{isBadValue(admin.phone) ? emDash : admin.phone}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.avatarUrl')}</dt>
<dd>{isBadValue(admin.avatar_url) ? emDash : admin.avatar_url}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.preferencesJson')}</dt>
<dd><DetailValue value={admin.preferences} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.lastLogin')}</dt>
<dd>{formatDate(admin.last_login)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{formatDate(admin.created_at)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.updatedAt')}</dt>
<dd>{formatDate(admin.updated_at)}</dd>
</div>
</dl>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/admins')}>Назад к списку</Button>
</CardContent>
</Card>
<Modal
title="Редактировать администратора"
open={editModal}
onCancel={() => setEditModal(false)}
onOk={handleSave}
confirmLoading={updateAdmin.isPending}
destroyOnHidden
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="nickname" label="Ник">
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
<Form.Item name="role" label="Роль">
<Select>
<Select.Option value="superadmin">superadmin</Select.Option>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
<Form.Item name="status" label="Статус">
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="blocked">blocked</Select.Option>
</Select>
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
</Form>
</Modal>
</Card>
<Dialog open={editModal} onOpenChange={setEditModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admins.editTitle')}</DialogTitle>
</DialogHeader>
<form id="admin-edit-form" className="space-y-4" onSubmit={form.handleSubmit(onSave)}>
<div className="space-y-2">
<Label htmlFor="nickname">{t('common.nickname')}</Label>
<Input id="nickname" {...form.register('nickname')} />
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('common.email')}</Label>
<Input id="email" {...form.register('email')} />
</div>
<div className="space-y-2">
<Label>{t('common.role')}</Label>
<Controller
name="role"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="superadmin">superadmin</SelectItem>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="moderator">moderator</SelectItem>
<SelectItem value="support">support</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active">active</SelectItem>
<SelectItem value="blocked">blocked</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.timezone')}</Label>
<Controller
name="timezone"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue placeholder={t('common.selectTimezone')} /></SelectTrigger>
<SelectContent>
<SelectItem value="UTC">UTC</SelectItem>
<SelectItem value="Europe/Moscow">Europe/Moscow (Москва)</SelectItem>
<SelectItem value="Europe/London">Europe/London (Лондон)</SelectItem>
<SelectItem value="Europe/Berlin">Europe/Berlin (Берлин)</SelectItem>
<SelectItem value="America/New_York">America/New_York (Нью-Йорк)</SelectItem>
<SelectItem value="Asia/Tokyo">Asia/Tokyo (Токио)</SelectItem>
<SelectItem value="Asia/Shanghai">Asia/Shanghai (Шанхай)</SelectItem>
<SelectItem value="Australia/Sydney">Australia/Sydney (Сидней)</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.language')}</Label>
<Controller
name="language"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue placeholder={t('common.selectLanguage')} /></SelectTrigger>
<SelectContent>
<SelectItem value="ru">{t('common.langRu')}</SelectItem>
<SelectItem value="en">{t('common.langEn')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t('common.phone')}</Label>
<Input id="phone" placeholder="+7 (999) 123-45-67" {...form.register('phone')} />
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal(false)}>{t('common.cancel')}</Button>
<Button type="submit" form="admin-edit-form" disabled={updateAdmin.isPending}>
{updateAdmin.isPending ? t('common.saving') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
+415 -264
View File
@@ -1,300 +1,451 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Input, Select, Tooltip, Spin } from 'antd';
import { InfoCircleOutlined, EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Info, Pencil, Trash2, Plus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useAdmins, useCreateAdmin, useUpdateAdmin, useDeleteAdmin, useAdmin } from '../../hooks/useAdmins';
import { Admin, AdminListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { useForm, Controller } from 'react-hook-form';
import { useAdmins, useCreateAdmin, useUpdateAdmin, useDeleteAdmin, useAdmin } from '@/hooks/useAdmins';
import { useExploreView } from '@/hooks/useExploreView';
import { Admin, AdminListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
import type { DenseRowModel } from '@/components/explore/DenseRowList';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
const TABLE_KEY = 'admins';
const roleBadgeVariant = (role: string) => {
if (role === 'superadmin') return 'destructive' as const;
if (role === 'admin') return 'default' as const;
if (role === 'moderator') return 'secondary' as const;
return 'outline' as const;
};
const statusBadgeVariant = (status: string) => (status === 'active' ? 'success' as const : 'destructive' as const);
interface CreateFormValues {
email: string;
password: string;
role: string;
}
interface EditFormValues {
nickname?: string;
email?: string;
role?: string;
status?: string;
timezone?: string;
language?: string;
phone?: string;
}
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? undefined : val);
function adminActions(
handlers: { onOpen: () => void; onEdit: () => void; onDelete: () => void },
t: TFunction
): RowAction[] {
return [
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
{ label: t('common.edit'), icon: <Pencil className="h-4 w-4" />, onClick: handlers.onEdit },
{
label: t('common.delete'),
icon: <Trash2 className="h-4 w-4" />,
onClick: handlers.onDelete,
destructive: true,
separatorBefore: true,
},
];
}
const AdminListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<AdminListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'email', order: 'asc' });
const { viewMode, setViewMode } = useExploreView();
const [params, setParams] = useState<AdminListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'email',
order: 'asc',
});
const { data, isLoading } = useAdmins(params);
const createAdmin = useCreateAdmin();
const updateAdmin = useUpdateAdmin();
const deleteAdmin = useDeleteAdmin();
// Модальное окно создания
const [createModal, setCreateModal] = useState(false);
const [createForm] = Form.useForm();
const createForm = useForm<CreateFormValues>({ defaultValues: { role: 'admin' } });
// Модальное окно редактирования
const [editModal, setEditModal] = useState<{ open: boolean; adminId: string | null }>({
open: false,
adminId: null,
});
const { data: editingAdmin, isLoading: loadingAdmin } = useAdmin(editModal.adminId || '');
const [editForm] = Form.useForm();
const editForm = useForm<EditFormValues>();
// Очистка значения от "undefined" и "-"
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; adminId: string | null }>({
open: false,
adminId: null,
});
// Заполнение формы редактирования при загрузке данных
useEffect(() => {
if (editModal.open && editingAdmin) {
setTimeout(() => {
editForm.setFieldsValue({
nickname: clean(editingAdmin.nickname),
email: clean(editingAdmin.email),
role: clean(editingAdmin.role),
status: clean(editingAdmin.status),
timezone: clean(editingAdmin.timezone),
language: clean(editingAdmin.language),
phone: clean(editingAdmin.phone),
});
}, 0);
editForm.reset({
nickname: clean(editingAdmin.nickname) as string,
email: clean(editingAdmin.email) as string,
role: clean(editingAdmin.role) as string,
status: clean(editingAdmin.status) as string,
timezone: clean(editingAdmin.timezone) as string,
language: clean(editingAdmin.language) as string,
phone: clean(editingAdmin.phone) as string,
});
}
}, [editingAdmin, editModal.open, editForm]);
const handleCreate = () => {
createForm.validateFields().then((values) => {
createAdmin.mutate(values, {
onSuccess: () => {
setCreateModal(false);
createForm.resetFields();
useEffect(() => {
if (createModal) {
createForm.reset({ email: '', password: '', role: 'admin' });
}
}, [createModal, createForm]);
const handleCreate = createForm.handleSubmit((values) => {
createAdmin.mutate(values, {
onSuccess: () => {
setCreateModal(false);
createForm.reset();
},
});
});
const handleEdit = (admin: Admin) => setEditModal({ open: true, adminId: admin.id });
const handleUpdate = editForm.handleSubmit((values) => {
if (!editModal.adminId) return;
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: editModal.adminId, data: payload },
{ onSuccess: () => setEditModal({ open: false, adminId: null }) }
);
});
const handleDelete = (id: string) => setDeleteConfirm({ open: true, adminId: id });
const confirmDelete = () => {
if (!deleteConfirm.adminId) return;
deleteAdmin.mutate(deleteConfirm.adminId, {
onSuccess: () => setDeleteConfirm({ open: false, adminId: null }),
});
};
const exploreStats = useMemo(() => {
if (data?.total === undefined) return [];
return [{ label: t('common.total'), value: data.total }];
}, [data?.total, t]);
const columns = useMemo<ColumnDef<Admin>[]>(
() => [
{ accessorKey: 'email', header: t('common.email'), enableSorting: true },
{ accessorKey: 'nickname', header: t('common.nickname'), enableSorting: true },
{
accessorKey: 'role',
header: t('common.role'),
enableSorting: true,
cell: ({ getValue }) => {
const role = getValue<string>();
return <Badge variant={roleBadgeVariant(role)}>{role}</Badge>;
},
});
});
};
const handleEdit = (admin: Admin) => {
// Не сбрасываем форму вручную, destroyOnHidden очистит её после предыдущего закрытия
setEditModal({ open: true, adminId: admin.id });
};
const handleUpdate = () => {
editForm.validateFields().then((values) => {
if (!editModal.adminId) return;
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: editModal.adminId, data: payload },
{ onSuccess: () => setEditModal({ open: false, adminId: null }) }
);
});
};
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить администратора?',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteAdmin.mutate(id),
});
};
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<Admin> | SorterResult<Admin>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
};
const roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
};
const columns: ColumnsType<Admin> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу администратора">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/admins/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Email',
dataIndex: 'email',
key: 'email',
sorter: true,
ellipsis: true,
},
{
title: 'Ник',
dataIndex: 'nickname',
key: 'nickname',
sorter: true,
ellipsis: true,
},
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
width: 120,
sorter: true,
render: (role: string) => (
<Tag color={roleColors[role] || 'default'}>{role}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : 'red'}>{status}</Tag>
),
},
{
title: 'Последний вход',
dataIndex: 'last_login',
key: 'last_login',
sorter: true,
},
{
title: 'Действия',
key: 'actions',
width: 80,
render: (_, record) => (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record)}
},
{
accessorKey: 'status',
header: t('common.status'),
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={statusBadgeVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{ accessorKey: 'last_login', header: t('common.lastLogin'), enableSorting: true },
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={adminActions({
onOpen: () => navigate(`/admins/${record.id}`),
onEdit: () => handleEdit(record),
onDelete: () => handleDelete(record.id),
}, t)}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
/>
</Tooltip>
</Space>
);
},
},
],
[navigate, t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => ({
id: record.id,
title: record.email,
subtitle: record.nickname && record.nickname !== '-' ? record.nickname : undefined,
badges: (
<>
<Badge variant={roleBadgeVariant(record.role)}>{record.role}</Badge>
<Badge variant={statusBadgeVariant(record.status)}>{formatStatusLabel(record.status)}</Badge>
</>
),
},
];
meta: record.last_login ? t('explore.loginAt', { date: record.last_login }) : undefined,
onActivate: () => navigate(`/admins/${record.id}`),
actions: adminActions({
onOpen: () => navigate(`/admins/${record.id}`),
onEdit: () => handleEdit(record),
onDelete: () => handleDelete(record.id),
}, t),
}));
}, [data?.data, navigate, t]);
return (
<div>
<h2>Администраторы</h2>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModal(true)}
style={{ marginBottom: 16 }}
<>
<ExploreListShell
title={t('admins.title')}
description={t('explore.descRowOpen')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
actions={
<Button onClick={() => setCreateModal(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('admins.add')}
</Button>
}
>
Добавить администратора
</Button>
<Table
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
/>
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
{/* Модальное окно создания */}
<Modal
title="Создать администратора"
open={createModal}
onCancel={() => setCreateModal(false)}
onOk={handleCreate}
confirmLoading={createAdmin.isPending}
destroyOnHidden
>
<Form form={createForm} layout="vertical" preserve={false}>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Form.Item name="role" label="Роль" rules={[{ required: true }]}>
<Select>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
<Dialog open={createModal} onOpenChange={setCreateModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admins.createTitle')}</DialogTitle>
</DialogHeader>
<form id="create-admin-form" className="space-y-4" onSubmit={handleCreate}>
<div className="space-y-2">
<Label>{t('common.email')}</Label>
<Input
type="email"
{...createForm.register('email', { required: true })}
/>
</div>
<div className="space-y-2">
<Label>{t('common.password')}</Label>
<Input
type="password"
{...createForm.register('password', { required: true })}
/>
</div>
<div className="space-y-2">
<Label>{t('common.role')}</Label>
<Controller
name="role"
control={createForm.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectRole')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="moderator">moderator</SelectItem>
<SelectItem value="support">support</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateModal(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" form="create-admin-form" disabled={createAdmin.isPending}>
{t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Модальное окно редактирования */}
<Modal
title="Редактировать администратора"
open={editModal.open}
onCancel={() => setEditModal({ open: false, adminId: null })}
onOk={handleUpdate}
confirmLoading={updateAdmin.isPending}
destroyOnHidden
>
{loadingAdmin ? (
<Spin />
) : (
<Form form={editForm} layout="vertical" preserve={false}>
<Form.Item name="nickname" label="Ник">
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
<Form.Item name="role" label="Роль">
<Select>
<Select.Option value="superadmin">superadmin</Select.Option>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
<Form.Item name="status" label="Статус">
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="blocked">blocked</Select.Option>
</Select>
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
</Form>
)}
</Modal>
</div>
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, adminId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admins.editTitle')}</DialogTitle>
</DialogHeader>
{loadingAdmin ? (
<div className="space-y-3 py-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="edit-admin-form" className="space-y-4" onSubmit={handleUpdate}>
<div className="space-y-2">
<Label>{t('common.nickname')}</Label>
<Input {...editForm.register('nickname')} />
</div>
<div className="space-y-2">
<Label>{t('common.email')}</Label>
<Input {...editForm.register('email')} />
</div>
<div className="space-y-2">
<Label>{t('common.role')}</Label>
<Controller
name="role"
control={editForm.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectRole')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="superadmin">superadmin</SelectItem>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="moderator">moderator</SelectItem>
<SelectItem value="support">support</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={editForm.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">active</SelectItem>
<SelectItem value="blocked">blocked</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.timezone')}</Label>
<Controller
name="timezone"
control={editForm.control}
render={({ field }) => (
<Select value={field.value ?? ''} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectTimezone')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="UTC">UTC</SelectItem>
<SelectItem value="Europe/Moscow">Europe/Moscow (Москва)</SelectItem>
<SelectItem value="Europe/London">Europe/London (Лондон)</SelectItem>
<SelectItem value="Europe/Berlin">Europe/Berlin (Берлин)</SelectItem>
<SelectItem value="America/New_York">America/New_York (Нью-Йорк)</SelectItem>
<SelectItem value="Asia/Tokyo">Asia/Tokyo (Токио)</SelectItem>
<SelectItem value="Asia/Shanghai">Asia/Shanghai (Шанхай)</SelectItem>
<SelectItem value="Australia/Sydney">Australia/Sydney (Сидней)</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.language')}</Label>
<Controller
name="language"
control={editForm.control}
render={({ field }) => (
<Select value={field.value ?? ''} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectLanguage')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ru">{t('common.langRu')}</SelectItem>
<SelectItem value="en">{t('common.langEn')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.phone')}</Label>
<Input placeholder="+7 (999) 123-45-67" {...editForm.register('phone')} />
</div>
</form>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal({ open: false, adminId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-admin-form" disabled={updateAdmin.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, adminId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admins.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, adminId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteAdmin.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
+266 -209
View File
@@ -1,233 +1,290 @@
import React, { useState, useEffect } from 'react';
import { Table, Tag, Space, Select, DatePicker, Spin } from 'antd';
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Link } from 'react-router-dom';
import { useAudit } from '../../hooks/useAudit';
import { useAdmin, useAdmins } from '../../hooks/useAdmins';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { AuditRecord, AuditListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
const { RangePicker } = DatePicker;
import { useAudit } from '@/hooks/useAudit';
import { useAdmin, useAdmins } from '@/hooks/useAdmins';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { AuditRecord, AuditListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { DataTable } from '@/components/data-table/DataTable';
import { ListPageHeader } from '@/components/ListPageHeader';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const TABLE_KEY = 'audit';
const ALL = '__all__';
const roleBadgeVariant = (role: string) => {
if (role === 'superadmin') return 'destructive' as const;
if (role === 'admin') return 'default' as const;
if (role === 'moderator') return 'secondary' as const;
return 'outline' as const;
};
const actionBadgeVariant = (action: string) => {
if (action === 'create') return 'success' as const;
if (action === 'delete') return 'destructive' as const;
if (action === 'update') return 'default' as const;
if (action === 'freeze' || action === 'block' || action === 'hide') return 'warning' as const;
return 'outline' as const;
};
const entityTypeBadgeVariant = (type: string) => {
if (type === 'user') return 'default' as const;
if (type === 'event') return 'success' as const;
if (type === 'review') return 'secondary' as const;
if (type === 'report') return 'destructive' as const;
return 'outline' as const;
};
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const toDateInput = (iso: string | undefined): string => {
if (!iso) return '';
return iso.slice(0, 10);
};
const dateToIso = (date: string, endOfDay = false): string | undefined => {
if (!date) return undefined;
const d = new Date(date);
if (endOfDay) d.setHours(23, 59, 59, 999);
else d.setHours(0, 0, 0, 0);
return d.toISOString();
};
const AdminCell: React.FC<{ adminId: string }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId);
if (loading) return <Skeleton className="h-4 w-24" />;
if (!admin) return <span>{adminId}</span>;
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
};
const EntityNameCell: React.FC<{ entityType: string; entityId: string }> = ({ entityType, entityId }) => {
const { data: user, isLoading: loadingUser } = useUser(entityType === 'user' ? entityId : '');
const { data: event, isLoading: loadingEvent } = useEvent(entityType === 'event' ? entityId : '');
if (entityType === 'user') {
if (loadingUser) return <Skeleton className="h-4 w-24" />;
if (!user) return <span>{entityId}</span>;
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
}
if (entityType === 'event') {
if (loadingEvent) return <Skeleton className="h-4 w-24" />;
if (!event) return <span>{entityId}</span>;
const name = !isBadValue(event.title) ? event.title : event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
if (entityType === 'review') {
return <Link to={`/reviews/${entityId}`}>{entityId}</Link>;
}
return <span>{entityId}</span>;
};
const AuditPage: React.FC = () => {
const [params, setParams] = useState<AuditListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'timestamp', order: 'desc' });
const { t } = useTranslation();
const [params, setParams] = useState<AuditListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'timestamp',
order: 'desc',
});
const { data, isLoading } = useAudit(params);
const { data: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 });
const [uniqueActions, setUniqueActions] = useState<string[]>([]);
const [adminSearch, setAdminSearch] = useState('');
useEffect(() => {
if (data?.data) {
const actions = new Set(data.data.map(record => record.action).filter(Boolean));
setUniqueActions(prev => {
const actions = new Set(data.data.map((record) => record.action).filter(Boolean));
setUniqueActions((prev) => {
const merged = new Set([...prev, ...actions]);
return Array.from(merged).sort();
});
}
}, [data]);
const AdminCell: React.FC<{ adminId: string }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId);
if (loading) return <Spin size="small" />;
if (!admin) return <span>{adminId}</span>;
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
};
const filteredAdmins = useMemo(() => {
const list = admins?.data || [];
if (!adminSearch.trim()) return list;
const q = adminSearch.toLowerCase();
return list.filter((a) => (a.email || a.id).toLowerCase().includes(q));
}, [admins, adminSearch]);
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const EntityNameCell: React.FC<{ entityType: string; entityId: string }> = ({ entityType, entityId }) => {
const { data: user, isLoading: loadingUser } = useUser(entityType === 'user' ? entityId : '');
const { data: event, isLoading: loadingEvent } = useEvent(entityType === 'event' ? entityId : '');
if (entityType === 'user') {
if (loadingUser) return <Spin size="small" />;
if (!user) return <span>{entityId}</span>;
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
}
if (entityType === 'event') {
if (loadingEvent) return <Spin size="small" />;
if (!event) return <span>{entityId}</span>;
const name = !isBadValue(event.title) ? event.title : event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
if (entityType === 'review') {
// для review показываем ссылку без загрузки, так как нет API для загрузки названия
return <Link to={`/reviews/${entityId}`}>{entityId}</Link>;
}
return <span>{entityId}</span>;
};
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<AuditRecord> | SorterResult<AuditRecord>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
};
const handleDateChange = (dates: any) => {
if (dates) {
setParams({
...params,
date_from: dates[0]?.toISOString(),
date_to: dates[1]?.toISOString(),
offset: 0,
});
} else {
setParams({ ...params, date_from: undefined, date_to: undefined, offset: 0 });
}
};
const roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
};
const actionColors: Record<string, string> = {
create: 'green',
update: 'blue',
delete: 'red',
freeze: 'orange',
unfreeze: 'cyan',
block: 'orange',
unblock: 'cyan',
hide: 'orange',
unhide: 'cyan',
login: 'geekblue',
logout: 'default',
};
const entityTypeColors: Record<string, string> = {
user: 'blue',
event: 'green',
calendar: 'orange',
review: 'purple',
report: 'red',
ticket: 'default',
subscription: 'cyan',
admin: 'magenta',
};
const columns: ColumnsType<AuditRecord> = [
{
title: 'Админ',
key: 'admin',
render: (_, record) => <AdminCell adminId={record.admin_id} />,
},
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
width: 120,
sorter: true,
render: (role: string) => (
<Tag color={roleColors[role] || 'default'}>{role}</Tag>
),
},
{
title: 'Действие',
dataIndex: 'action',
key: 'action',
width: 120,
sorter: true,
render: (action: string) => (
<Tag color={actionColors[action] || 'default'}>{action}</Tag>
),
},
{
title: 'Тип',
dataIndex: 'entity_type',
key: 'entity_type',
width: 120,
sorter: true,
render: (type: string) => (
<Tag color={entityTypeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Наименование',
key: 'entity_name',
render: (_, record) => (
<EntityNameCell entityType={record.entity_type} entityId={record.entity_id} />
),
},
{
title: 'Дата',
dataIndex: 'timestamp',
key: 'timestamp',
sorter: true,
},
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
{
title: 'Причина',
dataIndex: 'reason',
key: 'reason',
ellipsis: true,
},
];
const columns = useMemo<ColumnDef<AuditRecord>[]>(
() => [
{
id: 'admin',
header: t('audit.admin'),
enableSorting: false,
cell: ({ row }) => <AdminCell adminId={row.original.admin_id} />,
},
{
accessorKey: 'role',
header: t('common.role'),
enableSorting: true,
cell: ({ getValue }) => {
const role = getValue<string>();
return <Badge variant={roleBadgeVariant(role)}>{role}</Badge>;
},
},
{
accessorKey: 'action',
header: t('audit.action'),
enableSorting: true,
cell: ({ getValue }) => {
const action = getValue<string>();
return <Badge variant={actionBadgeVariant(action)}>{action}</Badge>;
},
},
{
accessorKey: 'entity_type',
header: t('common.type'),
enableSorting: true,
cell: ({ getValue }) => {
const type = getValue<string>();
return <Badge variant={entityTypeBadgeVariant(type)}>{type}</Badge>;
},
},
{
id: 'entity_name',
header: t('audit.name'),
enableSorting: false,
cell: ({ row }) => (
<EntityNameCell entityType={row.original.entity_type} entityId={row.original.entity_id} />
),
},
{ accessorKey: 'timestamp', header: t('common.date'), enableSorting: true },
{ accessorKey: 'ip', header: t('common.ip'), enableSorting: false },
{
accessorKey: 'reason',
header: t('common.reason'),
enableSorting: false,
cell: ({ getValue }) => {
const reason = getValue<string>();
return <span className="line-clamp-2">{reason}</span>;
},
},
],
[t]
);
return (
<div>
<h2>Аудит</h2>
<Space style={{ marginBottom: 16 }}>
<Select
placeholder="Администратор"
loading={loadingAdmins}
allowClear
onChange={(val) => setParams(prev => ({ ...prev, admin_id: val || undefined, offset: 0 }))}
style={{ width: 250 }}
showSearch
optionFilterProp="label"
>
{(admins?.data || []).map(admin => {
const label = admin.email || admin.id;
return (
<Select.Option key={admin.id} value={admin.id} label={label}>
{label}
</Select.Option>
);
})}
</Select>
<Select
placeholder="Действие"
allowClear
onChange={(val) => setParams(prev => ({ ...prev, action: val || undefined, offset: 0 }))}
style={{ width: 200 }}
>
{uniqueActions.map(action => (
<Select.Option key={action} value={action}>{action}</Select.Option>
))}
</Select>
<RangePicker
onChange={handleDateChange}
allowClear
/>
</Space>
<Table
<div className="space-y-6">
<ListPageHeader title={t('audit.title')} description={t('audit.description')} />
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-2">
<Label>{t('audit.searchAdmin')}</Label>
<Input
placeholder={t('audit.searchAdminPlaceholder')}
value={adminSearch}
onChange={(e) => setAdminSearch(e.target.value)}
className="w-[200px]"
/>
</div>
<div className="space-y-2">
<Label>{t('audit.admin')}</Label>
<Select
value={params.admin_id ?? ALL}
onValueChange={(val) =>
setParams((prev) => ({ ...prev, admin_id: val === ALL ? undefined : val, offset: 0 }))
}
disabled={loadingAdmins}
>
<SelectTrigger className="w-[250px]">
<SelectValue placeholder={t('common.selectAdmin')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('common.all')}</SelectItem>
{filteredAdmins.map((admin) => {
const label = admin.email || admin.id;
return (
<SelectItem key={admin.id} value={admin.id}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('audit.action')}</Label>
<Select
value={params.action ?? ALL}
onValueChange={(val) =>
setParams((prev) => ({ ...prev, action: val === ALL ? undefined : val, offset: 0 }))
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder={t('audit.action')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('common.all')}</SelectItem>
{uniqueActions.map((action) => (
<SelectItem key={action} value={action}>
{action}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('common.dateFrom')}</Label>
<Input
type="date"
value={toDateInput(params.date_from)}
onChange={(e) =>
setParams((prev) => ({
...prev,
date_from: dateToIso(e.target.value, false),
offset: 0,
}))
}
className="w-[160px]"
/>
</div>
<div className="space-y-2">
<Label>{t('common.dateTo')}</Label>
<Input
type="date"
value={toDateInput(params.date_to)}
onChange={(e) =>
setParams((prev) => ({
...prev,
date_to: dateToIso(e.target.value, true),
offset: 0,
}))
}
className="w-[160px]"
/>
</div>
</div>
<DataTable
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
data={data?.data ?? []}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</div>
);
+70 -37
View File
@@ -1,18 +1,27 @@
import React from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../store/authStore';
import { loginSchema, LoginFormValues } from '../../schemas/loginSchema';
import { useAuthStore } from '@/store/authStore';
import { loginSchema, LoginFormValues } from '@/schemas/loginSchema';
import { getDefaultRouteForRole, type AdminRole } from '@/config/adminAccess';
import { notify } from '@/lib/notify';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const { t } = useTranslation();
const { control, handleSubmit, formState: { errors, isSubmitting } } = useForm<LoginFormValues>({
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' },
});
@@ -20,51 +29,75 @@ const LoginPage: React.FC = () => {
const onFinish = async (values: LoginFormValues) => {
try {
await login(values.email, values.password);
navigate('/dashboard');
const role = useAuthStore.getState().user?.role as AdminRole | undefined;
navigate(getDefaultRouteForRole(role));
} catch (error) {
if (isAxiosError(error)) {
const apiError = error.response?.data?.error;
if (typeof apiError === 'string') {
message.error(t(`login.${apiError}`, { defaultValue: apiError }));
notify.error(t(`login.${apiError}`, { defaultValue: apiError }));
return;
}
}
message.error(t('login.error'));
notify.error(t('login.error'));
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Card title={t('login.title')} style={{ width: 400 }}>
<Form layout="vertical" onFinish={handleSubmit(onFinish)}>
<Form.Item
label={t('login.email')}
validateStatus={errors.email ? 'error' : ''}
help={errors.email?.message}
>
<Controller
name="email"
control={control}
render={({ field }) => <Input {...field} autoComplete="username" />}
/>
</Form.Item>
<Form.Item
label={t('login.password')}
validateStatus={errors.password ? 'error' : ''}
help={errors.password?.message}
>
<Controller
name="password"
control={control}
render={({ field }) => <Input.Password {...field} autoComplete="current-password" />}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={isSubmitting}>
{t('login.submit')}
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_hsl(221_83%_53%_/_0.18),_transparent_55%),linear-gradient(160deg,_hsl(220_20%_97%)_0%,_hsl(210_40%_96%)_45%,_hsl(221_40%_92%)_100%)]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.35]"
style={{
backgroundImage:
'linear-gradient(hsl(221 20% 70% / 0.15) 1px, transparent 1px), linear-gradient(90deg, hsl(221 20% 70% / 0.15) 1px, transparent 1px)',
backgroundSize: '48px 48px',
maskImage: 'radial-gradient(ellipse at center, black 30%, transparent 75%)',
}}
/>
<Card className="relative z-10 w-full max-w-md border-border/80 shadow-lg shadow-slate-900/5 backdrop-blur-sm">
<CardHeader className="space-y-3">
<p className="text-3xl font-semibold tracking-tight text-foreground">EventHub</p>
<div>
<CardTitle className="text-xl">{t('login.title')}</CardTitle>
<CardDescription className="mt-1">{t('login.subtitle')}</CardDescription>
</div>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit(onFinish)}>
<div className="space-y-2">
<Label htmlFor="email">{t('login.email')}</Label>
<Controller
name="email"
control={control}
render={({ field }) => (
<Input id="email" type="email" autoComplete="username" {...field} />
)}
/>
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('login.password')}</Label>
<Controller
name="password"
control={control}
render={({ field }) => (
<Input id="password" type="password" autoComplete="current-password" {...field} />
)}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? '…' : t('login.submit')}
</Button>
</Form.Item>
</Form>
</form>
</CardContent>
</Card>
</div>
);
+147 -99
View File
@@ -1,23 +1,65 @@
import React, { useState } from 'react';
import { Table, Button, Input, Space, Popconfirm, Tooltip, Spin } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Link } from 'react-router-dom';
import { useBannedWords, useAddBannedWord, useRemoveBannedWord } from '../../hooks/useBannedWords';
import { BannedWordListParams } from '../../api/bannedWordsApi';
import { useAdmin } from '../../hooks/useAdmins';
import { BannedWord } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { Trash2, Search } from 'lucide-react';
import { useBannedWords, useAddBannedWord, useRemoveBannedWord } from '@/hooks/useBannedWords';
import { BannedWordListParams } from '@/api/bannedWordsApi';
import { useAdmin } from '@/hooks/useAdmins';
import { BannedWord } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { DataTable } from '@/components/data-table/DataTable';
import { ListPageHeader } from '@/components/ListPageHeader';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
const TABLE_KEY = 'banned-words';
const formatDate = (date: string) => {
if (!date || date === '-' || date === 'undefined') return '-';
const d = new Date(date);
if (isNaN(d.getTime())) return date;
return d.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const AddedByCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loadingAdmin } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loadingAdmin) return <Skeleton className="h-4 w-24" />;
if (!admin) return <span>{adminId}</span>;
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
};
const BannedWordsPage: React.FC = () => {
const [params, setParams] = useState<BannedWordListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0 });
const { t } = useTranslation();
const [params, setParams] = useState<BannedWordListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
});
const { data, isLoading } = useBannedWords(params);
const addWord = useAddBannedWord();
const removeWord = useRemoveBannedWord();
const [newWord, setNewWord] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; word: string | null }>({
open: false,
word: null,
});
const handleAdd = () => {
if (newWord.trim()) {
@@ -27,108 +69,114 @@ const BannedWordsPage: React.FC = () => {
}
};
// Обработчик изменения таблицы (сортировка/пагинация)
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<BannedWord> | SorterResult<BannedWord>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
const handleSearch = () => {
setParams((prev) => ({ ...prev, q: searchQuery.trim() || undefined, offset: 0 }));
};
// Обработчик поиска
const handleSearch = (value: string) => {
setParams(prev => ({ ...prev, q: value || undefined, offset: 0 }));
const handleDelete = (word: string) => setDeleteConfirm({ open: true, word });
const confirmDelete = () => {
if (!deleteConfirm.word) return;
removeWord.mutate(deleteConfirm.word, {
onSuccess: () => setDeleteConfirm({ open: false, word: null }),
});
};
// Компонент для отображения "Кем добавлено"
const AddedByCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loadingAdmin } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loadingAdmin) return <Spin size="small" />;
if (!admin) return <span>{adminId}</span>;
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
};
const columns: ColumnsType<BannedWord> = [
{
title: 'Слово',
dataIndex: 'word',
key: 'word',
sorter: true,
},
{
title: 'Кем добавлено',
dataIndex: 'added_by',
key: 'added_by',
render: (addedBy: string) => <AddedByCell adminId={addedBy} />,
},
{
title: 'Дата добавления',
dataIndex: 'added_at',
key: 'added_at',
sorter: true,
render: (date: string) => {
if (!date || date === '-' || date === 'undefined') return '-';
return dayjs(date).format('DD.MM.YYYY HH:mm');
const columns = useMemo<ColumnDef<BannedWord>[]>(
() => [
{ accessorKey: 'word', header: t('common.word'), enableSorting: true },
{
accessorKey: 'added_by',
header: t('bannedWords.addedBy'),
enableSorting: false,
cell: ({ getValue }) => <AddedByCell adminId={getValue<string>()} />,
},
},
{
title: 'Действия',
key: 'actions',
width: 80,
render: (_, record) => (
<Popconfirm
title="Удалить слово?"
onConfirm={() => removeWord.mutate(record.word)}
>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
/>
</Tooltip>
</Popconfirm>
),
},
];
{
accessorKey: 'added_at',
header: t('bannedWords.addedAt'),
enableSorting: true,
cell: ({ getValue }) => formatDate(getValue<string>()),
},
{
id: 'actions',
header: t('common.actions'),
enableSorting: false,
cell: ({ row }) => (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
title={t('common.delete')}
onClick={() => handleDelete(row.original.word)}
>
<Trash2 className="h-4 w-4" />
</Button>
),
},
],
[t]
);
return (
<div>
<h2>Бан-слова</h2>
<Space style={{ marginBottom: 16 }}>
<div className="space-y-6">
<ListPageHeader
title={t('bannedWords.title')}
description={t('bannedWords.description')}
/>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Новое слово"
placeholder={t('bannedWords.placeholder')}
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onPressEnter={handleAdd}
style={{ width: 200 }}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
className="w-[200px]"
/>
<Button type="primary" onClick={handleAdd} loading={addWord.isPending}>
Добавить
<Button onClick={handleAdd} disabled={addWord.isPending || !newWord.trim()}>
{t('common.add')}
</Button>
<Input.Search
placeholder="Поиск по слову"
onSearch={handleSearch}
style={{ width: 200 }}
allowClear
/>
</Space>
<Table
<div className="flex items-center gap-2">
<Input
placeholder={t('explore.searchWord')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="w-[200px]"
/>
<Button variant="outline" size="icon" className="h-9 w-9" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
<DataTable
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
data={data?.data ?? []}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, word: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('bannedWords.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('bannedWords.deleteBody', { word: deleteConfirm.word })}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, word: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={removeWord.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
+136 -44
View File
@@ -1,65 +1,157 @@
import React from 'react';
import { Card, Descriptions, Spin, Button, Tag } from 'antd';
import { useCalendar } from '../../hooks/useCalendars';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { ArrowLeft } from 'lucide-react';
import { useCalendar } from '@/hooks/useCalendars';
import { formatDisplayValue } from '@/lib/utils';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: unknown) => (isBadValue(val) ? '-' : String(val));
if (isBadValue(dateStr)) return '-';
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
const text = formatDisplayValue(value);
if (text === '-') return <>{emptyLabel}</>;
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!calendar) return <p>Календарь не найден</p>;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
}
return <>{text}</>;
const tagsStr = Array.isArray(calendar.tags) && calendar.tags.length > 0 ? calendar.tags.join(', ') : '-';
};
const CalendarDetailPage: React.FC = () => {
<Card title={`Календарь ${calendar.title || calendar.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{calendar.id}</Descriptions.Item>
<Descriptions.Item label="Название">{calendar.title}</Descriptions.Item>
<Descriptions.Item label="Короткое имя">{displayValue(calendar.short_name)}</Descriptions.Item>
<Descriptions.Item label="Описание">{displayValue(calendar.description)}</Descriptions.Item>
<Descriptions.Item label="Тип">
<Tag color={calendar.type === 'commercial' ? 'gold' : 'blue'}>{calendar.type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={calendar.status === 'active' ? 'green' : calendar.status === 'frozen' ? 'orange' : 'red'}>
{calendar.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(calendar.reason)}</Descriptions.Item>
<Descriptions.Item label="Категория">{displayValue(calendar.category)}</Descriptions.Item>
<Descriptions.Item label="Владелец">
{!isBadValue(calendar.owner_id) ? (
<Link to={`/users/${calendar.owner_id}`}>{calendar.owner_id}</Link>
) : '-'}
</Descriptions.Item>
<Descriptions.Item label="Цвет">{displayValue(calendar.color)}</Descriptions.Item>
<Descriptions.Item label="Изображение">{displayValue(calendar.image_url)}</Descriptions.Item>
<Descriptions.Item label="Подтверждение">{displayValue(calendar.confirmation)}</Descriptions.Item>
<Descriptions.Item label="Теги">{tagsStr}</Descriptions.Item>
<Descriptions.Item label="Рейтинг">{calendar.rating_avg} ({calendar.rating_count})</Descriptions.Item>
<Descriptions.Item label="Настройки">
{calendar.settings ? JSON.stringify(calendar.settings) : '-'}
</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(calendar.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(calendar.updated_at)}</Descriptions.Item>
</Descriptions>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/calendars')}>Назад к списку</Button>
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: calendar, isLoading } = useCalendar(id || '');
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return t('common.emDash');
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!calendar) return <p className="text-muted-foreground">{t('calendars.notFound')}</p>;
const emDash = t('common.emDash');
const titleLabel = calendar.title || calendar.id;
return (
<>
<PageHeader
title={t('calendars.detailTitle', { name: titleLabel })}
breadcrumbs={[
{ label: t('calendars.title'), href: '/calendars' },
{ label: String(titleLabel) },
]}
actions={
<Button variant="outline" onClick={() => navigate('/calendars')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{calendar.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.title')}</dt>
<dd>{calendar.title}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.shortName')}</dt>
+456 -278
View File
@@ -1,17 +1,98 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, EditOutlined, DeleteOutlined, CalendarOutlined } from '@ant-design/icons';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Info, Pencil, Trash2 } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useCalendars, useUpdateCalendar, useDeleteCalendar, useCalendar, useCalendarStats } from '../../hooks/useCalendars';
import { Calendar, CalendarListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { useForm, Controller } from 'react-hook-form';
import { useCalendars, useUpdateCalendar, useDeleteCalendar, useCalendar, useCalendarStats } from '@/hooks/useCalendars';
import { useExploreView } from '@/hooks/useExploreView';
import { Calendar as CalendarType, CalendarListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreSearch } from '@/components/explore/ExploreSearch';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
import { ExploreInsightsCollapse } from '@/components/explore/ExploreInsightsCollapse';
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
import type { DenseRowModel } from '@/components/explore/DenseRowList';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { EntitySlideOver, type ExplorePreviewTarget } from '@/components/EntitySlideOver';
const TABLE_KEY = 'calendars';
const calendarTypeBadgeVariant = (type: string) => {
if (type === 'commercial') return 'secondary' as const;
return 'default' as const;
};
const calendarStatusVariant = (status: string) => {
if (status === 'active') return 'default' as const;
if (status === 'frozen') return 'secondary' as const;
return 'destructive' as const;
};
interface EditFormValues {
title?: string;
description?: string;
type?: string;
status?: string;
category?: string;
reason?: string;
}
function calendarActions(
handlers: {
onOpen: () => void;
onEdit: () => void;
onDelete: () => void;
},
t: TFunction
): RowAction[] {
return [
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
{ label: t('common.edit'), icon: <Pencil className="h-4 w-4" />, onClick: handlers.onEdit },
{
label: t('common.delete'),
icon: <Trash2 className="h-4 w-4" />,
onClick: handlers.onDelete,
destructive: true,
separatorBefore: true,
},
];
}
const CalendarListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<CalendarListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'created_at', order: 'desc' });
const { viewMode, setViewMode } = useExploreView();
const [preview, setPreview] = useState<ExplorePreviewTarget>(null);
const [params, setParams] = useState<CalendarListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'created_at',
order: 'desc',
});
const [searchDraft, setSearchDraft] = useState('');
const { data, isLoading } = useCalendars(params);
const { data: stats } = useCalendarStats();
const updateCalendar = useUpdateCalendar();
@@ -22,301 +103,398 @@ const CalendarListPage: React.FC = () => {
calendarId: null,
});
const { data: editingCalendar, isLoading: loadingCalendar } = useCalendar(editModal.calendarId || '');
const [editForm] = Form.useForm();
const [editHasErrors, setEditHasErrors] = useState(true);
const originalCalendarRef = useRef<Calendar | null>(null);
const editForm = useForm<EditFormValues>();
const originalCalendarRef = useRef<CalendarType | null>(null);
const validateEditForm = useCallback(() => {
const title = editForm.getFieldValue('title');
const status = editForm.getFieldValue('status');
setEditHasErrors(!title || !status);
}, [editForm]);
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; calendarId: string | null }>({
open: false,
calendarId: null,
});
const handleEdit = (id: string) => {
setEditModal({ open: true, calendarId: id });
};
const handleEdit = (id: string) => setEditModal({ open: true, calendarId: id });
const handleSaveEdit = () => {
editForm.validateFields().then((values) => {
if (!editModal.calendarId || !originalCalendarRef.current) return;
const original = originalCalendarRef.current;
const cleanedValues: Record<string, unknown> = {};
const handleSaveEdit = editForm.handleSubmit((values) => {
if (!editModal.calendarId || !originalCalendarRef.current) return;
const original = originalCalendarRef.current;
const cleanedValues: Record<string, unknown> = {};
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
allKeys.forEach((key) => {
const newVal = values[key];
if (newVal === '' || newVal === undefined || newVal === null) {
const origVal = (original as unknown as Record<string, unknown>)[key];
if (origVal && origVal !== '-' && origVal !== '' && origVal !== undefined && origVal !== null) {
cleanedValues[key] = null;
}
return;
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
allKeys.forEach((key) => {
const newVal = (values as Record<string, unknown>)[key];
if (newVal === '' || newVal === undefined || newVal === null) {
const origVal = (original as unknown as Record<string, unknown>)[key];
if (origVal && origVal !== '-' && origVal !== '' && origVal !== undefined && origVal !== null) {
cleanedValues[key] = null;
}
if (newVal === '-') return;
cleanedValues[key] = newVal;
});
const payload = Object.fromEntries(
Object.entries(cleanedValues).filter(([key, v]) => {
const origVal = (original as unknown as Record<string, unknown>)[key];
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
})
);
updateCalendar.mutate(
{ id: editModal.calendarId, data: payload },
{ onSuccess: () => setEditModal({ open: false, calendarId: null }) }
);
return;
}
if (newVal === '-') return;
cleanedValues[key] = newVal;
});
};
const payload = Object.fromEntries(
Object.entries(cleanedValues).filter(([key, v]) => {
const origVal = (original as unknown as Record<string, unknown>)[key];
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
})
);
updateCalendar.mutate(
{ id: editModal.calendarId, data: payload },
{ onSuccess: () => setEditModal({ open: false, calendarId: null }) }
);
});
useEffect(() => {
if (editingCalendar && editModal.open) {
originalCalendarRef.current = editingCalendar;
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? undefined : val);
setTimeout(() => {
editForm.setFieldsValue({
title: clean(editingCalendar.title),
description: clean(editingCalendar.description),
type: clean(editingCalendar.type),
status: clean(editingCalendar.status),
category: clean(editingCalendar.category),
reason: clean(editingCalendar.reason),
});
validateEditForm();
}, 0);
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
editForm.reset({
title: clean(editingCalendar.title) as string,
description: clean(editingCalendar.description) as string,
type: clean(editingCalendar.type) as string,
status: clean(editingCalendar.status) as string,
category: clean(editingCalendar.category) as string,
reason: clean(editingCalendar.reason) as string,
});
}
}, [editingCalendar, editModal.open, editForm, validateEditForm]);
}, [editingCalendar, editModal.open, editForm]);
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить календарь?',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteCalendar.mutate(id),
const handleDelete = (id: string) => setDeleteConfirm({ open: true, calendarId: id });
const confirmDelete = () => {
if (!deleteConfirm.calendarId) return;
deleteCalendar.mutate(deleteConfirm.calendarId, {
onSuccess: () => setDeleteConfirm({ open: false, calendarId: null }),
});
};
const handleTableChange = (
pagination: { current?: number; pageSize?: number },
_filters: unknown,
sorter: SorterResult<Calendar> | SorterResult<Calendar>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
const applySearch = () => {
setParams((prev) => ({ ...prev, q: searchDraft.trim() || undefined, offset: 0 }));
};
const columns: ColumnsType<Calendar> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу календаря">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/calendars/${record.id}`)}
/>
</Tooltip>
const exploreStats = useMemo(() => {
if (!stats) return [];
const items = [{ label: t('common.total'), value: stats.total_calendars }];
Object.entries(stats.calendars_by_status || {})
.slice(0, 3)
.forEach(([status, count]) => {
items.push({ label: formatStatusLabel(status), value: count });
});
return items.slice(0, 4);
}, [stats, t]);
const columns = useMemo<ColumnDef<CalendarType>[]>(
() => [
{
accessorKey: 'title',
header: t('common.title'),
enableSorting: true,
cell: ({ row, getValue }) => (
<button
type="button"
className="text-left font-medium text-primary hover:underline"
onClick={() => setPreview({ type: 'calendar', id: row.original.id })}
>
{getValue<string>() || row.original.id}
</button>
),
},
{
accessorKey: 'type',
header: t('common.type'),
size: 110,
enableSorting: true,
cell: ({ getValue }) => {
const type = getValue<string>();
return <Badge variant={calendarTypeBadgeVariant(type)}>{type}</Badge>;
},
},
{
accessorKey: 'status',
header: t('common.status'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={calendarStatusVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{
accessorKey: 'owner_id',
header: t('common.owner'),
enableSorting: false,
cell: ({ getValue }) => {
const ownerId = getValue<string>();
return <Link to={`/users/${ownerId}`}>{ownerId}</Link>;
},
},
{
id: 'rating',
header: t('common.rating'),
size: 90,
enableSorting: false,
cell: ({ row }) => `${row.original.rating_avg} (${row.original.rating_count})`,
},
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={calendarActions({
onOpen: () => navigate(`/calendars/${record.id}`),
onEdit: () => handleEdit(record.id),
onDelete: () => handleDelete(record.id),
}, t)}
/>
);
},
},
],
[navigate, t]
);
const editTitle = editForm.watch('title');
const editStatus = editForm.watch('status');
const editHasErrors = !editTitle || !editStatus;
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => ({
id: record.id,
title: record.title || record.id,
subtitle: record.owner_id ? t('explore.ownerId', { id: record.owner_id }) : undefined,
badges: (
<>
<Badge variant={calendarTypeBadgeVariant(record.type)}>{record.type}</Badge>
<Badge variant={calendarStatusVariant(record.status)}>{formatStatusLabel(record.status)}</Badge>
</>
),
},
{
title: 'Название',
dataIndex: 'title',
key: 'title',
ellipsis: true,
sorter: true,
},
{
title: 'Тип',
dataIndex: 'type',
key: 'type',
width: 110,
sorter: true,
render: (type: string) => (
<Tag color={type === 'commercial' ? 'gold' : 'blue'}>{type}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : status === 'frozen' ? 'orange' : 'red'}>
{status}
</Tag>
),
},
{
title: 'Владелец',
dataIndex: 'owner_id',
key: 'owner_id',
render: (ownerId: string) => (
<Link to={`/users/${ownerId}`}>{ownerId}</Link>
),
},
{
title: 'Рейтинг',
key: 'rating',
width: 90,
render: (_, record) => `${record.rating_avg} (${record.rating_count})`,
},
{
title: 'Действия',
key: 'actions',
width: 100,
render: (_, record) => (
<Space>
<Tooltip title="Редактировать">
<Button icon={<EditOutlined />} size="small" onClick={() => handleEdit(record.id)} />
</Tooltip>
<Tooltip title="Удалить">
<Button icon={<DeleteOutlined />} size="small" danger onClick={() => handleDelete(record.id)} />
</Tooltip>
</Space>
),
},
];
meta: `${record.rating_avg} (${record.rating_count})`,
onActivate: () => setPreview({ type: 'calendar', id: record.id }),
actions: calendarActions({
onOpen: () => navigate(`/calendars/${record.id}`),
onEdit: () => handleEdit(record.id),
onDelete: () => handleDelete(record.id),
}, t),
}));
}, [data?.data, navigate, t]);
return (
<div>
<h2>Календари</h2>
{stats && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Всего" value={stats.total_calendars} prefix={<CalendarOutlined />} />
</Card>
</Col>
{Object.entries(stats.calendars_by_status || {}).map(([status, count]) => (
<Col xs={12} sm={6} md={4} key={status}>
<Card>
<Statistic title={status} value={count} />
</Card>
</Col>
))}
{Object.entries(stats.calendars_by_type || {}).map(([type, count]) => (
<Col xs={12} sm={6} md={4} key={`type-${type}`}>
<Card>
<Statistic title={`Тип: ${type}`} value={count} />
</Card>
</Col>
))}
</Row>
)}
{stats?.top_calendars_by_rating && stats.top_calendars_by_rating.length > 0 && (
<Card title="Топ календарей по рейтингу" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={stats.top_calendars_by_rating}
columns={[
<>
<ExploreListShell
title={t('calendars.title')}
description={t('explore.descPreviewTitle')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
toolbar={
<ExploreSearch
value={searchDraft}
onChange={setSearchDraft}
onSubmit={applySearch}
placeholder={t('explore.searchCalendars')}
>
<Select
value={params.status ?? 'all'}
onValueChange={(v) =>
setParams((prev) => ({
...prev,
status: v === 'all' ? undefined : (v as CalendarListParams['status']),
offset: 0,
}))
}
>
<SelectTrigger className="w-36">
<SelectValue placeholder={t('common.status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.allStatuses')}</SelectItem>
<SelectItem value="active">active</SelectItem>
<SelectItem value="frozen">frozen</SelectItem>
<SelectItem value="deleted">deleted</SelectItem>
</SelectContent>
</Select>
<Select
value={params.type ?? 'all'}
onValueChange={(v) =>
setParams((prev) => ({
...prev,
type: v === 'all' ? undefined : (v as CalendarListParams['type']),
offset: 0,
}))
}
>
<SelectTrigger className="w-36">
<SelectValue placeholder={t('common.type')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.allTypes')}</SelectItem>
<SelectItem value="personal">personal</SelectItem>
<SelectItem value="commercial">commercial</SelectItem>
</SelectContent>
</Select>
</ExploreSearch>
}
>
{stats && (
<ExploreInsightsCollapse
title={t('explore.tops')}
tabs={[
{
title: 'Название',
dataIndex: 'title',
key: 'title',
render: (title: string, record) => (
<Link to={`/calendars/${record.id}`}>{title || record.id}</Link>
),
id: 'rating',
label: t('explore.byRating'),
rows: (stats.top_calendars_by_rating ?? []).map((row) => ({
id: row.id,
primary: <Link to={`/calendars/${row.id}`}>{row.title || row.id}</Link>,
value: row.rating_avg,
})),
},
{
id: 'reviews',
label: t('explore.byReviews'),
rows: (stats.top_calendars_by_reviews ?? []).map((row) => ({
id: row.id,
primary: <Link to={`/calendars/${row.id}`}>{row.title || row.id}</Link>,
value: row.rating_avg,
})),
},
{
id: 'positive',
label: t('explore.positive'),
rows: (stats.top_calendars_by_positive_reviews ?? []).map((row) => ({
id: row.id,
primary: <Link to={`/calendars/${row.id}`}>{row.title || row.id}</Link>,
value: row.rating_avg,
})),
},
{
id: 'negative',
label: t('explore.negative'),
rows: (stats.top_calendars_by_negative_reviews ?? []).map((row) => ({
id: row.id,
primary: <Link to={`/calendars/${row.id}`}>{row.title || row.id}</Link>,
value: row.rating_avg,
})),
},
{ title: 'Рейтинг', dataIndex: 'rating_avg', key: 'rating_avg' },
{ title: 'Отзывов', dataIndex: 'review_count', key: 'review_count' },
]}
/>
</Card>
)}
<Space style={{ marginBottom: 16 }} wrap>
<Input.Search
placeholder="Поиск"
allowClear
onSearch={(q) => setParams(prev => ({ ...prev, q: q || undefined, offset: 0 }))}
style={{ width: 220 }}
/>
<Select
placeholder="Статус"
allowClear
style={{ width: 140 }}
onChange={(status) => setParams(prev => ({ ...prev, status, offset: 0 }))}
options={[
{ value: 'active', label: 'active' },
{ value: 'frozen', label: 'frozen' },
{ value: 'deleted', label: 'deleted' },
]}
/>
<Select
placeholder="Тип"
allowClear
style={{ width: 140 }}
onChange={(type) => setParams(prev => ({ ...prev, type, offset: 0 }))}
options={[
{ value: 'personal', label: 'personal' },
{ value: 'commercial', label: 'commercial' },
]}
/>
</Space>
<Table
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
/>
<Modal
title="Редактировать календарь"
open={editModal.open}
onCancel={() => setEditModal({ open: false, calendarId: null })}
onOk={handleSaveEdit}
confirmLoading={updateCalendar.isPending}
destroyOnHidden
width={640}
okButtonProps={{ disabled: editHasErrors }}
>
{loadingCalendar ? (
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
) : (
<Form form={editForm} layout="vertical" preserve={false} onValuesChange={validateEditForm}>
<Form.Item label="Название" name="title" rules={[{ required: true, message: 'Введите название' }]}>
<Input />
</Form.Item>
<Form.Item label="Описание" name="description">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item label="Тип" name="type">
<Select>
<Select.Option value="personal">personal</Select.Option>
<Select.Option value="commercial">commercial</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Статус" name="status" rules={[{ required: true, message: 'Выберите статус' }]}>
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="frozen">frozen</Select.Option>
<Select.Option value="deleted">deleted</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Категория" name="category">
<Input />
</Form.Item>
<Form.Item label="Причина" name="reason">
<Input />
</Form.Item>
</Form>
)}
</Modal>
</div>
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, calendarId: null })}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('calendars.editTitle')}</DialogTitle>
</DialogHeader>
{loadingCalendar ? (
<div className="space-y-3 py-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="edit-calendar-form" className="space-y-4" onSubmit={handleSaveEdit}>
<div className="space-y-2">
<Label>{t('common.title')}</Label>
<Input {...editForm.register('title', { required: true })} />
</div>
<div className="space-y-2">
<Label>{t('common.description')}</Label>
<Textarea rows={3} {...editForm.register('description')} />
</div>
<div className="space-y-2">
<Label>{t('common.type')}</Label>
<Controller
name="type"
control={editForm.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectType')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="personal">personal</SelectItem>
<SelectItem value="commercial">commercial</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={editForm.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">active</SelectItem>
<SelectItem value="frozen">frozen</SelectItem>
<SelectItem value="deleted">deleted</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.category')}</Label>
<Input {...editForm.register('category')} />
</div>
<div className="space-y-2">
<Label>{t('common.reason')}</Label>
<Input {...editForm.register('reason')} />
</div>
</form>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal({ open: false, calendarId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-calendar-form" disabled={editHasErrors || updateCalendar.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, calendarId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('calendars.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, calendarId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteCalendar.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<EntitySlideOver target={preview} onOpenChange={(open) => !open && setPreview(null)} />
</>
);
};
+211 -176
View File
@@ -1,215 +1,250 @@
import React from 'react';
import { Card, Col, Row, Statistic, Spin, Alert, Button } from 'antd';
import {
UserOutlined,
CalendarOutlined,
StarOutlined,
TeamOutlined,
WarningOutlined,
BugOutlined,
ClockCircleOutlined,
DollarOutlined,
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useDashboardStats } from '../../hooks/useDashboard';
import { useAuthStore } from '../../store/authStore';
import DailyLineChartCard from '../../components/DailyLineChartCard';
import StatisticSparklineCard from '../../components/StatisticSparklineCard';
import {
Users,
Calendar,
Star,
UsersRound,
AlertTriangle,
Bug,
Clock,
CreditCard,
Inbox,
ArrowRight,
Activity,
} from 'lucide-react';
import { useDashboardStats } from '@/hooks/useDashboard';
import { useAuthStore } from '@/store/authStore';
import DailyLineChartCard from '@/components/DailyLineChartCard';
import StatisticSparklineCard from '@/components/StatisticSparklineCard';
import { KpiCard } from '@/components/ListPageHeader';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
function WorkCard({
title,
description,
href,
cta,
icon,
}: {
title: string;
description: string;
href: string;
cta: string;
icon: React.ReactNode;
}) {
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<div className="rounded-md bg-primary/10 p-2 text-primary">{icon}</div>
<div className="space-y-1">
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
</CardHeader>
<CardContent>
<Button asChild>
<Link to={href}>
{cta}
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</CardContent>
</Card>
);
}
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
const { data, isLoading, error } = useDashboardStats();
const role = useAuthStore((s) => s.user?.role);
const navigate = useNavigate();
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (error) return <Alert type="error" message="Ошибка загрузки статистики" />;
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28" />
))}
</div>
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{t('dashboard.loadStatsError')}</AlertDescription>
</Alert>
);
}
if (!data) return null;
if (role === 'moderator') {
return (
<div>
<h2>Модерация</h2>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Card><Statistic title="Рассмотрено" value={data.reports_reviewed ?? 0} prefix={<WarningOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="Отклонено" value={data.reports_dismissed ?? 0} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="Ср. время (ч)" value={data.avg_report_resolution_h ?? 0} precision={1} prefix={<ClockCircleOutlined />} /></Card>
</Col>
</Row>
<Button type="primary" style={{ marginTop: 16 }} onClick={() => navigate('/reports')}>
Перейти к жалобам
</Button>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.moderation')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleModeration')}</p>
</div>
<WorkCard
title={t('dashboard.reportsInboxTitle')}
description={t('dashboard.reportsInboxDesc')}
href="/inbox/reports"
cta={t('common.openInbox')}
icon={<Inbox className="h-5 w-5" />}
/>
<div className="grid gap-4 sm:grid-cols-3">
<KpiCard
label={t('dashboard.pending')}
value={data.reports_pending ?? 0}
icon={<AlertTriangle className="h-4 w-4" />}
/>
<KpiCard label={t('dashboard.reviewed')} value={data.reports_reviewed ?? 0} />
<KpiCard
label={t('dashboard.avgTimeH')}
value={(data.avg_report_resolution_h ?? 0).toFixed(1)}
icon={<Clock className="h-4 w-4" />}
/>
</div>
</div>
);
}
if (role === 'support') {
return (
<div>
<h2>Поддержка</h2>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Card><Statistic title="Назначено открытых" value={data.tickets_assigned_open ?? 0} prefix={<BugOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="Всего назначено" value={data.tickets_assigned_total ?? 0} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="Ср. решение (ч)" value={data.avg_ticket_resolution_h ?? 0} precision={1} prefix={<ClockCircleOutlined />} /></Card>
</Col>
</Row>
<Button type="primary" style={{ marginTop: 16 }} onClick={() => navigate('/tickets')}>
Перейти к тикетам
</Button>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.support')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleSupport')}</p>
</div>
<WorkCard
title={t('dashboard.ticketsInboxTitle')}
description={t('dashboard.ticketsInboxDesc')}
href="/inbox/tickets"
cta={t('common.openInbox')}
icon={<Bug className="h-5 w-5" />}
/>
<div className="grid gap-4 sm:grid-cols-3">
<KpiCard
label={t('dashboard.assignedOpen')}
value={data.tickets_assigned_open ?? 0}
icon={<Bug className="h-4 w-4" />}
/>
<KpiCard label={t('dashboard.assignedTotal')} value={data.tickets_assigned_total ?? 0} />
<KpiCard
label={t('dashboard.avgResolutionH')}
value={(data.avg_ticket_resolution_h ?? 0).toFixed(1)}
icon={<Clock className="h-4 w-4" />}
/>
</div>
</div>
);
}
const registrationsSparkline = data.registrations_by_day?.map((item) => ({
date: item.date,
value: item.count,
})) ?? [];
const eventsSparkline = data.events_by_day?.map((item) => ({
date: item.date,
value: item.count,
})) ?? [];
const registrationsSparkline =
data.registrations_by_day?.map((item) => ({ date: item.date, value: item.count })) ?? [];
const eventsSparkline = data.events_by_day?.map((item) => ({ date: item.date, value: item.count })) ?? [];
return (
<div>
<h2>Общая статистика</h2>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<StatisticSparklineCard
title="Пользователи"
value={data.users_total}
prefix={<UserOutlined />}
data={registrationsSparkline}
color="#52c41a"
gradientId="colorRegistrations"
/>
</Col>
<div className="space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.controlCenter')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleAdmin')}</p>
</div>
<Col xs={24} sm={12} lg={6}>
<StatisticSparklineCard
title="События"
value={data.events_total}
prefix={<TeamOutlined />}
data={eventsSparkline}
color="#1890ff"
gradientId="colorEvents"
/>
</Col>
<div className="grid gap-4 lg:grid-cols-2">
<WorkCard
title={t('dashboard.reportQueueTitle')}
description={t('dashboard.reportQueueDesc', {
count: data.reports_pending ?? data.reports_total ?? 0,
})}
href="/inbox/reports"
cta={t('dashboard.reportsInboxTitle')}
icon={<AlertTriangle className="h-5 w-5" />}
/>
<WorkCard
title={t('dashboard.ticketQueueTitle')}
description={t('dashboard.ticketQueueDesc', { count: data.tickets_open ?? 0 })}
href="/inbox/tickets"
cta={t('dashboard.ticketsInboxTitle')}
icon={<Bug className="h-5 w-5" />}
/>
</div>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Отзывы" value={data.reviews_total} prefix={<StarOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Календари" value={data.calendars_total} prefix={<CalendarOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы" value={data.reports_total} prefix={<WarningOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Тикеты (всего)" value={data.tickets_total} prefix={<BugOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Открытых тикетов"
value={data.tickets_open}
prefix={<ClockCircleOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Среднее время решения (ч)"
value={data.avg_ticket_resolution_h}
precision={1}
/>
</Card>
</Col>
<div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t('dashboard.quickOverview')}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Button variant="outline" className="justify-start gap-2 h-auto py-3" onClick={() => navigate('/users')}>
<Users className="h-4 w-4" /> {t('nav.users')}
</Button>
<Button variant="outline" className="justify-start gap-2 h-auto py-3" onClick={() => navigate('/events')}>
<Calendar className="h-4 w-4" /> {t('nav.events')}
</Button>
<Button variant="outline" className="justify-start gap-2 h-auto py-3" onClick={() => navigate('/monitoring')}>
<Activity className="h-4 w-4" /> {t('nav.monitoring')}
</Button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatisticSparklineCard
title={t('nav.users')}
value={data.users_total}
icon={<Users className="h-4 w-4" />}
data={registrationsSparkline}
color="hsl(142 71% 45%)"
gradientId="colorRegistrations"
/>
<StatisticSparklineCard
title={t('nav.events')}
value={data.events_total}
icon={<UsersRound className="h-4 w-4" />}
data={eventsSparkline}
color="hsl(221 83% 53%)"
gradientId="colorEvents"
/>
<KpiCard label={t('nav.reviews')} value={data.reviews_total} icon={<Star className="h-4 w-4" />} />
<KpiCard label={t('nav.calendars')} value={data.calendars_total} icon={<Calendar className="h-4 w-4" />} />
<KpiCard label={t('menu.reports')} value={data.reports_total} icon={<AlertTriangle className="h-4 w-4" />} />
<KpiCard label={t('dashboard.ticketsTotal')} value={data.tickets_total} icon={<Bug className="h-4 w-4" />} />
<KpiCard label={t('dashboard.ticketsOpen')} value={data.tickets_open} icon={<Clock className="h-4 w-4" />} />
<KpiCard label={t('dashboard.avgResolutionH')} value={(data.avg_ticket_resolution_h ?? 0).toFixed(1)} />
{data.pending_users_total !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Неподтверждённые" value={data.pending_users_total} prefix={<UserOutlined />} />
</Card>
</Col>
<KpiCard label={t('common.pendingUsers')} value={data.pending_users_total} icon={<Users className="h-4 w-4" />} />
)}
{data.reports_pending !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы: pending" value={data.reports_pending} />
</Card>
</Col>
)}
{data.reports_reviewed !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы: reviewed" value={data.reports_reviewed} />
</Card>
</Col>
)}
{data.reports_dismissed !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы: dismissed" value={data.reports_dismissed} />
</Card>
</Col>
<KpiCard
label={`${t('menu.reports')}: ${t('status.pending')}`}
value={data.reports_pending}
/>
)}
{data.subscriptions_total !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Подписки" value={data.subscriptions_total} prefix={<DollarOutlined />} />
</Card>
</Col>
<KpiCard label={t('menu.subscriptions')} value={data.subscriptions_total} icon={<CreditCard className="h-4 w-4" />} />
)}
{data.trial_subscriptions !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Пробные подписки" value={data.trial_subscriptions} />
</Card>
</Col>
<KpiCard label={t('dashboard.trialSubscriptions')} value={data.trial_subscriptions} />
)}
</Row>
</div>
<Row gutter={[16, 16]} style={{ marginTop: 32 }}>
<DailyLineChartCard title="События по дням" data={data.events_by_day} color="#1890ff" />
<DailyLineChartCard title="Регистрации по дням" data={data.registrations_by_day} color="#52c41a" />
<DailyLineChartCard title="Отзывы по дням" data={data.reviews_by_day} color="#faad14" />
<DailyLineChartCard title="Календари по дням" data={data.calendars_by_day} color="#722ed1" />
<DailyLineChartCard title="Жалобы по дням" data={data.reports_by_day} color="#ff4d4f" />
<DailyLineChartCard title="Тикеты по дням" data={data.tickets_by_day} color="#13c2c2" />
<DailyLineChartCard title="Подписки по дням" data={data.subscriptions_by_day} color="#eb2f96" />
<DailyLineChartCard title="Неподтверждённые по дням" data={data.pending_users_by_day} color="#fa8c16" />
</Row>
{role === 'superadmin' && (
<Alert
style={{ marginTop: 32 }}
type="info"
showIcon
message="Активность администраторов"
description={<>Подробный журнал действий в разделе <Link to="/audit">Аудит</Link>.</>}
/>
)}
<div className="grid gap-4 lg:grid-cols-2">
<DailyLineChartCard title={t('dashboard.eventsByDay')} data={data.events_by_day} color="hsl(221 83% 53%)" />
<DailyLineChartCard title={t('dashboard.registrationsByDay')} data={data.registrations_by_day} color="hsl(142 71% 45%)" />
<DailyLineChartCard title={t('dashboard.reviewsByDay')} data={data.reviews_by_day} color="hsl(38 92% 50%)" />
<DailyLineChartCard title={t('dashboard.reportsByDay')} data={data.reports_by_day} color="hsl(0 84% 60%)" />
<DailyLineChartCard title={t('dashboard.ticketsByDay')} data={data.tickets_by_day} color="hsl(188 78% 41%)" />
<DailyLineChartCard title={t('dashboard.subscriptionsByDay')} data={data.subscriptions_by_day} color="hsl(330 81% 60%)" />
</div>
</div>
);
};
+20 -14
View File
@@ -1,26 +1,32 @@
import React from 'react';
import { Button, Result } from 'antd';
import { useNavigate, useLocation } from 'react-router-dom';
import { getMenuSelectedKey, MENU_ITEMS } from '../../config/adminAccess';
import { useTranslation } from 'react-i18next';
import { getMenuSelectedKey, MENU_ITEMS } from '@/config/adminAccess';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
const ForbiddenPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const sectionKey = getMenuSelectedKey(location.pathname);
const sectionLabel =
MENU_ITEMS.find((item) => item.key === sectionKey)?.label ?? 'этот раздел';
const sectionItem = MENU_ITEMS.find((item) => item.key === sectionKey);
const sectionLabel = sectionItem ? t(sectionItem.label) : t('common.notFound');
return (
<Result
status="403"
title="403"
subTitle={`У вас нет доступа к разделу «${sectionLabel}».`}
extra={
<Button type="primary" onClick={() => navigate('/dashboard')}>
На дашборд
</Button>
}
/>
<div className="flex min-h-[50vh] items-center justify-center">
<Card className="max-w-md w-full text-center">
<CardHeader>
<CardTitle className="text-4xl font-bold">{t('errors.forbiddenTitle')}</CardTitle>
<CardDescription className="text-base">
{t('errors.forbiddenMessage', { section: sectionLabel })}
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => navigate('/dashboard')}>{t('common.toDashboard')}</Button>
</CardContent>
</Card>
</div>
);
};
+175 -62
View File
@@ -1,83 +1,196 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag } from 'antd';
import { Link } from 'react-router-dom';
import { useEvent } from '../../hooks/useEvents';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { ArrowLeft } from 'lucide-react';
import { useEvent } from '@/hooks/useEvents';
import { formatDisplayValue } from '@/lib/utils';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
const text = formatDisplayValue(value);
if (text === '-') return <>{emptyLabel}</>;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
}
return <>{text}</>;
};
const EventDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: event, isLoading } = useEvent(id || '');
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: any) => isBadValue(val) ? '-' : val;
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
if (isBadValue(dateStr)) return t('common.emDash');
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!event) return <p>Событие не найдено</p>;
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
// Подготовка сложных полей
const tagsStr = Array.isArray(event.tags) && event.tags.length > 0 ? event.tags.join(', ') : '-';
const recurrenceStr = event.recurrence ? `${(event.recurrence as any).freq || ''} (интервал: ${(event.recurrence as any).interval || ''})` : '-';
const locationStr = event.location ? JSON.stringify(event.location) : '-';
const attachmentsStr = !isBadValue(event.attachments) ? JSON.stringify(event.attachments) : '-';
const editHistoryStr = !isBadValue(event.edit_history) ? JSON.stringify(event.edit_history) : '-';
if (!event) return <p className="text-muted-foreground">{t('events.notFound')}</p>;
// ID-ссылки
const specialistLink = !isBadValue(event.specialist_id) ? (
<Link to={`/users/${event.specialist_id}`}>{event.specialist_id}</Link>
) : '-';
const calendarLink = !isBadValue(event.calendar_id) ? (
<Link to={`/calendars/${event.calendar_id}`}>{event.calendar_id}</Link>
) : '-';
const masterLink = !isBadValue(event.master_id) ? (
<Link to={`/events/${event.master_id}`}>{event.master_id}</Link>
) : '-';
const emDash = t('common.emDash');
const recurrenceStr = event.recurrence
? t('events.recurrenceInterval', {
freq: (event.recurrence as { freq?: string }).freq || '',
interval: (event.recurrence as { interval?: string | number }).interval || '',
})
: null;
const entityLink = (path: string, label: string) => (
<Link to={path} className="text-primary hover:underline">{label}</Link>
);
const titleLabel = event.title || event.id;
return (
<Card title={`Событие ${event.title || event.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{event.id}</Descriptions.Item>
<Descriptions.Item label="Название">{event.title}</Descriptions.Item>
<Descriptions.Item label="Описание">{displayValue(event.description) || '-'}</Descriptions.Item>
<Descriptions.Item label="Тип">
<Tag color={event.event_type === 'single' ? 'blue' : event.event_type === 'recurring' ? 'purple' : 'default'}>
{event.event_type}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={event.status === 'active' ? 'green' : event.status === 'cancelled' ? 'red' : 'gray'}>
{event.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(event.reason)}</Descriptions.Item>
<Descriptions.Item label="Дата и время начала">{formatDate(event.start_time)}</Descriptions.Item>
<Descriptions.Item label="Продолжительность (мин)">{event.duration ?? '-'}</Descriptions.Item>
<Descriptions.Item label="Вместимость">{displayValue(event.capacity)}</Descriptions.Item>
<Descriptions.Item label="Специалист">{specialistLink}</Descriptions.Item>
<Descriptions.Item label="Календарь">{calendarLink}</Descriptions.Item>
<Descriptions.Item label="Мастер-событие">{masterLink}</Descriptions.Item>
<Descriptions.Item label="Онлайн-ссылка">{displayValue(event.online_link)}</Descriptions.Item>
<Descriptions.Item label="Теги">{tagsStr}</Descriptions.Item>
<Descriptions.Item label="Рейтинг">{event.rating_avg} ({event.rating_count} оценок)</Descriptions.Item>
<Descriptions.Item label="Вложения">{attachmentsStr}</Descriptions.Item>
<Descriptions.Item label="История изменений">{editHistoryStr}</Descriptions.Item>
<Descriptions.Item label="Повторение">{recurrenceStr}</Descriptions.Item>
<Descriptions.Item label="Локация">{locationStr}</Descriptions.Item>
<Descriptions.Item label="Является экземпляром">{event.is_instance ? 'Да' : 'Нет'}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(event.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(event.updated_at)}</Descriptions.Item>
</Descriptions>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/events')}>Назад к списку</Button>
<>
<PageHeader
title={t('events.detailTitle', { name: titleLabel })}
breadcrumbs={[
{ label: t('events.title'), href: '/events' },
{ label: String(titleLabel) },
]}
actions={
<Button variant="outline" onClick={() => navigate('/events')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{event.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.title')}</dt>
<dd>{event.title}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.description')}</dt>
<dd><DetailValue value={event.description} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.type')}</dt>
<dd>
<Badge variant={event.event_type === 'single' ? 'default' : event.event_type === 'recurring' ? 'secondary' : 'outline'}>
{event.event_type}
</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={event.status === 'active' ? 'success' : event.status === 'cancelled' ? 'destructive' : 'secondary'}>
{event.status}
</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.reason')}</dt>
<dd><DetailValue value={event.reason} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.startDatetime')}</dt>
<dd>{formatDate(event.start_time)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.durationMin')}</dt>
<dd>{event.duration ?? emDash}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.capacity')}</dt>
<dd><DetailValue value={event.capacity} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.specialistId')}</dt>
<dd>
{!isBadValue(event.specialist_id)
? entityLink(`/users/${event.specialist_id}`, String(event.specialist_id))
: emDash}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.calendarId')}</dt>
<dd>
{!isBadValue(event.calendar_id)
? entityLink(`/calendars/${event.calendar_id}`, String(event.calendar_id))
: emDash}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.masterEvent')}</dt>
<dd>
{!isBadValue(event.master_id)
? entityLink(`/events/${event.master_id}`, String(event.master_id))
: emDash}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.onlineLink')}</dt>
<dd><DetailValue value={event.online_link} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.tags')}</dt>
<dd><DetailValue value={event.tags} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.rating')}</dt>
<dd>{t('events.ratingWithCount', { avg: event.rating_avg, count: event.rating_count })}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.attachments')}</dt>
<dd><DetailValue value={event.attachments} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.editHistory')}</dt>
<dd><DetailValue value={event.edit_history} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.recurrence')}</dt>
<dd>{recurrenceStr ?? emDash}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.location')}</dt>
<dd><DetailValue value={event.location} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.isInstance')}</dt>
<dd>{event.is_instance ? t('common.yes') : t('common.no')}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{formatDate(event.created_at)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.updatedAt')}</dt>
<dd>{formatDate(event.updated_at)}</dd>
</div>
</dl>
</CardContent>
</Card>
</>
);
};
+461 -284
View File
@@ -1,18 +1,127 @@
import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, DatePicker, InputNumber, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, EditOutlined, DeleteOutlined, CalendarOutlined } from '@ant-design/icons';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Info, Pencil, Trash2 } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useEvents, useUpdateEvent, useDeleteEvent, useEvent, useEventStats } from '../../hooks/useEvents';
import { Event, EventListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { useForm, Controller } from 'react-hook-form';
import { useEvents, useUpdateEvent, useDeleteEvent, useEvent, useEventStats } from '@/hooks/useEvents';
import { useExploreView } from '@/hooks/useExploreView';
import { Event, EventListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreSearch } from '@/components/explore/ExploreSearch';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
import { ExploreInsightsCollapse } from '@/components/explore/ExploreInsightsCollapse';
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
import type { DenseRowModel } from '@/components/explore/DenseRowList';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { EntitySlideOver, type ExplorePreviewTarget } from '@/components/EntitySlideOver';
const TABLE_KEY = 'events';
function isoToDatetimeLocal(iso: string | undefined | null): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function datetimeLocalToIso(local: string): string {
if (!local) return '';
const d = new Date(local);
if (Number.isNaN(d.getTime())) return '';
return d.toISOString();
}
function formatDateTime(iso: string): string {
if (!iso) return '-';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const eventTypeBadgeVariant = (type: string) => {
if (type === 'single') return 'default' as const;
if (type === 'recurring') return 'secondary' as const;
return 'outline' as const;
};
const eventStatusVariant = (status: string) => {
if (status === 'active') return 'default' as const;
if (status === 'cancelled') return 'destructive' as const;
return 'secondary' as const;
};
interface EditFormValues {
title?: string;
description?: string;
event_type?: string;
status?: string;
start_time?: string;
duration?: number | string;
capacity?: number | string;
specialist_id?: string;
calendar_id?: string;
online_link?: string;
tags?: string;
}
function eventActions(
handlers: {
onOpen: () => void;
onEdit: () => void;
onDelete: () => void;
},
t: TFunction
): RowAction[] {
return [
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
{ label: t('common.edit'), icon: <Pencil className="h-4 w-4" />, onClick: handlers.onEdit },
{
label: t('common.delete'),
icon: <Trash2 className="h-4 w-4" />,
onClick: handlers.onDelete,
destructive: true,
separatorBefore: true,
},
];
}
const EventListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<EventListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'id', order: 'asc' });
const { viewMode, setViewMode } = useExploreView();
const [preview, setPreview] = useState<ExplorePreviewTarget>(null);
const [searchDraft, setSearchDraft] = useState('');
const [params, setParams] = useState<EventListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'id',
order: 'asc',
});
const { data, isLoading } = useEvents(params);
const { data: stats } = useEventStats();
const updateEvent = useUpdateEvent();
@@ -23,310 +132,378 @@ const EventListPage: React.FC = () => {
eventId: null,
});
const { data: editingEvent, isLoading: loadingEvent } = useEvent(editModal.eventId || '');
const [editForm] = Form.useForm();
const [editHasErrors, setEditHasErrors] = useState(true);
const editForm = useForm<EditFormValues>();
const originalEventRef = useRef<Event | null>(null);
const handleEdit = (id: string) => {
setEditModal({ open: true, eventId: id });
};
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; eventId: string | null }>({
open: false,
eventId: null,
});
const handleSaveEdit = () => {
editForm.validateFields().then((values) => {
if (!editModal.eventId || !originalEventRef.current) return;
const original = originalEventRef.current;
const cleanedValues: Record<string, any> = {};
const handleEdit = (id: string) => setEditModal({ open: true, eventId: id });
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
allKeys.forEach((key) => {
const newVal = values[key];
if (newVal === '' || newVal === undefined || newVal === null) {
const origVal = (original as any)[key];
if (origVal && origVal !== '-' && origVal !== '' && origVal !== undefined && origVal !== null) {
cleanedValues[key] = null;
}
return;
const handleSaveEdit = editForm.handleSubmit((values) => {
if (!editModal.eventId || !originalEventRef.current) return;
const original = originalEventRef.current;
const cleanedValues: Record<string, unknown> = {};
const processedValues: Record<string, unknown> = {
...values,
start_time: values.start_time ? datetimeLocalToIso(values.start_time) : values.start_time,
tags: values.tags
? values.tags.split(',').map((t) => t.trim()).filter(Boolean)
: [],
duration: values.duration === '' || values.duration === undefined ? undefined : Number(values.duration),
capacity: values.capacity === '' || values.capacity === undefined ? undefined : Number(values.capacity),
};
const allKeys = new Set([...Object.keys(processedValues), ...Object.keys(original)]);
allKeys.forEach((key) => {
const newVal = processedValues[key];
if (newVal === '' || newVal === undefined || newVal === null) {
const origVal = (original as unknown as Record<string, unknown>)[key];
if (origVal && origVal !== '-' && origVal !== '' && origVal !== undefined && origVal !== null) {
cleanedValues[key] = null;
}
if (newVal === '-') return;
if (dayjs.isDayjs(newVal)) {
cleanedValues[key] = newVal.toISOString();
} else {
cleanedValues[key] = newVal;
}
});
const payload = Object.fromEntries(
Object.entries(cleanedValues).filter(([key, v]) => {
const origVal = (original as any)[key];
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
})
);
updateEvent.mutate(
{ id: editModal.eventId, data: payload },
{ onSuccess: () => setEditModal({ open: false, eventId: null }) }
);
return;
}
if (newVal === '-') return;
cleanedValues[key] = newVal;
});
};
const payload = Object.fromEntries(
Object.entries(cleanedValues).filter(([key, v]) => {
const origVal = (original as unknown as Record<string, unknown>)[key];
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
})
);
updateEvent.mutate(
{ id: editModal.eventId, data: payload },
{ onSuccess: () => setEditModal({ open: false, eventId: null }) }
);
});
useEffect(() => {
if (editingEvent && editModal.open) {
originalEventRef.current = editingEvent;
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
setTimeout(() => {
editForm.setFieldsValue({
title: clean(editingEvent.title),
description: clean(editingEvent.description),
event_type: clean(editingEvent.event_type),
status: clean(editingEvent.status),
start_time: clean(editingEvent.start_time) ? dayjs(editingEvent.start_time) : null,
duration: editingEvent.duration,
capacity: editingEvent.capacity,
specialist_id: clean(editingEvent.specialist_id),
calendar_id: clean(editingEvent.calendar_id),
online_link: clean(editingEvent.online_link),
tags: editingEvent.tags,
});
validateEditForm();
}, 0);
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
editForm.reset({
title: clean(editingEvent.title) as string,
description: clean(editingEvent.description) as string,
event_type: clean(editingEvent.event_type) as string,
status: clean(editingEvent.status) as string,
start_time: isoToDatetimeLocal(editingEvent.start_time),
duration: editingEvent.duration,
capacity: editingEvent.capacity ?? undefined,
specialist_id: clean(editingEvent.specialist_id) as string,
calendar_id: clean(editingEvent.calendar_id) as string,
online_link: clean(editingEvent.online_link) as string,
tags: (editingEvent.tags || []).join(', '),
});
}
}, [editingEvent, editModal.open, editForm]);
const validateEditForm = () => {
const title = editForm.getFieldValue('title');
const status = editForm.getFieldValue('status');
setEditHasErrors(!title || !status);
};
const handleDelete = (id: string) => setDeleteConfirm({ open: true, eventId: id });
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить событие?',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteEvent.mutate(id),
const confirmDelete = () => {
if (!deleteConfirm.eventId) return;
deleteEvent.mutate(deleteConfirm.eventId, {
onSuccess: () => setDeleteConfirm({ open: false, eventId: null }),
});
};
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<Event> | SorterResult<Event>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
const editTitle = editForm.watch('title');
const editStatus = editForm.watch('status');
const editHasErrors = !editTitle || !editStatus;
const exploreStats = useMemo(() => {
if (!stats) return [];
const items = [{ label: t('common.total'), value: stats.total_events }];
Object.entries(stats.events_by_status || {})
.slice(0, 3)
.forEach(([status, count]) => {
items.push({ label: formatStatusLabel(status), value: count });
});
return items.slice(0, 4);
}, [stats, t]);
const applySearch = () => {
setParams((prev) => ({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
q: searchDraft.trim() || undefined,
offset: 0,
}));
};
const typeColors: Record<string, string> = {
single: 'blue',
recurring: 'purple',
};
const columns = useMemo<ColumnDef<Event>[]>(
() => [
{
accessorKey: 'title',
header: t('common.title'),
enableSorting: true,
cell: ({ row, getValue }) => (
<button
type="button"
className="text-left font-medium text-primary hover:underline"
onClick={() => setPreview({ type: 'event', id: row.original.id })}
>
{getValue<string>() || row.original.id}
</button>
),
},
{
accessorKey: 'event_type',
header: t('common.type'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const type = getValue<string>();
return <Badge variant={eventTypeBadgeVariant(type)}>{type}</Badge>;
},
},
{
accessorKey: 'status',
header: t('common.status'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={eventStatusVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{
accessorKey: 'start_time',
header: t('common.start'),
size: 130,
enableSorting: true,
cell: ({ getValue }) => formatDateTime(getValue<string>()),
},
{
id: 'rating',
header: t('common.rating'),
size: 90,
enableSorting: false,
cell: ({ row }) => `${row.original.rating_avg} (${row.original.rating_count})`,
},
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={eventActions({
onOpen: () => navigate(`/events/${record.id}`),
onEdit: () => handleEdit(record.id),
onDelete: () => handleDelete(record.id),
}, t)}
/>
);
},
},
],
[navigate, t]
);
const columns: ColumnsType<Event> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу события">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/events/${record.id}`)}
/>
</Tooltip>
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => ({
id: record.id,
title: record.title || record.id,
subtitle: record.start_time ? formatDateTime(record.start_time) : undefined,
badges: (
<>
<Badge variant={eventTypeBadgeVariant(record.event_type)}>{record.event_type}</Badge>
<Badge variant={eventStatusVariant(record.status)}>{formatStatusLabel(record.status)}</Badge>
</>
),
},
{
title: 'Название',
dataIndex: 'title',
key: 'title',
ellipsis: true,
sorter: true,
},
{
title: 'Тип',
dataIndex: 'event_type',
key: 'event_type',
width: 100,
sorter: true,
render: (type: string) => (
<Tag color={typeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : status === 'cancelled' ? 'red' : 'gray'}>
{status}
</Tag>
),
},
{
title: 'Начало',
dataIndex: 'start_time',
key: 'start_time',
width: 130,
sorter: true,
render: (time: string) => time ? dayjs(time).format('DD.MM.YYYY HH:mm') : '-',
},
{
title: 'Рейтинг',
key: 'rating',
width: 90,
render: (_, record) => `${record.rating_avg} (${record.rating_count})`,
},
{
title: 'Действия',
key: 'actions',
width: 100,
render: (_, record) => (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record.id)}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
/>
</Tooltip>
</Space>
),
},
];
meta: `${record.rating_avg} (${record.rating_count})`,
onActivate: () => setPreview({ type: 'event', id: record.id }),
actions: eventActions({
onOpen: () => navigate(`/events/${record.id}`),
onEdit: () => handleEdit(record.id),
onDelete: () => handleDelete(record.id),
}, t),
}));
}, [data?.data, navigate, t]);
return (
<div>
<h2>События</h2>
{stats && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Всего" value={stats.total_events} prefix={<CalendarOutlined />} />
</Card>
</Col>
{Object.entries(stats.events_by_status || {}).map(([status, count]) => (
<Col xs={12} sm={6} md={4} key={status}>
<Card>
<Statistic title={status} value={count} />
</Card>
</Col>
))}
{Object.entries(stats.events_by_type || {}).map(([type, count]) => (
<Col xs={12} sm={6} md={4} key={`type-${type}`}>
<Card>
<Statistic title={`Тип: ${type}`} value={count} />
</Card>
</Col>
))}
</Row>
)}
{stats?.top_events_by_rating && stats.top_events_by_rating.length > 0 && (
<Card title="Топ событий по рейтингу" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={stats.top_events_by_rating}
columns={[
<>
<ExploreListShell
title={t('events.title')}
description={t('explore.descPreviewTitle')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
toolbar={
<ExploreSearch
value={searchDraft}
onChange={setSearchDraft}
onSubmit={applySearch}
placeholder={t('explore.searchEvents')}
/>
}
>
{stats?.top_events_by_rating && stats.top_events_by_rating.length > 0 && (
<ExploreInsightsCollapse
title={t('explore.tops')}
tabs={[
{
title: 'Название',
dataIndex: 'title',
key: 'title',
render: (title: string, record) => (
<Link to={`/events/${record.id}`}>{title || record.id}</Link>
),
id: 'rating',
label: t('explore.byRating'),
rows: stats.top_events_by_rating.map((row) => ({
id: row.id,
primary: <Link to={`/events/${row.id}`}>{row.title || row.id}</Link>,
value: (
<span>
{row.rating_avg}
{row.rating_count != null && (
<span className="ml-1 text-xs opacity-70">({row.rating_count})</span>
)}
</span>
),
})),
},
{ title: 'Рейтинг', dataIndex: 'rating_avg', key: 'rating_avg' },
{ title: 'Отзывов', dataIndex: 'review_count', key: 'review_count' },
]}
/>
</Card>
)}
<Table
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
/>
<Modal
title="Редактировать событие"
open={editModal.open}
onCancel={() => setEditModal({ open: false, eventId: null })}
onOk={handleSaveEdit}
confirmLoading={updateEvent.isPending}
destroyOnHidden
width={640}
okButtonProps={{ disabled: editHasErrors }}
>
{loadingEvent ? (
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
) : (
<Form form={editForm} layout="vertical" preserve={false} onValuesChange={validateEditForm}>
<Form.Item label="Название" name="title" rules={[{ required: true, message: 'Введите название' }]}>
<Input />
</Form.Item>
<Form.Item label="Описание" name="description">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item label="Тип" name="event_type">
<Select>
<Select.Option value="single">single</Select.Option>
<Select.Option value="recurring">recurring</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Статус" name="status" rules={[{ required: true, message: 'Выберите статус' }]}>
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="cancelled">cancelled</Select.Option>
<Select.Option value="completed">completed</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Дата и время начала" name="start_time">
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" />
</Form.Item>
<Form.Item label="Продолжительность (мин)" name="duration">
<InputNumber min={0} />
</Form.Item>
<Form.Item label="Вместимость" name="capacity">
<InputNumber min={0} />
</Form.Item>
<Form.Item label="Специалист (ID)" name="specialist_id">
<Input />
</Form.Item>
<Form.Item label="Календарь (ID)" name="calendar_id">
<Input />
</Form.Item>
<Form.Item label="Ссылка онлайн" name="online_link">
<Input />
</Form.Item>
<Form.Item label="Теги" name="tags">
<Select mode="tags" placeholder="Введите теги" />
</Form.Item>
</Form>
)}
</Modal>
</div>
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, eventId: null })}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('events.editTitle')}</DialogTitle>
</DialogHeader>
{loadingEvent ? (
<div className="space-y-3 py-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="edit-event-form" className="space-y-4" onSubmit={handleSaveEdit}>
<div className="space-y-2">
<Label>{t('common.title')}</Label>
<Input {...editForm.register('title', { required: true })} />
</div>
<div className="space-y-2">
<Label>{t('common.description')}</Label>
<Textarea rows={3} {...editForm.register('description')} />
</div>
<div className="space-y-2">
<Label>{t('common.type')}</Label>
<Controller
name="event_type"
control={editForm.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectType')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="single">single</SelectItem>
<SelectItem value="recurring">recurring</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={editForm.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">active</SelectItem>
<SelectItem value="cancelled">cancelled</SelectItem>
<SelectItem value="completed">completed</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('events.startDatetime')}</Label>
<Input type="datetime-local" {...editForm.register('start_time')} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('events.durationMin')}</Label>
<Input type="number" min={0} {...editForm.register('duration')} />
</div>
<div className="space-y-2">
<Label>{t('events.capacity')}</Label>
<Input type="number" min={0} {...editForm.register('capacity')} />
</div>
</div>
<div className="space-y-2">
<Label>{t('events.specialistId')}</Label>
<Input {...editForm.register('specialist_id')} />
</div>
<div className="space-y-2">
<Label>{t('events.calendarId')}</Label>
<Input {...editForm.register('calendar_id')} />
</div>
<div className="space-y-2">
<Label>{t('events.onlineLink')}</Label>
<Input {...editForm.register('online_link')} />
</div>
<div className="space-y-2">
<Label>{t('events.tags')}</Label>
<Input placeholder="tag1, tag2, tag3" {...editForm.register('tags')} />
</div>
</form>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal({ open: false, eventId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-event-form" disabled={editHasErrors || updateEvent.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, eventId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('events.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, eventId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteEvent.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<EntitySlideOver target={preview} onOpenChange={(open) => !open && setPreview(null)} />
</>
);
};
+280
View File
@@ -0,0 +1,280 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { useReports, useReport, useUpdateReport } from '@/hooks/useReports';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { selectAfterRemove, selectNeighborId, useInboxHotkeys } from '@/hooks/useInboxHotkeys';
import { formatStatusLabel } from '@/utils/statusLabels';
import { Report } from '@/types/api';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { EntitySlideOver, type ExplorePreviewTarget } from '@/components/EntitySlideOver';
type Filter = 'pending' | 'all';
function reportBadgeVariant(status: Report['status']) {
if (status === 'pending') return 'warning' as const;
if (status === 'reviewed') return 'success' as const;
return 'secondary' as const;
}
const ReportInboxPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [filter, setFilter] = useState<Filter>('pending');
const selectedFromUrl = searchParams.get('selected') ?? '';
const [preview, setPreview] = useState<ExplorePreviewTarget>(null);
const listParams = useMemo(
() => ({
limit: 50,
offset: 0,
sort: 'created_at',
order: 'desc' as const,
...(filter === 'pending' ? { status: 'pending' as const } : {}),
}),
[filter]
);
const { data: listData, isLoading: listLoading } = useReports(listParams);
const items = listData?.data ?? [];
const ids = useMemo(() => items.map((r) => r.id), [items]);
const [selectedId, setSelectedId] = useState('');
useEffect(() => {
if (selectedFromUrl && items.some((r) => r.id === selectedFromUrl)) {
setSelectedId(selectedFromUrl);
return;
}
if (!selectedId && items.length > 0) {
setSelectedId(items[0].id);
}
if (selectedId && !items.some((r) => r.id === selectedId) && items.length > 0) {
setSelectedId(items[0].id);
}
}, [items, selectedFromUrl, selectedId]);
const selectReport = useCallback(
(id: string) => {
setSelectedId(id);
setSearchParams({ selected: id });
},
[setSearchParams]
);
const { data: report, isLoading: detailLoading } = useReport(selectedId);
const updateReport = useUpdateReport();
const reporterId = report?.reporter_id ?? '';
const targetType = report?.target_type;
const targetId = report?.target_id ?? '';
const { data: reporter } = useUser(reporterId);
const { data: targetEvent } = useEvent(targetType === 'event' ? targetId : '');
const advanceAfterAction = useCallback(
(currentId: string) => {
const nextId = selectAfterRemove(ids, currentId);
if (nextId) selectReport(nextId);
},
[ids, selectReport]
);
const handleStatus = useCallback(
(status: 'reviewed' | 'dismissed') => {
if (!selectedId) return;
const current = selectedId;
updateReport.mutate(
{ id: current, data: { status } },
{ onSuccess: () => advanceAfterAction(current) }
);
},
[selectedId, updateReport, advanceAfterAction]
);
useInboxHotkeys({
enabled: !preview,
onNext: () => {
const next = selectNeighborId(ids, selectedId, 1);
if (next) selectReport(next);
},
onPrev: () => {
const prev = selectNeighborId(ids, selectedId, -1);
if (prev) selectReport(prev);
},
onOpen: () => {
if (selectedId) navigate(`/reports/${selectedId}`);
},
onPrimary: () => {
if (report?.status === 'pending') handleStatus('reviewed');
},
onSecondary: () => {
if (report?.status === 'pending') handleStatus('dismissed');
},
});
return (
<div className="flex h-[calc(100vh-7rem)] min-h-[480px] flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.reportsTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('inbox.reportsHints')}</p>
</div>
<div className="ml-auto flex gap-2">
<Button variant={filter === 'pending' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('pending')}>
{t('inbox.pending')}
</Button>
<Button variant={filter === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('all')}>
{t('inbox.all')}
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-[minmax(260px,360px)_1fr]">
<Card className="flex min-h-0 flex-col overflow-hidden">
<CardHeader className="py-3">
<CardTitle className="text-base">{t('inbox.queue', { count: items.length })}</CardTitle>
</CardHeader>
<CardContent className="flex-1 p-0">
<ScrollArea className="h-full max-h-[calc(100vh-14rem)]">
{listLoading ? (
<div className="space-y-2 p-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : items.length === 0 ? (
<p className="p-6 text-center text-sm text-muted-foreground">{t('common.queueEmpty')}</p>
) : (
<div className="divide-y">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => selectReport(item.id)}
className={cn(
'w-full px-3 py-3 text-left transition-colors hover:bg-muted/60',
selectedId === item.id && 'bg-muted'
)}
>
<div className="flex items-start justify-between gap-2">
<span className="font-medium text-sm line-clamp-2">{item.reason}</span>
<Badge variant={reportBadgeVariant(item.status)} className="shrink-0">
{formatStatusLabel(item.status)}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{item.target_type} · {dayjs(item.created_at).format('DD.MM HH:mm')}
</p>
</button>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col">
{!selectedId ? (
<CardContent className="flex flex-1 items-center justify-center text-muted-foreground">
{t('inbox.selectReport')}
</CardContent>
) : detailLoading ? (
<CardContent className="space-y-3 p-6">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-24 w-full" />
</CardContent>
) : report ? (
<>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-lg">{t('inbox.reportCard', { id: report.id })}</CardTitle>
<Badge variant={reportBadgeVariant(report.status)}>
{formatStatusLabel(report.status)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">{t('common.reason')}</dt>
<dd className="font-medium">{report.reason}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.targetType')}</dt>
<dd>{report.target_type}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.sender')}</dt>
<dd>
{reporter ? (
<button
type="button"
className="text-primary hover:underline"
onClick={() => setPreview({ type: 'user', id: reporter.id })}
>
{reporter.nickname || reporter.email || reporter.id}
</button>
) : (
report.reporter_id
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.target')}</dt>
<dd>
{targetType === 'event' && targetEvent ? (
<button
type="button"
className="text-primary hover:underline"
onClick={() => setPreview({ type: 'event', id: targetEvent.id })}
>
{targetEvent.title || targetEvent.id}
</button>
) : (
report.target_id
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{dayjs(report.created_at).format('DD.MM.YYYY HH:mm')}</dd>
</div>
</dl>
<Separator />
<Button variant="outline" asChild>
<Link to={`/reports/${report.id}`}>{t('inbox.fullCard')}</Link>
</Button>
</CardContent>
{report.status === 'pending' && (
<CardFooter className="gap-2 border-t bg-muted/30 p-4">
<Button onClick={() => handleStatus('reviewed')} disabled={updateReport.isPending}>
{t('common.reviewed')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">1</kbd>
</Button>
<Button variant="secondary" onClick={() => handleStatus('dismissed')} disabled={updateReport.isPending}>
{t('common.dismissed')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">2</kbd>
</Button>
</CardFooter>
)}
</>
) : (
<CardContent className="flex flex-1 items-center justify-center text-muted-foreground">
{t('reports.notFound')}
</CardContent>
)}
</Card>
</div>
<EntitySlideOver target={preview} onOpenChange={(open) => !open && setPreview(null)} />
</div>
);
};
export default ReportInboxPage;
+279
View File
@@ -0,0 +1,279 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { useTickets, useTicket, useUpdateTicket } from '@/hooks/useTickets';
import { useAuthStore } from '@/store/authStore';
import { selectAfterRemove, selectNeighborId, useInboxHotkeys } from '@/hooks/useInboxHotkeys';
import { formatStatusLabel } from '@/utils/statusLabels';
import { Ticket } from '@/types/api';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
type Filter = 'active' | 'mine' | 'all';
function ticketBadgeVariant(status: Ticket['status']) {
if (status === 'open') return 'warning' as const;
if (status === 'in_progress') return 'default' as const;
if (status === 'resolved' || status === 'closed') return 'success' as const;
return 'secondary' as const;
}
const TicketInboxPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [filter, setFilter] = useState<Filter>('active');
const selectedFromUrl = searchParams.get('selected') ?? '';
const meId = useAuthStore((s) => s.user?.id);
const listParams = useMemo(
() => ({
limit: 50,
offset: 0,
sort: 'last_seen',
order: 'desc' as const,
}),
[]
);
const { data: listData, isLoading: listLoading } = useTickets(listParams);
const allItems = listData?.data ?? [];
const items = useMemo(() => {
if (filter === 'all') return allItems;
if (filter === 'mine') {
return allItems.filter(
(t) =>
(t.status === 'open' || t.status === 'in_progress') &&
meId &&
t.assigned_to === meId
);
}
return allItems.filter((t) => t.status === 'open' || t.status === 'in_progress');
}, [allItems, filter, meId]);
const ids = useMemo(() => items.map((t) => t.id), [items]);
const [selectedId, setSelectedId] = useState('');
useEffect(() => {
if (selectedFromUrl && items.some((t) => t.id === selectedFromUrl)) {
setSelectedId(selectedFromUrl);
return;
}
if (!selectedId && items.length > 0) setSelectedId(items[0].id);
if (selectedId && !items.some((t) => t.id === selectedId) && items.length > 0) {
setSelectedId(items[0].id);
}
}, [items, selectedFromUrl, selectedId]);
const selectTicket = useCallback(
(id: string) => {
setSelectedId(id);
setSearchParams({ selected: id });
},
[setSearchParams]
);
const { data: ticket, isLoading: detailLoading } = useTicket(selectedId);
const updateTicket = useUpdateTicket();
const advanceAfterAction = useCallback(
(currentId: string) => {
const nextId = selectAfterRemove(ids, currentId);
if (nextId) selectTicket(nextId);
},
[ids, selectTicket]
);
const handleResolve = useCallback(() => {
if (!selectedId) return;
const current = selectedId;
updateTicket.mutate(
{ id: current, data: { status: 'resolved' } },
{ onSuccess: () => advanceAfterAction(current) }
);
}, [selectedId, updateTicket, advanceAfterAction]);
const handleInProgress = useCallback(() => {
if (!selectedId) return;
updateTicket.mutate({ id: selectedId, data: { status: 'in_progress' } });
}, [selectedId, updateTicket]);
useInboxHotkeys({
onNext: () => {
const next = selectNeighborId(ids, selectedId, 1);
if (next) selectTicket(next);
},
onPrev: () => {
const prev = selectNeighborId(ids, selectedId, -1);
if (prev) selectTicket(prev);
},
onOpen: () => {
if (selectedId) navigate(`/tickets/${selectedId}`);
},
onPrimary: () => {
if (ticket && (ticket.status === 'open' || ticket.status === 'in_progress')) {
handleResolve();
}
},
onSecondary: () => {
if (ticket?.status === 'open') handleInProgress();
},
});
return (
<div className="flex h-[calc(100vh-7rem)] min-h-[480px] flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.ticketsTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('inbox.ticketsHints')}</p>
</div>
<div className="ml-auto flex flex-wrap gap-2">
<Button variant={filter === 'active' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('active')}>
{t('inbox.active')}
</Button>
<Button variant={filter === 'mine' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('mine')}>
{t('inbox.mine')}
</Button>
<Button variant={filter === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('all')}>
{t('inbox.all')}
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-[minmax(260px,360px)_1fr]">
<Card className="flex min-h-0 flex-col overflow-hidden">
<CardHeader className="py-3">
<CardTitle className="text-base">{t('inbox.queue', { count: items.length })}</CardTitle>
</CardHeader>
<CardContent className="flex-1 p-0">
<ScrollArea className="h-full max-h-[calc(100vh-14rem)]">
{listLoading ? (
<div className="space-y-2 p-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : items.length === 0 ? (
<p className="p-6 text-center text-sm text-muted-foreground">{t('common.queueEmpty')}</p>
) : (
<div className="divide-y">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => selectTicket(item.id)}
className={cn(
'w-full px-3 py-3 text-left transition-colors hover:bg-muted/60',
selectedId === item.id && 'bg-muted'
)}
>
<div className="flex items-start justify-between gap-2">
<span className="font-medium text-sm line-clamp-2">
{item.error_message || item.id}
</span>
<Badge variant={ticketBadgeVariant(item.status)} className="shrink-0">
{formatStatusLabel(item.status)}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
×{item.count} · {dayjs(item.last_seen).format('DD.MM HH:mm')}
</p>
</button>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col">
{!selectedId ? (
<CardContent className="flex flex-1 items-center justify-center text-muted-foreground">
{t('inbox.selectTicket')}
</CardContent>
) : detailLoading ? (
<CardContent className="space-y-3 p-6">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-24 w-full" />
</CardContent>
) : ticket ? (
<>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-lg line-clamp-2">
{ticket.error_message || ticket.id}
</CardTitle>
<Badge variant={ticketBadgeVariant(ticket.status)}>
{formatStatusLabel(ticket.status)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">ID</dt>
<dd className="font-mono text-xs">{ticket.id}</dd>
</div>
<div>
<dt className="text-muted-foreground">Hash</dt>
<dd className="font-mono text-xs truncate">{ticket.error_hash}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.count')}</dt>
<dd>{ticket.count}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.assigned')}</dt>
<dd>{ticket.assigned_to || t('common.unassigned')}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.lastSeen')}</dt>
<dd>{dayjs(ticket.last_seen).format('DD.MM.YYYY HH:mm')}</dd>
</div>
</dl>
{ticket.stacktrace && (
<>
<Separator />
<div>
<p className="mb-1 text-sm text-muted-foreground">Stacktrace</p>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs">
{ticket.stacktrace}
</pre>
</div>
</>
)}
<Button variant="outline" asChild>
<Link to={`/tickets/${ticket.id}`}>{t('inbox.fullCard')}</Link>
</Button>
</CardContent>
{(ticket.status === 'open' || ticket.status === 'in_progress') && (
<CardFooter className="gap-2 border-t bg-muted/30 p-4">
{ticket.status === 'open' && (
<Button variant="secondary" onClick={handleInProgress} disabled={updateTicket.isPending}>
{t('common.inProgress')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">2</kbd>
</Button>
)}
<Button onClick={handleResolve} disabled={updateTicket.isPending}>
{t('common.resolved')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">1</kbd>
</Button>
</CardFooter>
)}
</>
) : (
<CardContent className="flex flex-1 items-center justify-center text-muted-foreground">
{t('tickets.notFound')}
</CardContent>
)}
</Card>
</div>
</div>
);
};
export default TicketInboxPage;
+302 -119
View File
@@ -1,20 +1,35 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Col, Row, Select, Table, Tabs } from 'antd';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { useMetricsStore, NodeMetric } from '../../store/metricsStore';
import { monitoringApi } from '../../api/monitoringApi';
import AreaTrendChart from '../../components/AreaTrendChart';
import { Activity, Cpu, HardDrive, Radio } from 'lucide-react';
import { useMetricsStore, NodeMetric } from '@/store/metricsStore';
import { monitoringApi } from '@/api/monitoringApi';
import AreaTrendChart from '@/components/AreaTrendChart';
import { KpiCard } from '@/components/ListPageHeader';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
const NODE_COLORS = ['#1677ff', '#52c41a', '#fa8c16', '#eb2f96', '#722ed1', '#13c2c2'];
const TIME_RANGES = [
{ label: '5 мин', value: 5 },
{ label: '15 мин', value: 15 },
{ label: '30 мин', value: 30 },
{ label: '1 час', value: 60 },
];
const TIME_RANGE_KEYS = [
{ key: 'monitoring.m5', value: 5 },
{ key: 'monitoring.m15', value: 15 },
{ key: 'monitoring.m30', value: 30 },
{ key: 'monitoring.h1', value: 60 },
] as const;
const bytesToMb = (value?: number) => (value ?? 0) / 1048576;
const formatTime = (ts: string) => dayjs(ts).format('HH:mm:ss');
const buildChartData = (metrics: NodeMetric[]) =>
@@ -41,11 +56,133 @@ const buildChartData = (metrics: NodeMetric[]) =>
const MetricChart: React.FC<{
data: ReturnType<typeof buildChartData>;
lines: Array<{ key: string; color: string; name: string }>;
}> = ({ data, lines }) => (
<AreaTrendChart data={data} xKey="time" series={lines} />
);
}> = ({ data, lines }) => <AreaTrendChart data={data} xKey="time" series={lines} />;
const NodeTabContent: React.FC<{
color: string;
chartData: ReturnType<typeof buildChartData>;
}> = ({ color, chartData }) => {
const { t } = useTranslation();
return (
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('monitoring.cpuMemory')}</CardTitle>
</CardHeader>
<CardContent>
<MetricChart
data={chartData}
lines={[
{ key: 'cpu', color, name: 'CPU %' },
{ key: 'memoryMb', color: '#1890ff', name: 'Total memory' },
{ key: 'memoryAvailableMb', color: '#13c2c2', name: 'Available memory' },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('monitoring.memoryByType')}</CardTitle>
</CardHeader>
<CardContent>
<MetricChart
data={chartData}
lines={[
{ key: 'memoryProcessesMb', color: '#1677ff', name: 'Processes' },
{ key: 'memoryAtomMb', color: '#52c41a', name: 'Atom' },
{ key: 'memoryBinaryMb', color: '#fa8c16', name: 'Binary' },
{ key: 'memoryEtsMb', color: '#eb2f96', name: 'ETS' },
]}
/>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('monitoring.wsQueue')}</CardTitle>
</CardHeader>
<CardContent>
<MetricChart
data={chartData}
lines={[
{ key: 'ws', color: '#52c41a', name: 'WS' },
{ key: 'runQueue', color: '#fa8c16', name: 'Run queue' },
{ key: 'processCount', color: '#722ed1', name: 'Processes' },
{ key: 'activeSessions', color: '#1677ff', name: 'Sessions' },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('monitoring.mnesia')}</CardTitle>
</CardHeader>
<CardContent>
<MetricChart
data={chartData}
lines={[
{ key: 'mnesiaCommits', color: '#722ed1', name: 'Commits' },
{ key: 'mnesiaFailures', color: '#ff4d4f', name: 'Failures' },
]}
/>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">I/O</CardTitle>
</CardHeader>
<CardContent>
<MetricChart
data={chartData}
lines={[
{ key: 'ioIn', color: '#1677ff', name: 'IO in' },
{ key: 'ioOut', color: '#52c41a', name: 'IO out' },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('monitoring.mnesiaTables')}</CardTitle>
</CardHeader>
<CardContent>
{Object.keys(chartData.at(-1)?.tableSizes ?? {}).length === 0 ? (
<p className="text-sm text-muted-foreground">{t('monitoring.noTableData')}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('monitoring.table')}</TableHead>
<TableHead>{t('monitoring.records')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(chartData.at(-1)?.tableSizes ?? {}).map(([table, size]) => (
<TableRow key={table}>
<TableCell className="font-mono text-xs">{table}</TableCell>
<TableCell className="tabular-nums">{size}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
};
const MonitoringPage: React.FC = () => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const nodeFromUrl = searchParams.get('node') ?? '';
const allHistory = useMetricsStore((s) => s.allHistory);
const current = useMetricsStore((s) => s.current);
const setAllHistory = useMetricsStore((s) => s.setAllHistory);
@@ -53,6 +190,7 @@ const MonitoringPage: React.FC = () => {
const visibleHistory = useMetricsStore((s) => s.visibleHistory);
const [minutes, setMinutes] = useState(15);
const [loadingHistory, setLoadingHistory] = useState(false);
const [activeNode, setActiveNode] = useState(nodeFromUrl);
useEffect(() => {
let cancelled = false;
@@ -69,9 +207,7 @@ const MonitoringPage: React.FC = () => {
} catch {
// live WS дополняет историю; REST недоступен — не блокируем страницу
} finally {
if (!cancelled) {
setLoadingHistory(false);
}
if (!cancelled) setLoadingHistory(false);
}
};
@@ -91,6 +227,22 @@ const MonitoringPage: React.FC = () => {
return Array.from(nodes).sort();
}, [allHistory, current]);
useEffect(() => {
if (allNodes.length === 0) return;
if (nodeFromUrl && allNodes.includes(nodeFromUrl)) {
if (activeNode !== nodeFromUrl) setActiveNode(nodeFromUrl);
return;
}
if (!activeNode || !allNodes.includes(activeNode)) {
setActiveNode(allNodes[0]);
}
}, [allNodes, activeNode, nodeFromUrl]);
const selectNode = (node: string) => {
setActiveNode(node);
setSearchParams({ node }, { replace: true });
};
const metricsByNode = useMemo(() => {
const source = visibleHistory.length > 0 ? visibleHistory : allHistory;
const map = new Map<string, NodeMetric[]>();
@@ -102,117 +254,148 @@ const MonitoringPage: React.FC = () => {
return map;
}, [visibleHistory, allHistory]);
const tabItems = allNodes.map((node, idx) => {
const nodeMetrics = metricsByNode.get(node) || [];
const chartData = buildChartData(nodeMetrics);
const latestTableSizes = chartData.at(-1)?.tableSizes ?? {};
const color = NODE_COLORS[idx % NODE_COLORS.length];
const latestByNode = useMemo(() => {
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;
}, [allHistory, current]);
return {
key: node,
label: <span style={{ color }}>{node}</span>,
children: (
<Row gutter={[16, 16]}>
<Col span={24}>
<Card title="CPU и память (МБ)">
<MetricChart
data={chartData}
lines={[
{ key: 'cpu', color, name: 'CPU %' },
{ key: 'memoryMb', color: '#1890ff', name: 'Память всего' },
{ key: 'memoryAvailableMb', color: '#13c2c2', name: 'Память доступно' },
]}
/>
</Card>
</Col>
<Col span={24}>
<Card title="Память по типам (МБ)">
<MetricChart
data={chartData}
lines={[
{ key: 'memoryProcessesMb', color: '#1677ff', name: 'Processes' },
{ key: 'memoryAtomMb', color: '#52c41a', name: 'Atom' },
{ key: 'memoryBinaryMb', color: '#fa8c16', name: 'Binary' },
{ key: 'memoryEtsMb', color: '#eb2f96', name: 'ETS' },
]}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="WebSocket и очередь">
<MetricChart
data={chartData}
lines={[
{ key: 'ws', color: '#52c41a', name: 'WS' },
{ key: 'runQueue', color: '#fa8c16', name: 'Run queue' },
{ key: 'processCount', color: '#722ed1', name: 'Processes' },
{ key: 'activeSessions', color: '#1677ff', name: 'Sessions' },
]}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="Mnesia">
<MetricChart
data={chartData}
lines={[
{ key: 'mnesiaCommits', color: '#722ed1', name: 'Commits' },
{ key: 'mnesiaFailures', color: '#ff4d4f', name: 'Failures' },
]}
/>
</Card>
</Col>
<Col span={24}>
<Card title="I/O">
<MetricChart
data={chartData}
lines={[
{ key: 'ioIn', color: '#1677ff', name: 'IO in' },
{ key: 'ioOut', color: '#52c41a', name: 'IO out' },
]}
/>
</Card>
</Col>
<Col span={24}>
<Card title="Размеры таблиц Mnesia (последняя точка)">
<Table
size="small"
pagination={false}
rowKey="table"
dataSource={Object.entries(latestTableSizes).map(([table, size]) => ({
table,
size,
}))}
columns={[
{ title: 'Таблица', dataIndex: 'table', key: 'table' },
{ title: 'Записей', dataIndex: 'size', key: 'size' },
]}
/>
</Card>
</Col>
</Row>
),
};
});
const summary = useMemo(() => {
const latests = Array.from(latestByNode.values());
if (latests.length === 0) {
return { online: 0, cpuPeak: 0, memAvg: 0 };
}
const cpuPeak = Math.max(...latests.map((m) => m.cpu_utilization ?? 0));
const memAvgs = latests.map((m) => {
const total = (m.memory_total ?? 0) + (m.memory_available ?? 0);
return total > 0 ? ((m.memory_total ?? 0) / total) * 100 : 0;
});
const memAvg = memAvgs.reduce((a, b) => a + b, 0) / memAvgs.length;
return { online: latests.length, cpuPeak, memAvg };
}, [latestByNode]);
const activeIdx = allNodes.indexOf(activeNode);
const activeColor = NODE_COLORS[activeIdx >= 0 ? activeIdx % NODE_COLORS.length : 0];
const activeChartData = buildChartData(metricsByNode.get(activeNode) || []);
const healthy = summary.online > 0 && summary.cpuPeak < 85;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>Мониторинг нод</h2>
<Select
value={minutes}
onChange={setMinutes}
options={TIME_RANGES}
style={{ width: 120 }}
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{t('monitoring.title')}</h1>
<Badge variant={healthy ? 'success' : summary.online === 0 ? 'secondary' : 'warning'}>
{summary.online === 0
? t('monitoring.noData')
: healthy
? t('monitoring.healthy', { count: summary.online })
: t('monitoring.load', { count: summary.online })}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{t('monitoring.subtitle', { minutes })}
</p>
</div>
<div className="flex flex-wrap gap-1.5">
{TIME_RANGE_KEYS.map((r) => (
<Button
key={r.value}
size="sm"
variant={minutes === r.value ? 'default' : 'outline'}
onClick={() => setMinutes(r.value)}
>
{t(r.key)}
</Button>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<KpiCard label={t('monitoring.onlineNodes')} value={summary.online} icon={<Radio className="h-4 w-4" />} />
<KpiCard
label={t('monitoring.cpuPeak')}
value={`${summary.cpuPeak.toFixed(0)}%`}
icon={<Cpu className="h-4 w-4" />}
/>
<KpiCard
label={t('monitoring.memoryAvg')}
value={`${summary.memAvg.toFixed(0)}%`}
icon={<HardDrive className="h-4 w-4" />}
/>
</div>
{allNodes.length === 0 ? (
<Card loading={loadingHistory}>
{loadingHistory ? 'Загрузка метрик…' : 'Ожидание метрик по WebSocket…'}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="h-4 w-4" />
{t('monitoring.waitingTitle')}
</CardTitle>
<CardDescription>{t('monitoring.waitingDesc')}</CardDescription>
</CardHeader>
<CardContent>
{loadingHistory ? (
<div className="space-y-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
) : (
<p className="text-sm text-muted-foreground">{t('monitoring.noPoints')}</p>
)}
</CardContent>
</Card>
) : (
<Tabs items={tabItems} />
<>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('monitoring.node')}
</span>
{allNodes.map((node, idx) => {
const color = NODE_COLORS[idx % NODE_COLORS.length];
const isActive = node === activeNode;
return (
<button
key={node}
type="button"
onClick={() => selectNode(node)}
className={cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-sm transition-colors',
isActive
? 'border-primary bg-primary/5 font-medium text-foreground ring-1 ring-primary/30'
: 'border-transparent bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: color }}
/>
{node}
</button>
);
})}
</div>
{activeNode && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t('monitoring.nodeNamed', { name: activeNode })}
</h2>
{loadingHistory && (
<span className="text-xs text-muted-foreground">{t('monitoring.refreshing')}</span>
)}
</div>
<NodeTabContent color={activeColor} chartData={activeChartData} />
</div>
)}
</>
)}
</div>
);
+303 -179
View File
@@ -1,200 +1,324 @@
import React, { useState } from 'react';
import { Card, Descriptions, Spin, Button, Form, Input, Select, Tag, message, Space, Avatar } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import { useAuthStore } from '../../store/authStore';
import { useUpdateProfile } from '../../hooks/useProfile';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import dayjs from 'dayjs';
import { Pencil } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { useUpdateProfile } from '@/hooks/useProfile';
import { formatStatusLabel } from '@/utils/statusLabels';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { notify } from '@/lib/notify';
type ProfileFormValues = {
nickname?: string;
timezone?: string;
language?: string;
phone?: string;
avatar_url?: string;
preferences?: string;
};
const roleBadgeVariant = (role: string) => {
if (role === 'superadmin') return 'destructive' as const;
if (role === 'admin') return 'default' as const;
return 'secondary' as const;
};
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const ProfilePage: React.FC = () => {
const { t } = useTranslation();
const { user } = useAuthStore();
const updateProfile = useUpdateProfile();
const [editing, setEditing] = useState(false);
const [form] = Form.useForm();
// Редактирование своего профиля доступно всем ролям (PUT /v1/admin/me)
const handleSave = () => {
if (!user) return;
form.validateFields().then((values) => {
const allowed = ['nickname', 'timezone', 'language', 'phone', 'avatar_url', 'preferences'] as const;
const payload = Object.fromEntries(
Object.entries(values).filter(([key, v]) =>
(allowed as readonly string[]).includes(key) && v !== '' && v !== undefined && v !== null
)
);
if (payload.preferences) {
try {
payload.preferences = JSON.parse(payload.preferences as string);
} catch {
message.error('Поле «Настройки» должно быть валидным JSON');
return;
}
}
updateProfile.mutate(
payload,
{
onSuccess: () => {
setEditing(false);
},
}
);
});
};
const profileSchema = useMemo(
() =>
z.object({
nickname: z.string().optional(),
timezone: z.string().optional(),
language: z.string().optional(),
phone: z.string().optional(),
avatar_url: z.string().optional(),
preferences: z
.string()
.optional()
.refine(
(v) =>
!v ||
v.trim() === '' ||
(() => {
try {
JSON.parse(v);
return true;
} catch {
return false;
}
})(),
{ message: t('profile.invalidJson') }
),
}),
[t]
);
const startEditing = () => {
if (!user) return;
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
form.setFieldsValue({
nickname: clean(user.nickname),
email: clean(user.email),
timezone: clean(user.timezone),
language: clean(user.language),
phone: clean(user.phone),
avatar_url: clean(user.avatar_url),
preferences: user.preferences && !isBadValue(user.preferences) ? JSON.stringify(user.preferences) : '',
});
setEditing(true);
};
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
nickname: '',
timezone: '',
language: '',
phone: '',
avatar_url: '',
preferences: '',
},
});
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
if (isBadValue(dateStr)) return t('common.emDash');
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (!user) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
const startEditing = () => {
if (!user) return;
const clean = (val: unknown) => (isBadValue(val) ? '' : String(val));
form.reset({
nickname: clean(user.nickname),
timezone: clean(user.timezone),
language: clean(user.language),
phone: clean(user.phone),
avatar_url: clean(user.avatar_url),
preferences:
user.preferences && !isBadValue(user.preferences)
? JSON.stringify(user.preferences, null, 2)
: '',
});
setEditing(true);
};
const onSave = (values: ProfileFormValues) => {
if (!user) return;
const allowed = ['nickname', 'timezone', 'language', 'phone', 'avatar_url', 'preferences'] as const;
const payload: Record<string, unknown> = {};
for (const key of allowed) {
const v = values[key];
if (v !== '' && v !== undefined && v !== null) {
if (key === 'preferences') {
try {
payload.preferences = JSON.parse(v);
} catch {
notify.error(t('profile.preferencesInvalidJson'));
return;
}
} else {
payload[key] = v;
}
}
}
updateProfile.mutate(payload, { onSuccess: () => setEditing(false) });
};
if (!user) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const displayName = !isBadValue(user.nickname) ? user.nickname! : user.email;
const avatarSrc = !isBadValue(user.avatar_url) ? user.avatar_url! : undefined;
const emDash = t('common.emDash');
return (
<Card title="Мой профиль">
{!editing ? (
<>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
<Avatar
size={64}
icon={<UserOutlined />}
src={!isBadValue(user.avatar_url) ? user.avatar_url! : undefined}
/>
<div>
<h3>{!isBadValue(user.nickname) ? user.nickname : user.email}</h3>
<Tag
color={
user.role === 'superadmin'
? 'red'
: user.role === 'admin'
? 'blue'
: user.role === 'moderator'
? 'purple'
: 'cyan'
}
>
{user.role}
</Tag>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>{t('profile.title')}</CardTitle>
<CardDescription>{t('profile.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{!editing ? (
<>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={avatarSrc} alt={displayName} />
<AvatarFallback>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div>
<h3 className="text-lg font-semibold">{displayName}</h3>
<Badge variant={roleBadgeVariant(user.role)} className="mt-1">
{user.role}
</Badge>
</div>
</div>
</div>
<Descriptions bordered column={1}>
<Descriptions.Item label="Email">{user.email}</Descriptions.Item>
<Descriptions.Item label="Ник">{isBadValue(user.nickname) ? '-' : user.nickname}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag
color={
user.role === 'superadmin'
? 'red'
: user.role === 'admin'
? 'blue'
: user.role === 'moderator'
? 'purple'
: 'cyan'
}
>
{user.role}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={user.status === 'active' ? 'green' : 'red'}>{user.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{isBadValue(user.timezone) ? '-' : user.timezone}</Descriptions.Item>
<Descriptions.Item label="Язык">{isBadValue(user.language) ? '-' : user.language}</Descriptions.Item>
<Descriptions.Item label="Телефон">{isBadValue(user.phone) ? '-' : user.phone}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">
{isBadValue(user.avatar_url) ? '-' : <a href={user.avatar_url || undefined} target="_blank" rel="noreferrer">{user.avatar_url}</a>}
</Descriptions.Item>
<Descriptions.Item label="Настройки">
{isBadValue(user.preferences) ? '-' : <pre>{JSON.stringify(user.preferences, null, 2)}</pre>}
</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(user.last_login)}</Descriptions.Item>
</Descriptions>
<Button type="primary" style={{ marginTop: 16 }} onClick={startEditing}>
Редактировать
</Button>
</>
) : (
<Form form={form} layout="vertical" onFinish={handleSave}>
<Form.Item label="Email" name="email">
<Input disabled />
</Form.Item>
<Form.Item label="Ник" name="nickname">
<Input />
</Form.Item>
<Form.Item label="Роль" name="role">
<Input disabled />
</Form.Item>
<Form.Item label="Статус" name="status">
<Input disabled />
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
<Form.Item name="avatar_url" label="URL аватара">
<Input placeholder="https://example.com/avatar.jpg" />
</Form.Item>
<Form.Item
name="preferences"
label="Настройки (JSON)"
rules={[
{
validator: (_, value) => {
if (value && value.trim().length > 0) {
try {
JSON.parse(value);
} catch {
return Promise.reject(new Error('Невалидный JSON'));
}
}
return Promise.resolve();
},
},
]}
>
<Input.TextArea rows={4} placeholder='{"key": "value"}' />
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={updateProfile.isPending}>
Сохранить
<Separator />
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.email')}</dt>
<dd>{user.email}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.nickname')}</dt>
<dd>{isBadValue(user.nickname) ? emDash : user.nickname}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={user.status === 'active' ? 'default' : 'destructive'}>
{formatStatusLabel(user.status)}
</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.timezone')}</dt>
<dd>{isBadValue(user.timezone) ? emDash : user.timezone}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.language')}</dt>
<dd>{isBadValue(user.language) ? emDash : user.language}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.phone')}</dt>
<dd>{isBadValue(user.phone) ? emDash : user.phone}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.avatarUrl')}</dt>
<dd>
{isBadValue(user.avatar_url) ? (
emDash
) : (
<a href={user.avatar_url!} target="_blank" rel="noreferrer" className="text-primary hover:underline">
{user.avatar_url}
</a>
)}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.preferences')}</dt>
<dd>
{isBadValue(user.preferences) ? (
emDash
) : (
<pre className="overflow-auto rounded-md bg-muted p-2 text-xs">
{JSON.stringify(user.preferences, null, 2)}
</pre>
)}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.lastLogin')}</dt>
<dd>{formatDate(user.last_login)}</dd>
</div>
</dl>
<Button onClick={startEditing}>
<Pencil className="h-4 w-4" />
{t('profile.edit')}
</Button>
<Button onClick={() => setEditing(false)}>Отмена</Button>
</Space>
</Form>
)}
</>
) : (
<form className="space-y-4" onSubmit={form.handleSubmit(onSave)}>
<div className="space-y-2">
<Label>{t('common.email')}</Label>
<Input value={user.email} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="nickname">{t('common.nickname')}</Label>
<Input id="nickname" {...form.register('nickname')} />
</div>
<div className="space-y-2">
<Label>{t('common.role')}</Label>
<Input value={user.role} disabled />
</div>
<div className="space-y-2">
<Label>{t('common.timezone')}</Label>
<Controller
name="timezone"
control={form.control}
render={({ field }) => (
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectTimezone')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="UTC">UTC</SelectItem>
<SelectItem value="Europe/Moscow">Europe/Moscow</SelectItem>
<SelectItem value="Europe/London">Europe/London</SelectItem>
<SelectItem value="Europe/Berlin">Europe/Berlin</SelectItem>
<SelectItem value="America/New_York">America/New_York</SelectItem>
<SelectItem value="Asia/Tokyo">Asia/Tokyo</SelectItem>
<SelectItem value="Asia/Shanghai">Asia/Shanghai</SelectItem>
<SelectItem value="Australia/Sydney">Australia/Sydney</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.language')}</Label>
<Controller
name="language"
control={form.control}
render={({ field }) => (
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectLanguage')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ru">{t('common.langRu')}</SelectItem>
<SelectItem value="en">{t('common.langEn')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t('common.phone')}</Label>
<Input id="phone" placeholder="+7 (999) 123-45-67" {...form.register('phone')} />
</div>
<div className="space-y-2">
<Label htmlFor="avatar_url">{t('profile.avatarUrl')}</Label>
<Input id="avatar_url" placeholder="https://example.com/avatar.jpg" {...form.register('avatar_url')} />
</div>
<div className="space-y-2">
<Label htmlFor="preferences">{t('profile.preferencesJson')}</Label>
<Textarea id="preferences" rows={4} placeholder='{"key": "value"}' {...form.register('preferences')} />
{form.formState.errors.preferences && (
<p className="text-sm text-destructive">{form.formState.errors.preferences.message}</p>
)}
</div>
<div className="flex gap-2">
<Button type="submit" disabled={updateProfile.isPending}>
{updateProfile.isPending ? t('common.saving') : t('common.save')}
</Button>
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
{t('common.cancel')}
</Button>
</div>
</form>
)}
</CardContent>
</Card>
);
};
+171 -64
View File
@@ -1,97 +1,204 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal } from 'antd';
import { Link } from 'react-router-dom';
import { useReport, useUpdateReport } from '../../hooks/useReports';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useAdmin } from '../../hooks/useAdmins';
import React, { useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft } from 'lucide-react';
import { useReport, useUpdateReport } from '@/hooks/useReports';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { useAdmin } from '@/hooks/useAdmins';
import { formatStatusLabel } from '@/utils/statusLabels';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayId = (val?: string | null, emDash = '—') => (!val || isBadValue(val)) ? emDash : val;
const isValidId = (val?: string | null) => val && !isBadValue(val);
const ReportDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: report, isLoading: reportLoading } = useReport(id || '');
const updateReport = useUpdateReport();
const [confirmStatus, setConfirmStatus] = useState<'reviewed' | 'dismissed' | null>(null);
const reporterId = report?.reporter_id;
const resolvedById = report?.resolved_by;
const targetType = report?.target_type;
const targetId = report?.target_id;
const emDash = t('common.emDash');
const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || '');
const { data: resolvedAdmin, isLoading: loadingResolver } = useAdmin(resolvedById || '');
const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetType === 'event' ? targetId || '' : '');
const handleStatusChange = (status: 'reviewed' | 'dismissed') => {
Modal.confirm({
title: `Отметить как «${status === 'reviewed' ? 'Рассмотрено' : 'Отклонено'}»?`,
okText: 'Да',
cancelText: 'Отмена',
onOk: () => updateReport.mutate({ id: id!, data: { status } }),
});
};
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const getLink = (entity: any, type: 'user' | 'admin' | 'event') => {
if (!entity) return <Spin size="small" />;
const getLink = (
entity: { id: string; title?: string | null; nickname?: string | null; email?: string | null },
type: 'user' | 'admin' | 'event'
) => {
let name = '';
if (type === 'event') {
name = !isBadValue(entity.title) ? entity.title : entity.id;
name = !isBadValue(entity.title) ? entity.title! : entity.id;
} else {
const nick = entity.nickname;
const email = entity.email;
if (!isBadValue(nick)) name = nick;
else if (!isBadValue(email)) name = email;
if (!isBadValue(nick)) name = nick!;
else if (!isBadValue(email)) name = email!;
else name = entity.id;
}
const to = type === 'user' ? `/users/${entity.id}` : type === 'admin' ? `/admins/${entity.id}` : `/events/${entity.id}`;
return <Link to={to}>{name}</Link>;
return <Link to={to} className="text-primary hover:underline">{name}</Link>;
};
const displayId = (val?: string | null) => (!val || isBadValue(val)) ? '-' : val;
const isValidId = (val?: string | null) => val && !isBadValue(val);
const handleConfirm = () => {
if (!confirmStatus || !id) return;
updateReport.mutate({ id, data: { status: confirmStatus } }, {
onSuccess: () => setConfirmStatus(null),
});
};
if (reportLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!report) return <p>Жалоба не найдена</p>;
if (reportLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (!report) return <p className="text-muted-foreground">{t('reports.notFound')}</p>;
const statusLabel = confirmStatus === 'reviewed'
? t('common.reviewed')
: t('common.dismissed');
return (
<Card title={`Жалоба ${report.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{report.id}</Descriptions.Item>
<Descriptions.Item label="Отправитель">
{loadingReporter && isValidId(reporterId) ? <Spin size="small" /> : (
reporter ? getLink(reporter, 'user') : displayId(reporterId)
<>
<PageHeader
title={t('reports.detailTitle', { id: report.id })}
breadcrumbs={[
{ label: t('reports.title'), href: '/reports' },
{ label: report.id },
]}
actions={
<Button variant="outline" onClick={() => navigate('/reports')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{report.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.sender')}</dt>
<dd>
{loadingReporter && isValidId(reporterId) ? (
<Skeleton className="h-4 w-24" />
) : reporter ? (
getLink(reporter, 'user')
) : (
displayId(reporterId, emDash)
)}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.targetType')}</dt>
<dd>{report.target_type}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.target')}</dt>
<dd>
{targetType === 'event' && loadingEvent && isValidId(targetId) ? (
<Skeleton className="h-4 w-24" />
) : targetType === 'event' && targetEvent ? (
getLink(targetEvent, 'event')
) : (
displayId(targetId, emDash)
)}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.reason')}</dt>
<dd>{report.reason}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={report.status === 'pending' ? 'warning' : report.status === 'reviewed' ? 'success' : 'secondary'}>
{formatStatusLabel(report.status)}
</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{report.created_at}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('reports.resolvedAt')}</dt>
<dd>{report.resolved_at || emDash}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('reports.resolvedBy')}</dt>
<dd>
{loadingResolver && isValidId(resolvedById) ? (
<Skeleton className="h-4 w-24" />
) : resolvedAdmin ? (
getLink(resolvedAdmin, 'admin')
) : (
displayId(resolvedById, emDash)
)}
</dd>
</div>
</dl>
{report.status === 'pending' && (
<div className="flex flex-wrap gap-2">
<Button onClick={() => setConfirmStatus('reviewed')} disabled={updateReport.isPending}>
{t('common.reviewed')}
</Button>
<Button variant="destructive" onClick={() => setConfirmStatus('dismissed')} disabled={updateReport.isPending}>
{t('common.dismissed')}
</Button>
</div>
)}
</Descriptions.Item>
<Descriptions.Item label="Тип цели">{report.target_type}</Descriptions.Item>
<Descriptions.Item label="Цель">
{targetType === 'event' && loadingEvent && isValidId(targetId) ? <Spin size="small" /> : (
targetType === 'event' && targetEvent ? getLink(targetEvent, 'event') : displayId(targetId)
)}
</Descriptions.Item>
<Descriptions.Item label="Причина">{report.reason}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={report.status === 'pending' ? 'orange' : report.status === 'reviewed' ? 'green' : 'default'}>
{report.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Создано">{report.created_at}</Descriptions.Item>
<Descriptions.Item label="Решено">{report.resolved_at || '-'}</Descriptions.Item>
<Descriptions.Item label="Кем решено">
{loadingResolver && isValidId(resolvedById) ? <Spin size="small" /> : (
resolvedAdmin ? getLink(resolvedAdmin, 'admin') : displayId(resolvedById)
)}
</Descriptions.Item>
</Descriptions>
{report.status === 'pending' && (
<Space style={{ marginTop: 16 }}>
<Button type="primary" onClick={() => handleStatusChange('reviewed')} loading={updateReport.isPending}>Рассмотрено</Button>
<Button danger onClick={() => handleStatusChange('dismissed')} loading={updateReport.isPending}>Отклонено</Button>
</Space>
)}
<Button style={{ marginTop: 16 }} onClick={() => navigate('/reports')}>Назад к списку</Button>
</Card>
</CardContent>
</Card>
<Dialog open={confirmStatus !== null} onOpenChange={(open) => !open && setConfirmStatus(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmMarkAs', { status: statusLabel })}</DialogTitle>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmStatus(null)}>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirm} disabled={updateReport.isPending}>
{updateReport.isPending ? t('common.saving') : t('common.yes')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
+315 -272
View File
@@ -1,21 +1,110 @@
import React, { useState } from 'react';
import { Table, Button, Tag, Modal, Descriptions, Tooltip, Spin, Space, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, EyeOutlined, WarningOutlined } from '@ant-design/icons';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Eye, Info } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useReports, useUpdateReport, useReportStats } from '../../hooks/useReports';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useAdmin } from '../../hooks/useAdmins';
import { Report, ReportListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
import { useReports, useUpdateReport, useReportStats } from '@/hooks/useReports';
import { useExploreView } from '@/hooks/useExploreView';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { useAdmin } from '@/hooks/useAdmins';
import { Report, ReportListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
import { ExploreInsightsCollapse } from '@/components/explore/ExploreInsightsCollapse';
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
import type { DenseRowModel } from '@/components/explore/DenseRowList';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
const TABLE_KEY = 'reports';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const targetTypeBadgeVariant = (type: string) => {
if (type === 'event') return 'default' as const;
if (type === 'calendar') return 'secondary' as const;
if (type === 'review') return 'outline' as const;
return 'outline' as const;
};
const reportStatusVariant = (status: string) => {
if (status === 'pending') return 'secondary' as const;
if (status === 'reviewed') return 'default' as const;
return 'outline' as const;
};
function reportActions(
handlers: { onOpen: () => void; onQuickView: () => void },
t: TFunction
): RowAction[] {
return [
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
{ label: t('common.quickView'), icon: <Eye className="h-4 w-4" />, onClick: handlers.onQuickView },
];
}
const ReporterCell: React.FC<{ reporterId: string }> = ({ reporterId }) => {
const { data: user, isLoading: loading } = useUser(reporterId);
if (loading) return <Skeleton className="h-4 w-24" />;
if (!user) return <span>{reporterId}</span>;
const isGoodString = (val: unknown): val is string => !isBadValue(val);
let name = '';
if (isGoodString(user.nickname)) {
name = user.nickname;
} else if (isGoodString(user.email)) {
name = user.email;
} else {
name = user.id;
}
return <Link to={`/users/${user.id}`}>{name}</Link>;
};
const TargetCell: React.FC<{ targetType: string; targetId: string }> = ({ targetType, targetId }) => {
const { data: event, isLoading: loading } = useEvent(targetType === 'event' ? targetId : '');
if (targetType === 'event') {
if (loading) return <Skeleton className="h-4 w-24" />;
if (event) {
const name = event.title || event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
return <span>{targetId}</span>;
}
return <span>{targetId}</span>;
};
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
<dt className="font-medium text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words">{children}</dd>
</>
);
}
const ReportListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<ReportListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'created_at', order: 'desc' });
const { viewMode, setViewMode } = useExploreView();
const [params, setParams] = useState<ReportListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'created_at',
order: 'desc',
});
const { data, isLoading } = useReports(params);
const { data: stats } = useReportStats();
const updateReport = useUpdateReport();
@@ -38,154 +127,17 @@ const ReportListPage: React.FC = () => {
setDetailModal({ open: true, report });
};
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<Report> | SorterResult<Report>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
};
// Цвета для типа цели
const targetTypeColors: Record<string, string> = {
event: 'blue',
calendar: 'green',
review: 'purple',
};
// Резолвер отправителя
const ReporterCell: React.FC<{ reporterId: string }> = ({ reporterId }) => {
const { data: user, isLoading: loading } = useUser(reporterId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{reporterId}</span>;
const isGoodString = (val: any): val is string => !isBadValue(val);
let name = '';
if (isGoodString(user.nickname)) {
name = user.nickname;
} else if (isGoodString(user.email)) {
name = user.email;
} else {
name = user.id;
}
return <Link to={`/users/${user.id}`}>{name}</Link>;
};
// Резолвер цели
const TargetCell: React.FC<{ targetType: string; targetId: string }> = ({ targetType, targetId }) => {
const { data: event, isLoading: loading } = useEvent(targetType === 'event' ? targetId : '');
if (targetType === 'event') {
if (loading) return <Spin size="small" />;
if (event) {
const name = event.title || event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
return <span>{targetId}</span>;
}
return <span>{targetId}</span>;
};
const columns: ColumnsType<Report> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу жалобы">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/reports/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Отправитель',
key: 'reporter',
ellipsis: true,
render: (_, record) => <ReporterCell reporterId={record.reporter_id} />,
},
{
title: 'Тип',
dataIndex: 'target_type',
key: 'target_type',
width: 100,
sorter: true,
render: (type: string) => (
<Tag color={targetTypeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Цель',
key: 'target',
ellipsis: true,
render: (_, record) => <TargetCell targetType={record.target_type} targetId={record.target_id} />,
},
{
title: 'Причина',
dataIndex: 'reason',
key: 'reason',
ellipsis: true,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'pending' ? 'orange' : status === 'reviewed' ? 'green' : 'default'}>
{formatStatusLabel(status)}
</Tag>
),
},
{
title: 'Создано',
dataIndex: 'created_at',
key: 'created_at',
width: 120,
sorter: true,
},
{
title: <EyeOutlined />,
key: 'view',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Быстрый просмотр">
<Button
icon={<EyeOutlined />}
size="small"
onClick={() => handleViewDetails(record)}
/>
</Tooltip>
),
},
];
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const renderLink = (entity: any, type: 'user' | 'admin' | 'event') => {
if (!entity) return <Spin size="small" />;
const renderLink = (entity: { id: string; nickname?: string | null; email?: string | null; title?: string | null }, type: 'user' | 'admin' | 'event') => {
let name = '';
if (type === 'event') {
name = !isBadValue(entity.title) ? entity.title : entity.id;
name = !isBadValue(entity.title) ? entity.title! : entity.id;
} else {
const nick = entity.nickname;
const email = entity.email;
if (!isBadValue(nick)) {
name = nick;
name = nick!;
} else if (!isBadValue(email)) {
name = email;
name = email!;
} else {
name = entity.id;
}
@@ -204,131 +156,222 @@ const ReportListPage: React.FC = () => {
const isValidId = (id?: string | null) => id && !isBadValue(id);
const exploreStats = useMemo(() => {
if (!stats) return [];
const items = [{ label: t('common.total'), value: stats.total_reports }];
Object.entries(stats.reports_by_status || {})
.slice(0, 3)
.forEach(([status, count]) => {
items.push({ label: formatStatusLabel(status), value: count });
});
return items.slice(0, 4);
}, [stats, t]);
const columns = useMemo<ColumnDef<Report>[]>(
() => [
{
id: 'reporter',
header: t('common.sender'),
enableSorting: false,
cell: ({ row }) => <ReporterCell reporterId={row.original.reporter_id} />,
},
{
accessorKey: 'target_type',
header: t('common.type'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const type = getValue<string>();
return <Badge variant={targetTypeBadgeVariant(type)}>{type}</Badge>;
},
},
{
id: 'target',
header: t('common.target'),
enableSorting: false,
cell: ({ row }) => (
<TargetCell targetType={row.original.target_type} targetId={row.original.target_id} />
),
},
{ accessorKey: 'reason', header: t('common.reason'), enableSorting: true },
{
accessorKey: 'status',
header: t('common.status'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={reportStatusVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{ accessorKey: 'created_at', header: t('common.createdAt'), size: 120, enableSorting: true },
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={reportActions({
onOpen: () => navigate(`/reports/${record.id}`),
onQuickView: () => handleViewDetails(record),
}, t)}
/>
);
},
},
],
[navigate, t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => ({
id: record.id,
title: record.reason,
subtitle: `${record.target_type} · ${record.target_id}`,
badges: (
<>
<Badge variant={targetTypeBadgeVariant(record.target_type)}>{record.target_type}</Badge>
<Badge variant={reportStatusVariant(record.status)}>
{formatStatusLabel(record.status)}
</Badge>
</>
),
meta: record.created_at,
actions: reportActions({
onOpen: () => navigate(`/reports/${record.id}`),
onQuickView: () => handleViewDetails(record),
}, t),
}));
}, [data?.data, navigate, t]);
return (
<div>
<h2>Жалобы</h2>
{stats && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Всего" value={stats.total_reports} prefix={<WarningOutlined />} />
</Card>
</Col>
{Object.entries(stats.reports_by_status || {}).map(([status, count]) => (
<Col xs={12} sm={6} md={4} key={status}>
<Card>
<Statistic title={status} value={count} />
</Card>
</Col>
))}
{Object.entries(stats.reports_by_target_type || {}).map(([type, count]) => (
<Col xs={12} sm={6} md={4} key={`type-${type}`}>
<Card>
<Statistic title={`Цель: ${type}`} value={count} />
</Card>
</Col>
))}
</Row>
)}
{stats?.top_targets_by_reports && stats.top_targets_by_reports.length > 0 && (
<Card title="Топ целей по жалобам" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey={(r) => `${r.target_type}-${r.target_id}`}
dataSource={stats.top_targets_by_reports}
columns={[
{ title: 'Тип', dataIndex: 'target_type', key: 'target_type' },
<>
<ExploreListShell
title={t('reports.title')}
description={t('explore.descActions')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
banner={{
text: t('inbox.bannerReports'),
to: '/inbox/reports',
actionLabel: t('common.openInbox'),
}}
>
{stats?.top_targets_by_reports && stats.top_targets_by_reports.length > 0 && (
<ExploreInsightsCollapse
title={t('explore.tops')}
tabs={[
{
title: 'ID цели',
dataIndex: 'target_id',
key: 'target_id',
render: (id: string, record) => (
<Link to={`/${record.target_type}s/${id}`}>{id}</Link>
),
id: 'reports',
label: t('explore.byReports'),
rows: stats.top_targets_by_reports.map((row) => ({
id: `${row.target_type}-${row.target_id}`,
primary: (
<Link to={`/${row.target_type}s/${row.target_id}`}>{row.target_id}</Link>
),
secondary: row.target_type,
value: row.report_count,
})),
},
{ title: 'Жалоб', dataIndex: 'report_count', key: 'report_count' },
]}
/>
</Card>
)}
<Table
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
/>
<Modal
title={`Жалоба ${detailModal.report?.id || ''}`}
open={detailModal.open}
onCancel={() => setDetailModal({ open: false, report: null })}
footer={null}
width={600}
>
{detailModal.report && (
<>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="ID">{detailModal.report.id}</Descriptions.Item>
<Descriptions.Item label="Отправитель">
{loadingReporter && isValidId(reporterId) ? <Spin size="small" /> : (
reporter ? renderLink(reporter, 'user') : displayId(reporterId)
)}
</Descriptions.Item>
<Descriptions.Item label="Тип цели">{detailModal.report.target_type}</Descriptions.Item>
<Descriptions.Item label="Цель">
{targetType === 'event' && loadingEvent && isValidId(targetId) ? <Spin size="small" /> : (
targetType === 'event' && targetEvent ? renderLink(targetEvent, 'event') : displayId(targetId)
)}
</Descriptions.Item>
<Descriptions.Item label="Причина">{detailModal.report.reason}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={detailModal.report.status === 'pending' ? 'orange' : detailModal.report.status === 'reviewed' ? 'green' : 'default'}>
{detailModal.report.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Создано">{detailModal.report.created_at}</Descriptions.Item>
<Descriptions.Item label="Решено">{detailModal.report.resolved_at || '-'}</Descriptions.Item>
<Descriptions.Item label="Кем решено">
{loadingResolver && isValidId(resolvedById) ? <Spin size="small" /> : (
resolvedAdmin ? renderLink(resolvedAdmin, 'admin') : displayId(resolvedById)
)}
</Descriptions.Item>
</Descriptions>
{detailModal.report.status === 'pending' && (
<Space style={{ marginTop: 16 }}>
<Button
type="primary"
onClick={() => {
updateReport.mutate(
{ id: detailModal.report!.id, data: { status: 'reviewed' } },
{ onSuccess: () => setDetailModal({ open: false, report: null }) }
);
}}
loading={updateReport.isPending}
>
Рассмотрено
</Button>
<Button
danger
onClick={() => {
updateReport.mutate(
{ id: detailModal.report!.id, data: { status: 'dismissed' } },
{ onSuccess: () => setDetailModal({ open: false, report: null }) }
);
}}
loading={updateReport.isPending}
>
Отклонено
</Button>
</Space>
)}
</>
)}
</Modal>
</div>
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
<Dialog open={detailModal.open} onOpenChange={(open) => !open && setDetailModal({ open: false, report: null })}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('reports.detailTitle', { id: detailModal.report?.id || '' })}</DialogTitle>
</DialogHeader>
{detailModal.report && (
<>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 text-sm">
<DetailRow label={t('reports.id')}>{detailModal.report.id}</DetailRow>
<DetailRow label={t('common.sender')}>
{loadingReporter && isValidId(reporterId) ? (
<Skeleton className="h-4 w-32" />
) : reporter ? (
renderLink(reporter, 'user')
) : (
displayId(reporterId)
)}
</DetailRow>
<DetailRow label={t('common.targetType')}>{detailModal.report.target_type}</DetailRow>
<DetailRow label={t('common.target')}>
{targetType === 'event' && loadingEvent && isValidId(targetId) ? (
<Skeleton className="h-4 w-32" />
) : targetType === 'event' && targetEvent ? (
renderLink(targetEvent, 'event')
) : (
displayId(targetId)
)}
</DetailRow>
<DetailRow label={t('common.reason')}>{detailModal.report.reason}</DetailRow>
<DetailRow label={t('common.status')}>
<Badge variant={reportStatusVariant(detailModal.report.status)}>
{formatStatusLabel(detailModal.report.status)}
</Badge>
</DetailRow>
<DetailRow label={t('common.createdAt')}>{detailModal.report.created_at}</DetailRow>
<DetailRow label={t('reports.resolvedAt')}>{detailModal.report.resolved_at || '-'}</DetailRow>
<DetailRow label={t('reports.resolvedBy')}>
{loadingResolver && isValidId(resolvedById) ? (
<Skeleton className="h-4 w-32" />
) : resolvedAdmin ? (
renderLink(resolvedAdmin, 'admin')
) : (
displayId(resolvedById)
)}
</DetailRow>
</dl>
{detailModal.report.status === 'pending' && (
<DialogFooter className="mt-4 sm:justify-start">
<Button
onClick={() => {
updateReport.mutate(
{ id: detailModal.report!.id, data: { status: 'reviewed' } },
{ onSuccess: () => setDetailModal({ open: false, report: null }) }
);
}}
disabled={updateReport.isPending}
>
{t('common.reviewed')}
</Button>
<Button
variant="destructive"
onClick={() => {
updateReport.mutate(
{ id: detailModal.report!.id, data: { status: 'dismissed' } },
{ onSuccess: () => setDetailModal({ open: false, report: null }) }
);
}}
disabled={updateReport.isPending}
>
{t('common.dismissed')}
</Button>
</DialogFooter>
)}
</>
)}
</DialogContent>
</Dialog>
</>
);
};
+190 -98
View File
@@ -1,13 +1,38 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal, Form, Input, message } from 'antd';
import { Link } from 'react-router-dom';
import { useReview, useUpdateReview } from '../../hooks/useReviews';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import dayjs from 'dayjs';
import { ArrowLeft } from 'lucide-react';
import { useReview, useUpdateReview } from '@/hooks/useReviews';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { formatDisplayValue } from '@/lib/utils';
import { formatStatusLabel } from '@/utils/statusLabels';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
interface StatusFormValues {
reason?: string;
}
const ReviewDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: review, isLoading: reviewLoading } = useReview(id || '');
@@ -16,128 +41,195 @@ const ReviewDetailPage: React.FC = () => {
const userId = review?.user_id;
const targetType = review?.target_type;
const targetId = review?.target_id;
const emDash = t('common.emDash');
const { data: user, isLoading: loadingUser } = useUser(userId || '');
const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetType === 'event' ? targetId || '' : '');
const [statusModal, setStatusModal] = useState<{
open: boolean;
newStatus: string;
}>({ open: false, newStatus: 'visible' });
const [statusForm] = Form.useForm();
const [statusModal, setStatusModal] = useState<{ open: boolean; newStatus: string }>({
open: false,
newStatus: 'visible',
});
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: any) => isBadValue(val) ? '-' : val;
const statusForm = useForm<StatusFormValues>({ defaultValues: { reason: '' } });
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
if (isBadValue(dateStr)) return emDash;
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const openStatusModal = (newStatus: string) => {
setStatusModal({ open: true, newStatus });
const currentReason = review?.reason && !isBadValue(review.reason) ? review.reason : '';
statusForm.reset({ reason: currentReason });
};
const handleStatusSubmit = (values: StatusFormValues) => {
if (!review) return;
const payload: { status: 'visible' | 'hidden' | 'deleted'; reason?: string } = {
status: statusModal.newStatus as 'visible' | 'hidden' | 'deleted',
};
if (values.reason?.trim()) payload.reason = values.reason.trim();
updateReview.mutate(
{ id: review.id, data: payload },
{
onSuccess: () => {
setStatusModal({ open: false, newStatus: 'visible' });
},
}
);
};
const getUserLink = () => {
if (loadingUser) return <Spin size="small" />;
if (!user) return displayValue(userId);
if (loadingUser) return <Skeleton className="h-4 w-24" />;
if (!user) return formatDisplayValue(userId) === '-' ? emDash : String(userId);
const name = !isBadValue(user.nickname) ? user.nickname : user.email || user.id;
return <Link to={`/users/${user.id}`}>{name}</Link>;
return <Link to={`/users/${user.id}`} className="text-primary hover:underline">{name}</Link>;
};
const getTargetLink = () => {
if (targetType === 'event') {
if (loadingEvent) return <Spin size="small" />;
if (loadingEvent) return <Skeleton className="h-4 w-24" />;
if (targetEvent) {
const name = targetEvent.title || targetEvent.id;
return <Link to={`/events/${targetEvent.id}`}>{name}</Link>;
return <Link to={`/events/${targetEvent.id}`} className="text-primary hover:underline">{name}</Link>;
}
return displayValue(targetId);
return formatDisplayValue(targetId) === '-' ? emDash : String(targetId);
}
return displayValue(targetId);
return formatDisplayValue(targetId) === '-' ? emDash : String(targetId);
};
// Открытие модалки смены статуса
const openStatusModal = (newStatus: string) => {
setStatusModal({ open: true, newStatus });
statusForm.resetFields();
// Предзаполняем причину, если была
const currentReason = review?.reason && !isBadValue(review.reason) ? review.reason : '';
statusForm.setFieldsValue({ reason: currentReason });
};
if (reviewLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const handleStatusSubmit = () => {
statusForm.validateFields().then((values) => {
if (!review) return;
const payload: any = { status: statusModal.newStatus };
if (values.reason && values.reason.trim() !== '') {
payload.reason = values.reason;
}
updateReview.mutate(
{ id: review.id, data: payload },
{
onSuccess: () => {
setStatusModal({ open: false, newStatus: 'visible' });
message.success('Статус обновлён');
}
}
);
});
};
if (!review) return <p className="text-muted-foreground">{t('reviews.notFound')}</p>;
if (reviewLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!review) return <p>Отзыв не найден</p>;
const reasonRequired = statusModal.newStatus !== 'visible';
return (
<Card title={`Отзыв ${review.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{review.id}</Descriptions.Item>
<Descriptions.Item label="Пользователь">{getUserLink()}</Descriptions.Item>
<Descriptions.Item label="Тип цели">
<Tag color={review.target_type === 'event' ? 'blue' : 'green'}>{review.target_type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Цель">{getTargetLink()}</Descriptions.Item>
<Descriptions.Item label="Оценка">{'⭐'.repeat(review.rating)} ({review.rating})</Descriptions.Item>
<Descriptions.Item label="Комментарий">{displayValue(review.comment)}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={review.status === 'visible' ? 'green' : review.status === 'hidden' ? 'orange' : 'red'}>
{review.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(review.reason)}</Descriptions.Item>
<Descriptions.Item label="Лайки">{isBadValue(review.likes) ? 0 : review.likes}</Descriptions.Item>
<Descriptions.Item label="Дизлайки">{isBadValue(review.dislikes) ? 0 : review.dislikes}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(review.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(review.updated_at)}</Descriptions.Item>
</Descriptions>
<Space style={{ marginTop: 16 }}>
<Button onClick={() => openStatusModal('visible')}>Показать</Button>
<Button onClick={() => openStatusModal('hidden')}>Скрыть</Button>
<Button danger onClick={() => openStatusModal('deleted')}>Удалить</Button>
</Space>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/reviews')}>Назад к списку</Button>
<>
<PageHeader
title={t('reviews.detailTitle', { id: review.id })}
breadcrumbs={[
{ label: t('reviews.title'), href: '/reviews' },
{ label: review.id },
]}
actions={
<Button variant="outline" onClick={() => navigate('/reviews')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{review.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.user')}</dt>
<dd>{getUserLink()}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.targetType')}</dt>
<dd>
<Badge variant={review.target_type === 'event' ? 'default' : 'success'}>{review.target_type}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.target')}</dt>
<dd>{getTargetLink()}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('reviews.score')}</dt>
<dd>{'⭐'.repeat(review.rating)} ({review.rating})</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.comment')}</dt>
<dd>{formatDisplayValue(review.comment) === '-' ? emDash : formatDisplayValue(review.comment)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={review.status === 'visible' ? 'success' : review.status === 'hidden' ? 'warning' : 'destructive'}>
{formatStatusLabel(review.status)}
</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.reason')}</dt>
<dd>{formatDisplayValue(review.reason) === '-' ? emDash : formatDisplayValue(review.reason)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.likes')}</dt>
<dd>{isBadValue(review.likes) ? 0 : review.likes}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.dislikes')}</dt>
<dd>{isBadValue(review.dislikes) ? 0 : review.dislikes}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{formatDate(review.created_at)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.updatedAt')}</dt>
<dd>{formatDate(review.updated_at)}</dd>
</div>
</dl>
<Modal
title={`Изменить статус на «${statusModal.newStatus}»`}
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => openStatusModal('visible')}>{t('common.show')}</Button>
<Button variant="outline" onClick={() => openStatusModal('hidden')}>{t('common.hide')}</Button>
<Button variant="destructive" onClick={() => openStatusModal('deleted')}>{t('common.delete')}</Button>
</div>
</CardContent>
</Card>
<Dialog
open={statusModal.open}
onCancel={() => setStatusModal({ open: false, newStatus: 'visible' })}
onOk={handleStatusSubmit}
confirmLoading={updateReview.isPending}
destroyOnHidden
onOpenChange={(open) => !open && setStatusModal({ open: false, newStatus: 'visible' })}
>
<Form form={statusForm} layout="vertical" preserve={false}>
<Form.Item
label="Причина"
name="reason"
rules={[
{
required: statusModal.newStatus !== 'visible',
message: 'Укажите причину',
},
]}
>
<Input.TextArea rows={3} placeholder="Введите причину изменения статуса" />
</Form.Item>
</Form>
</Modal>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.changeStatusTo', { status: formatStatusLabel(statusModal.newStatus) })}</DialogTitle>
</DialogHeader>
<form id="review-status-form" className="space-y-4" onSubmit={statusForm.handleSubmit(handleStatusSubmit)}>
<div className="space-y-2">
<Label htmlFor="reason">{t('common.reason')}</Label>
<Textarea
id="reason"
rows={3}
placeholder={t('common.enterReason')}
{...statusForm.register('reason', { required: reasonRequired })}
/>
{statusForm.formState.errors.reason && (
<p className="text-sm text-destructive">{t('reviews.reasonRequiredShort')}</p>
)}
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setStatusModal({ open: false, newStatus: 'visible' })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="review-status-form" disabled={updateReview.isPending}>
{updateReview.isPending ? t('common.saving') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
+521 -338
View File
@@ -1,387 +1,570 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, InputNumber, message, Tooltip, Input, Spin, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, EditOutlined, StarOutlined } from '@ant-design/icons';
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Link, useNavigate } from 'react-router-dom';
import { useReviews, useUpdateReview, useBulkUpdateReviews, useReview, useReviewStats } from '../../hooks/useReviews';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { Review, ReviewListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
import { Info, Pencil } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import {
useReviews,
useUpdateReview,
useBulkUpdateReviews,
useReview,
useReviewStats,
} from '@/hooks/useReviews';
import { useExploreView } from '@/hooks/useExploreView';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { Review, ReviewListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { notify } from '@/lib/notify';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
import { ExploreInsightsCollapse } from '@/components/explore/ExploreInsightsCollapse';
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
import type { DenseRowModel } from '@/components/explore/DenseRowList';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
const TABLE_KEY = 'reviews';
const typeBadgeVariant = (type: string) => {
if (type === 'calendar') return 'success' as const;
if (type === 'event') return 'default' as const;
if (type === 'review') return 'secondary' as const;
return 'outline' as const;
};
const statusBadgeVariant = (status: string) => {
if (status === 'visible') return 'success' as const;
if (status === 'hidden') return 'warning' as const;
return 'destructive' as const;
};
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
interface EditFormValues {
status: string;
reason?: string;
comment?: string;
rating?: number;
}
interface BulkStatusFormValues {
reason?: string;
}
function reviewActions(
handlers: { onOpen: () => void; onEdit: () => void },
t: TFunction
): RowAction[] {
return [
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
{ label: t('common.edit'), icon: <Pencil className="h-4 w-4" />, onClick: handlers.onEdit },
];
}
const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading: loading } = useUser(userId);
if (loading) return <Skeleton className="h-4 w-24" />;
if (!user) return <span>{userId}</span>;
const isGoodString = (val: unknown): val is string => !isBadValue(val);
let name = '';
if (isGoodString(user.nickname)) {
name = user.nickname;
} else if (isGoodString(user.email)) {
name = user.email;
} else {
name = user.id;
}
return <Link to={`/users/${user.id}`}>{name}</Link>;
};
const TargetCell: React.FC<{ targetType: string; targetId: string }> = ({ targetType, targetId }) => {
const { data: event, isLoading: loading } = useEvent(targetType === 'event' ? targetId : '');
if (targetType === 'event') {
if (loading) return <Skeleton className="h-4 w-24" />;
if (event) {
const name = event.title || event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
return <span>{targetId}</span>;
}
return <span>{targetId}</span>;
};
const ReviewListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<ReviewListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'created_at', order: 'desc' });
const { viewMode, setViewMode } = useExploreView();
const [params, setParams] = useState<ReviewListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'created_at',
order: 'desc',
});
const { data, isLoading } = useReviews(params);
const { data: stats } = useReviewStats();
const updateReview = useUpdateReview();
const bulkUpdate = useBulkUpdateReviews();
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
// Модальное окно для индивидуального редактирования
const [editModal, setEditModal] = useState<{ open: boolean; reviewId: string | null }>({
open: false,
reviewId: null,
});
const [editForm] = Form.useForm();
const { data: editingReview, isLoading: loadingReview } = useReview(editModal.reviewId || '');
const editForm = useForm<EditFormValues>();
// Модальное окно для массового изменения статуса с причиной
const [bulkStatusModal, setBulkStatusModal] = useState<{
open: boolean;
status: string;
}>({ open: false, status: 'hidden' });
const [bulkStatusForm] = Form.useForm();
const [bulkStatusModal, setBulkStatusModal] = useState<{ open: boolean; status: string }>({
open: false,
status: 'hidden',
});
const bulkStatusForm = useForm<BulkStatusFormValues>();
// ---- Индивидуальное редактирование ----
const handleEdit = (id: string) => {
setEditModal({ open: true, reviewId: id });
const pageIds = useMemo(() => (data?.data ?? []).map((r) => r.id), [data?.data]);
const allPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedRowKeys.includes(id));
const toggleSelectAll = () => {
if (allPageSelected) {
setSelectedRowKeys((prev) => prev.filter((id) => !pageIds.includes(id)));
} else {
setSelectedRowKeys((prev) => [...new Set([...prev, ...pageIds])]);
}
};
const handleSaveEdit = () => {
editForm.validateFields().then((values) => {
if (!editModal.reviewId) return;
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateReview.mutate(
{ id: editModal.reviewId, data: payload },
{ onSuccess: () => setEditModal({ open: false, reviewId: null }) }
);
});
const toggleRow = (id: string) => {
setSelectedRowKeys((prev) =>
prev.includes(id) ? prev.filter((k) => k !== id) : [...prev, id]
);
};
const handleEdit = (id: string) => setEditModal({ open: true, reviewId: id });
const handleSaveEdit = editForm.handleSubmit((values) => {
if (!editModal.reviewId) return;
const status = values.status;
if ((status === 'hidden' || status === 'deleted') && !values.reason?.trim()) {
editForm.setError('reason', { message: t('reviews.reasonRequired') });
return;
}
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateReview.mutate(
{ id: editModal.reviewId, data: payload },
{ onSuccess: () => setEditModal({ open: false, reviewId: null }) }
);
});
useEffect(() => {
if (editingReview && editModal.open) {
setTimeout(() => {
editForm.setFieldsValue({
status: editingReview.status,
reason: editingReview.reason,
comment: editingReview.comment,
rating: editingReview.rating,
});
}, 0);
editForm.reset({
status: editingReview.status,
reason: editingReview.reason ?? undefined,
comment: editingReview.comment ?? undefined,
rating: editingReview.rating,
});
}
}, [editingReview, editModal.open, editForm]);
// ---- Массовое изменение статуса ----
const openBulkStatusModal = (status: string) => {
if (selectedRowKeys.length === 0) {
message.warning('Выберите отзывы');
notify.info(t('reviews.selectReviews'));
return;
}
setBulkStatusModal({ open: true, status });
bulkStatusForm.resetFields();
bulkStatusForm.reset();
};
const handleBulkStatusSubmit = () => {
bulkStatusForm.validateFields().then((values) => {
const reason = values.reason ? values.reason.trim() : '';
const updates = selectedRowKeys.map(id => ({
id: id as string,
status: bulkStatusModal.status,
reason: reason || undefined, // если пусто, не передаем
}));
bulkUpdate.mutate(updates, {
onSuccess: () => {
setBulkStatusModal({ open: false, status: 'hidden' });
setSelectedRowKeys([]);
},
});
});
};
// ---- Таблица ----
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<Review> | SorterResult<Review>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
};
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading: loading } = useUser(userId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{userId}</span>;
const isGoodString = (val: any): val is string => !isBadValue(val);
let name = '';
if (isGoodString(user.nickname)) {
name = user.nickname;
} else if (isGoodString(user.email)) {
name = user.email;
} else {
name = user.id;
const handleBulkStatusSubmit = bulkStatusForm.handleSubmit((values) => {
const reasonRequired =
bulkStatusModal.status === 'hidden' || bulkStatusModal.status === 'deleted';
if (reasonRequired && !values.reason?.trim()) {
bulkStatusForm.setError('reason', { message: t('reviews.reasonRequiredShort') });
return;
}
return <Link to={`/users/${user.id}`}>{name}</Link>;
};
const TargetCell: React.FC<{ targetType: string; targetId: string }> = ({ targetType, targetId }) => {
const { data: event, isLoading: loading } = useEvent(targetType === 'event' ? targetId : '');
if (targetType === 'event') {
if (loading) return <Spin size="small" />;
if (event) {
const name = event.title || event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
return <span>{targetId}</span>;
}
return <span>{targetId}</span>;
};
const typeColors: Record<string, string> = {
calendar: 'green',
event: 'blue',
review: 'purple',
};
const columns: ColumnsType<Review> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу отзыва">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/reviews/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Пользователь',
key: 'user',
ellipsis: true,
render: (_, record) => <UserCell userId={record.user_id} />,
},
{
title: 'Тип',
dataIndex: 'target_type',
key: 'target_type',
width: 100,
sorter: true,
render: (type: string) => (
<Tag color={typeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Цель',
key: 'target',
ellipsis: true,
render: (_, record) => <TargetCell targetType={record.target_type} targetId={record.target_id} />,
},
{
title: 'Оценка',
dataIndex: 'rating',
key: 'rating',
width: 140,
sorter: true,
render: (rating: number) => '⭐'.repeat(rating),
},
{
title: 'Комментарий',
dataIndex: 'comment',
key: 'comment',
ellipsis: true,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'visible' ? 'green' : status === 'hidden' ? 'orange' : 'red'}>
{formatStatusLabel(status)}
</Tag>
),
},
{
title: 'Лайки / Дизлайки',
key: 'likes',
width: 120,
render: (_, record) => {
const likes = isBadValue(record.likes) ? 0 : record.likes;
const dislikes = isBadValue(record.dislikes) ? 0 : record.dislikes;
return `${likes} / ${dislikes}`;
const reason = values.reason ? values.reason.trim() : '';
const updates = selectedRowKeys.map((id) => ({
id,
status: bulkStatusModal.status,
reason: reason || undefined,
}));
bulkUpdate.mutate(updates, {
onSuccess: () => {
setBulkStatusModal({ open: false, status: 'hidden' });
setSelectedRowKeys([]);
},
},
{
title: '',
key: 'actions',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record.id)}
});
});
const exploreStats = useMemo(() => {
if (!stats) return [];
const items = [{ label: t('common.total'), value: stats.total_reviews }];
Object.entries(stats.reviews_by_status || {})
.slice(0, 3)
.forEach(([status, count]) => {
items.push({ label: formatStatusLabel(status), value: count });
});
return items.slice(0, 4);
}, [stats, t]);
const columns = useMemo<ColumnDef<Review>[]>(
() => [
{
id: 'select',
header: () => (
<input
type="checkbox"
className="h-4 w-4 rounded border"
checked={allPageSelected}
onChange={toggleSelectAll}
aria-label={t('reviews.selectAllAria')}
/>
</Tooltip>
),
},
];
),
size: 40,
enableSorting: false,
cell: ({ row }) => (
<input
type="checkbox"
className="h-4 w-4 rounded border"
checked={selectedRowKeys.includes(row.original.id)}
onChange={() => toggleRow(row.original.id)}
aria-label={t('reviews.selectReviewAria', { id: row.original.id })}
/>
),
},
{
id: 'user',
header: t('common.user'),
enableSorting: false,
cell: ({ row }) => <UserCell userId={row.original.user_id} />,
},
{
accessorKey: 'target_type',
header: t('common.type'),
enableSorting: true,
cell: ({ getValue }) => {
const type = getValue<string>();
return <Badge variant={typeBadgeVariant(type)}>{type}</Badge>;
},
},
{
id: 'target',
header: t('common.target'),
enableSorting: false,
cell: ({ row }) => (
<TargetCell targetType={row.original.target_type} targetId={row.original.target_id} />
),
},
{
accessorKey: 'rating',
header: t('reviews.score'),
enableSorting: true,
cell: ({ getValue }) => '⭐'.repeat(getValue<number>()),
},
{
accessorKey: 'comment',
header: t('common.comment'),
enableSorting: false,
cell: ({ getValue }) => {
const comment = getValue<string>();
return <span className="line-clamp-2">{comment}</span>;
},
},
{
accessorKey: 'status',
header: t('common.status'),
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={statusBadgeVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{
id: 'likes',
header: t('reviews.likesDislikes'),
enableSorting: false,
cell: ({ row }) => {
const likes = isBadValue(row.original.likes) ? 0 : row.original.likes;
const dislikes = isBadValue(row.original.dislikes) ? 0 : row.original.dislikes;
return `${likes} / ${dislikes}`;
},
},
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={reviewActions({
onOpen: () => navigate(`/reviews/${record.id}`),
onEdit: () => handleEdit(record.id),
}, t)}
/>
);
},
},
],
[allPageSelected, selectedRowKeys, navigate, t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => {
const likes = isBadValue(record.likes) ? 0 : record.likes;
const dislikes = isBadValue(record.dislikes) ? 0 : record.dislikes;
const title = record.comment
? record.comment.length > 60
? `${record.comment.slice(0, 60)}`
: record.comment
: t('reviews.ratingValue', { rating: record.rating });
return {
id: record.id,
title,
subtitle: `${record.target_type} · ${record.target_id}`,
badges: (
<>
<Badge variant={typeBadgeVariant(record.target_type)}>{record.target_type}</Badge>
<Badge variant={statusBadgeVariant(record.status)}>
{formatStatusLabel(record.status)}
</Badge>
<span className="text-xs">{'⭐'.repeat(record.rating)}</span>
</>
),
meta: `${likes} / ${dislikes}`,
actions: reviewActions({
onOpen: () => navigate(`/reviews/${record.id}`),
onEdit: () => handleEdit(record.id),
}, t),
};
});
}, [data?.data, navigate, t]);
return (
<div>
<h2>Отзывы</h2>
{stats && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Всего" value={stats.total_reviews} prefix={<StarOutlined />} />
</Card>
</Col>
{Object.entries(stats.reviews_by_status || {}).map(([status, count]) => (
<Col xs={12} sm={6} md={4} key={status}>
<Card>
<Statistic title={status} value={count} />
</Card>
</Col>
))}
{Object.entries(stats.reviews_by_target_type || {}).map(([type, count]) => (
<Col xs={12} sm={6} md={4} key={`type-${type}`}>
<Card>
<Statistic title={`Цель: ${type}`} value={count} />
</Card>
</Col>
))}
</Row>
)}
{stats?.top_targets_by_reviews && stats.top_targets_by_reviews.length > 0 && (
<Card title="Топ целей по отзывам" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey={(r) => `${r.target_type}-${r.target_id}`}
dataSource={stats.top_targets_by_reviews}
columns={[
{ title: 'Тип', dataIndex: 'target_type', key: 'target_type' },
<>
<ExploreListShell
title={t('reviews.title')}
description={t('explore.descBulk')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
>
{stats && (
<ExploreInsightsCollapse
title={t('explore.tops')}
tabs={[
{
title: 'ID цели',
dataIndex: 'target_id',
key: 'target_id',
render: (id: string, record) => (
<Link to={`/${record.target_type}s/${id}`}>{id}</Link>
),
id: 'all',
label: t('explore.allReviews'),
rows: (stats.top_targets_by_reviews ?? []).map((item) => ({
id: `${item.target_type}-${item.target_id}`,
primary: (
<Link to={`/${item.target_type}s/${item.target_id}`}>{item.target_id}</Link>
),
secondary: item.target_type,
value: item.review_count,
})),
},
{
id: 'positive',
label: t('explore.positive'),
rows: (stats.top_targets_by_positive_reviews ?? []).map((item) => ({
id: `${item.target_type}-${item.target_id}`,
primary: (
<Link to={`/${item.target_type}s/${item.target_id}`}>{item.target_id}</Link>
),
secondary: item.target_type,
value: item.review_count,
})),
},
{
id: 'negative',
label: t('explore.negative'),
rows: (stats.top_targets_by_negative_reviews ?? []).map((item) => ({
id: `${item.target_type}-${item.target_id}`,
primary: (
<Link to={`/${item.target_type}s/${item.target_id}`}>{item.target_id}</Link>
),
secondary: item.target_type,
value: item.review_count,
})),
},
{ title: 'Отзывов', dataIndex: 'review_count', key: 'review_count' },
]}
/>
</Card>
)}
<Space style={{ marginBottom: 16 }}>
<Button onClick={() => openBulkStatusModal('hidden')} disabled={selectedRowKeys.length === 0}>
Скрыть выбранные
</Button>
<Button onClick={() => openBulkStatusModal('visible')} disabled={selectedRowKeys.length === 0}>
Показать выбранные
</Button>
<Button danger onClick={() => openBulkStatusModal('deleted')} disabled={selectedRowKeys.length === 0}>
Удалить выбранные
</Button>
</Space>
<Table
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
/>
{/* Модальное окно индивидуального редактирования */}
<Modal
title="Редактировать отзыв"
open={editModal.open}
onCancel={() => setEditModal({ open: false, reviewId: null })}
onOk={handleSaveEdit}
confirmLoading={updateReview.isPending}
destroyOnHidden
>
{loadingReview ? (
<Spin />
) : (
<Form form={editForm} layout="vertical" preserve={false}>
<Form.Item label="Оценка" name="rating">
<InputNumber min={1} max={5} />
</Form.Item>
<Form.Item label="Комментарий" name="comment">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item label="Статус" name="status">
<Select>
<Select.Option value="visible">visible</Select.Option>
<Select.Option value="hidden">hidden</Select.Option>
<Select.Option value="deleted">deleted</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="Причина"
name="reason"
rules={[
({ getFieldValue }) => ({
validator(_, value) {
const status = getFieldValue('status');
if ((status === 'hidden' || status === 'deleted') && (!value || value.trim() === '')) {
return Promise.reject(new Error('Укажите причину изменения статуса'));
}
return Promise.resolve();
},
}),
]}
>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
)}
</Modal>
{/* Модальное окно для массового изменения с причиной */}
<Modal
title={`Изменить статус на «${bulkStatusModal.status}» для ${selectedRowKeys.length} отзывов`}
open={bulkStatusModal.open}
onCancel={() => setBulkStatusModal({ open: false, status: 'hidden' })}
onOk={handleBulkStatusSubmit}
confirmLoading={bulkUpdate.isPending}
destroyOnHidden
>
<Form form={bulkStatusForm} layout="vertical" preserve={false}>
<Form.Item
label="Причина"
name="reason"
rules={[
{
required: bulkStatusModal.status === 'hidden' || bulkStatusModal.status === 'deleted',
message: 'Укажите причину',
},
]}
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => openBulkStatusModal('hidden')}
disabled={selectedRowKeys.length === 0}
>
<Input.TextArea rows={3} placeholder="Общая причина для выбранных отзывов" />
</Form.Item>
</Form>
</Modal>
</div>
{t('reviews.hideSelected')}
</Button>
<Button
variant="outline"
onClick={() => openBulkStatusModal('visible')}
disabled={selectedRowKeys.length === 0}
>
{t('reviews.showSelected')}
</Button>
<Button
variant="destructive"
onClick={() => openBulkStatusModal('deleted')}
disabled={selectedRowKeys.length === 0}
>
{t('reviews.deleteSelected')}
</Button>
</div>
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
<Dialog
open={editModal.open}
onOpenChange={(open) => !open && setEditModal({ open: false, reviewId: null })}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('reviews.editTitle')}</DialogTitle>
</DialogHeader>
{loadingReview ? (
<div className="space-y-3 py-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="edit-review-form" className="space-y-4" onSubmit={handleSaveEdit}>
<div className="space-y-2">
<Label>{t('reviews.score')}</Label>
<Input
type="number"
min={1}
max={5}
{...editForm.register('rating', { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label>{t('common.comment')}</Label>
<Textarea rows={3} {...editForm.register('comment')} />
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={editForm.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="visible">visible</SelectItem>
<SelectItem value="hidden">hidden</SelectItem>
<SelectItem value="deleted">deleted</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.reason')}</Label>
<Textarea rows={2} {...editForm.register('reason')} />
{editForm.formState.errors.reason && (
<p className="text-sm text-destructive">
{editForm.formState.errors.reason.message}
</p>
)}
</div>
</form>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal({ open: false, reviewId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-review-form" disabled={updateReview.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={bulkStatusModal.open}
onOpenChange={(open) => !open && setBulkStatusModal({ open: false, status: 'hidden' })}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('reviews.bulkStatusTitle', {
status: bulkStatusModal.status,
count: selectedRowKeys.length,
})}
</DialogTitle>
</DialogHeader>
<form id="bulk-status-form" className="space-y-4" onSubmit={handleBulkStatusSubmit}>
<div className="space-y-2">
<Label>{t('common.reason')}</Label>
<Textarea
rows={3}
placeholder={t('reviews.bulkReasonPlaceholder')}
{...bulkStatusForm.register('reason')}
/>
{bulkStatusForm.formState.errors.reason && (
<p className="text-sm text-destructive">
{bulkStatusForm.formState.errors.reason.message}
</p>
)}
</div>
</form>
<DialogFooter>
<Button
variant="outline"
onClick={() => setBulkStatusModal({ open: false, status: 'hidden' })}
>
{t('common.cancel')}
</Button>
<Button type="submit" form="bulk-status-form" disabled={bulkUpdate.isPending}>
{t('common.apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
+444 -267
View File
@@ -1,18 +1,126 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, DatePicker, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd';
import { EditOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Link } from 'react-router-dom';
import { useSubscriptions, useUpdateSubscription, useDeleteSubscription, useSubscription, useSubscriptionStats } from '../../hooks/useSubscriptions';
import { useUser } from '../../hooks/useUsers';
import { Subscription, SubscriptionListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { Pencil, Trash2 } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import {
useSubscriptions,
useUpdateSubscription,
useDeleteSubscription,
useSubscription,
useSubscriptionStats,
} from '@/hooks/useSubscriptions';
import { useExploreView } from '@/hooks/useExploreView';
import { useUser } from '@/hooks/useUsers';
import { Subscription, SubscriptionListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
import { ExploreInsightsCollapse } from '@/components/explore/ExploreInsightsCollapse';
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
import type { DenseRowModel } from '@/components/explore/DenseRowList';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
const TABLE_KEY = 'subscriptions';
const ALL = '__all__';
const planBadgeVariant = (plan: string) => {
if (plan === 'trial') return 'secondary' as const;
if (plan === 'monthly') return 'default' as const;
if (plan === 'quarterly') return 'success' as const;
if (plan === 'biannual') return 'secondary' as const;
return 'warning' as const;
};
const statusBadgeVariant = (status: string) => {
if (status === 'active') return 'success' as const;
if (status === 'expired') return 'warning' as const;
return 'destructive' as const;
};
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const toDatetimeLocal = (iso: string | null | undefined): string => {
if (!iso || iso === '-') return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
interface EditFormValues {
plan: string;
status: string;
trial_used: boolean;
expires_at: string;
}
function subscriptionActions(
handlers: { onEdit: () => void; onDelete: () => void },
t: TFunction
): RowAction[] {
return [
{ label: t('common.edit'), icon: <Pencil className="h-4 w-4" />, onClick: handlers.onEdit },
{
label: t('common.delete'),
icon: <Trash2 className="h-4 w-4" />,
onClick: handlers.onDelete,
destructive: true,
separatorBefore: true,
},
];
}
const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading: loading } = useUser(userId);
if (loading) return <Skeleton className="h-4 w-24" />;
if (!user) return <span>{userId}</span>;
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
};
const SubscriptionListPage: React.FC = () => {
const [params, setParams] = useState<SubscriptionListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0 });
const { t } = useTranslation();
const { viewMode, setViewMode } = useExploreView();
const [params, setParams] = useState<SubscriptionListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
});
const { data, isLoading } = useSubscriptions(params);
const { data: stats } = useSubscriptionStats();
const updateSubscription = useUpdateSubscription();
@@ -22,282 +130,351 @@ const SubscriptionListPage: React.FC = () => {
open: false,
subscriptionId: null,
});
const { data: editingSubscription, isLoading: loadingSubscription } = useSubscription(editModal.subscriptionId || '');
const [form] = Form.useForm();
const { data: editingSubscription, isLoading: loadingSubscription } = useSubscription(
editModal.subscriptionId || ''
);
const form = useForm<EditFormValues>();
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; subscriptionId: string | null }>({
open: false,
subscriptionId: null,
});
// Заполняем форму с задержкой, когда данные загружены
useEffect(() => {
if (editingSubscription && editModal.open) {
setTimeout(() => {
form.setFieldsValue({
plan: editingSubscription.plan,
status: editingSubscription.status,
trial_used: editingSubscription.trial_used,
expires_at: editingSubscription.expires_at ? dayjs(editingSubscription.expires_at) : null,
});
}, 0);
form.reset({
plan: editingSubscription.plan,
status: editingSubscription.status,
trial_used: editingSubscription.trial_used,
expires_at: toDatetimeLocal(editingSubscription.expires_at),
});
}
}, [editingSubscription, editModal.open, form]);
const handleEdit = (sub: Subscription) => {
// Сбрасываем форму перед открытием
form.resetFields();
setEditModal({ open: true, subscriptionId: sub.id });
};
const handleEdit = (sub: Subscription) => setEditModal({ open: true, subscriptionId: sub.id });
const handleSave = () => {
form.validateFields().then((values) => {
if (!editModal.subscriptionId) return;
const payload = Object.fromEntries(
Object.entries(values)
.filter(([_, v]) => v !== '' && v !== undefined && v !== null)
.map(([key, val]) => {
if (dayjs.isDayjs(val)) return [key, val.toISOString()];
return [key, val];
})
);
updateSubscription.mutate(
{ id: editModal.subscriptionId, data: payload },
{ onSuccess: () => setEditModal({ open: false, subscriptionId: null }) }
);
const handleSave = form.handleSubmit((values) => {
if (!editModal.subscriptionId) return;
const payload: Record<string, unknown> = {};
if (values.plan) payload.plan = values.plan;
if (values.status) payload.status = values.status;
if (values.trial_used !== undefined) payload.trial_used = values.trial_used;
if (values.expires_at) payload.expires_at = new Date(values.expires_at).toISOString();
updateSubscription.mutate(
{ id: editModal.subscriptionId, data: payload },
{ onSuccess: () => setEditModal({ open: false, subscriptionId: null }) }
);
});
const handleDelete = (id: string) => setDeleteConfirm({ open: true, subscriptionId: id });
const confirmDelete = () => {
if (!deleteConfirm.subscriptionId) return;
deleteSubscription.mutate(deleteConfirm.subscriptionId, {
onSuccess: () => setDeleteConfirm({ open: false, subscriptionId: null }),
});
};
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить подписку?',
content: 'Это действие нельзя отменить.',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteSubscription.mutate(id),
});
};
const exploreStats = useMemo(() => {
if (!stats) return [];
const items = [
{ label: t('common.total'), value: stats.total_subscriptions },
{ label: t('subscriptions.trialCount'), value: stats.trial_subscriptions },
];
Object.entries(stats.subscriptions_by_status || {})
.slice(0, 2)
.forEach(([status, count]) => {
items.push({ label: formatStatusLabel(status), value: count });
});
return items.slice(0, 4);
}, [stats, t]);
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<Subscription> | SorterResult<Subscription>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
};
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
// Компонент для резолвинга пользователя
const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading: loading } = useUser(userId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{userId}</span>;
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
};
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const planColors: Record<string, string> = {
trial: 'cyan',
monthly: 'blue',
quarterly: 'green',
biannual: 'purple',
annual: 'orange',
};
const columns: ColumnsType<Subscription> = [
{
title: 'Пользователь',
key: 'user',
ellipsis: true,
render: (_, record) => <UserCell userId={record.user_id} />,
},
{
title: 'План',
dataIndex: 'plan',
key: 'plan',
width: 100,
sorter: true,
render: (plan: string) => (
<Tag color={planColors[plan] || 'default'}>{plan}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => {
const color =
status === 'active' ? 'green' :
status === 'expired' ? 'orange' : 'red';
return <Tag color={color}>{status}</Tag>;
const columns = useMemo<ColumnDef<Subscription>[]>(
() => [
{
id: 'user',
header: t('common.user'),
enableSorting: false,
cell: ({ row }) => <UserCell userId={row.original.user_id} />,
},
},
{
title: 'Пробный',
dataIndex: 'trial_used',
key: 'trial_used',
width: 100,
render: (v: boolean) => (v ? 'Да' : 'Нет'),
},
{ title: 'Начало', dataIndex: 'started_at', key: 'started_at', render: (val) => formatDate(val) },
{ title: 'Окончание', dataIndex: 'expires_at', key: 'expires_at', render: (val) => formatDate(val) },
{
title: 'Действия',
key: 'actions',
width: 100,
render: (_, record) => (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record)}
{
accessorKey: 'plan',
header: t('common.plan'),
enableSorting: true,
cell: ({ getValue }) => {
const plan = getValue<string>();
return <Badge variant={planBadgeVariant(plan)}>{plan}</Badge>;
},
},
{
accessorKey: 'status',
header: t('common.status'),
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={statusBadgeVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{
accessorKey: 'trial_used',
header: t('subscriptions.trial'),
enableSorting: false,
cell: ({ getValue }) => (getValue<boolean>() ? t('common.yes') : t('common.no')),
},
{
accessorKey: 'started_at',
header: t('common.start'),
enableSorting: false,
cell: ({ getValue }) => formatDate(getValue<string>()),
},
{
accessorKey: 'expires_at',
header: t('common.expiresAt'),
enableSorting: false,
cell: ({ getValue }) => formatDate(getValue<string>()),
},
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={subscriptionActions({
onEdit: () => handleEdit(record),
onDelete: () => handleDelete(record.id),
}, t)}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
/>
</Tooltip>
</Space>
);
},
},
],
[t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => ({
id: record.id,
title: record.user_id,
subtitle: t('subscriptions.expiresUntil', { date: formatDate(record.expires_at) }),
badges: (
<>
<Badge variant={planBadgeVariant(record.plan)}>{record.plan}</Badge>
<Badge variant={statusBadgeVariant(record.status)}>{formatStatusLabel(record.status)}</Badge>
{record.trial_used && <Badge variant="outline">{t('subscriptions.trialBadge')}</Badge>}
</>
),
},
];
meta: formatDate(record.started_at),
actions: subscriptionActions({
onEdit: () => handleEdit(record),
onDelete: () => handleDelete(record.id),
}, t),
}));
}, [data?.data, t]);
return (
<div>
<h2>Подписки</h2>
{stats && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Всего" value={stats.total_subscriptions} prefix={<DollarOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Пробные" value={stats.trial_subscriptions} />
</Card>
</Col>
{Object.entries(stats.subscriptions_by_status || {}).map(([status, count]) => (
<Col xs={12} sm={6} md={4} key={status}>
<Card>
<Statistic title={status} value={count} />
</Card>
</Col>
))}
{Object.entries(stats.subscriptions_by_plan || {}).map(([plan, count]) => (
<Col xs={12} sm={6} md={4} key={`plan-${plan}`}>
<Card>
<Statistic title={`План: ${plan}`} value={count} />
</Card>
</Col>
))}
</Row>
)}
{stats?.ending_paid_subscriptions && stats.ending_paid_subscriptions.length > 0 && (
<Card title="Заканчивающиеся платные подписки" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={stats.ending_paid_subscriptions}
columns={[
{ title: 'ID', dataIndex: 'id', key: 'id' },
{ title: 'Пользователь', dataIndex: 'user_id', key: 'user_id' },
{ title: 'План', dataIndex: 'plan', key: 'plan' },
{ title: 'Истекает', dataIndex: 'expires_at', key: 'expires_at' },
<>
<ExploreListShell
title={t('subscriptions.title')}
description={t('explore.descActions')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
toolbar={
<div className="flex flex-wrap gap-2">
<Select
value={params.plan ?? ALL}
onValueChange={(val) =>
setParams({
...params,
plan: val === ALL ? undefined : (val as SubscriptionListParams['plan']),
offset: 0,
})
}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder={t('common.plan')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('common.allPlans')}</SelectItem>
<SelectItem value="monthly">monthly</SelectItem>
<SelectItem value="quarterly">quarterly</SelectItem>
<SelectItem value="biannual">biannual</SelectItem>
<SelectItem value="annual">annual</SelectItem>
<SelectItem value="trial">trial</SelectItem>
</SelectContent>
</Select>
<Select
value={params.status ?? ALL}
onValueChange={(val) =>
setParams({
...params,
status: val === ALL ? undefined : (val as SubscriptionListParams['status']),
offset: 0,
})
}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder={t('common.status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('common.allStatuses')}</SelectItem>
<SelectItem value="active">active</SelectItem>
<SelectItem value="expired">expired</SelectItem>
<SelectItem value="cancelled">cancelled</SelectItem>
</SelectContent>
</Select>
</div>
}
>
{stats?.ending_paid_subscriptions && stats.ending_paid_subscriptions.length > 0 && (
<ExploreInsightsCollapse
title={t('explore.expiringSoon')}
tabs={[
{
id: 'ending',
label: t('explore.paid30d'),
rows: stats.ending_paid_subscriptions.map((sub) => ({
id: sub.id,
primary: <Link to={`/users/${sub.user_id}`}>{sub.user_id}</Link>,
secondary: `${sub.plan} · #${sub.id}`,
value: formatDate(sub.expires_at),
})),
},
]}
/>
</Card>
)}
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="План"
onChange={(val) => setParams({ ...params, plan: val, offset: 0 })}
style={{ width: 150 }}
>
<Select.Option value="monthly">monthly</Select.Option>
<Select.Option value="quarterly">quarterly</Select.Option>
<Select.Option value="biannual">biannual</Select.Option>
<Select.Option value="annual">annual</Select.Option>
<Select.Option value="trial">trial</Select.Option>
</Select>
<Select
allowClear
placeholder="Статус"
onChange={(val) => setParams({ ...params, status: val, offset: 0 })}
style={{ width: 150 }}
>
<Select.Option value="active">active</Select.Option>
<Select.Option value="expired">expired</Select.Option>
<Select.Option value="cancelled">cancelled</Select.Option>
</Select>
</Space>
<Table
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
/>
<Modal
title="Редактировать подписку"
open={editModal.open}
onCancel={() => setEditModal({ open: false, subscriptionId: null })}
onOk={handleSave}
confirmLoading={updateSubscription.isPending}
destroyOnHidden
>
{loadingSubscription ? (
<Spin />
) : (
<Form form={form} layout="vertical" preserve={false}>
<Form.Item label="План" name="plan" rules={[{ required: true }]}>
<Select>
<Select.Option value="monthly">monthly</Select.Option>
<Select.Option value="quarterly">quarterly</Select.Option>
<Select.Option value="biannual">biannual</Select.Option>
<Select.Option value="annual">annual</Select.Option>
<Select.Option value="trial">trial</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Статус" name="status" rules={[{ required: true }]}>
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="expired">expired</Select.Option>
<Select.Option value="cancelled">cancelled</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Пробный период использован" name="trial_used">
<Select>
<Select.Option value={true}>Да</Select.Option>
<Select.Option value={false}>Нет</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Дата окончания" name="expires_at">
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" style={{ width: '100%' }} />
</Form.Item>
</Form>
)}
</Modal>
</div>
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
<Dialog
open={editModal.open}
onOpenChange={(open) => !open && setEditModal({ open: false, subscriptionId: null })}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('subscriptions.editTitle')}</DialogTitle>
</DialogHeader>
{loadingSubscription ? (
<div className="space-y-3 py-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="edit-subscription-form" className="space-y-4" onSubmit={handleSave}>
<div className="space-y-2">
<Label>{t('common.plan')}</Label>
<Controller
name="plan"
control={form.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectPlan')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">monthly</SelectItem>
<SelectItem value="quarterly">quarterly</SelectItem>
<SelectItem value="biannual">biannual</SelectItem>
<SelectItem value="annual">annual</SelectItem>
<SelectItem value="trial">trial</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={form.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">active</SelectItem>
<SelectItem value="expired">expired</SelectItem>
<SelectItem value="cancelled">cancelled</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('subscriptions.trialUsed')}</Label>
<Controller
name="trial_used"
control={form.control}
render={({ field }) => (
<Select
value={String(field.value)}
onValueChange={(v) => field.onChange(v === 'true')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">{t('common.yes')}</SelectItem>
<SelectItem value="false">{t('common.no')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.expiresAt')}</Label>
<Input type="datetime-local" {...form.register('expires_at')} />
</div>
</form>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal({ open: false, subscriptionId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-subscription-form" disabled={updateSubscription.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={deleteConfirm.open}
onOpenChange={(open) => !open && setDeleteConfirm({ open: false, subscriptionId: null })}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('subscriptions.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, subscriptionId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteSubscription.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
+216 -109
View File
@@ -1,19 +1,53 @@
import React, { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Form, Select, Input } from 'antd';
import { Link } from 'react-router-dom';
import { useTicket, useUpdateTicket } from '../../hooks/useTickets';
import { useUser } from '../../hooks/useUsers';
import { useAdmin, useAdmins } from '../../hooks/useAdmins';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useForm, Controller } from 'react-hook-form';
import dayjs from 'dayjs';
import { ArrowLeft } from 'lucide-react';
import { useTicket, useUpdateTicket } from '@/hooks/useTickets';
import { useUser } from '@/hooks/useUsers';
import { useAdmin, useAdmins } from '@/hooks/useAdmins';
import { formatDisplayValue } from '@/lib/utils';
import { formatStatusLabel } from '@/utils/statusLabels';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const ticketStatusVariant = (status: string) => {
if (status === 'open') return 'destructive' as const;
if (status === 'in_progress') return 'default' as const;
if (status === 'resolved') return 'success' as const;
return 'secondary' as const;
};
interface TicketFormValues {
status: string;
assigned_to?: string;
resolution_note?: string;
}
const UNASSIGNED = '__none__';
const TicketDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: ticket, isLoading } = useTicket(id || '');
const updateTicket = useUpdateTicket();
const [form] = Form.useForm();
const { data: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 });
const reporterId = ticket?.reporter_id;
@@ -22,128 +56,201 @@ const TicketDetailPage: React.FC = () => {
const assignedId = ticket?.assigned_to;
const { data: assignedAdmin, isLoading: loadingAssigned } = useAdmin(assignedId || '');
const form = useForm<TicketFormValues>({
defaultValues: { status: 'open', assigned_to: UNASSIGNED, resolution_note: '' },
});
const emDash = t('common.emDash');
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return emDash;
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
useEffect(() => {
if (ticket) {
form.setFieldsValue({
form.reset({
status: ticket.status,
assigned_to: !isBadValue(ticket.assigned_to) ? ticket.assigned_to : undefined,
assigned_to: !isBadValue(ticket.assigned_to) ? ticket.assigned_to : UNASSIGNED,
resolution_note: !isBadValue(ticket.resolution_note) ? ticket.resolution_note : '',
});
}
}, [ticket, form]);
const handleSave = () => {
form.validateFields().then((values) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateTicket.mutate(
{ id: id!, data: payload },
{ onSuccess: () => navigate('/tickets') }
);
});
};
const onSubmit = (values: TicketFormValues) => {
const payload: Record<string, unknown> = {};
if (values.status) payload.status = values.status;
if (values.assigned_to && values.assigned_to !== UNASSIGNED) payload.assigned_to = values.assigned_to;
if (values.resolution_note?.trim()) payload.resolution_note = values.resolution_note.trim();
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
updateTicket.mutate(
{ id: id!, data: payload },
{ onSuccess: () => navigate('/tickets') }
);
};
const getUserLink = () => {
if (loadingReporter) return <Spin size="small" />;
if (!reporter) return reporterId || '-';
if (loadingReporter) return <Skeleton className="h-4 w-24" />;
if (!reporter) return reporterId || emDash;
const name = reporter.nickname && !isBadValue(reporter.nickname)
? reporter.nickname
: reporter.email;
return <Link to={`/users/${reporter.id}`}>{name || reporter.id}</Link>;
return <Link to={`/users/${reporter.id}`} className="text-primary hover:underline">{name || reporter.id}</Link>;
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!ticket) return <p>Тикет не найден</p>;
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!ticket) return <p className="text-muted-foreground">{t('tickets.notFound')}</p>;
return (
<Card title={`Тикет ${ticket.id}`}>
<Descriptions bordered column={1} size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="ID">{ticket.id}</Descriptions.Item>
<Descriptions.Item label="Отправитель">{getUserLink()}</Descriptions.Item>
<Descriptions.Item label="Хеш ошибки">{ticket.error_hash}</Descriptions.Item>
<Descriptions.Item label="Сообщение">{ticket.error_message}</Descriptions.Item>
<Descriptions.Item label="Стектрейс">
<pre style={{ maxHeight: 200, overflow: 'auto' }}>
{isBadValue(ticket.stacktrace) ? '-' : ticket.stacktrace}
</pre>
</Descriptions.Item>
<Descriptions.Item label="Контекст">
{isBadValue(ticket.context) ? '-' : ticket.context}
</Descriptions.Item>
<Descriptions.Item label="Количество">{ticket.count}</Descriptions.Item>
<Descriptions.Item label="Первый раз">{formatDate(ticket.first_seen)}</Descriptions.Item>
<Descriptions.Item label="Последний раз">{formatDate(ticket.last_seen)}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={
ticket.status === 'open' ? 'red' :
ticket.status === 'in_progress' ? 'blue' :
ticket.status === 'resolved' ? 'green' : 'default'
}>
{ticket.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Назначен">
{loadingAssigned ? <Spin size="small" /> : (
assignedAdmin ? (
<Link to={`/admins/${assignedAdmin.id}`}>
{!isBadValue(assignedAdmin.nickname) ? assignedAdmin.nickname : assignedAdmin.email || assignedAdmin.id}
</Link>
) : (isBadValue(ticket.assigned_to) ? '-' : ticket.assigned_to)
)}
</Descriptions.Item>
<Descriptions.Item label="Решение">
{isBadValue(ticket.resolution_note) ? '-' : ticket.resolution_note}
</Descriptions.Item>
</Descriptions>
<Form form={form} layout="vertical" onFinish={handleSave}>
<Form.Item label="Статус" name="status">
<Select>
<Select.Option value="open">open</Select.Option>
<Select.Option value="in_progress">in_progress</Select.Option>
<Select.Option value="resolved">resolved</Select.Option>
<Select.Option value="closed">closed</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Назначить" name="assigned_to">
<Select
placeholder="Выберите администратора"
loading={loadingAdmins}
allowClear
showSearch
optionFilterProp="label"
>
{(admins?.data || []).map((admin) => {
const label = `${admin.email}${admin.nickname && admin.nickname !== '-' ? ` ${admin.nickname}` : ''}`;
return (
<Select.Option key={admin.id} value={admin.id} label={label}>
{label}
</Select.Option>
);
})}
</Select>
</Form.Item>
<Form.Item label="Комментарий решения" name="resolution_note">
<Input.TextArea rows={3} />
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={updateTicket.isPending}>
Сохранить
<>
<PageHeader
title={t('tickets.detailTitle', { id: ticket.id })}
breadcrumbs={[
{ label: t('tickets.title'), href: '/tickets' },
{ label: ticket.id },
]}
actions={
<Button variant="outline" onClick={() => navigate('/tickets')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
<Button onClick={() => navigate('/tickets')}>Назад к списку</Button>
</Space>
</Form>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{ticket.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.sender')}</dt>
<dd>{getUserLink()}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.errorHash')}</dt>
<dd>{ticket.error_hash}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.errorMessage')}</dt>
<dd>{ticket.error_message}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.stacktrace')}</dt>
<dd>
<pre className="max-h-[200px] overflow-auto rounded-md bg-muted p-2 text-xs">
{isBadValue(ticket.stacktrace) ? emDash : ticket.stacktrace}
</pre>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.context')}</dt>
<dd>{formatDisplayValue(ticket.context) === '-' ? emDash : formatDisplayValue(ticket.context)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.count')}</dt>
<dd>{ticket.count}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.firstSeen')}</dt>
<dd>{formatDate(ticket.first_seen)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.lastSeen')}</dt>
<dd>{formatDate(ticket.last_seen)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={ticketStatusVariant(ticket.status)}>{formatStatusLabel(ticket.status)}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.assigned')}</dt>
<dd>
{loadingAssigned ? (
<Skeleton className="h-4 w-24" />
) : assignedAdmin ? (
<Link to={`/admins/${assignedAdmin.id}`} className="text-primary hover:underline">
{!isBadValue(assignedAdmin.nickname) ? assignedAdmin.nickname : assignedAdmin.email || assignedAdmin.id}
</Link>
) : (
isBadValue(ticket.assigned_to) ? emDash : ticket.assigned_to
)}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.resolution')}</dt>
<dd>{isBadValue(ticket.resolution_note) ? emDash : ticket.resolution_note}</dd>
</div>
</dl>
<form className="space-y-4 border-t pt-6" onSubmit={form.handleSubmit(onSubmit)}>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">{formatStatusLabel('open')}</SelectItem>
<SelectItem value="in_progress">{formatStatusLabel('in_progress')}</SelectItem>
<SelectItem value="resolved">{formatStatusLabel('resolved')}</SelectItem>
<SelectItem value="closed">{formatStatusLabel('closed')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.assign')}</Label>
<Controller
name="assigned_to"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAdmins}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectAdmin')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNASSIGNED}>{t('common.unassigned')}</SelectItem>
{(admins?.data || []).map((admin) => {
const label = `${admin.email}${admin.nickname && admin.nickname !== '-' ? ` ${admin.nickname}` : ''}`;
return (
<SelectItem key={admin.id} value={admin.id}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="resolution_note">{t('common.resolutionNote')}</Label>
<Textarea id="resolution_note" rows={3} {...form.register('resolution_note')} />
</div>
<Button type="submit" disabled={updateTicket.isPending}>
{updateTicket.isPending ? t('common.saving') : t('common.save')}
</Button>
</form>
</CardContent>
</Card>
</>
);
};
+209 -143
View File
@@ -1,164 +1,230 @@
import React, { useState } from 'react';
import { Table, Button, Tag, Popconfirm, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, DeleteOutlined, BugOutlined, ClockCircleOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Info, Trash2 } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useTickets, useDeleteTicket, useTicketStats } from '../../hooks/useTickets';
import { useAdmin } from '../../hooks/useAdmins';
import { Ticket, TicketListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
import { useTickets, useDeleteTicket, useTicketStats } from '@/hooks/useTickets';
import { useExploreView } from '@/hooks/useExploreView';
import { useAdmin } from '@/hooks/useAdmins';
import { Ticket, TicketListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
import type { DenseRowModel } from '@/components/explore/DenseRowList';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
const TABLE_KEY = 'tickets';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const ticketStatusVariant = (status: string) => {
if (status === 'open') return 'destructive' as const;
if (status === 'in_progress') return 'default' as const;
if (status === 'resolved') return 'secondary' as const;
return 'outline' as const;
};
function ticketActions(
handlers: { onOpen: () => void; onDelete: () => void },
t: TFunction
): RowAction[] {
return [
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
{
label: t('common.delete'),
icon: <Trash2 className="h-4 w-4" />,
onClick: handlers.onDelete,
destructive: true,
separatorBefore: true,
},
];
}
const AssignedCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loading) return <Skeleton className="h-4 w-20" />;
if (!admin) return <span>{adminId}</span>;
const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{!isBadValue(name) ? name : admin.id}</Link>;
};
const TicketListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<TicketListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'last_seen', order: 'desc' });
const { viewMode, setViewMode } = useExploreView();
const [params, setParams] = useState<TicketListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'last_seen',
order: 'desc',
});
const { data, isLoading } = useTickets(params);
const deleteTicket = useDeleteTicket();
const { data: stats } = useTicketStats();
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; ticketId: string | null }>({
open: false,
ticketId: null,
});
// Резолвер администратора для колонки "Назначен"
const AssignedCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loading) return <Spin size="small" />;
if (!admin) return <span>{adminId}</span>;
const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{!isBadValue(name) ? name : admin.id}</Link>;
const handleDelete = (id: string) => setDeleteConfirm({ open: true, ticketId: id });
const confirmDelete = () => {
if (!deleteConfirm.ticketId) return;
deleteTicket.mutate(deleteConfirm.ticketId, {
onSuccess: () => setDeleteConfirm({ open: false, ticketId: null }),
});
};
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<Ticket> | SorterResult<Ticket>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
};
const exploreStats = useMemo(() => {
if (!stats) return [];
return [
{ label: t('common.total'), value: stats.total_tickets },
{ label: formatStatusLabel('open'), value: stats.open },
{ label: formatStatusLabel('in_progress'), value: stats.in_progress },
{ label: formatStatusLabel('resolved'), value: stats.resolved },
];
}, [stats, t]);
const columns: ColumnsType<Ticket> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу тикета">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/tickets/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Ошибка',
dataIndex: 'error_message',
key: 'error_message',
ellipsis: true,
sorter: true,
render: (text: string) => isBadValue(text) ? '-' : text,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => {
const color =
status === 'open' ? 'red' :
status === 'in_progress' ? 'blue' :
status === 'resolved' ? 'green' : 'default';
return <Tag color={color}>{formatStatusLabel(status)}</Tag>;
const columns = useMemo<ColumnDef<Ticket>[]>(
() => [
{
accessorKey: 'error_message',
header: t('common.error'),
enableSorting: true,
cell: ({ getValue }) => {
const text = getValue<string>();
return isBadValue(text) ? '-' : text;
},
},
},
{
title: 'Назначен',
dataIndex: 'assigned_to',
key: 'assigned_to',
render: (assigned: string) => <AssignedCell adminId={assigned} />,
},
{ title: 'Повторов', dataIndex: 'count', key: 'count', width: 80, sorter: true },
{ title: 'Первый раз', dataIndex: 'first_seen', key: 'first_seen', sorter: true },
{ title: 'Последний', dataIndex: 'last_seen', key: 'last_seen', sorter: true },
{
title: 'Действия',
key: 'actions',
width: 80,
render: (_, record) => (
<Popconfirm
title="Удалить тикет?"
onConfirm={() => deleteTicket.mutate(record.id)}
>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
{
accessorKey: 'status',
header: t('common.status'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={ticketStatusVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{
accessorKey: 'assigned_to',
header: t('common.assigned'),
enableSorting: false,
cell: ({ getValue }) => <AssignedCell adminId={getValue<string | null>()} />,
},
{ accessorKey: 'count', header: t('common.count'), size: 80, enableSorting: true },
{ accessorKey: 'first_seen', header: t('common.firstSeen'), enableSorting: true },
{ accessorKey: 'last_seen', header: t('common.lastSeen'), enableSorting: true },
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={ticketActions({
onOpen: () => navigate(`/tickets/${record.id}`),
onDelete: () => handleDelete(record.id),
}, t)}
/>
</Tooltip>
</Popconfirm>
),
},
];
);
},
},
],
[navigate, t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => {
const errorText = isBadValue(record.error_message) ? record.id : record.error_message;
const title =
typeof errorText === 'string' && errorText.length > 80
? `${errorText.slice(0, 80)}`
: errorText;
return {
id: record.id,
title,
subtitle: record.assigned_to && record.assigned_to !== '-' ? record.assigned_to : undefined,
badges: (
<Badge variant={ticketStatusVariant(record.status)}>
{formatStatusLabel(record.status)}
</Badge>
),
meta: `×${record.count} · ${record.last_seen}`,
onActivate: () => navigate(`/tickets/${record.id}`),
actions: ticketActions({
onOpen: () => navigate(`/tickets/${record.id}`),
onDelete: () => handleDelete(record.id),
}, t),
};
});
}, [data?.data, navigate, t]);
return (
<div>
<h2>Тикеты</h2>
{stats && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Всего тикетов" value={stats.total_tickets} prefix={<BugOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Открыто" value={stats.open} styles={{ content: { color: 'red' } }} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="В работе" value={stats.in_progress} prefix={<ClockCircleOutlined />} styles={{ content: { color: 'blue' } }} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Решено" value={stats.resolved} prefix={<CheckCircleOutlined />} styles={{ content: { color: 'green' } }} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Закрыто" value={stats.closed} prefix={<CloseCircleOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Всего ошибок" value={stats.total_errors} />
</Card>
</Col>
</Row>
)}
<Table
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
/>
</div>
<>
<ExploreListShell
title={t('tickets.title')}
description={
stats
? t('tickets.statsHint', { closed: stats.closed, errors: stats.total_errors })
: t('explore.descActions')
}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
banner={{
text: t('inbox.bannerTickets'),
to: '/inbox/tickets',
actionLabel: t('common.openInbox'),
}}
>
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, ticketId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('tickets.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, ticketId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteTicket.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
+152 -36
View File
@@ -1,55 +1,171 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag } from 'antd';
import { useUser } from '../../hooks/useUsers';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { ArrowLeft } from 'lucide-react';
import { useUser } from '@/hooks/useUsers';
import { formatDisplayValue } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { PageHeader } from '@/components/PageHeader';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const roleBadgeVariant = (role: string) => {
if (role === 'user') return 'default' as const;
if (role === 'bot') return 'secondary' as const;
return 'outline' as const;
};
const statusBadgeVariant = (status: string) => {
if (status === 'active') return 'default' as const;
if (status === 'frozen') return 'warning' as const;
return 'destructive' as const;
};
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
const text = formatDisplayValue(value);
if (text === '-') return <>{emptyLabel}</>;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
}
return <>{text}</>;
};
const UserDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: user, isLoading } = useUser(id || '');
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: any) => (isBadValue(val) ? '-' : val);
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
if (isBadValue(dateStr)) return t('common.emDash');
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!user) return <p>Пользователь не найден</p>;
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!user) return <p className="text-muted-foreground">{t('users.notFound')}</p>;
const titleName = !isBadValue(user.nickname) ? user.nickname : user.email || user.id;
const emDash = t('common.emDash');
return (
<Card title={`Пользователь ${displayValue(user.nickname) !== '-' ? user.nickname : user.email || user.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{user.id}</Descriptions.Item>
<Descriptions.Item label="Email">{displayValue(user.email)}</Descriptions.Item>
<Descriptions.Item label="Ник">{displayValue(user.nickname)}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag color={user.role === 'user' ? 'blue' : 'purple'}>{user.role}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={user.status === 'active' ? 'green' : user.status === 'frozen' ? 'orange' : 'red'}>
{user.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(user.reason)}</Descriptions.Item>
<Descriptions.Item label="Телефон">{displayValue(user.phone)}</Descriptions.Item>
<Descriptions.Item label="Язык">{displayValue(user.language)}</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{displayValue(user.timezone)}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">{displayValue(user.avatar_url)}</Descriptions.Item>
<Descriptions.Item label="Социальные ссылки">{displayValue(user.social_links)}</Descriptions.Item>
<Descriptions.Item label="Настройки">{displayValue(user.preferences)}</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(user.last_login)}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(user.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(user.updated_at)}</Descriptions.Item>
</Descriptions>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/users')}>Назад к списку</Button>
</Card>
<div>
<PageHeader
title={String(titleName)}
description={`ID ${user.id}`}
breadcrumbs={[
{ label: t('users.title'), href: '/users' },
{ label: String(titleName) },
]}
actions={
<Button variant="outline" onClick={() => navigate('/users')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
}
/>
<Card>
<CardContent className="space-y-6 pt-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{user.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.email')}</dt>
<dd>
<DetailValue value={user.email} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.nickname')}</dt>
<dd>
<DetailValue value={user.nickname} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.role')}</dt>
<dd>
<Badge variant={roleBadgeVariant(user.role)}>{user.role}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={statusBadgeVariant(user.status)}>{user.status}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.reason')}</dt>
<dd>
<DetailValue value={user.reason} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.phone')}</dt>
<dd>
<DetailValue value={user.phone} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.language')}</dt>
<dd>
<DetailValue value={user.language} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.timezone')}</dt>
<dd>
<DetailValue value={user.timezone} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.avatarUrl')}</dt>
<dd>
<DetailValue value={user.avatar_url} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.socialLinks')}</dt>
<dd>
<DetailValue value={user.social_links} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.preferencesJson')}</dt>
<dd>
<DetailValue value={user.preferences} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.lastLogin')}</dt>
<dd>{formatDate(user.last_login)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{formatDate(user.created_at)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.updatedAt')}</dt>
<dd>{formatDate(user.updated_at)}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
);
};
+425 -312
View File
@@ -1,379 +1,492 @@
import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, EditOutlined, LockOutlined, UnlockOutlined, DeleteOutlined, UserOutlined } from '@ant-design/icons';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Info, Pencil, Lock, Unlock, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useUsers, useUpdateUser, useDeleteUser, useUser, useUserStats } from '../../hooks/useUsers';
import { User, UserListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
import { useForm, Controller } from 'react-hook-form';
import { useUsers, useUpdateUser, useDeleteUser, useUser, useUserStats } from '@/hooks/useUsers';
import { useExploreView } from '@/hooks/useExploreView';
import { User, UserListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreSearch } from '@/components/explore/ExploreSearch';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
import type { DenseRowModel } from '@/components/explore/DenseRowList';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { EntitySlideOver, type ExplorePreviewTarget } from '@/components/EntitySlideOver';
const TABLE_KEY = 'users';
const roleBadgeClass = (role: string) => {
if (role === 'user') return 'default';
if (role === 'bot') return 'secondary';
return 'outline';
};
const statusBadgeVariant = (status: string) => {
if (status === 'active') return 'default' as const;
if (status === 'frozen') return 'secondary' as const;
return 'destructive' as const;
};
interface EditFormValues {
email?: string;
nickname?: string;
role?: string;
status?: string;
reason?: string;
}
interface StatusFormValues {
reason?: string;
}
function userActions(
record: User,
handlers: {
onOpen: () => void;
onEdit: () => void;
onStatus: () => void;
onDelete: () => void;
},
t: TFunction
): RowAction[] {
const isDeleted = record.status === 'deleted';
return [
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
{ label: t('common.edit'), icon: <Pencil className="h-4 w-4" />, onClick: handlers.onEdit },
{
label: record.status === 'active' ? t('common.freeze') : t('common.unfreeze'),
icon: record.status === 'active' ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />,
onClick: handlers.onStatus,
disabled: isDeleted,
},
{
label: t('common.delete'),
icon: <Trash2 className="h-4 w-4" />,
onClick: handlers.onDelete,
disabled: isDeleted,
destructive: true,
separatorBefore: true,
},
];
}
const UserListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<UserListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'id', order: 'asc' });
const { viewMode, setViewMode } = useExploreView();
const [preview, setPreview] = useState<ExplorePreviewTarget>(null);
const [searchDraft, setSearchDraft] = useState('');
const [params, setParams] = useState<UserListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'id',
order: 'asc',
});
const { data, isLoading } = useUsers(params);
const { data: stats } = useUserStats();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
// Редактирование
const [editModal, setEditModal] = useState<{ open: boolean; userId: string | null }>({
open: false,
userId: null,
});
const { data: editingUser, isLoading: loadingUser } = useUser(editModal.userId || '');
const [editForm] = Form.useForm();
const [editHasErrors, setEditHasErrors] = useState(true);
const editForm = useForm<EditFormValues>();
const originalUserRef = useRef<User | null>(null);
// Изменение статуса
const [statusModal, setStatusModal] = useState<{
open: boolean;
userId: string | null;
newStatus: string;
currentReason: string;
}>({
}>({ open: false, userId: null, newStatus: 'active', currentReason: '' });
const statusForm = useForm<StatusFormValues>();
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; userId: string | null }>({
open: false,
userId: null,
newStatus: 'active',
currentReason: '',
});
const [statusForm] = Form.useForm();
// ==================== Редактирование ====================
const handleEdit = (id: string) => {
setEditModal({ open: true, userId: id });
};
const handleEdit = (id: string) => setEditModal({ open: true, userId: id });
const handleSaveEdit = () => {
editForm.validateFields().then((values) => {
if (!editModal.userId || !originalUserRef.current) return;
const original = originalUserRef.current;
const cleanedValues: Record<string, any> = {};
const handleSaveEdit = editForm.handleSubmit((values) => {
if (!editModal.userId || !originalUserRef.current) return;
const original = originalUserRef.current;
const cleanedValues: Record<string, unknown> = {};
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
allKeys.forEach((key) => {
const newVal = values[key];
if (newVal === '' || newVal === undefined || newVal === null) {
const origVal = (original as any)[key];
if (origVal && origVal !== '-' && origVal !== '' && origVal !== undefined && origVal !== null) {
cleanedValues[key] = null;
}
return;
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
allKeys.forEach((key) => {
const newVal = (values as Record<string, unknown>)[key];
if (newVal === '' || newVal === undefined || newVal === null) {
const origVal = (original as unknown as Record<string, unknown>)[key];
if (origVal && origVal !== '-' && origVal !== '' && origVal !== undefined && origVal !== null) {
cleanedValues[key] = null;
}
if (newVal === '-') return;
cleanedValues[key] = newVal;
});
const payload = Object.fromEntries(
Object.entries(cleanedValues).filter(([key, v]) => {
const origVal = (original as any)[key];
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
})
);
updateUser.mutate(
{ id: editModal.userId, data: payload },
{ onSuccess: () => setEditModal({ open: false, userId: null }) }
);
return;
}
if (newVal === '-') return;
cleanedValues[key] = newVal;
});
};
const payload = Object.fromEntries(
Object.entries(cleanedValues).filter(([key, v]) => {
const origVal = (original as unknown as Record<string, unknown>)[key];
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
})
);
updateUser.mutate(
{ id: editModal.userId, data: payload },
{ onSuccess: () => setEditModal({ open: false, userId: null }) }
);
});
useEffect(() => {
if (editingUser && editModal.open) {
originalUserRef.current = editingUser;
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
setTimeout(() => {
editForm.setFieldsValue({
email: clean(editingUser.email),
nickname: clean(editingUser.nickname),
role: clean(editingUser.role),
status: clean(editingUser.status),
reason: clean(editingUser.reason),
});
validateEditForm();
}, 0);
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
editForm.reset({
email: clean(editingUser.email) as string,
nickname: clean(editingUser.nickname) as string,
role: clean(editingUser.role) as string,
status: clean(editingUser.status) as string,
reason: clean(editingUser.reason) as string,
});
}
}, [editingUser, editModal.open, editForm]);
const validateEditForm = () => {
const role = editForm.getFieldValue('role');
const status = editForm.getFieldValue('status');
const hasErrors = !role || !status;
setEditHasErrors(hasErrors);
};
// ==================== Изменение статуса ====================
const handleStatusChange = (user: User) => {
if (user.status === 'deleted') return;
const newStatus = user.status === 'active' ? 'frozen' : 'active';
const currentReason = user.reason && user.reason !== '-' ? user.reason : '';
setStatusModal({
open: true,
userId: user.id,
newStatus,
currentReason,
});
setStatusModal({ open: true, userId: user.id, newStatus, currentReason });
};
useEffect(() => {
if (statusModal.open) {
setTimeout(() => {
statusForm.setFieldsValue({ reason: statusModal.currentReason });
}, 0);
statusForm.reset({ reason: statusModal.currentReason });
}
}, [statusModal.open, statusModal.currentReason, statusForm]);
const handleStatusSave = () => {
statusForm.validateFields().then((values) => {
if (!statusModal.userId) return;
const payload: any = { status: statusModal.newStatus };
if (values.reason && values.reason.trim() !== '') {
payload.reason = values.reason;
}
updateUser.mutate(
{ id: statusModal.userId, data: payload },
{ onSuccess: () => setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' }) }
);
const handleStatusSave = statusForm.handleSubmit((values) => {
if (!statusModal.userId) return;
const payload: Record<string, string> = { status: statusModal.newStatus };
if (values.reason?.trim()) payload.reason = values.reason.trim();
updateUser.mutate(
{ id: statusModal.userId, data: payload },
{ onSuccess: () => setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' }) }
);
});
const handleDelete = (id: string) => setDeleteConfirm({ open: true, userId: id });
const confirmDelete = () => {
if (!deleteConfirm.userId) return;
deleteUser.mutate(deleteConfirm.userId, {
onSuccess: () => setDeleteConfirm({ open: false, userId: null }),
});
};
// ==================== Удаление ====================
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить пользователя?',
content: 'Это действие нельзя отменить. При необходимости предварительно укажите причину через редактирование.',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteUser.mutate(id),
});
};
const editRole = editForm.watch('role');
const editStatus = editForm.watch('status');
const editHasErrors = !editRole || !editStatus;
// ==================== Таблица ====================
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<User> | SorterResult<User>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
const exploreStats = useMemo(() => {
if (!stats) return [];
const items = [
{ label: t('common.total'), value: stats.total_users },
{ label: t('common.pendingUsers'), value: stats.pending_users },
];
Object.entries(stats.users_by_status || {})
.slice(0, 2)
.forEach(([status, count]) => {
items.push({ label: formatStatusLabel(status), value: count });
});
return items.slice(0, 4);
}, [stats, t]);
const applySearch = () => {
setParams((prev) => ({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
q: searchDraft.trim() || undefined,
offset: 0,
}));
};
const columns: ColumnsType<User> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу пользователя">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/users/${record.id}`)}
/>
</Tooltip>
),
},
{ title: 'Email', dataIndex: 'email', key: 'email', sorter: true, ellipsis: true },
{ title: 'Ник', dataIndex: 'nickname', key: 'nickname', sorter: true, ellipsis: true },
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
width: 100,
sorter: true,
render: (role: string) => {
const color = role === 'user' ? 'blue' : role === 'bot' ? 'purple' : 'default';
return <Tag color={color}>{role}</Tag>;
const columns = useMemo<ColumnDef<User>[]>(
() => [
{
accessorKey: 'nickname',
header: t('common.user'),
enableSorting: true,
cell: ({ row, getValue }) => {
const nick = getValue<string | null>();
const label = nick || row.original.email || row.original.id;
return (
<button
type="button"
className="text-left font-medium text-primary hover:underline"
onClick={() => setPreview({ type: 'user', id: row.original.id })}
>
{label}
</button>
);
},
},
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : status === 'frozen' ? 'orange' : 'red'}>
{formatStatusLabel(status)}
</Tag>
),
},
{ title: 'Последний вход', dataIndex: 'last_login', key: 'last_login', sorter: true },
{
title: 'Действия',
key: 'actions',
width: 120,
render: (_, record) => {
const isDeleted = record.status === 'deleted';
return (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record.id)}
/>
</Tooltip>
<Tooltip title={record.status === 'active' ? 'Заморозить' : 'Разморозить'}>
<Button
icon={record.status === 'active' ? <LockOutlined /> : <UnlockOutlined />}
size="small"
onClick={() => handleStatusChange(record)}
disabled={isDeleted}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
disabled={isDeleted}
/>
</Tooltip>
</Space>
);
{ accessorKey: 'email', header: t('common.email'), enableSorting: true },
{
accessorKey: 'role',
header: t('common.role'),
enableSorting: true,
cell: ({ getValue }) => {
const role = getValue<string>();
return <Badge variant={roleBadgeClass(role) as 'default'}>{role}</Badge>;
},
},
},
];
{
accessorKey: 'status',
header: t('common.status'),
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={statusBadgeVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{ accessorKey: 'last_login', header: t('common.lastLogin'), enableSorting: true },
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={userActions(record, {
onOpen: () => navigate(`/users/${record.id}`),
onEdit: () => handleEdit(record.id),
onStatus: () => handleStatusChange(record),
onDelete: () => handleDelete(record.id),
}, t)}
/>
);
},
},
],
[navigate, t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => {
const title = record.nickname || record.email || record.id;
return {
id: record.id,
title,
subtitle: record.email && record.nickname ? record.email : undefined,
badges: (
<>
<Badge variant={roleBadgeClass(record.role) as 'default'}>{record.role}</Badge>
<Badge variant={statusBadgeVariant(record.status)}>{formatStatusLabel(record.status)}</Badge>
</>
),
meta: record.last_login ? t('explore.loginAt', { date: record.last_login }) : undefined,
onActivate: () => setPreview({ type: 'user', id: record.id }),
actions: userActions(record, {
onOpen: () => navigate(`/users/${record.id}`),
onEdit: () => handleEdit(record.id),
onStatus: () => handleStatusChange(record),
onDelete: () => handleDelete(record.id),
}, t),
};
});
}, [data?.data, navigate, t]);
return (
<div>
<h2>Пользователи</h2>
{stats && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Всего" value={stats.total_users} prefix={<UserOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Неподтверждённые" value={stats.pending_users} />
</Card>
</Col>
{Object.entries(stats.users_by_status || {}).map(([status, count]) => (
<Col xs={12} sm={6} md={4} key={status}>
<Card>
<Statistic title={status} value={count} />
</Card>
</Col>
))}
{Object.entries(stats.users_by_role || {}).map(([role, count]) => (
<Col xs={12} sm={6} md={4} key={`role-${role}`}>
<Card>
<Statistic title={`Роль: ${role}`} value={count} />
</Card>
</Col>
))}
</Row>
)}
<Table
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
/>
{/* Модальное окно редактирования */}
<Modal
title="Редактировать пользователя"
open={editModal.open}
onCancel={() => setEditModal({ open: false, userId: null })}
onOk={handleSaveEdit}
confirmLoading={updateUser.isPending}
destroyOnHidden
okButtonProps={{ disabled: editHasErrors }}
<>
<ExploreListShell
title={t('users.title')}
description={t('explore.descPreviewName')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
toolbar={
<ExploreSearch
value={searchDraft}
onChange={setSearchDraft}
onSubmit={applySearch}
placeholder={t('explore.searchUsers')}
/>
}
>
{loadingUser ? (
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
) : (
<Form form={editForm} layout="vertical" preserve={false} onValuesChange={validateEditForm}>
<Form.Item label="Email" name="email">
<Input />
</Form.Item>
<Form.Item label="Ник" name="nickname">
<Input />
</Form.Item>
<Form.Item
label="Роль"
name="role"
rules={[{ required: true, message: 'Выберите роль' }]}
>
<Select placeholder="Выберите роль">
<Select.Option value="user">user</Select.Option>
<Select.Option value="bot">bot</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="Статус"
name="status"
rules={[{ required: true, message: 'Выберите статус' }]}
>
<Select placeholder="Выберите статус">
<Select.Option value="active">active</Select.Option>
<Select.Option value="frozen">frozen</Select.Option>
<Select.Option value="deleted">deleted</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="Причина"
name="reason"
rules={[
({ getFieldValue }) => ({
validator(_, value) {
const status = getFieldValue('status');
if (status && status !== 'active' && (!value || value.trim() === '')) {
return Promise.reject(new Error('Укажите причину изменения статуса'));
}
return Promise.resolve();
},
}),
]}
>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
)}
</Modal>
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
{/* Модальное окно изменения статуса */}
<Modal
title={`Изменить статус на «${statusModal.newStatus}»`}
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, userId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.editTitle')}</DialogTitle>
</DialogHeader>
{loadingUser ? (
<div className="space-y-3 py-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="edit-user-form" className="space-y-4" onSubmit={handleSaveEdit}>
<div className="space-y-2">
<Label>{t('common.email')}</Label>
<Input {...editForm.register('email')} />
</div>
<div className="space-y-2">
<Label>{t('common.nickname')}</Label>
<Input {...editForm.register('nickname')} />
</div>
<div className="space-y-2">
<Label>{t('common.role')}</Label>
<Controller
name="role"
control={editForm.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectRole')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">user</SelectItem>
<SelectItem value="bot">bot</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={editForm.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">active</SelectItem>
<SelectItem value="frozen">frozen</SelectItem>
<SelectItem value="deleted">deleted</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.reason')}</Label>
<Textarea rows={3} {...editForm.register('reason')} />
</div>
</form>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal({ open: false, userId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-user-form" disabled={editHasErrors || updateUser.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={statusModal.open}
onCancel={() => setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' })}
onOk={handleStatusSave}
confirmLoading={updateUser.isPending}
destroyOnHidden
onOpenChange={(open) =>
!open && setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' })
}
>
<Form form={statusForm} layout="vertical" preserve={false}>
<Form.Item
label="Причина"
name="reason"
rules={[
{
required: statusModal.newStatus !== 'active',
message: 'Укажите причину',
},
]}
>
<Input.TextArea rows={3} placeholder="Введите причину изменения статуса" />
</Form.Item>
</Form>
</Modal>
</div>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.statusTitle', { status: statusModal.newStatus })}</DialogTitle>
</DialogHeader>
<form id="status-form" className="space-y-4" onSubmit={handleStatusSave}>
<div className="space-y-2">
<Label>{t('common.reason')}</Label>
<Textarea
rows={3}
placeholder={t('common.enterReason')}
{...statusForm.register('reason', { required: statusModal.newStatus !== 'active' })}
/>
</div>
</form>
<DialogFooter>
<Button
variant="outline"
onClick={() =>
setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' })
}
>
{t('common.cancel')}
</Button>
<Button type="submit" form="status-form" disabled={updateUser.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, userId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('users.deleteHint')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, userId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteUser.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<EntitySlideOver target={preview} onOpenChange={(open) => !open && setPreview(null)} />
</>
);
};
+10 -2
View File
@@ -34,7 +34,15 @@ export interface CalendarRankItem {
id: string;
title: string;
rating_avg: number;
review_count: number;
/** Calendars may expose this; events use rating_count from API */
review_count?: number;
}
export interface EventRankItem {
id: string;
title: string;
rating_avg: number;
rating_count?: number;
}
export interface CalendarStats {
@@ -51,7 +59,7 @@ export interface EventStats {
total_events: number;
events_by_type: Record<string, number>;
events_by_status: Record<string, number>;
top_events_by_rating: CalendarRankItem[];
top_events_by_rating: EventRankItem[];
}
export interface UserStats {
+7 -36
View File
@@ -1,38 +1,9 @@
const STATUS_LABELS: Record<string, Record<string, string>> = {
ru: {
active: 'Активен',
frozen: 'Заморожен',
deleted: 'Удалён',
pending: 'Ожидает',
reviewed: 'Рассмотрено',
dismissed: 'Отклонено',
open: 'Открыт',
in_progress: 'В работе',
resolved: 'Решён',
closed: 'Закрыт',
visible: 'Видимый',
hidden: 'Скрыт',
cancelled: 'Отменён',
completed: 'Завершён',
},
en: {
active: 'Active',
frozen: 'Frozen',
deleted: 'Deleted',
pending: 'Pending',
reviewed: 'Reviewed',
dismissed: 'Dismissed',
open: 'Open',
in_progress: 'In progress',
resolved: 'Resolved',
closed: 'Closed',
visible: 'Visible',
hidden: 'Hidden',
cancelled: 'Cancelled',
completed: 'Completed',
},
};
import i18n from '@/i18n';
export function formatStatusLabel(status: string, locale: 'ru' | 'en' = 'ru'): string {
return STATUS_LABELS[locale]?.[status] ?? status;
/** Localized status label; falls back to raw status code. */
export function formatStatusLabel(status: string, locale?: string): string {
const lng = (locale ?? i18n.language ?? 'ru').startsWith('en') ? 'en' : 'ru';
const key = `status.${status}`;
const translated = i18n.t(key, { lng });
return translated === key ? status : translated;
}
+5
View File
@@ -14,6 +14,11 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"noUnusedLocals": true,
+8 -1
View File
@@ -1,11 +1,18 @@
import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
const apiBaseUrl = process.env.VITE_API_BASE_URL ?? 'https://admin-api.dev.eventhub.local';
const wsUrl = process.env.VITE_WS_URL ?? 'wss://admin-ws.dev.eventhub.local';
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/v1': {