add:table filters

This commit is contained in:
2026-03-09 17:24:00 +09:00
parent 794fbad9bb
commit 63fea501a1
9 changed files with 591 additions and 77 deletions

View File

@@ -56,7 +56,7 @@ function SupportApp() {
>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
{activeTab === 'tickets' && <UserPage isAuthenticated={authState === 'authenticated'} />}
{activeTab === 'admin' && <AdminPage isAuthenticated={authState === 'authenticated'} />}
{activeTab === 'admin' && <AdminPage isAuthenticated={authState === 'authenticated'} user={user} />}
</Layout>
)
}

View File

@@ -1,4 +1,5 @@
import { Badge } from '../ui/Badge.tsx'
import { Button } from '../ui/Button.tsx'
import { parseDescription } from '../../lib/ticket.ts'
import type { Ticket } from '../../lib/types.ts'
@@ -8,12 +9,28 @@ function formatDate(iso: string): string {
})
}
interface AdminTableProps {
tickets: Ticket[]
interface PaginationProps {
page: number
totalPages: number
total: number
pageSize: number
onPrev: () => void
onNext: () => void
}
export function AdminTable({ tickets }: AdminTableProps) {
if (tickets.length === 0) {
interface AdminTableProps {
tickets: Ticket[]
onOpen: (ticket: Ticket) => void
currentUserId: string | null
pagination: PaginationProps
}
export function AdminTable({ tickets, onOpen, currentUserId, pagination }: AdminTableProps) {
const { page, totalPages, total, pageSize, onPrev, onNext } = pagination
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
if (tickets.length === 0 && total === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-border-100 bg-bg-200 py-16 text-center">
<p className="text-sm text-fg-300">No tickets in the system.</p>
@@ -28,7 +45,7 @@ export function AdminTable({ tickets }: AdminTableProps) {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-100 bg-bg-200">
{(['Subject', 'Type', 'Status', ...(hasBilling ? ['Transaction'] : []), 'Description', 'Created'] as const).map(col => (
{(['Subject', 'User', 'Type', 'Status', ...(hasBilling ? ['Transaction'] : []), 'Description', 'Created'] as const).map(col => (
<th
key={col}
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-fg-300"
@@ -36,6 +53,7 @@ export function AdminTable({ tickets }: AdminTableProps) {
{col}
</th>
))}
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border-100 bg-bg-100">
@@ -44,9 +62,23 @@ export function AdminTable({ tickets }: AdminTableProps) {
const hasTxn = ticket.type === 'billing' && txnId !== null
return (
<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 font-medium text-fg-100">
{ticket.subject}
<div className="flex items-center gap-2">
{ticket.subject}
{currentUserId && ticket.userId === currentUserId && (
<span className="inline-flex items-center rounded-full border border-border-100 bg-bg-300 px-1.5 py-0.5 text-[10px] font-medium text-fg-300">
mine
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-xs text-fg-200 whitespace-nowrap">
{ticket.username ?? <span className="italic text-fg-300">guest</span>}
</td>
<td className="px-4 py-3 text-xs capitalize text-fg-200">
{ticket.type.replace('-', ' ')}
@@ -73,11 +105,43 @@ export function AdminTable({ tickets }: AdminTableProps) {
<td className="whitespace-nowrap px-4 py-3 text-xs text-fg-300">
{formatDate(ticket.createdAt)}
</td>
<td className="px-4 py-3 text-right">
<Button
variant="ghost"
onClick={e => { e.stopPropagation(); onOpen(ticket) }}
>
Open
</Button>
</td>
</tr>
)
})}
</tbody>
</table>
{/* Pagination footer */}
<div className="flex items-center justify-between border-t border-border-100 bg-bg-200 px-4 py-3">
<p className="text-xs text-fg-300">
{total === 0 ? 'No tickets' : `${start}${end} of ${total}`}
</p>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onPrev} disabled={page <= 1}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M9 3L5 7l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Prev
</Button>
<span className="text-xs text-fg-300">
{page} / {totalPages}
</span>
<Button variant="ghost" onClick={onNext} disabled={page >= totalPages}>
Next
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</Button>
</div>
</div>
</div>
)
}

View File

@@ -21,11 +21,15 @@ const TYPE_LABELS: Record<Ticket['type'], string> = {
const HOLD_DURATION = 2000 // ms
interface HoldToCloseProps {
interface HoldButtonProps {
onComplete: () => Promise<void>
label: string
completingLabel: string
icon: React.ReactNode
ariaLabel: string
}
function HoldToClose({ onComplete }: HoldToCloseProps) {
function HoldButton({ onComplete, label, completingLabel, icon, ariaLabel }: HoldButtonProps) {
const [progress, setProgress] = useState(0) // 01
const [completing, setCompleting] = useState(false)
const rafRef = useRef<number | null>(null)
@@ -87,7 +91,7 @@ function HoldToClose({ onComplete }: HoldToCloseProps) {
: 'text-fg-300 hover:text-fg-100 hover:bg-bg-300'
}
`}
aria-label="Hold to close ticket"
aria-label={ariaLabel}
>
{/* Progress ring */}
<span className="relative flex items-center justify-center shrink-0" style={{ width: size, height: size }}>
@@ -112,27 +116,50 @@ function HoldToClose({ onComplete }: HoldToCloseProps) {
/>
</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>
{icon}
</span>
<span className="text-xs font-medium">
{completing ? 'Closing…' : isHolding ? 'Keep holding…' : 'Hold to close'}
{completing ? completingLabel : isHolding ? 'Keep holding…' : label}
</span>
</button>
)
}
// Close icon (×)
const CloseIcon = (
<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>
)
// Delete icon (trash)
const DeleteIcon = (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1.5 3h9M4.5 3V2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v1M5 5.5v3M7 5.5v3M2.5 3l.5 7a.5.5 0 0 0 .5.5h5a.5.5 0 0 0 .5-.5l.5-7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
// Reopen icon (arrow rotating back)
const ReopenIcon = (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1.5 6a4.5 4.5 0 1 0 .9-2.7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
<path d="M1.5 2v2.5H4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
interface TicketDetailProps {
ticket: Ticket
onCloseTicket: (id: string) => Promise<void>
onCloseTicket?: (id: string) => Promise<void>
onDeleteTicket?: (id: string) => Promise<void>
onReopenTicket?: (id: string) => Promise<void>
}
export function TicketDetail({ ticket, onCloseTicket }: TicketDetailProps) {
export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTicket }: TicketDetailProps) {
const { txnId, body } = parseDescription(ticket.description)
const txn = txnId ? FAKE_TRANSACTIONS.find(t => t.id === txnId) ?? null : null
const isClosed = ticket.status === 'closed'
const hasAnyAction = onCloseTicket || onReopenTicket || onDeleteTicket
return (
<div className="flex flex-col gap-4">
@@ -170,14 +197,46 @@ export function TicketDetail({ ticket, onCloseTicket }: TicketDetailProps) {
)}
</div>
{/* Footer: ticket ID + close action */}
{/* Footer: ticket ID + actions */}
<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 className="flex items-center gap-1">
{!hasAnyAction && (
<span className="text-xs text-fg-300 italic">Read only</span>
)}
{isClosed ? (
onReopenTicket ? (
<HoldButton
onComplete={() => onReopenTicket(ticket.id)}
label="Hold to reopen"
completingLabel="Reopening…"
icon={ReopenIcon}
ariaLabel="Hold to reopen ticket"
/>
) : onCloseTicket ? (
<span className="text-xs text-fg-300 italic">This ticket is closed.</span>
) : null
) : (
onCloseTicket && (
<HoldButton
onComplete={() => onCloseTicket(ticket.id)}
label="Hold to close"
completingLabel="Closing…"
icon={CloseIcon}
ariaLabel="Hold to close ticket"
/>
)
)}
{onDeleteTicket && (
<HoldButton
onComplete={() => onDeleteTicket(ticket.id)}
label="Hold to delete"
completingLabel="Deleting…"
icon={DeleteIcon}
ariaLabel="Hold to delete ticket"
/>
)}
</div>
</div>
</div>

View File

@@ -66,6 +66,22 @@ export const localAdapter = {
},
}
// ─── Paginated response envelope ─────────────────────────────────────────────
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface TicketFilters {
status?: Ticket['status']
type?: TicketType
mine?: boolean // restrict to the current user's tickets
}
// ─── Storage API ──────────────────────────────────────────────────────────────
export const storage = {
@@ -78,13 +94,44 @@ export const storage = {
}
},
// Admin view — all DB tickets when authenticated, localStorage when guest
async getAllTickets(isAuthenticated: boolean): Promise<Ticket[]> {
if (!isAuthenticated) return localAdapter.getTickets()
// Admin view — paginated from API when authenticated, sliced localStorage when guest
async getAllTickets(
isAuthenticated: boolean,
page = 1,
pageSize = 20,
filters: TicketFilters = {},
): Promise<PaginatedResponse<Ticket>> {
if (!isAuthenticated) {
let all = localAdapter.getTickets()
if (filters.status) all = all.filter(t => t.status === filters.status)
if (filters.type) all = all.filter(t => t.type === filters.type)
const start = (page - 1) * pageSize
return {
data: all.slice(start, start + pageSize),
total: all.length,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
}
}
const params = new URLSearchParams({ page: String(page) })
if (filters.status) params.set('status', filters.status)
if (filters.type) params.set('type', filters.type)
if (filters.mine) params.set('mine', 'true')
try {
return await apiFetch<Ticket[]>('/api/tickets/all')
return await apiFetch<PaginatedResponse<Ticket>>(`/api/tickets/all?${params}`)
} catch {
return localAdapter.getTickets()
const all = localAdapter.getTickets()
const start = (page - 1) * pageSize
return {
data: all.slice(start, start + pageSize),
total: all.length,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
}
}
},

View File

@@ -9,6 +9,7 @@ export type TicketType =
export interface Ticket {
id: string
userId: string | null
username: string | null
subject: string
description: string
type: TicketType

View File

@@ -1,14 +1,15 @@
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 { Ticket } from '../lib/types.ts'
import type { PaginatedResponse, TicketFilters } from '../lib/storage.ts'
import { useModal } from '../hooks/useModal.ts'
import type { Ticket, User } from '../lib/types.ts'
interface StatCardProps {
label: string
value: number
}
// ─── Stat card ────────────────────────────────────────────────────────────────
function StatCard({ label, value }: StatCardProps) {
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>
@@ -17,24 +18,194 @@ function StatCard({ label, value }: StatCardProps) {
)
}
interface AdminPageProps {
// ─── Filter bar ───────────────────────────────────────────────────────────────
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
}
export function AdminPage({ isAuthenticated }: AdminPageProps) {
const [tickets, setTickets] = useState<Ticket[]>([])
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>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
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 detailModal = useModal()
useEffect(() => {
storage.getAllTickets(isAuthenticated).then(setTickets)
}, [isAuthenticated])
storage.getAllTickets(isAuthenticated, page, 20, filters).then(setResult)
}, [isAuthenticated, page, filters])
const handleFilterChange = (next: TicketFilters) => {
setFilters(next)
setPage(1) // reset to first page on filter change
}
const stats = {
total: tickets.length,
open: tickets.filter(t => t.status === 'open').length,
inProgress: tickets.filter(t => t.status === 'in-progress').length,
resolved: tickets.filter(t => t.status === 'resolved').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,
}
const handleOpen = (ticket: Ticket) => {
setSelectedTicket(ticket)
detailModal.open()
}
const handleDetailClose = () => {
detailModal.close()
setSelectedTicket(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) => {
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)
}
}
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)
}
}
const handleDeleteTicket = async (id: string) => {
await storage.deleteTicket(id)
handleDetailClose()
await refetch()
}
// 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">
@@ -45,13 +216,46 @@ export function AdminPage({ isAuthenticated }: 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>
<AdminTable tickets={tickets} />
<FilterBar
filters={filters}
isAuthenticated={isAuthenticated}
onChange={handleFilterChange}
/>
<AdminTable
tickets={result.data}
onOpen={handleOpen}
currentUserId={user?.id ?? null}
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 && (
<TicketDetail
ticket={selectedTicket}
onCloseTicket={canModify(selectedTicket) ? handleCloseTicket : undefined}
onReopenTicket={canModify(selectedTicket) ? handleReopenTicket : undefined}
onDeleteTicket={canModify(selectedTicket) ? handleDeleteTicket : undefined}
/>
)}
</Modal>
</>
)
}