Восстановлен потерянный код: календари, мониторинг, refresh, stats на list-страницах.

This commit is contained in:
2026-07-07 22:59:19 +03:00
parent 01ce33f322
commit 3a9ee13999
25 changed files with 977 additions and 80 deletions
@@ -0,0 +1,66 @@
import React from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag } from 'antd';
import { useCalendar } from '../../hooks/useCalendars';
import dayjs from 'dayjs';
const CalendarDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: calendar, isLoading } = useCalendar(id || '');
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: unknown) => (isBadValue(val) ? '-' : String(val));
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
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 (!calendar) return <p>Календарь не найден</p>;
const tagsStr = Array.isArray(calendar.tags) && calendar.tags.length > 0 ? calendar.tags.join(', ') : '-';
return (
<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>
</Card>
);
};
export default CalendarDetailPage;
+298
View File
@@ -0,0 +1,298 @@
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 { 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';
const CalendarListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<CalendarListParams>({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' });
const { data, isLoading } = useCalendars(params);
const { data: stats } = useCalendarStats();
const updateCalendar = useUpdateCalendar();
const deleteCalendar = useDeleteCalendar();
const [editModal, setEditModal] = useState<{ open: boolean; calendarId: string | null }>({
open: false,
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 validateEditForm = useCallback(() => {
const title = editForm.getFieldValue('title');
const status = editForm.getFieldValue('status');
setEditHasErrors(!title || !status);
}, [editForm]);
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 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;
}
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);
}
}, [editingCalendar, editModal.open, editForm, validateEditForm]);
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить календарь?',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteCalendar.mutate(id),
});
};
const handleTableChange = (
pagination: { current?: number; pageSize?: number },
_filters: unknown,
sorter: SorterResult<Calendar> | SorterResult<Calendar>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current || 1) - 1) * (pagination.pageSize || prev.limit || 20),
limit: pagination.pageSize || prev.limit,
}));
};
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>
),
},
{
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>
),
},
];
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>
))}
</Row>
)}
<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={{
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/>
<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>
);
};
export default CalendarListPage;
+1 -2
View File
@@ -36,8 +36,7 @@ const EventDetailPage: React.FC = () => {
<Link to={`/users/${event.specialist_id}`}>{event.specialist_id}</Link>
) : '-';
const calendarLink = !isBadValue(event.calendar_id) ? (
// Пока нет страницы календаря, просто ID, но можно обернуть в ссылку, если появится
<span>{event.calendar_id}</span>
<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>
+20 -3
View File
@@ -1,8 +1,8 @@
import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, DatePicker, InputNumber } from 'antd';
import { InfoCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
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 { useNavigate } from 'react-router-dom';
import { useEvents, useUpdateEvent, useDeleteEvent, useEvent } from '../../hooks/useEvents';
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';
@@ -11,6 +11,7 @@ const EventListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<EventListParams>({ limit: 20, offset: 0, sort: 'id', order: 'asc' });
const { data, isLoading } = useEvents(params);
const { data: stats } = useEventStats();
const updateEvent = useUpdateEvent();
const deleteEvent = useDeleteEvent();
@@ -213,6 +214,22 @@ const EventListPage: React.FC = () => {
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>
))}
</Row>
)}
<Table
columns={columns}
dataSource={data?.data}
+152
View File
@@ -0,0 +1,152 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Col, Row, Select, Tabs } from 'antd';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import dayjs from 'dayjs';
import { useMetricsStore, NodeMetric } from '../../store/metricsStore';
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 formatTime = (ts: string) => dayjs(ts).format('HH:mm:ss');
const buildChartData = (metrics: NodeMetric[]) =>
metrics.map((m) => ({
time: formatTime(m.timestamp),
cpu: m.cpu_utilization ?? 0,
memoryMb: (m.memory_total ?? 0) / 1048576,
ws: m.ws_connections ?? 0,
runQueue: m.run_queue ?? 0,
mnesiaCommits: m.mnesia_commits ?? 0,
mnesiaFailures: m.mnesia_failures ?? 0,
}));
const MetricChart: React.FC<{ data: ReturnType<typeof buildChartData>; lines: Array<{ key: string; color: string; name: string }> }> = ({
data,
lines,
}) => (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" minTickGap={30} />
<YAxis />
<Tooltip />
<Legend />
{lines.map((line) => (
<Line key={line.key} type="monotone" dataKey={line.key} stroke={line.color} name={line.name} dot={false} />
))}
</LineChart>
</ResponsiveContainer>
);
const MonitoringPage: React.FC = () => {
const allHistory = useMetricsStore((s) => s.allHistory);
const current = useMetricsStore((s) => s.current);
const filterVisibleHistory = useMetricsStore((s) => s.filterVisibleHistory);
const visibleHistory = useMetricsStore((s) => s.visibleHistory);
const [minutes, setMinutes] = useState(15);
useEffect(() => {
filterVisibleHistory(minutes);
}, [minutes, allHistory.length, filterVisibleHistory]);
const allNodes = useMemo(() => {
const nodes = new Set<string>();
[...allHistory, ...(current ? [current] : [])].forEach((m) => nodes.add(m.node));
return Array.from(nodes).sort();
}, [allHistory, current]);
const metricsByNode = useMemo(() => {
const source = visibleHistory.length > 0 ? visibleHistory : allHistory;
const map = new Map<string, NodeMetric[]>();
source.forEach((m) => {
const list = map.get(m.node) || [];
list.push(m);
map.set(m.node, list);
});
return map;
}, [visibleHistory, allHistory]);
const tabItems = allNodes.map((node, idx) => {
const nodeMetrics = metricsByNode.get(node) || [];
const chartData = buildChartData(nodeMetrics);
const color = NODE_COLORS[idx % NODE_COLORS.length];
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: 'Память МБ' },
]}
/>
</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' },
]}
/>
</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>
</Row>
),
};
});
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>
{allNodes.length === 0 ? (
<Card>Ожидание метрик по WebSocket</Card>
) : (
<Tabs items={tabItems} />
)}
</div>
);
};
export default MonitoringPage;
+20 -3
View File
@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { Table, Button, Tag, Modal, Descriptions, Tooltip, Spin, Space } from 'antd';
import { InfoCircleOutlined, EyeOutlined } from '@ant-design/icons';
import { Table, Button, Tag, Modal, Descriptions, Tooltip, Spin, Space, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, EyeOutlined, WarningOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useReports, useUpdateReport } from '../../hooks/useReports';
import { useReports, useUpdateReport, useReportStats } from '../../hooks/useReports';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useAdmin } from '../../hooks/useAdmins';
@@ -13,6 +13,7 @@ const ReportListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<ReportListParams>({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' });
const { data, isLoading } = useReports(params);
const { data: stats } = useReportStats();
const updateReport = useUpdateReport();
const [detailModal, setDetailModal] = useState<{ open: boolean; report: Report | null }>({
@@ -204,6 +205,22 @@ const ReportListPage: React.FC = () => {
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>
))}
</Row>
)}
<Table
columns={columns}
dataSource={data?.data}
+20 -3
View File
@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, InputNumber, message, Tooltip, Input, Spin } from 'antd';
import { InfoCircleOutlined, EditOutlined } from '@ant-design/icons';
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 { Link, useNavigate } from 'react-router-dom';
import { useReviews, useUpdateReview, useBulkUpdateReviews, useReview } from '../../hooks/useReviews';
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';
@@ -12,6 +12,7 @@ const ReviewListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<ReviewListParams>({ limit: 20, 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[]>([]);
@@ -238,6 +239,22 @@ const ReviewListPage: React.FC = () => {
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>
))}
</Row>
)}
<Space style={{ marginBottom: 16 }}>
<Button onClick={() => openBulkStatusModal('hidden')} disabled={selectedRowKeys.length === 0}>
Скрыть выбранные
@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, DatePicker, Tooltip, Spin } from 'antd';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
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 { Link } from 'react-router-dom';
import { useSubscriptions, useUpdateSubscription, useDeleteSubscription, useSubscription } from '../../hooks/useSubscriptions';
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';
@@ -11,6 +11,7 @@ import dayjs from 'dayjs';
const SubscriptionListPage: React.FC = () => {
const [params, setParams] = useState<SubscriptionListParams>({ limit: 20, offset: 0 });
const { data, isLoading } = useSubscriptions(params);
const { data: stats } = useSubscriptionStats();
const updateSubscription = useUpdateSubscription();
const deleteSubscription = useDeleteSubscription();
@@ -178,6 +179,27 @@ const SubscriptionListPage: React.FC = () => {
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>
))}
</Row>
)}
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
+25 -3
View File
@@ -1,8 +1,8 @@
import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip } from 'antd';
import { InfoCircleOutlined, EditOutlined, LockOutlined, UnlockOutlined, DeleteOutlined } from '@ant-design/icons';
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 { useNavigate } from 'react-router-dom';
import { useUsers, useUpdateUser, useDeleteUser, useUser } from '../../hooks/useUsers';
import { useUsers, useUpdateUser, useDeleteUser, useUser, useUserStats } from '../../hooks/useUsers';
import { User, UserListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
@@ -10,6 +10,7 @@ const UserListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<UserListParams>({ limit: 20, offset: 0, sort: 'id', order: 'asc' });
const { data, isLoading } = useUsers(params);
const { data: stats } = useUserStats();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
@@ -247,6 +248,27 @@ const UserListPage: React.FC = () => {
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>
))}
</Row>
)}
<Table
columns={columns}
dataSource={data?.data}