Report popup buttons and the page's search/Back controls overflowed off-screen on mobile instead of wrapping, and the sidebar's custom date range inputs spilled outside the filter box on desktop. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
212 lines
9.4 KiB
TypeScript
212 lines
9.4 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo, useState } from "react";
|
|
import { ArrowLeft, HelpCircle, Search } from "lucide-react";
|
|
import { REPORTS, REPORT_CATEGORIES, type ReportKey, type ReportCategory } from "./ReportCatalog";
|
|
import EventsDropdown from "./EventsDropdown";
|
|
|
|
type EventLite = { id: string; title: string };
|
|
type DatePreset = "all_time" | "this_month" | "last_month" | "this_year" | "custom";
|
|
|
|
export default function ReportsShell({
|
|
events, isAdmin,
|
|
showPastEvents, setShowPastEvents,
|
|
showInactiveEvents, setShowInactiveEvents,
|
|
showClosedEvents, setShowClosedEvents,
|
|
selectedEventIds, setSelectedEventIds,
|
|
dateFrom, setDateFrom, dateTo, setDateTo,
|
|
report, setReport,
|
|
onViewReport, busy,
|
|
onOpenGuide,
|
|
onBack,
|
|
}: {
|
|
events: EventLite[]; isAdmin: boolean;
|
|
showPastEvents: boolean; setShowPastEvents: (v: boolean) => void;
|
|
showInactiveEvents: boolean; setShowInactiveEvents: (v: boolean) => void;
|
|
showClosedEvents: boolean; setShowClosedEvents: (v: boolean) => void;
|
|
selectedEventIds: string[]; setSelectedEventIds: (v: string[]) => void;
|
|
dateFrom: string; setDateFrom: (v: string) => void; dateTo: string; setDateTo: (v: string) => void;
|
|
report: ReportKey; setReport: (r: ReportKey) => void;
|
|
onViewReport: () => void; busy: boolean;
|
|
onOpenGuide: () => void;
|
|
onBack?: () => void;
|
|
}) {
|
|
const [search, setSearch] = useState("");
|
|
const [category, setCategory] = useState<"All" | ReportCategory>("All");
|
|
const [datePreset, setDatePreset] = useState<DatePreset>("all_time");
|
|
|
|
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
|
const applyPreset = (preset: DatePreset) => {
|
|
setDatePreset(preset);
|
|
const now = new Date();
|
|
if (preset === "this_month") {
|
|
setDateFrom(iso(new Date(now.getFullYear(), now.getMonth(), 1)));
|
|
setDateTo(iso(new Date(now.getFullYear(), now.getMonth() + 1, 0)));
|
|
} else if (preset === "last_month") {
|
|
setDateFrom(iso(new Date(now.getFullYear(), now.getMonth() - 1, 1)));
|
|
setDateTo(iso(new Date(now.getFullYear(), now.getMonth(), 0)));
|
|
} else if (preset === "this_year") {
|
|
setDateFrom(iso(new Date(now.getFullYear(), 0, 1)));
|
|
setDateTo(iso(new Date(now.getFullYear(), 11, 31)));
|
|
} else if (preset === "all_time") {
|
|
setDateFrom(""); setDateTo("");
|
|
}
|
|
// 'custom' leaves dateFrom/dateTo as whatever's typed in the fields below
|
|
};
|
|
|
|
const filteredReports = useMemo(() => {
|
|
return REPORTS.filter(r => {
|
|
if (category !== "All" && r.category !== category) return false;
|
|
if (search.trim()) {
|
|
const q = search.trim().toLowerCase();
|
|
if (!r.label.toLowerCase().includes(q) && !r.description.toLowerCase().includes(q)) return false;
|
|
}
|
|
return true;
|
|
});
|
|
}, [search, category]);
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex flex-wrap items-start justify-between gap-3 sm:gap-4 mb-6">
|
|
<div className="min-w-0">
|
|
<h1 className="text-2xl font-semibold text-gray-900">Reports</h1>
|
|
<p className="text-sm text-gray-500 mt-0.5">View, export, or email operational reports for events.</p>
|
|
</div>
|
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
|
<div className="relative flex-1 min-w-0 sm:flex-none">
|
|
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
className="w-full sm:w-56 border rounded-lg pl-8 pr-3 py-2 text-sm"
|
|
placeholder="Search reports…"
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
{onBack && (
|
|
<button type="button" className="shrink-0 flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={onBack}>
|
|
<ArrowLeft className="w-4 h-4" /> Back
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col lg:flex-row gap-6">
|
|
{/* Sidebar: universal filters */}
|
|
<aside className="lg:w-72 shrink-0 space-y-5">
|
|
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-4">
|
|
<div className="text-sm font-semibold">Filters</div>
|
|
|
|
<div>
|
|
<div className="text-xs font-medium text-gray-600 mb-1">Events</div>
|
|
<EventsDropdown options={events.map(ev => ({ value: ev.id, label: ev.title }))} value={selectedEventIds} onChange={setSelectedEventIds} />
|
|
</div>
|
|
|
|
<div className="space-y-1.5 text-sm">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked={showPastEvents} onChange={e => setShowPastEvents(e.target.checked)} />
|
|
Include past events
|
|
</label>
|
|
{isAdmin && (
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked={showInactiveEvents} onChange={e => setShowInactiveEvents(e.target.checked)} />
|
|
Include inactive events
|
|
</label>
|
|
)}
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked={showClosedEvents} onChange={e => setShowClosedEvents(e.target.checked)} />
|
|
Include closed (cashed-up) events
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-xs font-medium text-gray-600 mb-1">Date range (where applicable)</div>
|
|
<select className="w-full border rounded px-2 py-1.5 text-sm mb-2" value={datePreset} onChange={e => applyPreset(e.target.value as DatePreset)}>
|
|
<option value="all_time">All time</option>
|
|
<option value="this_month">This month</option>
|
|
<option value="last_month">Last month</option>
|
|
<option value="this_year">This year</option>
|
|
<option value="custom">Custom</option>
|
|
</select>
|
|
{datePreset === "custom" && (
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-[11px] text-gray-500 mb-0.5">From</div>
|
|
<input type="date" className="w-full min-w-0 border rounded px-2 py-1.5 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<div className="text-[11px] text-gray-500 mb-0.5">To</div>
|
|
<input type="date" className="w-full min-w-0 border rounded px-2 py-1.5 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<button type="button" className="w-full flex items-start gap-3 text-left text-sm border rounded-xl p-4 bg-white shadow-sm hover:bg-gray-50" onClick={onOpenGuide}>
|
|
<HelpCircle className="w-5 h-5 text-gray-500 shrink-0" />
|
|
<span>
|
|
<span className="block font-medium text-gray-800">Need help?</span>
|
|
<span className="block text-xs text-gray-500">View our reporting guide</span>
|
|
</span>
|
|
</button>
|
|
</aside>
|
|
|
|
{/* Main: categories, report grid */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex flex-wrap gap-2 mb-4">
|
|
{(["All", ...REPORT_CATEGORIES] as const).map(c => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
className={"px-3 py-1.5 text-sm rounded-lg border " + (category === c ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50")}
|
|
onClick={() => setCategory(c)}
|
|
>
|
|
{c}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid sm:grid-cols-2 xl:grid-cols-4 gap-3">
|
|
{filteredReports.map(r => {
|
|
const Icon = r.icon;
|
|
const selected = report === r.key;
|
|
return (
|
|
<button
|
|
key={r.key}
|
|
type="button"
|
|
onClick={() => setReport(r.key)}
|
|
className={"text-left border rounded-xl p-4 transition " + (selected ? "border-indigo-500 ring-2 ring-indigo-100 bg-indigo-50/40" : "border-gray-200 hover:border-gray-300 bg-white")}
|
|
>
|
|
<div className="w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center mb-3">
|
|
<Icon className="w-5 h-5 text-gray-600" />
|
|
</div>
|
|
<div className="text-sm font-semibold text-gray-900 mb-1">{r.label}</div>
|
|
<div className="text-xs text-gray-500 mb-3">{r.description}</div>
|
|
<div className="flex items-center justify-between text-[11px] text-gray-400">
|
|
<span>{r.category}</span>
|
|
<span>{r.fields} fields</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
{filteredReports.length === 0 && (
|
|
<div className="col-span-full text-sm text-gray-500 py-8 text-center">No reports match your search.</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-6 flex items-center justify-between bg-gray-50 border rounded-xl px-4 py-3">
|
|
<div className="text-xs text-gray-500">Filters will be applied to the selected report where relevant.</div>
|
|
<button
|
|
disabled={busy}
|
|
onClick={onViewReport}
|
|
className="px-4 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
|
|
>
|
|
{busy ? "Loading…" : "View report"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|