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

246 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useRef, useEffect } from 'react'
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'
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric'
})
}
function Checkbox({
checked,
indeterminate = false,
disabled = false,
onChange,
ariaLabel,
}: {
checked: boolean
indeterminate?: boolean
disabled?: boolean
onChange: (checked: boolean) => void
ariaLabel: string
}) {
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate
}, [indeterminate])
return (
<input
ref={ref}
type="checkbox"
aria-label={ariaLabel}
checked={checked}
disabled={disabled}
onChange={e => onChange(e.target.checked)}
className={`
h-3.5 w-3.5 rounded border border-border-200 bg-bg-300
checked:bg-fg-100 checked:border-fg-100
focus-visible:ring-2 focus-visible:ring-ring-100 focus-visible:outline-none
${disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer'}
`}
/>
)
}
interface PaginationProps {
page: number
totalPages: number
total: number
pageSize: number
onPrev: () => void
onNext: () => void
}
interface AdminTableProps {
tickets: Ticket[]
onOpen: (ticket: Ticket) => void
currentUserId: string | null
selection: Set<string>
onSelectionChange: (selection: Set<string>) => void
pagination: PaginationProps
}
export function AdminTable({
tickets,
onOpen,
currentUserId,
selection,
onSelectionChange,
pagination,
}: AdminTableProps) {
const { page, totalPages, total, pageSize, onPrev, onNext } = pagination
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
// When unauthenticated, currentUserId is null and all local tickets have userId: null —
// the user owns all of them. When authenticated, only match on userId.
const isOwned = (ticket: Ticket) =>
currentUserId === null ? true : ticket.userId === currentUserId
const selectableIds = tickets.filter(isOwned).map(t => t.id)
const selectedOnPage = selectableIds.filter(id => selection.has(id))
const allSelected = selectableIds.length > 0 && selectedOnPage.length === selectableIds.length
const someSelected = selectedOnPage.length > 0 && !allSelected
const handleHeaderChange = (checked: boolean) => {
const next = new Set(selection)
if (checked) {
selectableIds.forEach(id => next.add(id))
} else {
selectableIds.forEach(id => next.delete(id))
}
onSelectionChange(next)
}
const handleRowChange = (id: string, checked: boolean) => {
const next = new Set(selection)
checked ? next.add(id) : next.delete(id)
onSelectionChange(next)
}
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>
</div>
)
}
const hasBilling = tickets.some(t => t.type === 'billing')
return (
<div className="overflow-hidden rounded-lg border border-border-100">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-100 bg-bg-200">
{/* Select-all checkbox */}
<th className="w-10 px-4 py-3">
<Checkbox
checked={allSelected}
indeterminate={someSelected}
disabled={selectableIds.length === 0}
onChange={handleHeaderChange}
ariaLabel="Select all owned tickets on this page"
/>
</th>
{(['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"
>
{col}
</th>
))}
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border-100 bg-bg-100">
{tickets.map(ticket => {
const { txnId, txnLine, body: displayDescription } = parseDescription(ticket.description)
const hasTxn = ticket.type === 'billing' && txnId !== null
const owned = isOwned(ticket)
const isSelected = selection.has(ticket.id)
return (
<tr
key={ticket.id}
className={`transition-colors cursor-pointer ${isSelected ? 'bg-bg-200' : 'hover:bg-bg-200'}`}
onClick={() => onOpen(ticket)}
>
{/* Row checkbox — stop propagation so clicking it doesn't open the modal */}
<td
className="w-10 px-4 py-3"
onClick={e => e.stopPropagation()}
>
<Checkbox
checked={isSelected}
disabled={!owned}
onChange={checked => handleRowChange(ticket.id, checked)}
ariaLabel={`Select ticket: ${ticket.subject}`}
/>
</td>
<td className="px-4 py-3 font-medium text-fg-100">
<div className="flex items-center gap-2">
{ticket.subject}
{owned && (
<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('-', ' ')}
</td>
<td className="px-4 py-3">
<Badge status={ticket.status} />
</td>
{hasBilling && (
<td className="px-4 py-3 text-xs text-fg-200 whitespace-nowrap">
{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">
{txnLine!.split(' — ')[0]}
</span>
) : (
<span className="text-fg-300 italic"></span>
)}
</td>
)}
<td className="max-w-xs px-4 py-3 text-xs text-fg-300">
<span className="line-clamp-2">
{displayDescription || <span className="italic">No description</span>}
</span>
</td>
<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>
)
}