update:cors
This commit is contained in:
@@ -15,6 +15,7 @@ const isProd = process.env.NODE_ENV === 'production'
|
|||||||
const app = Fastify({ logger: true })
|
const app = Fastify({ logger: true })
|
||||||
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
|
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
origin: process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
origin: process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { Layout } from './components/ui/Layout.tsx'
|
|||||||
import { Tabs } from './components/ui/Tabs.tsx'
|
import { Tabs } from './components/ui/Tabs.tsx'
|
||||||
import { UserPage } from './pages/UserPage.tsx'
|
import { UserPage } from './pages/UserPage.tsx'
|
||||||
import { AdminPage } from './pages/AdminPage.tsx'
|
import { AdminPage } from './pages/AdminPage.tsx'
|
||||||
|
import { AdminStatsPage } from './pages/AdminStatsPage.tsx'
|
||||||
import { LoginPage } from './pages/LoginPage.tsx'
|
import { LoginPage } from './pages/LoginPage.tsx'
|
||||||
import { useAuth } from './hooks/useAuth.ts'
|
import { useAuth } from './hooks/useAuth.ts'
|
||||||
import { AuthBar } from './components/ui/AuthBar.tsx'
|
import { AuthBar } from './components/ui/AuthBar.tsx'
|
||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||||
import { NotFound } from './pages/NotFound.tsx'
|
import { NotFound } from './pages/NotFound.tsx'
|
||||||
|
|
||||||
type TabValue = 'tickets' | 'admin'
|
type TabValue = 'tickets' | 'admin' | 'stats'
|
||||||
|
|
||||||
function SupportApp() {
|
function SupportApp() {
|
||||||
const [activeTab, setActiveTab] = useState<TabValue>('tickets')
|
const [activeTab, setActiveTab] = useState<TabValue>('tickets')
|
||||||
@@ -41,11 +42,9 @@ function SupportApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs: { value: TabValue; label: string }[] = [
|
const tabs: { value: TabValue; label: string }[] = [
|
||||||
{
|
{ value: 'tickets', label: 'My Tickets' },
|
||||||
value: 'tickets',
|
|
||||||
label: 'My Tickets',
|
|
||||||
},
|
|
||||||
{ value: 'admin', label: 'Admin' },
|
{ value: 'admin', label: 'Admin' },
|
||||||
|
{ value: 'stats', label: 'Admin Stats' },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +56,7 @@ function SupportApp() {
|
|||||||
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||||
{activeTab === 'tickets' && <UserPage isAuthenticated={authState === 'authenticated'} />}
|
{activeTab === 'tickets' && <UserPage isAuthenticated={authState === 'authenticated'} />}
|
||||||
{activeTab === 'admin' && <AdminPage isAuthenticated={authState === 'authenticated'} user={user} />}
|
{activeTab === 'admin' && <AdminPage isAuthenticated={authState === 'authenticated'} user={user} />}
|
||||||
|
{activeTab === 'stats' && <AdminStatsPage isAuthenticated={authState === 'authenticated'} />}
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef, useEffect } from 'react'
|
||||||
import { Badge } from '../ui/Badge.tsx'
|
import { Badge } from '../ui/Badge.tsx'
|
||||||
import { Button } from '../ui/Button.tsx'
|
import { Button } from '../ui/Button.tsx'
|
||||||
import { parseDescription } from '../../lib/ticket.ts'
|
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<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 {
|
interface PaginationProps {
|
||||||
page: number
|
page: number
|
||||||
totalPages: number
|
totalPages: number
|
||||||
@@ -22,14 +60,50 @@ interface AdminTableProps {
|
|||||||
tickets: Ticket[]
|
tickets: Ticket[]
|
||||||
onOpen: (ticket: Ticket) => void
|
onOpen: (ticket: Ticket) => void
|
||||||
currentUserId: string | null
|
currentUserId: string | null
|
||||||
|
selection: Set<string>
|
||||||
|
onSelectionChange: (selection: Set<string>) => void
|
||||||
pagination: PaginationProps
|
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 { page, totalPages, total, pageSize, onPrev, onNext } = pagination
|
||||||
const start = (page - 1) * pageSize + 1
|
const start = (page - 1) * pageSize + 1
|
||||||
const end = Math.min(page * pageSize, total)
|
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) {
|
if (tickets.length === 0 && total === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-border-100 bg-bg-200 py-16 text-center">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-border-100 bg-bg-200 py-16 text-center">
|
||||||
@@ -45,6 +119,16 @@ export function AdminTable({ tickets, onOpen, currentUserId, pagination }: Admin
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border-100 bg-bg-200">
|
<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 => (
|
{(['Subject', 'User', 'Type', 'Status', ...(hasBilling ? ['Transaction'] : []), 'Description', 'Created'] as const).map(col => (
|
||||||
<th
|
<th
|
||||||
key={col}
|
key={col}
|
||||||
@@ -60,17 +144,31 @@ export function AdminTable({ tickets, onOpen, currentUserId, pagination }: Admin
|
|||||||
{tickets.map(ticket => {
|
{tickets.map(ticket => {
|
||||||
const { txnId, txnLine, body: displayDescription } = parseDescription(ticket.description)
|
const { txnId, txnLine, body: displayDescription } = parseDescription(ticket.description)
|
||||||
const hasTxn = ticket.type === 'billing' && txnId !== null
|
const hasTxn = ticket.type === 'billing' && txnId !== null
|
||||||
|
const owned = isOwned(ticket)
|
||||||
|
const isSelected = selection.has(ticket.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={ticket.id}
|
key={ticket.id}
|
||||||
className="transition-colors hover:bg-bg-200 cursor-pointer"
|
className={`transition-colors cursor-pointer ${isSelected ? 'bg-bg-200' : 'hover:bg-bg-200'}`}
|
||||||
onClick={() => onOpen(ticket)}
|
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">
|
<td className="px-4 py-3 font-medium text-fg-100">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{ticket.subject}
|
{ticket.subject}
|
||||||
{currentUserId && ticket.userId === currentUserId && (
|
{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">
|
<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
|
mine
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import type { Ticket, TicketType } from "./types";
|
import type { Ticket, TicketType } from './types'
|
||||||
import { env } from "../env";
|
import { env } from '../env'
|
||||||
|
|
||||||
const API = env.apiUrl;
|
const API = env.apiUrl
|
||||||
|
|
||||||
// ─── API error with structured body ──────────────────────────────────────────
|
// ─── API error with structured body ──────────────────────────────────────────
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly status: number;
|
readonly status: number
|
||||||
readonly code: string;
|
readonly code: string
|
||||||
|
|
||||||
constructor(status: number, code: string, message: string) {
|
constructor(status: number, code: string, message: string) {
|
||||||
super(message);
|
super(message)
|
||||||
this.name = "ApiError";
|
this.name = 'ApiError'
|
||||||
this.status = status;
|
this.status = status
|
||||||
this.code = code;
|
this.code = code
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,49 +22,43 @@ export class ApiError extends Error {
|
|||||||
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const res = await fetch(`${API}${path}`, {
|
const res = await fetch(`${API}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
credentials: "include",
|
credentials: 'include',
|
||||||
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) },
|
||||||
});
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// Try to parse a structured error body; fall back to a generic message
|
// Try to parse a structured error body; fall back to a generic message
|
||||||
let code = `http_${res.status}`;
|
let code = `http_${res.status}`
|
||||||
let message = `API error ${res.status}`;
|
let message = `API error ${res.status}`
|
||||||
try {
|
try {
|
||||||
const body = await res.json();
|
const body = await res.json()
|
||||||
if (body?.error) code = body.error;
|
if (body?.error) code = body.error
|
||||||
if (body?.message) message = body.message;
|
if (body?.message) message = body.message
|
||||||
} catch {
|
} catch { /* non-JSON body — keep defaults */ }
|
||||||
/* non-JSON body — keep defaults */
|
throw new ApiError(res.status, code, message)
|
||||||
}
|
}
|
||||||
throw new ApiError(res.status, code, message);
|
return res.json()
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Local (localStorage) adapter ────────────────────────────────────────────
|
// ─── Local (localStorage) adapter ────────────────────────────────────────────
|
||||||
|
|
||||||
const LOCAL_KEY = "support_tickets";
|
const LOCAL_KEY = 'support_tickets'
|
||||||
|
|
||||||
function localGet(): Ticket[] {
|
function localGet(): Ticket[] {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? "[]");
|
return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]')
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function localSet(tickets: Ticket[]) {
|
function localSet(tickets: Ticket[]) {
|
||||||
localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets));
|
localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const localAdapter = {
|
export const localAdapter = {
|
||||||
getTickets: (): Ticket[] => localGet(),
|
getTickets: (): Ticket[] => localGet(),
|
||||||
|
|
||||||
createTicket: (data: {
|
createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => {
|
||||||
subject: string;
|
|
||||||
description: string;
|
|
||||||
type: TicketType;
|
|
||||||
}): Ticket => {
|
|
||||||
const ticket: Ticket = {
|
const ticket: Ticket = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
userId: null,
|
userId: null,
|
||||||
@@ -72,44 +66,44 @@ export const localAdapter = {
|
|||||||
subject: data.subject,
|
subject: data.subject,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
status: "open",
|
status: 'open',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
}
|
||||||
localSet([ticket, ...localGet()]);
|
localSet([ticket, ...localGet()])
|
||||||
return ticket;
|
return ticket
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
|
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
|
||||||
const tickets = localGet();
|
const tickets = localGet()
|
||||||
const idx = tickets.findIndex((t) => t.id === id);
|
const idx = tickets.findIndex(t => t.id === id)
|
||||||
if (idx === -1) return null;
|
if (idx === -1) return null
|
||||||
tickets[idx] = { ...tickets[idx], ...patch };
|
tickets[idx] = { ...tickets[idx], ...patch }
|
||||||
localSet(tickets);
|
localSet(tickets)
|
||||||
return tickets[idx];
|
return tickets[idx]
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTicket: (id: string): boolean => {
|
deleteTicket: (id: string): boolean => {
|
||||||
const before = localGet();
|
const before = localGet()
|
||||||
const after = before.filter((t) => t.id !== id);
|
const after = before.filter(t => t.id !== id)
|
||||||
localSet(after);
|
localSet(after)
|
||||||
return after.length < before.length;
|
return after.length < before.length
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
// ─── Paginated response envelope ─────────────────────────────────────────────
|
// ─── Paginated response envelope ─────────────────────────────────────────────
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
data: T[];
|
data: T[]
|
||||||
total: number;
|
total: number
|
||||||
page: number;
|
page: number
|
||||||
pageSize: number;
|
pageSize: number
|
||||||
totalPages: number;
|
totalPages: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketFilters {
|
export interface TicketFilters {
|
||||||
status?: Ticket["status"];
|
status?: Ticket['status']
|
||||||
type?: TicketType;
|
type?: TicketType
|
||||||
mine?: boolean; // restrict to the current user's tickets
|
mine?: boolean // restrict to the current user's tickets
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Storage API ──────────────────────────────────────────────────────────────
|
// ─── Storage API ──────────────────────────────────────────────────────────────
|
||||||
@@ -118,9 +112,9 @@ export const storage = {
|
|||||||
// User's own tickets — API when authenticated, localStorage when guest
|
// User's own tickets — API when authenticated, localStorage when guest
|
||||||
async getTickets(): Promise<Ticket[]> {
|
async getTickets(): Promise<Ticket[]> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<Ticket[]>("/api/tickets");
|
return await apiFetch<Ticket[]>('/api/tickets')
|
||||||
} catch {
|
} catch {
|
||||||
return localAdapter.getTickets();
|
return localAdapter.getTickets()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -132,79 +126,72 @@ export const storage = {
|
|||||||
filters: TicketFilters = {},
|
filters: TicketFilters = {},
|
||||||
): Promise<PaginatedResponse<Ticket>> {
|
): Promise<PaginatedResponse<Ticket>> {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
let all = localAdapter.getTickets();
|
let all = localAdapter.getTickets()
|
||||||
if (filters.status) all = all.filter((t) => t.status === filters.status);
|
if (filters.status) all = all.filter(t => t.status === filters.status)
|
||||||
if (filters.type) all = all.filter((t) => t.type === filters.type);
|
if (filters.type) all = all.filter(t => t.type === filters.type)
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize
|
||||||
return {
|
return {
|
||||||
data: all.slice(start, start + pageSize),
|
data: all.slice(start, start + pageSize),
|
||||||
total: all.length,
|
total: all.length,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
|
totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({ page: String(page) });
|
const params = new URLSearchParams({ page: String(page) })
|
||||||
if (filters.status) params.set("status", filters.status);
|
if (filters.status) params.set('status', filters.status)
|
||||||
if (filters.type) params.set("type", filters.type);
|
if (filters.type) params.set('type', filters.type)
|
||||||
if (filters.mine) params.set("mine", "true");
|
if (filters.mine) params.set('mine', 'true')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await apiFetch<PaginatedResponse<Ticket>>(
|
return await apiFetch<PaginatedResponse<Ticket>>(`/api/tickets/all?${params}`)
|
||||||
`/api/tickets/all?${params}`,
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
const all = localAdapter.getTickets();
|
const all = localAdapter.getTickets()
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize
|
||||||
return {
|
return {
|
||||||
data: all.slice(start, start + pageSize),
|
data: all.slice(start, start + pageSize),
|
||||||
total: all.length,
|
total: all.length,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
|
totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async createTicket(data: {
|
async createTicket(data: { subject: string; description: string; type: TicketType }): Promise<Ticket> {
|
||||||
subject: string;
|
|
||||||
description: string;
|
|
||||||
type: TicketType;
|
|
||||||
}): Promise<Ticket> {
|
|
||||||
try {
|
try {
|
||||||
return await apiFetch<Ticket>("/api/tickets", {
|
return await apiFetch<Ticket>('/api/tickets', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Re-throw structured API errors (e.g. profanity, ticket limit) — don't silently
|
// 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.
|
// fall back to localStorage, as these are intentional rejections from the server.
|
||||||
if (err instanceof ApiError) throw err;
|
if (err instanceof ApiError) throw err
|
||||||
return localAdapter.createTicket(data);
|
return localAdapter.createTicket(data)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateTicket(
|
async updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null> {
|
||||||
id: string,
|
|
||||||
patch: Partial<Ticket>,
|
|
||||||
): Promise<Ticket | null> {
|
|
||||||
try {
|
try {
|
||||||
return await apiFetch<Ticket>(`/api/tickets/${id}`, {
|
return await apiFetch<Ticket>(`/api/tickets/${id}`, {
|
||||||
method: "PATCH",
|
method: 'PATCH',
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
});
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
return localAdapter.updateTicket(id, patch);
|
if (err instanceof ApiError) throw err
|
||||||
|
return localAdapter.updateTicket(id, patch)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteTicket(id: string): Promise<boolean> {
|
async deleteTicket(id: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/tickets/${id}`, { method: "DELETE" });
|
await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' })
|
||||||
return true;
|
return true
|
||||||
} catch {
|
} catch (err) {
|
||||||
return localAdapter.deleteTicket(id);
|
if (err instanceof ApiError) throw err
|
||||||
|
return localAdapter.deleteTicket(id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { storage } from '../lib/storage.ts'
|
|||||||
import type { PaginatedResponse, TicketFilters } from '../lib/storage.ts'
|
import type { PaginatedResponse, TicketFilters } from '../lib/storage.ts'
|
||||||
import { useModal } from '../hooks/useModal.ts'
|
import { useModal } from '../hooks/useModal.ts'
|
||||||
import type { Ticket, User } from '../lib/types.ts'
|
import type { Ticket, User } from '../lib/types.ts'
|
||||||
|
import { Button } from '../components/ui/Button.tsx'
|
||||||
// ─── Stat card ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StatCard({ label, value }: { label: string; value: number }) {
|
function StatCard({ label, value }: { label: string; value: number }) {
|
||||||
return (
|
return (
|
||||||
@@ -18,8 +17,6 @@ function StatCard({ label, value }: { label: string; value: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Filter bar ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const STATUS_OPTIONS: { value: Ticket['status'] | ''; label: string }[] = [
|
const STATUS_OPTIONS: { value: Ticket['status'] | ''; label: string }[] = [
|
||||||
{ value: '', label: 'All statuses' },
|
{ value: '', label: 'All statuses' },
|
||||||
{ value: 'open', label: 'Open' },
|
{ value: 'open', label: 'Open' },
|
||||||
@@ -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">
|
<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" />
|
<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" />
|
<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>
|
</svg>
|
||||||
My tickets
|
My tickets
|
||||||
</button>
|
</button>
|
||||||
@@ -121,12 +118,11 @@ function ChevronIcon() {
|
|||||||
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-fg-300"
|
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"
|
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>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface AdminPageProps {
|
interface AdminPageProps {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
@@ -142,15 +138,19 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [filters, setFilters] = useState<TicketFilters>({})
|
const [filters, setFilters] = useState<TicketFilters>({})
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null)
|
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()
|
const detailModal = useModal()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
storage.getAllTickets(isAuthenticated, page, 20, filters).then(setResult)
|
storage.getAllTickets(isAuthenticated, page, 20, filters).then(setResult)
|
||||||
|
setSelection(new Set()) // clear selection whenever the visible page changes
|
||||||
}, [isAuthenticated, page, filters])
|
}, [isAuthenticated, page, filters])
|
||||||
|
|
||||||
const handleFilterChange = (next: TicketFilters) => {
|
const handleFilterChange = (next: TicketFilters) => {
|
||||||
setFilters(next)
|
setFilters(next)
|
||||||
setPage(1) // reset to first page on filter change
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
@@ -168,6 +168,7 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
|
|||||||
const handleDetailClose = () => {
|
const handleDetailClose = () => {
|
||||||
detailModal.close()
|
detailModal.close()
|
||||||
setSelectedTicket(null)
|
setSelectedTicket(null)
|
||||||
|
setActionError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const refetch = async () => {
|
const refetch = async () => {
|
||||||
@@ -180,25 +181,49 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseTicket = async (id: string) => {
|
const handleCloseTicket = async (id: string) => {
|
||||||
|
try {
|
||||||
const updated = await storage.updateTicket(id, { status: 'closed' })
|
const updated = await storage.updateTicket(id, { status: 'closed' })
|
||||||
if (updated) {
|
if (updated) {
|
||||||
setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) }))
|
setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) }))
|
||||||
setSelectedTicket(updated)
|
setSelectedTicket(updated)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
setActionError('Failed to close ticket. Please try again.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReopenTicket = async (id: string) => {
|
const handleReopenTicket = async (id: string) => {
|
||||||
|
try {
|
||||||
const updated = await storage.updateTicket(id, { status: 'open' })
|
const updated = await storage.updateTicket(id, { status: 'open' })
|
||||||
if (updated) {
|
if (updated) {
|
||||||
setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) }))
|
setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) }))
|
||||||
setSelectedTicket(updated)
|
setSelectedTicket(updated)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
setActionError('Failed to reopen ticket. Please try again.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteTicket = async (id: string) => {
|
const handleDeleteTicket = async (id: string) => {
|
||||||
|
try {
|
||||||
await storage.deleteTicket(id)
|
await storage.deleteTicket(id)
|
||||||
handleDetailClose()
|
handleDetailClose()
|
||||||
await refetch()
|
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.
|
// A ticket is owned by the current user if their IDs match.
|
||||||
@@ -228,10 +253,39 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
|
|||||||
onChange={handleFilterChange}
|
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
|
<AdminTable
|
||||||
tickets={result.data}
|
tickets={result.data}
|
||||||
onOpen={handleOpen}
|
onOpen={handleOpen}
|
||||||
currentUserId={user?.id ?? null}
|
currentUserId={user?.id ?? null}
|
||||||
|
selection={selection}
|
||||||
|
onSelectionChange={setSelection}
|
||||||
pagination={{
|
pagination={{
|
||||||
page: result.page,
|
page: result.page,
|
||||||
totalPages: result.totalPages,
|
totalPages: result.totalPages,
|
||||||
@@ -248,12 +302,20 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
|
|||||||
title={selectedTicket?.subject ?? ''}
|
title={selectedTicket?.subject ?? ''}
|
||||||
>
|
>
|
||||||
{selectedTicket && (
|
{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
|
<TicketDetail
|
||||||
ticket={selectedTicket}
|
ticket={selectedTicket}
|
||||||
onCloseTicket={canModify(selectedTicket) ? handleCloseTicket : undefined}
|
onCloseTicket={canModify(selectedTicket) ? handleCloseTicket : undefined}
|
||||||
onReopenTicket={canModify(selectedTicket) ? handleReopenTicket : undefined}
|
onReopenTicket={canModify(selectedTicket) ? handleReopenTicket : undefined}
|
||||||
onDeleteTicket={canModify(selectedTicket) ? handleDeleteTicket : undefined}
|
onDeleteTicket={canModify(selectedTicket) ? handleDeleteTicket : undefined}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
238
frontend/src/pages/AdminStatsPage.tsx
Normal file
238
frontend/src/pages/AdminStatsPage.tsx
Normal file
@@ -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<TicketType, { label: string; color: string }> = {
|
||||||
|
'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<TicketType, number>()
|
||||||
|
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<TicketType | null>(null)
|
||||||
|
|
||||||
|
if (slices.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
className="w-full max-w-[260px]"
|
||||||
|
aria-label="Ticket type distribution pie chart"
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<path
|
||||||
|
key={slice.type}
|
||||||
|
d={describeArc(slice.startAngle, slice.endAngle)}
|
||||||
|
fill={slice.color}
|
||||||
|
opacity={hovered && !isHovered ? 0.4 : 1}
|
||||||
|
style={{ transform, transformOrigin: 'center', transition: 'opacity 0.15s, transform 0.15s' }}
|
||||||
|
onMouseEnter={() => setHovered(slice.type)}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Centre label — shows hovered slice detail or total */}
|
||||||
|
{hovered ? (
|
||||||
|
<>
|
||||||
|
<text x={CX} y={CY - 7} textAnchor="middle" className="fill-fg-100" fontSize="13" fontWeight="600">
|
||||||
|
{slices.find(s => s.type === hovered)?.count}
|
||||||
|
</text>
|
||||||
|
<text x={CX} y={CY + 8} textAnchor="middle" className="fill-fg-300" fontSize="7">
|
||||||
|
{TYPE_CONFIG[hovered].label}
|
||||||
|
</text>
|
||||||
|
<text x={CX} y={CY + 19} textAnchor="middle" className="fill-fg-300" fontSize="7">
|
||||||
|
{((slices.find(s => s.type === hovered)?.pct ?? 0) * 100).toFixed(1)}%
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<text x={CX} y={CY - 5} textAnchor="middle" className="fill-fg-100" fontSize="13" fontWeight="600">
|
||||||
|
{slices.reduce((s, d) => s + d.count, 0)}
|
||||||
|
</text>
|
||||||
|
<text x={CX} y={CY + 9} textAnchor="middle" className="fill-fg-300" fontSize="8">
|
||||||
|
tickets
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Legend({ slices }: { slices: SliceData[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 min-w-[160px]">
|
||||||
|
{slices.map(slice => (
|
||||||
|
<div key={slice.type} className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: slice.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-fg-200">{slice.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs tabular-nums">
|
||||||
|
<span className="text-fg-100 font-medium">{slice.count}</span>
|
||||||
|
<span className="text-fg-300 w-9 text-right">{(slice.pct * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface AdminStatsPageProps {
|
||||||
|
isAuthenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminStatsPage({ isAuthenticated }: AdminStatsPageProps) {
|
||||||
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-lg font-semibold text-fg-100">Stats</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-fg-300">
|
||||||
|
{isAuthenticated ? 'Ticket breakdown across all users' : 'Breakdown of your local tickets'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<p className="text-sm text-fg-300">Loading…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEmpty && (
|
||||||
|
<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 to display.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !isEmpty && (
|
||||||
|
<div className="rounded-lg border border-border-100 bg-bg-200 p-6">
|
||||||
|
<p className="mb-5 text-xs font-medium uppercase tracking-wider text-fg-300">
|
||||||
|
Ticket type distribution
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col items-center gap-8 sm:flex-row sm:items-center sm:gap-12">
|
||||||
|
<PieChart slices={slices} />
|
||||||
|
<Legend slices={slices} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
|||||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
const [serverLimitHit, setServerLimitHit] = useState(false)
|
const [serverLimitHit, setServerLimitHit] = useState(false)
|
||||||
const [contentError, setContentError] = useState<string | null>(null)
|
const [contentError, setContentError] = useState<string | null>(null)
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null)
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null)
|
||||||
|
|
||||||
const newTicketModal = useModal()
|
const newTicketModal = useModal()
|
||||||
@@ -93,14 +94,19 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
|||||||
const handleDetailClose = () => {
|
const handleDetailClose = () => {
|
||||||
detailModal.close()
|
detailModal.close()
|
||||||
setSelectedTicket(null)
|
setSelectedTicket(null)
|
||||||
|
setActionError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseTicket = async (id: string) => {
|
const handleCloseTicket = async (id: string) => {
|
||||||
|
try {
|
||||||
const updated = await storage.updateTicket(id, { status: 'closed' })
|
const updated = await storage.updateTicket(id, { status: 'closed' })
|
||||||
if (updated) {
|
if (updated) {
|
||||||
setTickets(prev => prev.map(t => t.id === id ? updated : t))
|
setTickets(prev => prev.map(t => t.id === id ? updated : t))
|
||||||
setSelectedTicket(updated)
|
setSelectedTicket(updated)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
setActionError('Failed to close ticket. Please try again.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
||||||
@@ -178,7 +184,17 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
|||||||
onClose={handleDetailClose}
|
onClose={handleDetailClose}
|
||||||
title={selectedTicket?.subject ?? ''}
|
title={selectedTicket?.subject ?? ''}
|
||||||
>
|
>
|
||||||
{selectedTicket && <TicketDetail ticket={selectedTicket} onCloseTicket={handleCloseTicket} />}
|
{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={handleCloseTicket} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user