324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { AdminTable } from '../components/admin/AdminTable.tsx'
|
||
import { TicketDetail } from '../components/tickets/TicketDetail.tsx'
|
||
import { Modal } from '../components/ui/Modal.tsx'
|
||
import { storage } from '../lib/storage.ts'
|
||
import type { PaginatedResponse, TicketFilters } from '../lib/storage.ts'
|
||
import { useModal } from '../hooks/useModal.ts'
|
||
import type { Ticket, User } from '../lib/types.ts'
|
||
import { Button } from '../components/ui/Button.tsx'
|
||
|
||
function StatCard({ label, value }: { label: string; value: number }) {
|
||
return (
|
||
<div className="rounded-lg border border-border-100 bg-bg-200 px-4 py-3">
|
||
<p className="text-xs text-fg-300">{label}</p>
|
||
<p className="mt-1 text-2xl font-semibold text-fg-100">{value}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const STATUS_OPTIONS: { value: Ticket['status'] | ''; label: string }[] = [
|
||
{ value: '', label: 'All statuses' },
|
||
{ value: 'open', label: 'Open' },
|
||
{ value: 'in-progress', label: 'In progress' },
|
||
{ value: 'resolved', label: 'Resolved' },
|
||
{ value: 'closed', label: 'Closed' },
|
||
]
|
||
|
||
const TYPE_OPTIONS: { value: Ticket['type'] | ''; label: string }[] = [
|
||
{ value: '', label: 'All types' },
|
||
{ value: 'bug', label: 'Bug' },
|
||
{ value: 'billing', label: 'Billing' },
|
||
{ value: 'account', label: 'Account' },
|
||
{ value: 'feature-request', label: 'Feature request' },
|
||
{ value: 'feedback', label: 'Feedback' },
|
||
{ value: 'other', label: 'Other' },
|
||
]
|
||
|
||
const selectClass = `
|
||
rounded-md border border-border-100 bg-bg-200 px-3 py-1.5 text-xs text-fg-100
|
||
outline-none transition-colors cursor-pointer appearance-none pr-7
|
||
hover:border-border-200 focus:border-border-200 focus:ring-1 focus:ring-ring-100
|
||
`
|
||
|
||
interface FilterBarProps {
|
||
filters: TicketFilters
|
||
isAuthenticated: boolean
|
||
onChange: (f: TicketFilters) => void
|
||
}
|
||
|
||
function FilterBar({ filters, isAuthenticated, onChange }: FilterBarProps) {
|
||
const hasActive = !!(filters.status || filters.type || filters.mine)
|
||
|
||
return (
|
||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||
{/* Status */}
|
||
<div className="relative">
|
||
<select
|
||
className={selectClass}
|
||
value={filters.status ?? ''}
|
||
onChange={e => onChange({ ...filters, status: (e.target.value as Ticket['status']) || undefined })}
|
||
>
|
||
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
<ChevronIcon />
|
||
</div>
|
||
|
||
{/* Type */}
|
||
<div className="relative">
|
||
<select
|
||
className={selectClass}
|
||
value={filters.type ?? ''}
|
||
onChange={e => onChange({ ...filters, type: (e.target.value as Ticket['type']) || undefined })}
|
||
>
|
||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
<ChevronIcon />
|
||
</div>
|
||
|
||
{/* Mine toggle — only visible when authenticated */}
|
||
{isAuthenticated && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange({ ...filters, mine: !filters.mine })}
|
||
className={`
|
||
inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium
|
||
transition-colors cursor-pointer
|
||
${filters.mine
|
||
? 'border-fg-100/30 bg-fg-100/10 text-fg-100'
|
||
: 'border-border-100 bg-bg-200 text-fg-300 hover:border-border-200 hover:text-fg-200'
|
||
}
|
||
`}
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||
<circle cx="6" cy="4" r="2.5" stroke="currentColor" strokeWidth="1.3"/>
|
||
<path d="M1.5 10.5c0-2.21 2.015-4 4.5-4s4.5 1.79 4.5 4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
|
||
</svg>
|
||
My tickets
|
||
</button>
|
||
)}
|
||
|
||
{/* Clear */}
|
||
{hasActive && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange({})}
|
||
className="text-xs text-fg-300 hover:text-fg-200 transition-colors cursor-pointer"
|
||
>
|
||
Clear filters
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ChevronIcon() {
|
||
return (
|
||
<svg
|
||
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-fg-300"
|
||
width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"
|
||
>
|
||
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
|
||
interface AdminPageProps {
|
||
isAuthenticated: boolean
|
||
user: User | null
|
||
}
|
||
|
||
const EMPTY_PAGE: PaginatedResponse<Ticket> = {
|
||
data: [], total: 0, page: 1, pageSize: 20, totalPages: 1,
|
||
}
|
||
|
||
export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
|
||
const [result, setResult] = useState<PaginatedResponse<Ticket>>(EMPTY_PAGE)
|
||
const [page, setPage] = useState(1)
|
||
const [filters, setFilters] = useState<TicketFilters>({})
|
||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null)
|
||
const [selection, setSelection] = useState<Set<string>>(new Set())
|
||
const [batchDeleting, setBatchDeleting] = useState(false)
|
||
const [actionError, setActionError] = useState<string | null>(null)
|
||
const detailModal = useModal()
|
||
|
||
useEffect(() => {
|
||
storage.getAllTickets(isAuthenticated, page, 20, filters).then(setResult)
|
||
setSelection(new Set()) // clear selection whenever the visible page changes
|
||
}, [isAuthenticated, page, filters])
|
||
|
||
const handleFilterChange = (next: TicketFilters) => {
|
||
setFilters(next)
|
||
setPage(1)
|
||
}
|
||
|
||
const stats = {
|
||
total: result.total,
|
||
open: result.data.filter(t => t.status === 'open').length,
|
||
inProgress: result.data.filter(t => t.status === 'in-progress').length,
|
||
resolved: result.data.filter(t => t.status === 'resolved').length,
|
||
}
|
||
|
||
const handleOpen = (ticket: Ticket) => {
|
||
setSelectedTicket(ticket)
|
||
detailModal.open()
|
||
}
|
||
|
||
const handleDetailClose = () => {
|
||
detailModal.close()
|
||
setSelectedTicket(null)
|
||
setActionError(null)
|
||
}
|
||
|
||
const refetch = async () => {
|
||
const fresh = await storage.getAllTickets(isAuthenticated, page, 20, filters)
|
||
if (fresh.data.length === 0 && page > 1) {
|
||
setPage(p => p - 1)
|
||
} else {
|
||
setResult(fresh)
|
||
}
|
||
}
|
||
|
||
const handleCloseTicket = async (id: string) => {
|
||
try {
|
||
const updated = await storage.updateTicket(id, { status: 'closed' })
|
||
if (updated) {
|
||
setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) }))
|
||
setSelectedTicket(updated)
|
||
}
|
||
} catch {
|
||
setActionError('Failed to close ticket. Please try again.')
|
||
}
|
||
}
|
||
|
||
const handleReopenTicket = async (id: string) => {
|
||
try {
|
||
const updated = await storage.updateTicket(id, { status: 'open' })
|
||
if (updated) {
|
||
setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) }))
|
||
setSelectedTicket(updated)
|
||
}
|
||
} catch {
|
||
setActionError('Failed to reopen ticket. Please try again.')
|
||
}
|
||
}
|
||
|
||
const handleDeleteTicket = async (id: string) => {
|
||
try {
|
||
await storage.deleteTicket(id)
|
||
handleDetailClose()
|
||
await refetch()
|
||
} catch {
|
||
setActionError('Failed to delete ticket. Please try again.')
|
||
}
|
||
}
|
||
|
||
const handleBatchDelete = async () => {
|
||
if (selection.size === 0) return
|
||
setBatchDeleting(true)
|
||
try {
|
||
await Promise.all([...selection].map(id => storage.deleteTicket(id)))
|
||
setSelection(new Set())
|
||
await refetch()
|
||
} finally {
|
||
setBatchDeleting(false)
|
||
}
|
||
}
|
||
|
||
// A ticket is owned by the current user if their IDs match.
|
||
// Unauthenticated (guest) tickets have userId: null — those are always editable locally.
|
||
const canModify = (ticket: Ticket) =>
|
||
!isAuthenticated || (user !== null && ticket.userId === user.id)
|
||
|
||
return (
|
||
<>
|
||
<div className="mb-6">
|
||
<h1 className="text-lg font-semibold text-fg-100">Admin</h1>
|
||
<p className="mt-0.5 text-sm text-fg-300">
|
||
{isAuthenticated ? 'All tickets across the system' : 'Your local tickets'}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mb-6 grid grid-cols-4 gap-3">
|
||
<StatCard label="Total" value={stats.total} />
|
||
<StatCard label="Open" value={stats.open} />
|
||
<StatCard label="In Progress" value={stats.inProgress} />
|
||
<StatCard label="Resolved" value={stats.resolved} />
|
||
</div>
|
||
|
||
<FilterBar
|
||
filters={filters}
|
||
isAuthenticated={isAuthenticated}
|
||
onChange={handleFilterChange}
|
||
/>
|
||
|
||
{/* Batch action toolbar — visible only when tickets are selected */}
|
||
{selection.size > 0 && (
|
||
<div className="mb-3 flex items-center justify-between rounded-lg border border-border-100 bg-bg-200 px-4 py-2.5">
|
||
<p className="text-xs text-fg-200">
|
||
<span className="font-medium text-fg-100">{selection.size}</span>
|
||
{' '}ticket{selection.size !== 1 ? 's' : ''} selected
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelection(new Set())}
|
||
className="text-xs text-fg-300 hover:text-fg-200 transition-colors cursor-pointer"
|
||
>
|
||
Clear
|
||
</button>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={handleBatchDelete}
|
||
disabled={batchDeleting}
|
||
className="text-red-400 hover:text-red-300"
|
||
>
|
||
{batchDeleting ? 'Deleting…' : `Delete ${selection.size}`}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<AdminTable
|
||
tickets={result.data}
|
||
onOpen={handleOpen}
|
||
currentUserId={user?.id ?? null}
|
||
selection={selection}
|
||
onSelectionChange={setSelection}
|
||
pagination={{
|
||
page: result.page,
|
||
totalPages: result.totalPages,
|
||
total: result.total,
|
||
pageSize: result.pageSize,
|
||
onPrev: () => setPage(p => p - 1),
|
||
onNext: () => setPage(p => p + 1),
|
||
}}
|
||
/>
|
||
|
||
<Modal
|
||
isOpen={detailModal.isOpen}
|
||
onClose={handleDetailClose}
|
||
title={selectedTicket?.subject ?? ''}
|
||
>
|
||
{selectedTicket && (
|
||
<>
|
||
{actionError && (
|
||
<div className="mb-4 flex items-start gap-2.5 rounded-lg border border-red-500/30 bg-red-500/10 px-3.5 py-3">
|
||
<span className="mt-0.5 text-sm">⚠️</span>
|
||
<p className="text-xs leading-relaxed text-red-400">{actionError}</p>
|
||
</div>
|
||
)}
|
||
<TicketDetail
|
||
ticket={selectedTicket}
|
||
onCloseTicket={canModify(selectedTicket) ? handleCloseTicket : undefined}
|
||
onReopenTicket={canModify(selectedTicket) ? handleReopenTicket : undefined}
|
||
onDeleteTicket={canModify(selectedTicket) ? handleDeleteTicket : undefined}
|
||
/>
|
||
</>
|
||
)}
|
||
</Modal>
|
||
</>
|
||
)
|
||
}
|