add:table filters
This commit is contained in:
@@ -1,62 +1,147 @@
|
||||
import { eq, count } from 'drizzle-orm'
|
||||
import { db } from '../db/index.ts'
|
||||
import { tickets } from '../db/schema.ts'
|
||||
import type { StorageAdapter, Ticket, TicketType } from '../types.ts'
|
||||
import { eq, count, desc, and, type SQL } from "drizzle-orm";
|
||||
import { db } from "../db/index.ts";
|
||||
import { tickets, users } from "../db/schema.ts";
|
||||
import type {
|
||||
StorageAdapter,
|
||||
Ticket,
|
||||
TicketType,
|
||||
PaginatedTickets,
|
||||
TicketFilters,
|
||||
} from "../types.ts";
|
||||
|
||||
// Explicit column selection shared by all ticket queries
|
||||
const ticketSelect = {
|
||||
id: tickets.id,
|
||||
userId: tickets.userId,
|
||||
subject: tickets.subject,
|
||||
description: tickets.description,
|
||||
type: tickets.type,
|
||||
status: tickets.status,
|
||||
createdAt: tickets.createdAt,
|
||||
username: users.username,
|
||||
};
|
||||
|
||||
// Let TypeScript infer the row type directly from the select shape
|
||||
type TicketRow = {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
subject: string;
|
||||
description: string;
|
||||
type: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
username: string | null;
|
||||
};
|
||||
|
||||
export class SQLiteAdapter implements StorageAdapter {
|
||||
async getTickets(): Promise<Ticket[]> {
|
||||
const rows = await db.select().from(tickets).orderBy(tickets.createdAt)
|
||||
return rows.map(toTicket).reverse()
|
||||
const rows = await db
|
||||
.select(ticketSelect)
|
||||
.from(tickets)
|
||||
.leftJoin(users, eq(tickets.userId, users.id))
|
||||
.orderBy(desc(tickets.createdAt));
|
||||
return rows.map(toTicket);
|
||||
}
|
||||
|
||||
async getTicketsByUser(userId: string): Promise<Ticket[]> {
|
||||
const rows = await db
|
||||
.select(ticketSelect)
|
||||
.from(tickets)
|
||||
.leftJoin(users, eq(tickets.userId, users.id))
|
||||
.where(eq(tickets.userId, userId))
|
||||
.orderBy(desc(tickets.createdAt));
|
||||
return rows.map(toTicket);
|
||||
}
|
||||
|
||||
async getTicketsPaginated(
|
||||
limit: number,
|
||||
offset: number,
|
||||
filters: TicketFilters = {},
|
||||
): Promise<PaginatedTickets> {
|
||||
const conditions: SQL[] = [];
|
||||
if (filters.status) conditions.push(eq(tickets.status, filters.status));
|
||||
if (filters.type) conditions.push(eq(tickets.type, filters.type));
|
||||
if (filters.userId) conditions.push(eq(tickets.userId, filters.userId));
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [rows, totalResult] = await Promise.all([
|
||||
db
|
||||
.select(ticketSelect)
|
||||
.from(tickets)
|
||||
.leftJoin(users, eq(tickets.userId, users.id))
|
||||
.where(where)
|
||||
.orderBy(desc(tickets.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: count() }).from(tickets).where(where),
|
||||
]);
|
||||
return {
|
||||
data: rows.map(toTicket),
|
||||
total: totalResult[0]?.count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getTicket(id: string): Promise<Ticket | null> {
|
||||
const rows = await db.select().from(tickets).where(eq(tickets.id, id))
|
||||
return rows[0] ? toTicket(rows[0]) : null
|
||||
const rows = await db
|
||||
.select(ticketSelect)
|
||||
.from(tickets)
|
||||
.leftJoin(users, eq(tickets.userId, users.id))
|
||||
.where(eq(tickets.id, id));
|
||||
return rows[0] ? toTicket(rows[0]) : null;
|
||||
}
|
||||
|
||||
async countTicketsByUser(userId: string): Promise<number> {
|
||||
const result = await db
|
||||
.select({ count: count() })
|
||||
.from(tickets)
|
||||
.where(eq(tickets.userId, userId))
|
||||
return result[0]?.count ?? 0
|
||||
.where(eq(tickets.userId, userId));
|
||||
return result[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
async createTicket(
|
||||
data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }
|
||||
data: Pick<Ticket, "subject" | "description" | "type"> & {
|
||||
userId?: string;
|
||||
},
|
||||
): Promise<Ticket> {
|
||||
const id = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
await db.insert(tickets).values({
|
||||
id,
|
||||
userId: data.userId ?? null,
|
||||
subject: data.subject,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
status: 'open',
|
||||
status: "open",
|
||||
createdAt: now,
|
||||
})
|
||||
return (await this.getTicket(id))!
|
||||
});
|
||||
return (await this.getTicket(id))!;
|
||||
}
|
||||
|
||||
async updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null> {
|
||||
await db.update(tickets).set(patch).where(eq(tickets.id, id))
|
||||
return this.getTicket(id)
|
||||
async updateTicket(
|
||||
id: string,
|
||||
patch: Partial<Ticket>,
|
||||
): Promise<Ticket | null> {
|
||||
// Strip username — it's a derived field from the join, not a column
|
||||
const { username: _, ...columnPatch } = patch as Ticket;
|
||||
await db.update(tickets).set(columnPatch).where(eq(tickets.id, id));
|
||||
return this.getTicket(id);
|
||||
}
|
||||
|
||||
async deleteTicket(id: string): Promise<void> {
|
||||
await db.delete(tickets).where(eq(tickets.id, id))
|
||||
await db.delete(tickets).where(eq(tickets.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
function toTicket(row: typeof tickets.$inferSelect): Ticket {
|
||||
function toTicket(row: TicketRow): Ticket {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
username: row.username ?? null,
|
||||
subject: row.subject,
|
||||
description: row.description,
|
||||
type: row.type as TicketType,
|
||||
status: row.status as Ticket['status'],
|
||||
status: row.status as Ticket["status"],
|
||||
createdAt: row.createdAt,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { Ticket, TicketType } from "../types.ts";
|
||||
import { TICKET_LIMIT } from "../types.ts";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.isAuthenticated) {
|
||||
return reply.status(401).send({ error: "Unauthorized" });
|
||||
@@ -9,14 +11,42 @@ async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
||||
}
|
||||
|
||||
export const ticketsRouter: FastifyPluginAsync = async (app) => {
|
||||
// GET /api/tickets/all — admin view, returns all tickets in the system
|
||||
app.get("/all", { preHandler: requireAuth }, async (req) => {
|
||||
return req.storage.getTickets();
|
||||
// GET /api/tickets/all — admin view, paginated with optional filters
|
||||
app.get<{
|
||||
Querystring: {
|
||||
page?: string;
|
||||
status?: Ticket["status"];
|
||||
type?: TicketType;
|
||||
mine?: string; // "true" to restrict to the requesting user's tickets
|
||||
};
|
||||
}>("/all", { preHandler: requireAuth }, async (req) => {
|
||||
const page = Math.max(1, parseInt(req.query.page ?? "1", 10) || 1);
|
||||
const offset = (page - 1) * PAGE_SIZE;
|
||||
|
||||
const filters = {
|
||||
status: req.query.status,
|
||||
type: req.query.type,
|
||||
// If mine=true, scope to the current user's tickets
|
||||
userId: req.query.mine === "true" ? req.user!.id : undefined,
|
||||
};
|
||||
|
||||
const result = await req.storage.getTicketsPaginated(
|
||||
PAGE_SIZE,
|
||||
offset,
|
||||
filters,
|
||||
);
|
||||
return {
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
totalPages: Math.max(1, Math.ceil(result.total / PAGE_SIZE)),
|
||||
};
|
||||
});
|
||||
|
||||
// GET /api/tickets
|
||||
// GET /api/tickets — returns only the current user's tickets
|
||||
app.get("/", { preHandler: requireAuth }, async (req) => {
|
||||
return req.storage.getTickets();
|
||||
return req.storage.getTicketsByUser(req.user!.id);
|
||||
});
|
||||
|
||||
// GET /api/tickets/:id
|
||||
@@ -61,21 +91,31 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
|
||||
return reply.status(201).send(ticket);
|
||||
});
|
||||
|
||||
// PATCH /api/tickets/:id
|
||||
// PATCH /api/tickets/:id — user may only update their own tickets
|
||||
app.patch<{
|
||||
Params: { id: string };
|
||||
Body: Partial<Ticket>;
|
||||
}>("/:id", { preHandler: requireAuth }, async (req, reply) => {
|
||||
const existing = await req.storage.getTicket(req.params.id);
|
||||
if (!existing) return reply.status(404).send({ error: "Not found" });
|
||||
if (existing.userId !== req.user!.id) {
|
||||
return reply.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
const ticket = await req.storage.updateTicket(req.params.id, req.body);
|
||||
if (!ticket) return reply.status(404).send({ error: "Not found" });
|
||||
return ticket;
|
||||
});
|
||||
|
||||
// DELETE /api/tickets/:id
|
||||
// DELETE /api/tickets/:id — user may only delete their own tickets
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const existing = await req.storage.getTicket(req.params.id);
|
||||
if (!existing) return reply.status(404).send({ error: "Not found" });
|
||||
if (existing.userId !== req.user!.id) {
|
||||
return reply.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
await req.storage.deleteTicket(req.params.id);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ export type TicketType =
|
||||
export interface Ticket {
|
||||
id: string
|
||||
userId: string | null
|
||||
username: string | null
|
||||
subject: string
|
||||
description: string
|
||||
type: TicketType
|
||||
@@ -26,8 +27,21 @@ export interface Ticket {
|
||||
|
||||
export const TICKET_LIMIT = 3
|
||||
|
||||
export interface TicketFilters {
|
||||
status?: Ticket['status']
|
||||
type?: TicketType
|
||||
userId?: string
|
||||
}
|
||||
|
||||
export interface PaginatedTickets {
|
||||
data: Ticket[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface StorageAdapter {
|
||||
getTickets(): Promise<Ticket[]>
|
||||
getTicketsByUser(userId: string): Promise<Ticket[]>
|
||||
getTicketsPaginated(limit: number, offset: number, filters?: TicketFilters): Promise<PaginatedTickets>
|
||||
getTicket(id: string): Promise<Ticket | null>
|
||||
countTicketsByUser(userId: string): Promise<number>
|
||||
createTicket(data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }): Promise<Ticket>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) // 0–1
|
||||
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>
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export type TicketType =
|
||||
export interface Ticket {
|
||||
id: string
|
||||
userId: string | null
|
||||
username: string | null
|
||||
subject: string
|
||||
description: string
|
||||
type: TicketType
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user