chrysopedia/frontend/src/pages/AdminReports.tsx
jlightner c6efec8363 feat: Split key moment card header into standalone h3 title and flex-ro…
- "frontend/src/pages/TechniquePage.tsx"
- "frontend/src/App.css"

GSD-Task: S03/T01
2026-03-30 08:55:48 +00:00

246 lines
8.2 KiB
TypeScript

/**
* Admin content reports management page.
*
* Lists user-submitted issue reports with filtering by status,
* inline triage (acknowledge/resolve/dismiss), and admin notes.
*/
import { useEffect, useState } from "react";
import {
fetchReports,
updateReport,
type ContentReport,
} from "../api/public-client";
const STATUS_OPTIONS = [
{ value: "", label: "All" },
{ value: "open", label: "Open" },
{ value: "acknowledged", label: "Acknowledged" },
{ value: "resolved", label: "Resolved" },
{ value: "dismissed", label: "Dismissed" },
];
const STATUS_ACTIONS: Record<string, { label: string; next: string }[]> = {
open: [
{ label: "Acknowledge", next: "acknowledged" },
{ label: "Resolve", next: "resolved" },
{ label: "Dismiss", next: "dismissed" },
],
acknowledged: [
{ label: "Resolve", next: "resolved" },
{ label: "Dismiss", next: "dismissed" },
{ label: "Reopen", next: "open" },
],
resolved: [{ label: "Reopen", next: "open" }],
dismissed: [{ label: "Reopen", next: "open" }],
};
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function reportTypeLabel(rt: string): string {
return rt.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase());
}
export default function AdminReports() {
const [reports, setReports] = useState<ContentReport[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [noteText, setNoteText] = useState("");
const [actionLoading, setActionLoading] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const res = await fetchReports({
status: statusFilter || undefined,
limit: 100,
});
setReports(res.items);
setTotal(res.total);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load reports");
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, [statusFilter]);
const handleAction = async (reportId: string, newStatus: string) => {
setActionLoading(reportId);
try {
const updated = await updateReport(reportId, {
status: newStatus,
...(noteText.trim() ? { admin_notes: noteText.trim() } : {}),
});
setReports((prev) =>
prev.map((r) => (r.id === reportId ? updated : r)),
);
setNoteText("");
if (newStatus === "resolved" || newStatus === "dismissed") {
setExpandedId(null);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Action failed");
} finally {
setActionLoading(null);
}
};
const toggleExpand = (id: string) => {
if (expandedId === id) {
setExpandedId(null);
setNoteText("");
} else {
setExpandedId(id);
const report = reports.find((r) => r.id === id);
setNoteText(report?.admin_notes ?? "");
}
};
return (
<div className="admin-reports">
<h2 className="admin-reports__title">Content Reports</h2>
<p className="admin-reports__subtitle">
{total} report{total !== 1 ? "s" : ""} total
</p>
{/* Status filter */}
<div className="admin-reports__filters">
<div className="sort-toggle" role="group" aria-label="Filter by status">
{STATUS_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`sort-toggle__btn${statusFilter === opt.value ? " sort-toggle__btn--active" : ""}`}
onClick={() => setStatusFilter(opt.value)}
aria-pressed={statusFilter === opt.value}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Content */}
{loading ? (
<div className="loading">Loading reports</div>
) : error ? (
<div className="loading error-text">Error: {error}</div>
) : reports.length === 0 ? (
<div className="empty-state">
{statusFilter ? `No ${statusFilter} reports.` : "No reports yet."}
</div>
) : (
<div className="admin-reports__list">
{reports.map((report) => (
<div
key={report.id}
className={`report-card report-card--${report.status}`}
>
<div
className="report-card__header"
onClick={() => toggleExpand(report.id)}
>
<div className="report-card__meta">
<span className={`pill pill--${report.status}`}>
{report.status}
</span>
<span className="pill">{reportTypeLabel(report.report_type)}</span>
<span className="report-card__date">
{formatDate(report.created_at)}
</span>
</div>
<div className="report-card__summary">
{report.content_title && (
<span className="report-card__content-title">
{report.content_title}
</span>
)}
<span className="report-card__description">
{report.description.length > 120
? report.description.slice(0, 120) + "…"
: report.description}
</span>
</div>
</div>
{expandedId === report.id && (
<div className="report-card__detail">
<div className="report-card__full-description">
<strong>Full description:</strong>
<p>{report.description}</p>
</div>
{report.page_url && (
<div className="report-card__url">
<strong>Page:</strong>{" "}
<a href={report.page_url} target="_blank" rel="noopener noreferrer">
{report.page_url}
</a>
</div>
)}
<div className="report-card__info-row">
<span>Type: {report.content_type}</span>
{report.content_id && <span>ID: {report.content_id.slice(0, 8)}</span>}
{report.resolved_at && (
<span>Resolved: {formatDate(report.resolved_at)}</span>
)}
</div>
{/* Admin notes */}
<label className="report-card__notes-label">
Admin notes:
<textarea
className="report-card__notes"
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={2}
placeholder="Add notes…"
/>
</label>
{/* Action buttons */}
<div className="report-card__actions">
{(STATUS_ACTIONS[report.status] ?? []).map((action) => (
<button
key={action.next}
className={`btn btn--${action.next === "resolved" ? "primary" : action.next === "dismissed" ? "danger" : "secondary"}`}
onClick={() => handleAction(report.id, action.next)}
disabled={actionLoading === report.id}
>
{actionLoading === report.id ? "…" : action.label}
</button>
))}
{noteText !== (report.admin_notes ?? "") && (
<button
className="btn btn--secondary"
onClick={() => handleAction(report.id, report.status)}
disabled={actionLoading === report.id}
>
Save notes
</button>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}