From 1f417414503c3946e05199411cbbf036218d48b2 Mon Sep 17 00:00:00 2001 From: kokopi Date: Mon, 9 Mar 2026 21:58:13 +0900 Subject: [PATCH] update:cors --- backend/src/index.ts | 1 + frontend/src/App.tsx | 10 +- frontend/src/components/admin/AdminTable.tsx | 104 +++++++- frontend/src/lib/storage.ts | 175 +++++++------- frontend/src/pages/AdminPage.tsx | 146 ++++++++---- frontend/src/pages/AdminStatsPage.tsx | 238 +++++++++++++++++++ frontend/src/pages/UserPage.tsx | 26 +- 7 files changed, 551 insertions(+), 149 deletions(-) create mode 100644 frontend/src/pages/AdminStatsPage.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index 1ff5f09..0fa93b8 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,6 +15,7 @@ const isProd = process.env.NODE_ENV === 'production' const app = Fastify({ logger: true }) await app.register(cors, { + methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], origin: process.env.FRONTEND_URL ?? 'http://localhost:5173', credentials: true, }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a9f01cb..b182aef 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,13 +3,14 @@ import { Layout } from './components/ui/Layout.tsx' import { Tabs } from './components/ui/Tabs.tsx' import { UserPage } from './pages/UserPage.tsx' import { AdminPage } from './pages/AdminPage.tsx' +import { AdminStatsPage } from './pages/AdminStatsPage.tsx' import { LoginPage } from './pages/LoginPage.tsx' import { useAuth } from './hooks/useAuth.ts' import { AuthBar } from './components/ui/AuthBar.tsx' import { BrowserRouter, Route, Routes } from 'react-router-dom' import { NotFound } from './pages/NotFound.tsx' -type TabValue = 'tickets' | 'admin' +type TabValue = 'tickets' | 'admin' | 'stats' function SupportApp() { const [activeTab, setActiveTab] = useState('tickets') @@ -41,11 +42,9 @@ function SupportApp() { } const tabs: { value: TabValue; label: string }[] = [ - { - value: 'tickets', - label: 'My Tickets', - }, + { value: 'tickets', label: 'My Tickets' }, { value: 'admin', label: 'Admin' }, + { value: 'stats', label: 'Admin Stats' }, ] return ( @@ -57,6 +56,7 @@ function SupportApp() { {activeTab === 'tickets' && } {activeTab === 'admin' && } + {activeTab === 'stats' && } ) } diff --git a/frontend/src/components/admin/AdminTable.tsx b/frontend/src/components/admin/AdminTable.tsx index 2f29735..b30b81c 100644 --- a/frontend/src/components/admin/AdminTable.tsx +++ b/frontend/src/components/admin/AdminTable.tsx @@ -1,3 +1,4 @@ +import { useRef, useEffect } from 'react' import { Badge } from '../ui/Badge.tsx' import { Button } from '../ui/Button.tsx' import { parseDescription } from '../../lib/ticket.ts' @@ -9,6 +10,43 @@ function formatDate(iso: string): string { }) } +function Checkbox({ + checked, + indeterminate = false, + disabled = false, + onChange, + ariaLabel, +}: { + checked: boolean + indeterminate?: boolean + disabled?: boolean + onChange: (checked: boolean) => void + ariaLabel: string +}) { + const ref = useRef(null) + + useEffect(() => { + if (ref.current) ref.current.indeterminate = indeterminate + }, [indeterminate]) + + return ( + 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 @@ -22,14 +60,50 @@ interface AdminTableProps { tickets: Ticket[] onOpen: (ticket: Ticket) => void currentUserId: string | null + selection: Set + onSelectionChange: (selection: Set) => void pagination: PaginationProps } -export function AdminTable({ tickets, onOpen, currentUserId, pagination }: AdminTableProps) { +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 (
@@ -45,6 +119,16 @@ export function AdminTable({ tickets, onOpen, currentUserId, pagination }: Admin + {/* Select-all checkbox */} + {(['Subject', 'User', 'Type', 'Status', ...(hasBilling ? ['Transaction'] : []), 'Description', 'Created'] as const).map(col => ( onOpen(ticket)} > + {/* Row checkbox — stop propagation so clicking it doesn't open the modal */} +
+ + { 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 (
e.stopPropagation()} + > + handleRowChange(ticket.id, checked)} + ariaLabel={`Select ticket: ${ticket.subject}`} + /> +
{ticket.subject} - {currentUserId && ticket.userId === currentUserId && ( + {owned && ( mine diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index cc5e702..53378a5 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,19 +1,19 @@ -import type { Ticket, TicketType } from "./types"; -import { env } from "../env"; +import type { Ticket, TicketType } from './types' +import { env } from '../env' -const API = env.apiUrl; +const API = env.apiUrl // ─── API error with structured body ────────────────────────────────────────── export class ApiError extends Error { - readonly status: number; - readonly code: string; + readonly status: number + readonly code: string constructor(status: number, code: string, message: string) { - super(message); - this.name = "ApiError"; - this.status = status; - this.code = code; + super(message) + this.name = 'ApiError' + this.status = status + this.code = code } } @@ -22,49 +22,43 @@ export class ApiError extends Error { async function apiFetch(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${API}${path}`, { ...init, - credentials: "include", - headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, - }); + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) }, + }) if (!res.ok) { // Try to parse a structured error body; fall back to a generic message - let code = `http_${res.status}`; - let message = `API error ${res.status}`; + let code = `http_${res.status}` + let message = `API error ${res.status}` try { - const body = await res.json(); - if (body?.error) code = body.error; - if (body?.message) message = body.message; - } catch { - /* non-JSON body — keep defaults */ - } - throw new ApiError(res.status, code, message); + const body = await res.json() + if (body?.error) code = body.error + if (body?.message) message = body.message + } catch { /* non-JSON body — keep defaults */ } + throw new ApiError(res.status, code, message) } - return res.json(); + return res.json() } // ─── Local (localStorage) adapter ──────────────────────────────────────────── -const LOCAL_KEY = "support_tickets"; +const LOCAL_KEY = 'support_tickets' function localGet(): Ticket[] { try { - return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? "[]"); + return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]') } catch { - return []; + return [] } } function localSet(tickets: Ticket[]) { - localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets)); + localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets)) } export const localAdapter = { getTickets: (): Ticket[] => localGet(), - createTicket: (data: { - subject: string; - description: string; - type: TicketType; - }): Ticket => { + createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => { const ticket: Ticket = { id: crypto.randomUUID(), userId: null, @@ -72,44 +66,44 @@ export const localAdapter = { subject: data.subject, description: data.description, type: data.type, - status: "open", + status: 'open', createdAt: new Date().toISOString(), - }; - localSet([ticket, ...localGet()]); - return ticket; + } + localSet([ticket, ...localGet()]) + return ticket }, updateTicket: (id: string, patch: Partial): Ticket | null => { - const tickets = localGet(); - const idx = tickets.findIndex((t) => t.id === id); - if (idx === -1) return null; - tickets[idx] = { ...tickets[idx], ...patch }; - localSet(tickets); - return tickets[idx]; + const tickets = localGet() + const idx = tickets.findIndex(t => t.id === id) + if (idx === -1) return null + tickets[idx] = { ...tickets[idx], ...patch } + localSet(tickets) + return tickets[idx] }, deleteTicket: (id: string): boolean => { - const before = localGet(); - const after = before.filter((t) => t.id !== id); - localSet(after); - return after.length < before.length; + const before = localGet() + const after = before.filter(t => t.id !== id) + localSet(after) + return after.length < before.length }, -}; +} // ─── Paginated response envelope ───────────────────────────────────────────── export interface PaginatedResponse { - data: T[]; - total: number; - page: number; - pageSize: number; - totalPages: number; + 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 + status?: Ticket['status'] + type?: TicketType + mine?: boolean // restrict to the current user's tickets } // ─── Storage API ────────────────────────────────────────────────────────────── @@ -118,9 +112,9 @@ export const storage = { // User's own tickets — API when authenticated, localStorage when guest async getTickets(): Promise { try { - return await apiFetch("/api/tickets"); + return await apiFetch('/api/tickets') } catch { - return localAdapter.getTickets(); + return localAdapter.getTickets() } }, @@ -132,79 +126,72 @@ export const storage = { filters: TicketFilters = {}, ): Promise> { 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; + 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"); + 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>( - `/api/tickets/all?${params}`, - ); + return await apiFetch>(`/api/tickets/all?${params}`) } catch { - const all = localAdapter.getTickets(); - const start = (page - 1) * pageSize; + 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)), - }; + } } }, - async createTicket(data: { - subject: string; - description: string; - type: TicketType; - }): Promise { + async createTicket(data: { subject: string; description: string; type: TicketType }): Promise { try { - return await apiFetch("/api/tickets", { - method: "POST", + return await apiFetch('/api/tickets', { + method: 'POST', body: JSON.stringify(data), - }); + }) } catch (err) { // Re-throw structured API errors (e.g. profanity, ticket limit) — don't silently // fall back to localStorage, as these are intentional rejections from the server. - if (err instanceof ApiError) throw err; - return localAdapter.createTicket(data); + if (err instanceof ApiError) throw err + return localAdapter.createTicket(data) } }, - async updateTicket( - id: string, - patch: Partial, - ): Promise { + async updateTicket(id: string, patch: Partial): Promise { try { return await apiFetch(`/api/tickets/${id}`, { - method: "PATCH", + method: 'PATCH', body: JSON.stringify(patch), - }); - } catch { - return localAdapter.updateTicket(id, patch); + }) + } catch (err) { + if (err instanceof ApiError) throw err + return localAdapter.updateTicket(id, patch) } }, async deleteTicket(id: string): Promise { try { - await apiFetch(`/api/tickets/${id}`, { method: "DELETE" }); - return true; - } catch { - return localAdapter.deleteTicket(id); + await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' }) + return true + } catch (err) { + if (err instanceof ApiError) throw err + return localAdapter.deleteTicket(id) } }, -}; +} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 1b8f73f..41dfc4c 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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) { `} > My tickets @@ -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" > - + ) } -// ─── Page ───────────────────────────────────────────────────────────────────── interface AdminPageProps { isAuthenticated: boolean @@ -142,22 +138,26 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) { const [page, setPage] = useState(1) const [filters, setFilters] = useState({}) const [selectedTicket, setSelectedTicket] = useState(null) + const [selection, setSelection] = useState>(new Set()) + const [batchDeleting, setBatchDeleting] = useState(false) + const [actionError, setActionError] = useState(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) {
- - + + - +
+ {/* Batch action toolbar — visible only when tickets are selected */} + {selection.size > 0 && ( +
+

+ {selection.size} + {' '}ticket{selection.size !== 1 ? 's' : ''} selected +

+
+ + +
+
+ )} + {selectedTicket && ( - + <> + {actionError && ( +
+ ⚠️ +

{actionError}

+
+ )} + + )} diff --git a/frontend/src/pages/AdminStatsPage.tsx b/frontend/src/pages/AdminStatsPage.tsx new file mode 100644 index 0000000..a741660 --- /dev/null +++ b/frontend/src/pages/AdminStatsPage.tsx @@ -0,0 +1,238 @@ +import { useState, useEffect } from 'react' +import { storage } from '../lib/storage.ts' +import type { Ticket, TicketType } from '../lib/types.ts' + + +interface SliceData { + type: TicketType + count: number + pct: number // 0–1 + color: string + label: string + startAngle: number + endAngle: number +} + +const TYPE_CONFIG: Record = { + 'bug': { label: 'Bug', color: '#f87171' }, // red-400 + 'billing': { label: 'Billing', color: '#fb923c' }, // orange-400 + 'account': { label: 'Account', color: '#facc15' }, // yellow-400 + 'feature-request': { label: 'Feature Request', color: '#34d399' }, // emerald-400 + 'feedback': { label: 'Feedback', color: '#60a5fa' }, // blue-400 + 'other': { label: 'Other', color: '#a78bfa' }, // violet-400 +} + +const CX = 100 +const CY = 100 +const R = 80 +const GAP_DEG = 1.5 // small gap between slices in degrees + +function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) { + const rad = (angleDeg - 90) * (Math.PI / 180) + return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) } +} + +function describeArc(startDeg: number, endDeg: number): string { + // Inset the gap equally from each side + const s = startDeg + GAP_DEG / 2 + const e = endDeg - GAP_DEG / 2 + + const start = polarToCartesian(CX, CY, R, s) + const end = polarToCartesian(CX, CY, R, e) + const inner = { start: polarToCartesian(CX, CY, R * 0.45, s), end: polarToCartesian(CX, CY, R * 0.45, e) } + const large = e - s > 180 ? 1 : 0 + + return [ + `M ${start.x} ${start.y}`, + `A ${R} ${R} 0 ${large} 1 ${end.x} ${end.y}`, + `L ${inner.end.x} ${inner.end.y}`, + `A ${R * 0.45} ${R * 0.45} 0 ${large} 0 ${inner.start.x} ${inner.start.y}`, + 'Z', + ].join(' ') +} + +function buildSlices(tickets: Ticket[]): SliceData[] { + if (tickets.length === 0) return [] + + const counts = new Map() + for (const t of tickets) { + counts.set(t.type, (counts.get(t.type) ?? 0) + 1) + } + + // Sort descending by count for a cleaner visual + const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]) + + let angle = 0 + return sorted.map(([type, count]) => { + const pct = count / tickets.length + const sweep = pct * 360 + const start = angle + const end = angle + sweep + angle = end + return { + type, + count, + pct, + color: TYPE_CONFIG[type].color, + label: TYPE_CONFIG[type].label, + startAngle: start, + endAngle: end, + } + }) +} + + +function PieChart({ slices }: { slices: SliceData[] }) { + const [hovered, setHovered] = useState(null) + + if (slices.length === 0) return null + + return ( + + {slices.map(slice => { + const isHovered = hovered === slice.type + // Scale up the hovered slice slightly + const transform = isHovered + ? `translate(${CX}px, ${CY}px) scale(1.04) translate(${-CX}px, ${-CY}px)` + : undefined + + return ( + setHovered(slice.type)} + onMouseLeave={() => setHovered(null)} + className="cursor-pointer" + /> + ) + })} + + {/* Centre label — shows hovered slice detail or total */} + {hovered ? ( + <> + + {slices.find(s => s.type === hovered)?.count} + + + {TYPE_CONFIG[hovered].label} + + + {((slices.find(s => s.type === hovered)?.pct ?? 0) * 100).toFixed(1)}% + + + ) : ( + <> + + {slices.reduce((s, d) => s + d.count, 0)} + + + tickets + + + )} + + ) +} + +function Legend({ slices }: { slices: SliceData[] }) { + return ( +
+ {slices.map(slice => ( +
+
+ + {slice.label} +
+
+ {slice.count} + {(slice.pct * 100).toFixed(1)}% +
+
+ ))} +
+ ) +} + + +interface AdminStatsPageProps { + isAuthenticated: boolean +} + +export function AdminStatsPage({ isAuthenticated }: AdminStatsPageProps) { + const [tickets, setTickets] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + setLoading(true) + + const load = async () => { + if (isAuthenticated) { + // Fetch all pages until we have the full dataset + let page = 1 + const all: Ticket[] = [] + while (true) { + const res = await storage.getAllTickets(true, page, 100) + all.push(...res.data) + if (page >= res.totalPages) break + page++ + } + setTickets(all) + } else { + // Guest — use their local tickets only + const local = await storage.getTickets() + setTickets(local) + } + setLoading(false) + } + + load() + }, [isAuthenticated]) + + const slices = buildSlices(tickets) + const isEmpty = !loading && tickets.length === 0 + + return ( + <> +
+

Stats

+

+ {isAuthenticated ? 'Ticket breakdown across all users' : 'Breakdown of your local tickets'} +

+
+ + {loading && ( +
+

Loading…

+
+ )} + + {isEmpty && ( +
+

No tickets to display.

+
+ )} + + {!loading && !isEmpty && ( +
+

+ Ticket type distribution +

+
+ + +
+
+ )} + + ) +} diff --git a/frontend/src/pages/UserPage.tsx b/frontend/src/pages/UserPage.tsx index abd9d31..b484e20 100644 --- a/frontend/src/pages/UserPage.tsx +++ b/frontend/src/pages/UserPage.tsx @@ -67,6 +67,7 @@ export function UserPage({ isAuthenticated }: UserPageProps) { const [tickets, setTickets] = useState([]) const [serverLimitHit, setServerLimitHit] = useState(false) const [contentError, setContentError] = useState(null) + const [actionError, setActionError] = useState(null) const [selectedTicket, setSelectedTicket] = useState(null) const newTicketModal = useModal() @@ -93,13 +94,18 @@ export function UserPage({ isAuthenticated }: UserPageProps) { const handleDetailClose = () => { detailModal.close() setSelectedTicket(null) + setActionError(null) } const handleCloseTicket = async (id: string) => { - const updated = await storage.updateTicket(id, { status: 'closed' }) - if (updated) { - setTickets(prev => prev.map(t => t.id === id ? updated : t)) - setSelectedTicket(updated) + try { + const updated = await storage.updateTicket(id, { status: 'closed' }) + if (updated) { + setTickets(prev => prev.map(t => t.id === id ? updated : t)) + setSelectedTicket(updated) + } + } catch { + setActionError('Failed to close ticket. Please try again.') } } @@ -178,7 +184,17 @@ export function UserPage({ isAuthenticated }: UserPageProps) { onClose={handleDetailClose} title={selectedTicket?.subject ?? ''} > - {selectedTicket && } + {selectedTicket && ( + <> + {actionError && ( +
+ ⚠️ +

{actionError}

+
+ )} + + + )} )