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