Files
personal-support-ticket-system/frontend/src/pages/AdminPage.tsx
2026-03-09 21:58:13 +09:00

324 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
)
}