add:ticket detail
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { Badge } from '../ui/Badge.tsx'
|
import { Badge } from '../ui/Badge.tsx'
|
||||||
|
import { parseDescription } from '../../lib/ticket.ts'
|
||||||
import type { Ticket } from '../../lib/types.ts'
|
import type { Ticket } from '../../lib/types.ts'
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
@@ -7,14 +8,6 @@ function formatDate(iso: string): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse transaction reference encoded by NewTicketForm into the description
|
|
||||||
// Format: "[Transaction: TXN-XXXXX — Label $X.XX on Date]\n\n..."
|
|
||||||
function parseTransaction(description: string): { txnLine: string; body: string } | null {
|
|
||||||
const match = description.match(/^\[Transaction: ([^\]]+)\]\n?\n?(.*)$/s)
|
|
||||||
if (!match) return null
|
|
||||||
return { txnLine: match[1].trim(), body: match[2].trim() }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdminTableProps {
|
interface AdminTableProps {
|
||||||
tickets: Ticket[]
|
tickets: Ticket[]
|
||||||
}
|
}
|
||||||
@@ -47,8 +40,8 @@ export function AdminTable({ tickets }: AdminTableProps) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border-100 bg-bg-100">
|
<tbody className="divide-y divide-border-100 bg-bg-100">
|
||||||
{tickets.map(ticket => {
|
{tickets.map(ticket => {
|
||||||
const txn = ticket.type === 'billing' ? parseTransaction(ticket.description) : null
|
const { txnId, txnLine, body: displayDescription } = parseDescription(ticket.description)
|
||||||
const displayDescription = txn ? txn.body : ticket.description
|
const hasTxn = ticket.type === 'billing' && txnId !== null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={ticket.id} className="transition-colors hover:bg-bg-200">
|
<tr key={ticket.id} className="transition-colors hover:bg-bg-200">
|
||||||
@@ -63,9 +56,9 @@ export function AdminTable({ tickets }: AdminTableProps) {
|
|||||||
</td>
|
</td>
|
||||||
{hasBilling && (
|
{hasBilling && (
|
||||||
<td className="px-4 py-3 text-xs text-fg-200 whitespace-nowrap">
|
<td className="px-4 py-3 text-xs text-fg-200 whitespace-nowrap">
|
||||||
{txn ? (
|
{hasTxn ? (
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-border-100 bg-bg-300 px-2 py-1 font-mono text-fg-200">
|
<span className="inline-flex items-center gap-1.5 rounded-md border border-border-100 bg-bg-300 px-2 py-1 font-mono text-fg-200">
|
||||||
{txn.txnLine.split(' — ')[0]}
|
{txnLine!.split(' — ')[0]}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-fg-300 italic">—</span>
|
<span className="text-fg-300 italic">—</span>
|
||||||
|
|||||||
185
frontend/src/components/tickets/TicketDetail.tsx
Normal file
185
frontend/src/components/tickets/TicketDetail.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { Badge } from '../ui/Badge.tsx'
|
||||||
|
import { FAKE_TRANSACTIONS } from './NewTicketForm.tsx'
|
||||||
|
import { parseDescription } from '../../lib/ticket.ts'
|
||||||
|
import type { Ticket } from '../../lib/types.ts'
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('en-US', {
|
||||||
|
month: 'long', day: 'numeric', year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<Ticket['type'], string> = {
|
||||||
|
'bug': 'Bug',
|
||||||
|
'billing': 'Billing',
|
||||||
|
'account': 'Account',
|
||||||
|
'feature-request':'Feature Request',
|
||||||
|
'feedback': 'Feedback',
|
||||||
|
'other': 'Other',
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOLD_DURATION = 2000 // ms
|
||||||
|
|
||||||
|
interface HoldToCloseProps {
|
||||||
|
onComplete: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoldToClose({ onComplete }: HoldToCloseProps) {
|
||||||
|
const [progress, setProgress] = useState(0) // 0–1
|
||||||
|
const [completing, setCompleting] = useState(false)
|
||||||
|
const rafRef = useRef<number | null>(null)
|
||||||
|
const startRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||||
|
rafRef.current = null
|
||||||
|
startRef.current = null
|
||||||
|
setProgress(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
if (!startRef.current) return
|
||||||
|
const elapsed = now - startRef.current
|
||||||
|
const next = Math.min(elapsed / HOLD_DURATION, 1)
|
||||||
|
setProgress(next)
|
||||||
|
if (next < 1) {
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
} else {
|
||||||
|
setCompleting(true)
|
||||||
|
onComplete().finally(() => setCompleting(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (completing) return
|
||||||
|
startRef.current = performance.now()
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up on unmount
|
||||||
|
useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }, [])
|
||||||
|
|
||||||
|
// SVG arc helpers
|
||||||
|
const size = 28
|
||||||
|
const stroke = 2.5
|
||||||
|
const r = (size - stroke) / 2
|
||||||
|
const circ = 2 * Math.PI * r
|
||||||
|
const dash = progress * circ
|
||||||
|
|
||||||
|
const isHolding = progress > 0 && progress < 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={start}
|
||||||
|
onMouseUp={cancel}
|
||||||
|
onMouseLeave={cancel}
|
||||||
|
onTouchStart={start}
|
||||||
|
onTouchEnd={cancel}
|
||||||
|
disabled={completing}
|
||||||
|
className={`
|
||||||
|
relative inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm
|
||||||
|
select-none transition-colors duration-150 cursor-pointer
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed
|
||||||
|
${isHolding
|
||||||
|
? 'text-red-400 bg-red-950/40'
|
||||||
|
: 'text-fg-300 hover:text-fg-100 hover:bg-bg-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
aria-label="Hold to close ticket"
|
||||||
|
>
|
||||||
|
{/* Progress ring */}
|
||||||
|
<span className="relative flex items-center justify-center shrink-0" style={{ width: size, height: size }}>
|
||||||
|
{/* Track */}
|
||||||
|
<svg width={size} height={size} className="absolute inset-0 -rotate-90" aria-hidden="true">
|
||||||
|
<circle
|
||||||
|
cx={size / 2} cy={size / 2} r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeOpacity={0.15}
|
||||||
|
strokeWidth={stroke}
|
||||||
|
/>
|
||||||
|
{/* Fill arc */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2} cy={size / 2} r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${dash} ${circ}`}
|
||||||
|
style={{ transition: progress === 0 ? 'stroke-dasharray 0.15s ease' : 'none' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* Icon */}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{completing ? 'Closing…' : isHolding ? 'Keep holding…' : 'Hold to close'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TicketDetailProps {
|
||||||
|
ticket: Ticket
|
||||||
|
onCloseTicket: (id: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketDetail({ ticket, onCloseTicket }: TicketDetailProps) {
|
||||||
|
const { txnId, body } = parseDescription(ticket.description)
|
||||||
|
const txn = txnId ? FAKE_TRANSACTIONS.find(t => t.id === txnId) ?? null : null
|
||||||
|
const isClosed = ticket.status === 'closed'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* Status + meta row */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge status={ticket.status} />
|
||||||
|
<span className="text-xs text-fg-300">·</span>
|
||||||
|
<span className="text-xs text-fg-200 capitalize">{TYPE_LABELS[ticket.type]}</span>
|
||||||
|
<span className="text-xs text-fg-300">·</span>
|
||||||
|
<span className="text-xs text-fg-300">Opened {formatDate(ticket.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transaction card — only for billing tickets with a linked txn */}
|
||||||
|
{txn && (
|
||||||
|
<div className="rounded-md border border-border-100 bg-bg-300 px-3 py-2.5">
|
||||||
|
<p className="text-xs font-medium text-fg-300 mb-1.5">Linked transaction</p>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs font-medium text-fg-100">{txn.label}</span>
|
||||||
|
<span className="text-xs text-fg-300">{txn.id} · {txn.date}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-fg-100 shrink-0">{txn.amount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<p className="text-xs font-medium text-fg-200">Description</p>
|
||||||
|
{body ? (
|
||||||
|
<p className="text-sm text-fg-100 leading-relaxed whitespace-pre-wrap">{body}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs italic text-fg-300">No description provided.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: ticket ID + close action */}
|
||||||
|
<div className="flex items-center justify-between border-t border-border-100 pt-3">
|
||||||
|
<p className="text-xs text-fg-300 font-mono">ID: {ticket.id}</p>
|
||||||
|
{isClosed ? (
|
||||||
|
<span className="text-xs text-fg-300 italic">This ticket is closed.</span>
|
||||||
|
) : (
|
||||||
|
<HoldToClose onComplete={() => onCloseTicket(ticket.id)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,10 +10,10 @@ function formatDate(iso: string): string {
|
|||||||
|
|
||||||
interface TicketTableProps {
|
interface TicketTableProps {
|
||||||
tickets: Ticket[]
|
tickets: Ticket[]
|
||||||
onDelete: (id: string) => void
|
onOpen: (ticket: Ticket) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketTable({ tickets, onDelete }: TicketTableProps) {
|
export function TicketTable({ tickets, onOpen }: TicketTableProps) {
|
||||||
if (tickets.length === 0) {
|
if (tickets.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-border-100 bg-bg-200 py-16 text-center">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-border-100 bg-bg-200 py-16 text-center">
|
||||||
@@ -36,16 +36,20 @@ export function TicketTable({ tickets, onDelete }: TicketTableProps) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border-100 bg-bg-100">
|
<tbody className="divide-y divide-border-100 bg-bg-100">
|
||||||
{tickets.map((ticket) => (
|
{tickets.map((ticket) => (
|
||||||
<tr key={ticket.id} className="transition-colors hover:bg-bg-200">
|
<tr
|
||||||
|
key={ticket.id}
|
||||||
|
className="transition-colors hover:bg-bg-200 cursor-pointer"
|
||||||
|
onClick={() => onOpen(ticket)}
|
||||||
|
>
|
||||||
<td className="px-4 py-3 text-fg-100">{ticket.subject}</td>
|
<td className="px-4 py-3 text-fg-100">{ticket.subject}</td>
|
||||||
<td className="px-4 py-3"><Badge status={ticket.status} /></td>
|
<td className="px-4 py-3"><Badge status={ticket.status} /></td>
|
||||||
<td className="px-4 py-3 text-xs text-fg-300">{formatDate(ticket.createdAt)}</td>
|
<td className="px-4 py-3 text-xs text-fg-300">{formatDate(ticket.createdAt)}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="ghost"
|
||||||
onClick={() => onDelete(ticket.id)}
|
onClick={e => { e.stopPropagation(); onOpen(ticket) }}
|
||||||
>
|
>
|
||||||
Delete
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
--color-bg-400: oklch(0.18 0 0);
|
--color-bg-400: oklch(0.18 0 0);
|
||||||
|
|
||||||
--color-fg-100: oklch(0.92 0 0);
|
--color-fg-100: oklch(0.92 0 0);
|
||||||
--color-fg-200: oklch(0.6 0 0);
|
--color-fg-200: oklch(0.65 0 0);
|
||||||
--color-fg-300: oklch(0.4 0 0);
|
--color-fg-300: oklch(0.5 0 0);
|
||||||
|
|
||||||
--color-border-100: oklch(1 0 0 / 9%);
|
--color-border-100: oklch(1 0 0 / 9%);
|
||||||
--color-border-200: oklch(1 0 0 / 30%);
|
--color-border-200: oklch(1 0 0 / 30%);
|
||||||
|
|||||||
16
frontend/src/lib/ticket.ts
Normal file
16
frontend/src/lib/ticket.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface ParsedDescription {
|
||||||
|
txnId: string | null // e.g. "TXN-48291"
|
||||||
|
txnLine: string | null // e.g. "TXN-48291 — Pro Plan — Monthly $12.00 on Mar 1, 2026"
|
||||||
|
body: string // the user-written description, without the prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDescription(description: string): ParsedDescription {
|
||||||
|
const match = description.match(/^\[Transaction: ([^ ]+)([^\]]*)\]\n?\n?(.*)$/s)
|
||||||
|
if (!match) return { txnId: null, txnLine: null, body: description }
|
||||||
|
|
||||||
|
const txnId = match[1].trim()
|
||||||
|
const txnLine = (txnId + match[2]).trim()
|
||||||
|
const body = match[3].trim()
|
||||||
|
|
||||||
|
return { txnId, txnLine, body }
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Modal } from '../components/ui/Modal.tsx'
|
import { Modal } from '../components/ui/Modal.tsx'
|
||||||
import { Button } from '../components/ui/Button.tsx'
|
import { Button } from '../components/ui/Button.tsx'
|
||||||
import { TicketTable } from '../components/tickets/TicketTable.tsx'
|
import { TicketTable } from '../components/tickets/TicketTable.tsx'
|
||||||
|
import { TicketDetail } from '../components/tickets/TicketDetail.tsx'
|
||||||
import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx'
|
import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx'
|
||||||
import { useModal } from '../hooks/useModal.ts'
|
import { useModal } from '../hooks/useModal.ts'
|
||||||
import { storage } from '../lib/storage.ts'
|
import { storage } from '../lib/storage.ts'
|
||||||
import type { Ticket } from '../lib/types.ts'
|
import type { Ticket } from '../lib/types.ts'
|
||||||
import { PlusIcon } from '../components/icons/plus.tsx'
|
import { PlusIcon } from '../components/icons/plus.tsx'
|
||||||
|
|
||||||
const TICKET_LIMIT = 3
|
const TICKET_LIMIT = 10
|
||||||
|
|
||||||
interface UserPageProps {
|
interface UserPageProps {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
@@ -65,7 +66,10 @@ function TicketLimitReached({ onClose, fromServer }: { onClose: () => void; from
|
|||||||
export function UserPage({ isAuthenticated }: UserPageProps) {
|
export function UserPage({ isAuthenticated }: UserPageProps) {
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
const [serverLimitHit, setServerLimitHit] = useState(false)
|
const [serverLimitHit, setServerLimitHit] = useState(false)
|
||||||
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null)
|
||||||
|
|
||||||
const newTicketModal = useModal()
|
const newTicketModal = useModal()
|
||||||
|
const detailModal = useModal()
|
||||||
|
|
||||||
const atLimit = isAuthenticated && tickets.length >= TICKET_LIMIT
|
const atLimit = isAuthenticated && tickets.length >= TICKET_LIMIT
|
||||||
const showLimitScreen = atLimit || serverLimitHit
|
const showLimitScreen = atLimit || serverLimitHit
|
||||||
@@ -74,12 +78,29 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
|||||||
storage.getTickets().then(setTickets)
|
storage.getTickets().then(setTickets)
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
// Reset server limit flag whenever the modal closes
|
const handleNewClose = () => {
|
||||||
const handleClose = () => {
|
|
||||||
newTicketModal.close()
|
newTicketModal.close()
|
||||||
setServerLimitHit(false)
|
setServerLimitHit(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOpen = (ticket: Ticket) => {
|
||||||
|
setSelectedTicket(ticket)
|
||||||
|
detailModal.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDetailClose = () => {
|
||||||
|
detailModal.close()
|
||||||
|
setSelectedTicket(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseTicket = async (id: string) => {
|
||||||
|
const updated = await storage.updateTicket(id, { status: 'closed' })
|
||||||
|
if (updated) {
|
||||||
|
setTickets(prev => prev.map(t => t.id === id ? updated : t))
|
||||||
|
setSelectedTicket(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
||||||
if (atLimit) return
|
if (atLimit) return
|
||||||
try {
|
try {
|
||||||
@@ -88,19 +109,12 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
|||||||
newTicketModal.close()
|
newTicketModal.close()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code === 'ticket_limit_reached') {
|
if (err?.code === 'ticket_limit_reached') {
|
||||||
// Backend rejected — switch the open modal to the limit screen immediately
|
|
||||||
// and re-sync the ticket list so atLimit also becomes true
|
|
||||||
setServerLimitHit(true)
|
setServerLimitHit(true)
|
||||||
storage.getTickets().then(setTickets)
|
storage.getTickets().then(setTickets)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
await storage.deleteTicket(id)
|
|
||||||
setTickets(prev => prev.filter(t => t.id !== id))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
@@ -126,18 +140,28 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TicketTable tickets={tickets} onDelete={handleDelete} />
|
<TicketTable tickets={tickets} onOpen={handleOpen} />
|
||||||
|
|
||||||
|
{/* New ticket modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={newTicketModal.isOpen}
|
isOpen={newTicketModal.isOpen}
|
||||||
onClose={handleClose}
|
onClose={handleNewClose}
|
||||||
title={showLimitScreen ? 'Ticket Limit Reached' : 'New Ticket'}
|
title={showLimitScreen ? 'Ticket Limit Reached' : 'New Ticket'}
|
||||||
>
|
>
|
||||||
{showLimitScreen
|
{showLimitScreen
|
||||||
? <TicketLimitReached onClose={handleClose} fromServer={serverLimitHit && !atLimit} />
|
? <TicketLimitReached onClose={handleNewClose} fromServer={serverLimitHit && !atLimit} />
|
||||||
: <NewTicketForm onSubmit={handleCreate} />
|
: <NewTicketForm onSubmit={handleCreate} />
|
||||||
}
|
}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Ticket detail modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={detailModal.isOpen}
|
||||||
|
onClose={handleDetailClose}
|
||||||
|
title={selectedTicket?.subject ?? ''}
|
||||||
|
>
|
||||||
|
{selectedTicket && <TicketDetail ticket={selectedTicket} onCloseTicket={handleCloseTicket} />}
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user