feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
@@ -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,283 +130,352 @@ 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionListPage;
|
||||
export default SubscriptionListPage;
|
||||
|
||||
Reference in New Issue
Block a user