134 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|