Implement support ticket system with role request and bug report functionalities
- Introduced a new support ticket system allowing users to submit bug reports and role requests. - Implemented endpoints for creating, updating, and managing support tickets, including file attachments. - Enhanced Telegram bot integration to handle role requests directly within the bot, enabling admins to approve or reject requests without accessing the website. - Updated database schema to include support ticket entities and their relationships. - Improved API documentation to reflect new support ticket endpoints and their usage. - Added necessary localization for support ticket features in both Russian and English.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import { useState, type ChangeEvent } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { TicketStatusBadge } from '@/features/support/TicketStatusBadge'
|
||||
import { TicketAttachmentImage } from '@/features/support/TicketAttachmentImage'
|
||||
import { addAdminComment, approveRoleRequest, closeTicket, getAdminTicket, rejectRoleRequest, resolveTicket } from './api'
|
||||
|
||||
const MAX_FILES = 5
|
||||
|
||||
export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId: string; onOpenChange: (open: boolean) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [reply, setReply] = useState('')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin-ticket', ticketId], queryFn: () => getAdminTicket(ticketId) })
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-ticket', ticketId] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-tickets'] })
|
||||
}
|
||||
|
||||
const replyMutation = useMutation({
|
||||
mutationFn: () => addAdminComment(ticketId, reply.trim(), files),
|
||||
onSuccess: async () => {
|
||||
setReply('')
|
||||
setFiles([])
|
||||
await invalidate()
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||
})
|
||||
|
||||
const onActionError = () => toast.error(t('auth.genericError'))
|
||||
|
||||
const resolveMutation = useMutation({
|
||||
mutationFn: () => resolveTicket(ticketId),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.support.resolved'))
|
||||
await invalidate()
|
||||
},
|
||||
onError: onActionError,
|
||||
})
|
||||
|
||||
const closeMutation = useMutation({
|
||||
mutationFn: () => closeTicket(ticketId),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.support.closed'))
|
||||
await invalidate()
|
||||
},
|
||||
onError: onActionError,
|
||||
})
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: () => approveRoleRequest(ticketId),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.support.approved'))
|
||||
await invalidate()
|
||||
},
|
||||
onError: onActionError,
|
||||
})
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: () => rejectRoleRequest(ticketId),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.support.rejected'))
|
||||
await invalidate()
|
||||
},
|
||||
onError: onActionError,
|
||||
})
|
||||
|
||||
const handleFilesChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setFiles(Array.from(e.target.files ?? []).slice(0, MAX_FILES))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{data && <TicketStatusBadge status={data.status} />}
|
||||
{data && t(`support.type.${data.type}`)}
|
||||
{data && <span className="text-sm font-normal text-muted-foreground">— {data.userName}</span>}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||||
|
||||
{data && (
|
||||
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto">
|
||||
{data.type === 'RoleRequest' && (
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
{data.requestedRoleName
|
||||
? t('support.requestedExistingRole', { role: data.requestedRoleName })
|
||||
: t('support.requestedNewRole', {
|
||||
name: data.proposedRoleName,
|
||||
configs: data.proposedMaxConfigs,
|
||||
ip: data.proposedMaxIpLimit,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.comments.map((comment) => (
|
||||
<div key={comment.id} className="rounded-md border border-border p-3">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{comment.authorName}</span>
|
||||
<span>{new Date(comment.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm">{comment.body}</p>
|
||||
{comment.attachments.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{comment.attachments.map((attachment) => (
|
||||
<TicketAttachmentImage key={attachment.id} attachment={attachment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.status !== 'Closed' && (
|
||||
<div className="flex flex-wrap gap-2 border-t border-border pt-3">
|
||||
{data.type === 'RoleRequest' && data.status === 'Open' ? (
|
||||
<>
|
||||
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate()}>
|
||||
{t('admin.support.approve')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={rejectMutation.isPending} onClick={() => rejectMutation.mutate()}>
|
||||
{t('admin.support.reject')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{data.status === 'Open' && (
|
||||
<Button size="sm" disabled={resolveMutation.isPending} onClick={() => resolveMutation.mutate()}>
|
||||
{t('admin.support.resolve')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" disabled={closeMutation.isPending} onClick={() => closeMutation.mutate()}>
|
||||
{t('admin.support.close')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.status !== 'Closed' && (
|
||||
<form
|
||||
className="flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (reply.trim()) replyMutation.mutate()
|
||||
}}
|
||||
>
|
||||
<Textarea value={reply} onChange={(e) => setReply(e.target.value)} placeholder={t('support.replyPlaceholder')} />
|
||||
<Input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple onChange={handleFilesChange} />
|
||||
<Button type="submit" size="sm" disabled={!reply.trim() || replyMutation.isPending}>
|
||||
{t('support.reply')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { apiRequest, apiUpload } from '@/shared/api/client'
|
||||
import type {
|
||||
PagedList,
|
||||
TicketCommentDto,
|
||||
TicketDetailDto,
|
||||
TicketStatus,
|
||||
TicketSummaryDto,
|
||||
TicketType,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listAllTickets(type: TicketType | undefined, status: TicketStatus | undefined, page: number, pageSize: number) {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (type) params.set('type', type)
|
||||
if (status) params.set('status', status)
|
||||
return apiRequest<PagedList<TicketSummaryDto>>(`/admin/support/tickets?${params}`)
|
||||
}
|
||||
|
||||
export function getAdminTicket(id: string) {
|
||||
return apiRequest<TicketDetailDto>(`/admin/support/tickets/${id}`)
|
||||
}
|
||||
|
||||
export function addAdminComment(ticketId: string, body: string, files: File[]) {
|
||||
const formData = new FormData()
|
||||
formData.set('body', body)
|
||||
files.forEach((file) => formData.append('files', file))
|
||||
return apiUpload<TicketCommentDto>(`/admin/support/tickets/${ticketId}/comments`, formData)
|
||||
}
|
||||
|
||||
export function resolveTicket(ticketId: string) {
|
||||
return apiRequest<void>(`/admin/support/tickets/${ticketId}/resolve`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function closeTicket(ticketId: string) {
|
||||
return apiRequest<void>(`/admin/support/tickets/${ticketId}/close`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function approveRoleRequest(ticketId: string) {
|
||||
return apiRequest<void>(`/admin/support/tickets/${ticketId}/approve`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function rejectRoleRequest(ticketId: string, reason?: string) {
|
||||
return apiRequest<void>(`/admin/support/tickets/${ticketId}/reject`, { method: 'POST', body: { reason } })
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, type ChangeEvent } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { createBugReportTicket } from './api'
|
||||
|
||||
const MAX_FILES = 5
|
||||
|
||||
export function CreateBugReportDialog() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => createBugReportTicket(message.trim(), files),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('support.ticketCreated'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['my-tickets'] })
|
||||
setOpen(false)
|
||||
setMessage('')
|
||||
setFiles([])
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||
})
|
||||
|
||||
const handleFilesChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setFiles(Array.from(e.target.files ?? []).slice(0, MAX_FILES))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">{t('support.reportBug')}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('support.reportBug')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (message.trim()) mutation.mutate()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="bug-message">{t('support.messageLabel')}</Label>
|
||||
<Textarea id="bug-message" value={message} onChange={(e) => setMessage(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="bug-files">{t('support.attachmentsLabel')}</Label>
|
||||
<Input
|
||||
id="bug-files"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
multiple
|
||||
onChange={handleFilesChange}
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t('support.filesSelected', { count: files.length })}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={!message.trim() || mutation.isPending}>
|
||||
{t('support.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { createRoleRequestTicket, listSelectableRoles } from './api'
|
||||
|
||||
type Mode = 'existing' | 'new'
|
||||
|
||||
export function CreateRoleRequestDialog() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [mode, setMode] = useState<Mode>('existing')
|
||||
const [roleId, setRoleId] = useState('')
|
||||
const [newRoleName, setNewRoleName] = useState('')
|
||||
const [newRoleMaxConfigs, setNewRoleMaxConfigs] = useState('')
|
||||
const [newRoleMaxIpLimit, setNewRoleMaxIpLimit] = useState('')
|
||||
const [justification, setJustification] = useState('')
|
||||
|
||||
const rolesQuery = useQuery({ queryKey: ['selectable-roles'], queryFn: listSelectableRoles, enabled: open })
|
||||
|
||||
const resetForm = () => {
|
||||
setMode('existing')
|
||||
setRoleId('')
|
||||
setNewRoleName('')
|
||||
setNewRoleMaxConfigs('')
|
||||
setNewRoleMaxIpLimit('')
|
||||
setJustification('')
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createRoleRequestTicket(
|
||||
mode === 'existing'
|
||||
? { existingRoleId: roleId, justification: justification.trim() }
|
||||
: {
|
||||
newRoleName: newRoleName.trim(),
|
||||
newRoleMaxConfigs: Number(newRoleMaxConfigs),
|
||||
newRoleMaxIpLimit: Number(newRoleMaxIpLimit),
|
||||
justification: justification.trim(),
|
||||
},
|
||||
),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('support.ticketCreated'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['my-tickets'] })
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof HttpError && error.status === 409
|
||||
? t('support.roleRequestPending')
|
||||
: error instanceof HttpError
|
||||
? error.detail
|
||||
: t('auth.genericError')
|
||||
toast.error(message)
|
||||
},
|
||||
})
|
||||
|
||||
const canSubmit =
|
||||
justification.trim().length > 0 &&
|
||||
(mode === 'existing'
|
||||
? roleId.length > 0
|
||||
: newRoleName.trim().length > 0 && newRoleMaxConfigs !== '' && newRoleMaxIpLimit !== '')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">{t('support.requestRole')}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('support.requestRole')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (canSubmit) mutation.mutate()
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" checked={mode === 'existing'} onChange={() => setMode('existing')} />
|
||||
{t('support.existingRole')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" checked={mode === 'new'} onChange={() => setMode('new')} />
|
||||
{t('support.newRole')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mode === 'existing' ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('support.selectRole')}</Label>
|
||||
<Select value={roleId} onValueChange={setRoleId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('support.selectRole')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rolesQuery.data?.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="new-role-name">{t('support.newRoleName')}</Label>
|
||||
<Input id="new-role-name" value={newRoleName} onChange={(e) => setNewRoleName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="new-role-configs">{t('support.newRoleMaxConfigs')}</Label>
|
||||
<Input
|
||||
id="new-role-configs"
|
||||
type="number"
|
||||
min={-1}
|
||||
value={newRoleMaxConfigs}
|
||||
onChange={(e) => setNewRoleMaxConfigs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="new-role-ip">{t('support.newRoleMaxIpLimit')}</Label>
|
||||
<Input
|
||||
id="new-role-ip"
|
||||
type="number"
|
||||
min={-1}
|
||||
value={newRoleMaxIpLimit}
|
||||
onChange={(e) => setNewRoleMaxIpLimit(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="justification">{t('support.justification')}</Label>
|
||||
<Textarea id="justification" value={justification} onChange={(e) => setJustification(e.target.value)} required />
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
|
||||
{t('support.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react'
|
||||
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 { CreateBugReportDialog } from './CreateBugReportDialog'
|
||||
import { CreateRoleRequestDialog } from './CreateRoleRequestDialog'
|
||||
import { TicketDetailDialog } from './TicketDetailDialog'
|
||||
import { TicketStatusBadge } from './TicketStatusBadge'
|
||||
import { listMyTickets } from './api'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function SupportTicketList() {
|
||||
const { t } = useTranslation()
|
||||
const [page, setPage] = useState(1)
|
||||
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['my-tickets', page],
|
||||
queryFn: () => listMyTickets(undefined, undefined, page, PAGE_SIZE),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CreateBugReportDialog />
|
||||
<CreateRoleRequestDialog />
|
||||
</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('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={() => setSelectedTicketId(ticket.id)}>
|
||||
<CardHeader className="flex-row items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">{t(`support.type.${ticket.type}`)}</CardTitle>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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 && (
|
||||
<TicketDetailDialog ticketId={selectedTicketId} onOpenChange={(open) => !open && setSelectedTicketId(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { TicketAttachmentDto } from '@/shared/api/types'
|
||||
import { fetchAttachmentBlob } from './api'
|
||||
|
||||
export function TicketAttachmentImage({ attachment }: { attachment: TicketAttachmentDto }) {
|
||||
const [url, setUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null
|
||||
let cancelled = false
|
||||
|
||||
fetchAttachmentBlob(attachment.id)
|
||||
.then((blob) => {
|
||||
if (cancelled) return
|
||||
objectUrl = URL.createObjectURL(blob)
|
||||
setUrl(objectUrl)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [attachment.id])
|
||||
|
||||
if (!url) return <div className="h-20 w-20 animate-pulse rounded-md bg-muted" />
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<img src={url} alt={attachment.fileName} className="h-20 w-20 rounded-md border border-border object-cover" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState, type ChangeEvent } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { TicketStatusBadge } from './TicketStatusBadge'
|
||||
import { TicketAttachmentImage } from './TicketAttachmentImage'
|
||||
import { addTicketComment, getTicket, reopenTicket } from './api'
|
||||
|
||||
const MAX_FILES = 5
|
||||
|
||||
export function TicketDetailDialog({ ticketId, onOpenChange }: { ticketId: string; onOpenChange: (open: boolean) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [reply, setReply] = useState('')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['ticket', ticketId], queryFn: () => getTicket(ticketId) })
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['ticket', ticketId] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['my-tickets'] })
|
||||
}
|
||||
|
||||
const replyMutation = useMutation({
|
||||
mutationFn: () => addTicketComment(ticketId, reply.trim(), files),
|
||||
onSuccess: async () => {
|
||||
setReply('')
|
||||
setFiles([])
|
||||
await invalidate()
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||
})
|
||||
|
||||
const reopenMutation = useMutation({
|
||||
mutationFn: () => reopenTicket(ticketId),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('support.reopened'))
|
||||
await invalidate()
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const handleFilesChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setFiles(Array.from(e.target.files ?? []).slice(0, MAX_FILES))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{data && <TicketStatusBadge status={data.status} />}
|
||||
{data && t(`support.type.${data.type}`)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||||
|
||||
{data && (
|
||||
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto">
|
||||
{data.type === 'RoleRequest' && (
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
{data.requestedRoleName
|
||||
? t('support.requestedExistingRole', { role: data.requestedRoleName })
|
||||
: t('support.requestedNewRole', {
|
||||
name: data.proposedRoleName,
|
||||
configs: data.proposedMaxConfigs,
|
||||
ip: data.proposedMaxIpLimit,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.comments.map((comment) => (
|
||||
<div key={comment.id} className="rounded-md border border-border p-3">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{comment.authorName}</span>
|
||||
<span>{new Date(comment.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm">{comment.body}</p>
|
||||
{comment.attachments.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{comment.attachments.map((attachment) => (
|
||||
<TicketAttachmentImage key={attachment.id} attachment={attachment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.status === 'Resolved' && (
|
||||
<Button variant="outline" size="sm" disabled={reopenMutation.isPending} onClick={() => reopenMutation.mutate()}>
|
||||
{t('support.reopen')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{data.status !== 'Closed' && (
|
||||
<form
|
||||
className="flex flex-col gap-2 border-t border-border pt-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (reply.trim()) replyMutation.mutate()
|
||||
}}
|
||||
>
|
||||
<Textarea value={reply} onChange={(e) => setReply(e.target.value)} placeholder={t('support.replyPlaceholder')} />
|
||||
<Input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple onChange={handleFilesChange} />
|
||||
<Button type="submit" size="sm" disabled={!reply.trim() || replyMutation.isPending}>
|
||||
{t('support.reply')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import type { TicketStatus } from '@/shared/api/types'
|
||||
|
||||
const VARIANT: Record<TicketStatus, 'warning' | 'success' | 'outline'> = {
|
||||
Open: 'warning',
|
||||
Resolved: 'success',
|
||||
Closed: 'outline',
|
||||
}
|
||||
|
||||
export function TicketStatusBadge({ status }: { status: TicketStatus }) {
|
||||
const { t } = useTranslation()
|
||||
return <Badge variant={VARIANT[status]}>{t(`support.status.${status}`)}</Badge>
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiRequest, apiUpload, getAccessToken } from '@/shared/api/client'
|
||||
import type {
|
||||
PagedList,
|
||||
RoleDto,
|
||||
TicketCommentDto,
|
||||
TicketDetailDto,
|
||||
TicketStatus,
|
||||
TicketSummaryDto,
|
||||
TicketType,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
function ticketsQuery(type: TicketType | undefined, status: TicketStatus | undefined, page: number, pageSize: number) {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (type) params.set('type', type)
|
||||
if (status) params.set('status', status)
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
export function listMyTickets(type: TicketType | undefined, status: TicketStatus | undefined, page: number, pageSize: number) {
|
||||
return apiRequest<PagedList<TicketSummaryDto>>(`/support/tickets?${ticketsQuery(type, status, page, pageSize)}`)
|
||||
}
|
||||
|
||||
export function getTicket(id: string) {
|
||||
return apiRequest<TicketDetailDto>(`/support/tickets/${id}`)
|
||||
}
|
||||
|
||||
export function listSelectableRoles() {
|
||||
return apiRequest<RoleDto[]>('/support/roles')
|
||||
}
|
||||
|
||||
export function createBugReportTicket(message: string, files: File[]) {
|
||||
const formData = new FormData()
|
||||
formData.set('message', message)
|
||||
files.forEach((file) => formData.append('files', file))
|
||||
return apiUpload<TicketDetailDto>('/support/tickets/bug-reports', formData)
|
||||
}
|
||||
|
||||
export function createRoleRequestTicket(payload: {
|
||||
existingRoleId?: string
|
||||
newRoleName?: string
|
||||
newRoleMaxConfigs?: number
|
||||
newRoleMaxIpLimit?: number
|
||||
justification: string
|
||||
}) {
|
||||
return apiRequest<TicketDetailDto>('/support/tickets/role-requests', { method: 'POST', body: payload })
|
||||
}
|
||||
|
||||
export function addTicketComment(ticketId: string, body: string, files: File[]) {
|
||||
const formData = new FormData()
|
||||
formData.set('body', body)
|
||||
files.forEach((file) => formData.append('files', file))
|
||||
return apiUpload<TicketCommentDto>(`/support/tickets/${ticketId}/comments`, formData)
|
||||
}
|
||||
|
||||
export function reopenTicket(ticketId: string) {
|
||||
return apiRequest<void>(`/support/tickets/${ticketId}/reopen`, { method: 'POST' })
|
||||
}
|
||||
|
||||
/** Вложения отдаются авторизованным эндпоинтом (не статикой) — обычный <img src> не может передать
|
||||
* Authorization-заголовок, поэтому качаем как Blob и рендерим через Object URL (см. TicketAttachmentImage). */
|
||||
export async function fetchAttachmentBlob(id: string): Promise<Blob> {
|
||||
const headers: Record<string, string> = {}
|
||||
const token = getAccessToken()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
const response = await fetch(`/api/support/attachments/${id}`, { headers, credentials: 'include' })
|
||||
if (!response.ok) throw new Error('Не удалось загрузить вложение')
|
||||
return response.blob()
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SupportRouteImport } from './routes/support'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as RegisterRouteImport } from './routes/register'
|
||||
import { Route as NewsRouteImport } from './routes/news'
|
||||
@@ -19,6 +20,7 @@ import { Route as AdminRouteImport } from './routes/admin'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
||||
import { Route as AdminUsersRouteImport } from './routes/admin/users'
|
||||
import { Route as AdminSupportRouteImport } from './routes/admin/support'
|
||||
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
||||
import { Route as AdminNodesRouteImport } from './routes/admin/nodes'
|
||||
import { Route as AdminNewsRouteImport } from './routes/admin/news'
|
||||
@@ -27,6 +29,11 @@ import { Route as AdminAuditRouteImport } from './routes/admin/audit'
|
||||
import { Route as AdminAppsRouteImport } from './routes/admin/apps'
|
||||
import { Route as AdminActivationRouteImport } from './routes/admin/activation'
|
||||
|
||||
const SupportRoute = SupportRouteImport.update({
|
||||
id: '/support',
|
||||
path: '/support',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
@@ -77,6 +84,11 @@ const AdminUsersRoute = AdminUsersRouteImport.update({
|
||||
path: '/users',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminSupportRoute = AdminSupportRouteImport.update({
|
||||
id: '/support',
|
||||
path: '/support',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminRolesRoute = AdminRolesRouteImport.update({
|
||||
id: '/roles',
|
||||
path: '/roles',
|
||||
@@ -122,6 +134,7 @@ export interface FileRoutesByFullPath {
|
||||
'/news': typeof NewsRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/support': typeof SupportRoute
|
||||
'/admin/activation': typeof AdminActivationRoute
|
||||
'/admin/apps': typeof AdminAppsRoute
|
||||
'/admin/audit': typeof AdminAuditRoute
|
||||
@@ -129,6 +142,7 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/news': typeof AdminNewsRoute
|
||||
'/admin/nodes': typeof AdminNodesRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
'/admin/support': typeof AdminSupportRoute
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
}
|
||||
@@ -140,6 +154,7 @@ export interface FileRoutesByTo {
|
||||
'/news': typeof NewsRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/support': typeof SupportRoute
|
||||
'/admin/activation': typeof AdminActivationRoute
|
||||
'/admin/apps': typeof AdminAppsRoute
|
||||
'/admin/audit': typeof AdminAuditRoute
|
||||
@@ -147,6 +162,7 @@ export interface FileRoutesByTo {
|
||||
'/admin/news': typeof AdminNewsRoute
|
||||
'/admin/nodes': typeof AdminNodesRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
'/admin/support': typeof AdminSupportRoute
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/admin': typeof AdminIndexRoute
|
||||
}
|
||||
@@ -160,6 +176,7 @@ export interface FileRoutesById {
|
||||
'/news': typeof NewsRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/support': typeof SupportRoute
|
||||
'/admin/activation': typeof AdminActivationRoute
|
||||
'/admin/apps': typeof AdminAppsRoute
|
||||
'/admin/audit': typeof AdminAuditRoute
|
||||
@@ -167,6 +184,7 @@ export interface FileRoutesById {
|
||||
'/admin/news': typeof AdminNewsRoute
|
||||
'/admin/nodes': typeof AdminNodesRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
'/admin/support': typeof AdminSupportRoute
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
}
|
||||
@@ -181,6 +199,7 @@ export interface FileRouteTypes {
|
||||
| '/news'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/support'
|
||||
| '/admin/activation'
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
@@ -188,6 +207,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/news'
|
||||
| '/admin/nodes'
|
||||
| '/admin/roles'
|
||||
| '/admin/support'
|
||||
| '/admin/users'
|
||||
| '/admin/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -199,6 +219,7 @@ export interface FileRouteTypes {
|
||||
| '/news'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/support'
|
||||
| '/admin/activation'
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
@@ -206,6 +227,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/news'
|
||||
| '/admin/nodes'
|
||||
| '/admin/roles'
|
||||
| '/admin/support'
|
||||
| '/admin/users'
|
||||
| '/admin'
|
||||
id:
|
||||
@@ -218,6 +240,7 @@ export interface FileRouteTypes {
|
||||
| '/news'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/support'
|
||||
| '/admin/activation'
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
@@ -225,6 +248,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/news'
|
||||
| '/admin/nodes'
|
||||
| '/admin/roles'
|
||||
| '/admin/support'
|
||||
| '/admin/users'
|
||||
| '/admin/'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -238,10 +262,18 @@ export interface RootRouteChildren {
|
||||
NewsRoute: typeof NewsRoute
|
||||
RegisterRoute: typeof RegisterRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
SupportRoute: typeof SupportRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/support': {
|
||||
id: '/support'
|
||||
path: '/support'
|
||||
fullPath: '/support'
|
||||
preLoaderRoute: typeof SupportRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
@@ -312,6 +344,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminUsersRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/support': {
|
||||
id: '/admin/support'
|
||||
path: '/support'
|
||||
fullPath: '/admin/support'
|
||||
preLoaderRoute: typeof AdminSupportRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/roles': {
|
||||
id: '/admin/roles'
|
||||
path: '/roles'
|
||||
@@ -372,6 +411,7 @@ interface AdminRouteChildren {
|
||||
AdminNewsRoute: typeof AdminNewsRoute
|
||||
AdminNodesRoute: typeof AdminNodesRoute
|
||||
AdminRolesRoute: typeof AdminRolesRoute
|
||||
AdminSupportRoute: typeof AdminSupportRoute
|
||||
AdminUsersRoute: typeof AdminUsersRoute
|
||||
AdminIndexRoute: typeof AdminIndexRoute
|
||||
}
|
||||
@@ -384,6 +424,7 @@ const AdminRouteChildren: AdminRouteChildren = {
|
||||
AdminNewsRoute: AdminNewsRoute,
|
||||
AdminNodesRoute: AdminNodesRoute,
|
||||
AdminRolesRoute: AdminRolesRoute,
|
||||
AdminSupportRoute: AdminSupportRoute,
|
||||
AdminUsersRoute: AdminUsersRoute,
|
||||
AdminIndexRoute: AdminIndexRoute,
|
||||
}
|
||||
@@ -399,6 +440,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
NewsRoute: NewsRoute,
|
||||
RegisterRoute: RegisterRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
SupportRoute: SupportRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -52,6 +52,9 @@ function RootLayout() {
|
||||
<Link to="/news" className="text-muted-foreground hover:text-foreground">
|
||||
{t('nav.news')}
|
||||
</Link>
|
||||
<Link to="/support" className="text-muted-foreground hover:text-foreground">
|
||||
{t('nav.support')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link to="/settings" className="text-muted-foreground hover:text-foreground">
|
||||
|
||||
@@ -14,6 +14,7 @@ const TABS = [
|
||||
{ to: '/admin/nodes', key: 'nodes' },
|
||||
{ to: '/admin/apps', key: 'apps' },
|
||||
{ to: '/admin/news', key: 'news' },
|
||||
{ to: '/admin/support', key: 'support' },
|
||||
{ to: '/admin/audit', key: 'audit' },
|
||||
] as const
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute } 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 { TicketStatusBadge } from '@/features/support/TicketStatusBadge'
|
||||
import { AdminTicketDetailDialog } from '@/features/admin/support/AdminTicketDetailDialog'
|
||||
import { listAllTickets } from '@/features/admin/support/api'
|
||||
|
||||
export const Route = createFileRoute('/admin/support')({ component: AdminSupportPage })
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
function AdminSupportPage() {
|
||||
const { t } = useTranslation()
|
||||
const [page, setPage] = useState(1)
|
||||
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['admin-tickets', page],
|
||||
queryFn: () => listAllTickets(undefined, undefined, page, PAGE_SIZE),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{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={() => setSelectedTicketId(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>
|
||||
<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 && setSelectedTicketId(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRequireActivated } from '@/features/auth/guards'
|
||||
import { SupportTicketList } from '@/features/support/SupportTicketList'
|
||||
|
||||
export const Route = createFileRoute('/support')({ component: SupportPage })
|
||||
|
||||
function SupportPage() {
|
||||
const { t } = useTranslation()
|
||||
const { isReady } = useRequireActivated()
|
||||
|
||||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 px-6 py-10">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('nav.support')}</h1>
|
||||
<SupportTicketList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -91,3 +91,36 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
const text = await response.text()
|
||||
return (text ? JSON.parse(text) : undefined) as T
|
||||
}
|
||||
|
||||
type UploadOptions = {
|
||||
method?: 'POST' | 'PUT'
|
||||
skipRefresh?: boolean
|
||||
}
|
||||
|
||||
/** Как apiRequest, но для multipart/form-data (вложения к тикетам) — без JSON.stringify и
|
||||
* без Content-Type (браузер сам проставляет boundary). */
|
||||
export async function apiUpload<T>(path: string, formData: FormData, options: UploadOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method: options.method ?? 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.status === 401 && !options.skipRefresh) {
|
||||
const refreshed = await refreshAccessToken()
|
||||
if (refreshed) return apiUpload<T>(path, formData, { ...options, skipRefresh: true })
|
||||
onUnauthorized?.()
|
||||
throw await parseError(response)
|
||||
}
|
||||
|
||||
if (!response.ok) throw await parseError(response)
|
||||
|
||||
if (response.status === 204) return undefined as T
|
||||
|
||||
const text = await response.text()
|
||||
return (text ? JSON.parse(text) : undefined) as T
|
||||
}
|
||||
|
||||
@@ -238,3 +238,48 @@ export type AuditLogDto = {
|
||||
source: AuditSource
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type TicketType = 'BugReport' | 'RoleRequest'
|
||||
export type TicketStatus = 'Open' | 'Resolved' | 'Closed'
|
||||
|
||||
export type TicketAttachmentDto = {
|
||||
id: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export type TicketCommentDto = {
|
||||
id: string
|
||||
authorId: string
|
||||
authorName: string
|
||||
body: string
|
||||
createdAt: string
|
||||
attachments: TicketAttachmentDto[]
|
||||
}
|
||||
|
||||
/** Строка списка тикетов — один DTO для своего списка и админского (видит только свои userId/userName). */
|
||||
export type TicketSummaryDto = {
|
||||
id: string
|
||||
userId: string
|
||||
userName: string
|
||||
type: TicketType
|
||||
status: TicketStatus
|
||||
createdAt: string
|
||||
lastActivityAt: string
|
||||
}
|
||||
|
||||
export type TicketDetailDto = {
|
||||
id: string
|
||||
userId: string
|
||||
userName: string
|
||||
type: TicketType
|
||||
status: TicketStatus
|
||||
requestedRoleId: string | null
|
||||
requestedRoleName: string | null
|
||||
proposedRoleName: string | null
|
||||
proposedMaxConfigs: number | null
|
||||
proposedMaxIpLimit: number | null
|
||||
createdAt: string
|
||||
comments: TicketCommentDto[]
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ const resources = {
|
||||
dashboard: 'Мои конфиги',
|
||||
instructions: 'Инструкции',
|
||||
news: 'Новости',
|
||||
support: 'Поддержка',
|
||||
settings: 'Настройки',
|
||||
admin: 'Админка',
|
||||
logout: 'Выйти',
|
||||
@@ -121,6 +122,42 @@ const resources = {
|
||||
empty: 'Пока нет новостей.',
|
||||
},
|
||||
|
||||
support: {
|
||||
title: 'Поддержка',
|
||||
empty: 'У вас пока нет обращений.',
|
||||
reportBug: 'Сообщить об ошибке',
|
||||
requestRole: 'Запросить роль',
|
||||
submit: 'Отправить',
|
||||
messageLabel: 'Опишите проблему или предложение',
|
||||
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
|
||||
filesSelected: '{{count}} файл(ов) выбрано',
|
||||
existingRole: 'Существующая роль',
|
||||
newRole: 'Новая роль',
|
||||
selectRole: 'Выберите роль',
|
||||
newRoleName: 'Название роли',
|
||||
newRoleMaxConfigs: 'Количество конфигов (-1 — без лимита)',
|
||||
newRoleMaxIpLimit: 'Количество IP (-1 — без лимита)',
|
||||
justification: 'Обоснование',
|
||||
ticketCreated: 'Обращение отправлено.',
|
||||
roleRequestPending: 'У вас уже есть необработанная заявка на роль.',
|
||||
reply: 'Ответить',
|
||||
replyPlaceholder: 'Написать комментарий…',
|
||||
reopen: 'Переоткрыть',
|
||||
reopened: 'Тикет переоткрыт.',
|
||||
lastActivity: 'Последняя активность: {{date}}',
|
||||
requestedExistingRole: 'Запрошена роль: {{role}}',
|
||||
requestedNewRole: 'Запрошена новая роль «{{name}}» (конфигов: {{configs}}, IP: {{ip}})',
|
||||
type: {
|
||||
BugReport: 'Ошибка/предложение',
|
||||
RoleRequest: 'Заявка на роль',
|
||||
},
|
||||
status: {
|
||||
Open: 'Открыт',
|
||||
Resolved: 'Решён',
|
||||
Closed: 'Закрыт',
|
||||
},
|
||||
},
|
||||
|
||||
settings: {
|
||||
changePassword: 'Сменить пароль',
|
||||
currentPassword: 'Текущий пароль',
|
||||
@@ -159,6 +196,7 @@ const resources = {
|
||||
nodes: 'Ноды',
|
||||
apps: 'Приложения',
|
||||
news: 'Новости',
|
||||
support: 'Поддержка',
|
||||
audit: 'Аудит',
|
||||
},
|
||||
users: {
|
||||
@@ -289,6 +327,17 @@ const resources = {
|
||||
deleted: 'Новость удалена.',
|
||||
confirmDelete: 'Удалить новость?',
|
||||
},
|
||||
support: {
|
||||
empty: 'Обращений пока нет.',
|
||||
resolve: 'Решено',
|
||||
resolved: 'Тикет отмечен как решённый.',
|
||||
close: 'Закрыть',
|
||||
closed: 'Тикет закрыт.',
|
||||
approve: 'Одобрить',
|
||||
approved: 'Заявка одобрена, роль выдана.',
|
||||
reject: 'Отклонить',
|
||||
rejected: 'Заявка отклонена.',
|
||||
},
|
||||
audit: {
|
||||
time: 'Время',
|
||||
action: 'Действие',
|
||||
@@ -356,6 +405,7 @@ const resources = {
|
||||
dashboard: 'My configs',
|
||||
instructions: 'Instructions',
|
||||
news: 'News',
|
||||
support: 'Support',
|
||||
settings: 'Settings',
|
||||
admin: 'Admin',
|
||||
logout: 'Log out',
|
||||
@@ -428,6 +478,42 @@ const resources = {
|
||||
empty: 'No news yet.',
|
||||
},
|
||||
|
||||
support: {
|
||||
title: 'Support',
|
||||
empty: 'You have no tickets yet.',
|
||||
reportBug: 'Report a bug',
|
||||
requestRole: 'Request a role',
|
||||
submit: 'Submit',
|
||||
messageLabel: 'Describe the issue or suggestion',
|
||||
attachmentsLabel: 'Screenshots (optional, up to 5)',
|
||||
filesSelected: '{{count}} file(s) selected',
|
||||
existingRole: 'Existing role',
|
||||
newRole: 'New role',
|
||||
selectRole: 'Select a role',
|
||||
newRoleName: 'Role name',
|
||||
newRoleMaxConfigs: 'Max configs (-1 = unlimited)',
|
||||
newRoleMaxIpLimit: 'Max IPs (-1 = unlimited)',
|
||||
justification: 'Justification',
|
||||
ticketCreated: 'Ticket submitted.',
|
||||
roleRequestPending: 'You already have a pending role request.',
|
||||
reply: 'Reply',
|
||||
replyPlaceholder: 'Write a comment…',
|
||||
reopen: 'Reopen',
|
||||
reopened: 'Ticket reopened.',
|
||||
lastActivity: 'Last activity: {{date}}',
|
||||
requestedExistingRole: 'Requested role: {{role}}',
|
||||
requestedNewRole: 'Requested new role "{{name}}" (configs: {{configs}}, IPs: {{ip}})',
|
||||
type: {
|
||||
BugReport: 'Bug/suggestion',
|
||||
RoleRequest: 'Role request',
|
||||
},
|
||||
status: {
|
||||
Open: 'Open',
|
||||
Resolved: 'Resolved',
|
||||
Closed: 'Closed',
|
||||
},
|
||||
},
|
||||
|
||||
settings: {
|
||||
changePassword: 'Change password',
|
||||
currentPassword: 'Current password',
|
||||
@@ -466,6 +552,7 @@ const resources = {
|
||||
nodes: 'Nodes',
|
||||
apps: 'Apps',
|
||||
news: 'News',
|
||||
support: 'Support',
|
||||
audit: 'Audit',
|
||||
},
|
||||
users: {
|
||||
@@ -596,6 +683,17 @@ const resources = {
|
||||
deleted: 'Post deleted.',
|
||||
confirmDelete: 'Delete this post?',
|
||||
},
|
||||
support: {
|
||||
empty: 'No tickets yet.',
|
||||
resolve: 'Resolve',
|
||||
resolved: 'Ticket marked as resolved.',
|
||||
close: 'Close',
|
||||
closed: 'Ticket closed.',
|
||||
approve: 'Approve',
|
||||
approved: 'Request approved, role granted.',
|
||||
reject: 'Reject',
|
||||
rejected: 'Request rejected.',
|
||||
},
|
||||
audit: {
|
||||
time: 'Time',
|
||||
action: 'Action',
|
||||
|
||||
Reference in New Issue
Block a user