Add pagination to admin stops page (50/page)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m37s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m37s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ import { redirect } from "next/navigation";
|
|||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
import { type Stop } from "@/components/admin/stops/types";
|
import { type Stop } from "@/components/admin/stops/types";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
const StopIcon = () => (
|
const StopIcon = () => (
|
||||||
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
|
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
|
||||||
@@ -14,7 +16,15 @@ const StopIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default async function AdminStopsPage() {
|
interface PageProps {
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminStopsPage({ searchParams }: PageProps) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const page = Math.max(1, parseInt(params.page ?? "1", 10) || 1);
|
||||||
|
const offset = (page - 1) * PAGE_SIZE;
|
||||||
|
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
|
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
if (!adminUser) return <AdminAccessDenied />;
|
||||||
@@ -24,6 +34,7 @@ export default async function AdminStopsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let stops: Stop[] = [];
|
let stops: Stop[] = [];
|
||||||
|
let totalCount = 0;
|
||||||
let error: { message: string } | null = null;
|
let error: { message: string } | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -31,6 +42,13 @@ export default async function AdminStopsPage() {
|
|||||||
? `AND brand_id = '${adminUser.brand_id}'`
|
? `AND brand_id = '${adminUser.brand_id}'`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
// Get total count for pagination
|
||||||
|
const countResult = await pool.query<{ count: string }>(`
|
||||||
|
SELECT count(*) as count FROM stops WHERE status = 'active' ${brandFilter}
|
||||||
|
`);
|
||||||
|
totalCount = parseInt(countResult.rows[0]?.count ?? "0", 10);
|
||||||
|
|
||||||
|
// Get paginated stops
|
||||||
const { rows } = await pool.query<Stop& { brand_name: string }>(`
|
const { rows } = await pool.query<Stop& { brand_name: string }>(`
|
||||||
SELECT
|
SELECT
|
||||||
s.id,
|
s.id,
|
||||||
@@ -51,6 +69,7 @@ export default async function AdminStopsPage() {
|
|||||||
WHERE s.status = 'active'
|
WHERE s.status = 'active'
|
||||||
${brandFilter}
|
${brandFilter}
|
||||||
ORDER BY s.date ASC
|
ORDER BY s.date ASC
|
||||||
|
LIMIT ${PAGE_SIZE} OFFSET ${offset}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
stops = rows.map((r) => ({
|
stops = rows.map((r) => ({
|
||||||
@@ -73,6 +92,8 @@ export default async function AdminStopsPage() {
|
|||||||
error = { message: e instanceof Error ? e.message : String(e) };
|
error = { message: e instanceof Error ? e.message : String(e) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||||
@@ -81,7 +102,7 @@ export default async function AdminStopsPage() {
|
|||||||
<nav className="flex items-center gap-2 text-xs sm:text-sm mb-6">
|
<nav className="flex items-center gap-2 text-xs sm:text-sm mb-6">
|
||||||
<a href="/admin" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Admin</a>
|
<a href="/admin" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Admin</a>
|
||||||
<span className="text-[var(--admin-text-muted)]">/</span>
|
<span className="text-[var(--admin-text-muted)]">/</span>
|
||||||
<span className="text-[var(--admin-text-primary)] font-medium">Stops& Routes</span>
|
<span className="text-[var(--admin-text-primary)] font-medium">Stops & Routes</span>
|
||||||
</nav>
|
</nav>
|
||||||
<h1 className="text-2xl sm:text-3xl font-black text-red-600 tracking-tight">
|
<h1 className="text-2xl sm:text-3xl font-black text-red-600 tracking-tight">
|
||||||
Error loading stops
|
Error loading stops
|
||||||
@@ -112,7 +133,12 @@ export default async function AdminStopsPage() {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||||
<StopsDashboardClient stops={stops} />
|
<StopsDashboardClient
|
||||||
|
stops={stops}
|
||||||
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalCount={totalCount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import StopsList from "./StopsList";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
stops: Stop[];
|
stops: Stop[];
|
||||||
|
page?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
totalCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TABS: { value: StopView; label: string; hint: string }[] = [
|
const TABS: { value: StopView; label: string; hint: string }[] = [
|
||||||
@@ -16,7 +19,7 @@ const TABS: { value: StopView; label: string; hint: string }[] = [
|
|||||||
{ value: "list", label: "List", hint: "All stops in order" },
|
{ value: "list", label: "List", hint: "All stops in order" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function StopsDashboardClient({ stops }: Props) {
|
export default function StopsDashboardClient({ stops, page = 1, totalPages = 1, totalCount = 0 }: Props) {
|
||||||
const [view, setView] = useState<StopView>("calendar");
|
const [view, setView] = useState<StopView>("calendar");
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -52,7 +55,7 @@ export default function StopsDashboardClient({ stops }: Props) {
|
|||||||
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.6 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
|
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0 0.6 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
@@ -134,6 +137,55 @@ export default function StopsDashboardClient({ stops }: Props) {
|
|||||||
value={stats.venues}
|
value={stats.venues}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="relative flex items-center justify-between border-t border-[var(--admin-border-light)] pt-4 mt-4">
|
||||||
|
<p className="font-mono text-xs text-[var(--admin-text-muted)]">
|
||||||
|
Showing {((page - 1) * 50) + 1}–{Math.min(page * 50, totalCount)} of {totalCount}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<a
|
||||||
|
href={page > 1 ? `?page=${page - 1}` : "#"}
|
||||||
|
aria-disabled={page <= 1}
|
||||||
|
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
|
||||||
|
page <= 1
|
||||||
|
? "text-[var(--admin-text-muted)] cursor-not-allowed pointer-events-none"
|
||||||
|
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</a>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||||
|
const p = i + 1;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={p}
|
||||||
|
href={`?page=${p}`}
|
||||||
|
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
|
||||||
|
p === page
|
||||||
|
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]"
|
||||||
|
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<a
|
||||||
|
href={page < totalPages ? `?page=${page + 1}` : "#"}
|
||||||
|
aria-disabled={page >= totalPages}
|
||||||
|
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
|
||||||
|
page >= totalPages
|
||||||
|
? "text-[var(--admin-text-muted)] cursor-not-allowed pointer-events-none"
|
||||||
|
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Active view */}
|
{/* Active view */}
|
||||||
|
|||||||
Reference in New Issue
Block a user