- Added new configuration options for user plans in `.env.example`, including `Plans__MaxCustomConfigCount` and `Plans__MinCustomConfigCount`. - Introduced `MapPlanEndpoints` in `Program.cs` to handle plan-related API routes. - Implemented `SetUserPlan` endpoint in `RoleEndpoints` to allow admins to assign plans to users. - Removed deprecated role request approval endpoints from `AdminSupportEndpoints`. - Updated `ITelegramNotifier` and related classes to reflect changes in role request handling and payment notifications. - Refactored role management commands to remove `MaxConfigs` and focus on `MaxIpLimit` and billing settings. - Enhanced billing request handling to accommodate plan changes instead of role changes. - Updated various interfaces and command handlers to support new plan management features.
137 lines
5.4 KiB
TypeScript
137 lines
5.4 KiB
TypeScript
import { useState } from 'react'
|
||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { Button } from '@/shared/ui/button'
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||
import { TicketStatusBadge } from '@/features/support/TicketStatusBadge'
|
||
import { AdminTicketDetailDialog } from '@/features/admin/support/AdminTicketDetailDialog'
|
||
import { listAllTickets } from '@/features/admin/support/api'
|
||
import type { TicketStatus, TicketType } from '@/shared/api/types'
|
||
|
||
const TYPES: TicketType[] = ['BugReport', 'ExtensionRequest']
|
||
const STATUSES: TicketStatus[] = ['Open', 'Resolved', 'Closed']
|
||
|
||
export const Route = createFileRoute('/admin/support')({
|
||
component: AdminSupportPage,
|
||
// Ссылка из Telegram-уведомления ведёт на этот же роут с ?ticket=<id> — отдельного роута
|
||
// на конкретный тикет нет, он открывается диалогом поверх списка.
|
||
validateSearch: (search: Record<string, unknown>): { ticket?: string } => ({
|
||
ticket: typeof search.ticket === 'string' ? search.ticket : undefined,
|
||
}),
|
||
})
|
||
|
||
const PAGE_SIZE = 20
|
||
|
||
function AdminSupportPage() {
|
||
const { t } = useTranslation()
|
||
const navigate = useNavigate({ from: Route.fullPath })
|
||
const { ticket: selectedTicketId } = Route.useSearch()
|
||
const [page, setPage] = useState(1)
|
||
const [type, setType] = useState<TicketType | 'all'>('all')
|
||
const [status, setStatus] = useState<TicketStatus | 'all'>('all')
|
||
|
||
const typeFilter = type === 'all' ? undefined : type
|
||
const statusFilter = status === 'all' ? undefined : status
|
||
|
||
const { data, isLoading, isError, refetch } = useQuery({
|
||
queryKey: ['admin-tickets', page, typeFilter, statusFilter],
|
||
queryFn: () => listAllTickets(typeFilter, statusFilter, page, PAGE_SIZE),
|
||
})
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-wrap gap-2">
|
||
<Select
|
||
value={type}
|
||
onValueChange={(v) => {
|
||
setType(v as TicketType | 'all')
|
||
setPage(1)
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-56">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">{t('admin.support.allTypes')}</SelectItem>
|
||
{TYPES.map((ty) => (
|
||
<SelectItem key={ty} value={ty}>
|
||
{t(`support.type.${ty}`)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select
|
||
value={status}
|
||
onValueChange={(v) => {
|
||
setStatus(v as TicketStatus | 'all')
|
||
setPage(1)
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-48">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">{t('admin.support.allStatuses')}</SelectItem>
|
||
{STATUSES.map((st) => (
|
||
<SelectItem key={st} value={st}>
|
||
{t(`support.status.${st}`)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||
|
||
{isError && (
|
||
<div className="flex items-center gap-2">
|
||
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
|
||
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
||
{t('activation.retry')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{data?.items.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.support.empty')}</p>}
|
||
|
||
{data && data.items.length > 0 && (
|
||
<div className="flex flex-col gap-3">
|
||
{data.items.map((ticket) => (
|
||
<Card key={ticket.id} className="cursor-pointer" onClick={() => navigate({ search: { ticket: ticket.id } })}>
|
||
<CardHeader className="flex-row items-center justify-between gap-2">
|
||
<CardTitle className="text-base">
|
||
{ticket.userName} — {t(`support.type.${ticket.type}`)}
|
||
</CardTitle>
|
||
<TicketStatusBadge status={ticket.status} />
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-1">
|
||
{ticket.messagePreview && <p className="truncate text-xs text-muted-foreground">{ticket.messagePreview}</p>}
|
||
<p className="text-xs text-muted-foreground">
|
||
{t('support.lastActivity', { date: new Date(ticket.lastActivityAt).toLocaleString() })}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{data && data.total > PAGE_SIZE && (
|
||
<div className="flex justify-end gap-2 text-sm">
|
||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||
{t('admin.prev')}
|
||
</Button>
|
||
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
|
||
{t('admin.next')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{selectedTicketId && (
|
||
<AdminTicketDetailDialog ticketId={selectedTicketId} onOpenChange={(open) => !open && navigate({ search: {} })} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|