Files
PnvPanel/frontend/src/routes/admin/support.tsx
T
Leonid Pershin fad03c2834
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
Enhance user plan management and update related endpoints
- 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.
2026-07-23 22:52:20 +03:00

137 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}