fifteen/apps/web/app/admin/page.tsx
2026-03-01 21:58:53 +01:00

134 lines
4.9 KiB
TypeScript

import Link from "next/link";
import { sql } from "@/lib/db";
type CityRow = {
slug: string;
name: string;
country_code: string;
status: string;
last_ingested: string | null;
poi_count: number;
grid_count: number;
error_message: string | null;
};
const STATUS_STYLES: Record<string, { label: string; className: string }> = {
ready: { label: "Ready", className: "bg-green-100 text-green-800" },
processing: { label: "Processing", className: "bg-yellow-100 text-yellow-800" },
error: { label: "Error", className: "bg-red-100 text-red-800" },
pending: { label: "Pending", className: "bg-blue-100 text-blue-800" },
empty: { label: "Empty", className: "bg-gray-100 text-gray-600" },
};
async function getCities(): Promise<CityRow[]> {
return Promise.resolve(sql<CityRow[]>`
SELECT
c.slug, c.name, c.country_code, c.status,
c.last_ingested, c.error_message,
COALESCE(p.poi_count, 0)::int AS poi_count,
COALESCE(g.grid_count, 0)::int AS grid_count
FROM cities c
LEFT JOIN (
SELECT city_slug, COUNT(*) AS poi_count FROM raw_pois GROUP BY city_slug
) p ON p.city_slug = c.slug
LEFT JOIN (
SELECT city_slug, COUNT(*) AS grid_count FROM grid_points GROUP BY city_slug
) g ON g.city_slug = c.slug
ORDER BY c.name
`);
}
export const dynamic = "force-dynamic";
export default async function AdminDashboard() {
const cities = await getCities();
return (
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">City Management</h1>
<p className="text-sm text-gray-500 mt-1">
{cities.length} {cities.length === 1 ? "city" : "cities"} configured
</p>
</div>
<Link href="/admin/cities/new" className="btn-primary">
+ Add City
</Link>
</div>
{cities.length === 0 ? (
<div className="card text-center py-12">
<p className="text-gray-500 mb-4">No cities configured yet.</p>
<Link href="/admin/cities/new" className="btn-primary">
Add your first city
</Link>
</div>
) : (
<div className="card p-0 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{["Name", "Country", "POIs", "Grid Points", "Last Ingested", "Status", "Actions"].map(
(h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide"
>
{h}
</th>
),
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{cities.map((city) => {
const badge = STATUS_STYLES[city.status] ?? STATUS_STYLES.empty;
return (
<tr key={city.slug} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{city.name}</td>
<td className="px-4 py-3 text-gray-500">
{city.country_code || "—"}
</td>
<td className="px-4 py-3 text-gray-700">
{city.poi_count.toLocaleString()}
</td>
<td className="px-4 py-3 text-gray-700">
{city.grid_count.toLocaleString()}
</td>
<td className="px-4 py-3 text-gray-500">
{city.last_ingested
? new Date(city.last_ingested).toLocaleDateString()
: "Never"}
</td>
<td className="px-4 py-3">
<span className={`badge ${badge.className}`}>
{badge.label}
{city.status === "processing" && (
<span className="ml-1 inline-block animate-spin"></span>
)}
</span>
{city.error_message && (
<p className="text-xs text-red-500 mt-1 max-w-xs truncate" title={city.error_message}>
{city.error_message}
</p>
)}
</td>
<td className="px-4 py-3">
<Link
href={`/admin/cities/${city.slug}`}
className="text-brand-600 hover:underline text-sm"
>
Manage
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}