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,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()
|
||||
}
|
||||
Reference in New Issue
Block a user