- 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.
118 lines
4.7 KiB
TypeScript
118 lines
4.7 KiB
TypeScript
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 === 'ExtensionRequest' && (
|
|
<div className="rounded-md border border-border p-3 text-sm">
|
|
{t('support.requestedExtension', { days: data.requestedDays })}
|
|
</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' && data.type === 'BugReport' && (
|
|
<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>
|
|
)
|
|
}
|