update:cors

This commit is contained in:
2026-03-09 21:58:13 +09:00
parent 40448571f0
commit 1f41741450
7 changed files with 551 additions and 149 deletions

View File

@@ -6,8 +6,7 @@ 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'
// ─── Stat card ────────────────────────────────────────────────────────────────
import { Button } from '../components/ui/Button.tsx'
function StatCard({ label, value }: { label: string; value: number }) {
return (
@@ -18,24 +17,22 @@ function StatCard({ label, value }: { label: string; value: number }) {
)
}
// ─── Filter bar ───────────────────────────────────────────────────────────────
const STATUS_OPTIONS: { value: Ticket['status'] | ''; label: string }[] = [
{ value: '', label: 'All statuses' },
{ value: 'open', label: 'Open' },
{ value: '', label: 'All statuses' },
{ value: 'open', label: 'Open' },
{ value: 'in-progress', label: 'In progress' },
{ value: 'resolved', label: 'Resolved' },
{ value: 'closed', label: 'Closed' },
{ 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: '', 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' },
{ value: 'feedback', label: 'Feedback' },
{ value: 'other', label: 'Other' },
]
const selectClass = `
@@ -94,8 +91,8 @@ function FilterBar({ filters, isAuthenticated, onChange }: FilterBarProps) {
`}
>
<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" />
<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>
@@ -121,12 +118,11 @@ function ChevronIcon() {
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" />
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
interface AdminPageProps {
isAuthenticated: boolean
@@ -142,22 +138,26 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
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) // reset to first page on filter change
setPage(1)
}
const stats = {
total: result.total,
open: result.data.filter(t => t.status === 'open').length,
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,
resolved: result.data.filter(t => t.status === 'resolved').length,
}
const handleOpen = (ticket: Ticket) => {
@@ -168,6 +168,7 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
const handleDetailClose = () => {
detailModal.close()
setSelectedTicket(null)
setActionError(null)
}
const refetch = async () => {
@@ -180,25 +181,49 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
}
const handleCloseTicket = async (id: string) => {
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)
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) => {
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)
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) => {
await storage.deleteTicket(id)
handleDetailClose()
await refetch()
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.
@@ -216,10 +241,10 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
</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="Total" value={stats.total} />
<StatCard label="Open" value={stats.open} />
<StatCard label="In Progress" value={stats.inProgress} />
<StatCard label="Resolved" value={stats.resolved} />
<StatCard label="Resolved" value={stats.resolved} />
</div>
<FilterBar
@@ -228,10 +253,39 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
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,
@@ -248,12 +302,20 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
title={selectedTicket?.subject ?? ''}
>
{selectedTicket && (
<TicketDetail
ticket={selectedTicket}
onCloseTicket={canModify(selectedTicket) ? handleCloseTicket : undefined}
onReopenTicket={canModify(selectedTicket) ? handleReopenTicket : undefined}
onDeleteTicket={canModify(selectedTicket) ? handleDeleteTicket : undefined}
/>
<>
{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>
</>