From 79728be5e115fdc9508d9b2200c78885386e1f0f Mon Sep 17 00:00:00 2001 From: Vivek Agarwal <62823320+singhalvivek@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:49:01 +0530 Subject: [PATCH 1/5] Create Event fix (#76) --- .../app/components/explorer/AuthWidget.tsx | 9 +- code/src/app/ops/events/page.tsx | 1038 +++++++++-------- code/src/app/ops/lib/constants.ts | 3 + 3 files changed, 576 insertions(+), 474 deletions(-) diff --git a/code/src/app/components/explorer/AuthWidget.tsx b/code/src/app/components/explorer/AuthWidget.tsx index dec7817..bc0f393 100644 --- a/code/src/app/components/explorer/AuthWidget.tsx +++ b/code/src/app/components/explorer/AuthWidget.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import { LogOut, MapPin } from "lucide-react"; import { useProfile } from "../feed/useProfile"; import { clearTokens, isAuthenticated, buildGoogleAuthUrl } from "../../ops/lib/auth"; @@ -12,7 +13,13 @@ import { GOOGLE_CLIENT_ID } from "../../ops/lib/constants"; */ export default function AuthWidget() { const { profile, isAdmin, loading } = useProfile(); - const loggedIn = typeof window !== "undefined" && isAuthenticated(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const loggedIn = mounted && isAuthenticated(); function signIn() { // Map sign-in: return to the map (home). Not the report flow. diff --git a/code/src/app/ops/events/page.tsx b/code/src/app/ops/events/page.tsx index 998afac..e122013 100644 --- a/code/src/app/ops/events/page.tsx +++ b/code/src/app/ops/events/page.tsx @@ -1,473 +1,565 @@ -"use client"; - -import { useEffect, useState, useCallback } from "react"; -import { adminFetch } from "../lib/api"; -import { ADMIN_API } from "../lib/constants"; -import { AdminEvent } from "../lib/types"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Check, - X, - Loader2, - RefreshCw, - PartyPopper, - Calendar, - CheckCircle2, - XCircle, - Plus, -} from "lucide-react"; -import { toast } from "sonner"; - -function formatDate(dateStr: string | null) { - if (!dateStr) return "—"; - return new Date(dateStr).toLocaleDateString("en-IN", { - day: "numeric", - month: "short", - year: "numeric", - }); -} - -const STATUS_STYLES: Record = { - APPROVED: "bg-emerald-600 text-white", - REJECTED: "bg-red-600 text-white", - PENDING: "bg-amber-600 text-white", -}; - -export default function EventsPage() { - const [view, setView] = useState<"pending" | "history">("pending"); - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(true); - const [processing, setProcessing] = useState>(new Set()); - // Map of eventId → current display name input value - const [displayNames, setDisplayNames] = useState>({}); - - const [showCreateForm, setShowCreateForm] = useState(false); - const [creating, setCreating] = useState(false); - const [createForm, setCreateForm] = useState({ - name: "", - organisation: "", - address: "", - start_time: "", - end_time: "", - link: "", - type: "OTHER", - }); - - const loadEvents = useCallback(async (signal?: AbortSignal) => { - setLoading(true); - try { - const url = view === "pending" ? ADMIN_API.pendingEvents : ADMIN_API.eventHistory; - const res = await adminFetch(url, { signal }); - if (!res.ok) throw new Error(`Failed: ${res.status}`); - const json = await res.json(); - const list: AdminEvent[] = json.data?.events ?? []; - setEvents(list); - // Pre-fill display name inputs: use existing override if set, else original name - const names: Record = {}; - list.forEach((e) => { - names[e.id] = e.display_name ?? e.name; - }); - setDisplayNames(names); - } catch (err: unknown) { - if (err instanceof DOMException && err.name === "AbortError") return; // stale request, ignore - toast.error(`Failed to load events: ${err}`); - } finally { - setLoading(false); - } - }, [view]); - - useEffect(() => { - setEvents([]); // clear stale data immediately when tab changes - const controller = new AbortController(); - loadEvents(controller.signal); - return () => controller.abort(); - }, [loadEvents]); - - function setProcessingState(id: number, active: boolean) { - setProcessing((prev) => { - const next = new Set(prev); - if (active) { next.add(id); } else { next.delete(id); } - return next; - }); - } - - async function handleApprove(event: AdminEvent) { - setProcessingState(event.id, true); - try { - const currentDisplayName = displayNames[event.id]?.trim(); - // Only send display_name if it differs from the original scraped name - const body = - currentDisplayName && currentDisplayName !== event.name - ? { display_name: currentDisplayName } - : {}; - - const res = await adminFetch(ADMIN_API.approveEvent(event.id), { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) throw new Error(`${res.status}`); - toast.success(`Approved: ${currentDisplayName || event.name}`); - setEvents((prev) => prev.filter((e) => e.id !== event.id)); - } catch (err) { - toast.error(`Failed to approve: ${err}`); - } finally { - setProcessingState(event.id, false); - } - } - - function setField(field: string, value: string) { - setCreateForm((prev) => ({ ...prev, [field]: value })); - } - - // datetime-local gives "YYYY-MM-DDTHH:mm" — backend needs seconds - function toLocalDateTime(val: string): string | null { - if (!val) return null; - return val.length === 16 ? val + ":00" : val; - } - - async function handleCreate(e: React.FormEvent) { - e.preventDefault(); - setCreating(true); - try { - const res = await adminFetch(ADMIN_API.createEvent, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: createForm.name, - organisation: createForm.organisation || null, - address: createForm.address, - start_time: toLocalDateTime(createForm.start_time), - end_time: toLocalDateTime(createForm.end_time) || null, - link: createForm.link, - type: createForm.type, - }), - }); - if (!res.ok) throw new Error(`${res.status}`); - toast.success("Event created and live on /api/v1/events"); - setShowCreateForm(false); - setCreateForm({ name: "", organisation: "", address: "", start_time: "", end_time: "", link: "", type: "OTHER" }); - setView("history"); - loadEvents(); - } catch (err) { - toast.error(`Failed to create event: ${err}`); - } finally { - setCreating(false); - } - } - - async function handleReject(eventId: number) { - setProcessingState(eventId, true); - try { - const res = await adminFetch(ADMIN_API.rejectEvent(eventId), { method: "PUT" }); - if (!res.ok) throw new Error(`${res.status}`); - toast.success("Event rejected"); - setEvents((prev) => prev.filter((e) => e.id !== eventId)); - } catch (err) { - toast.error(`Failed to reject: ${err}`); - } finally { - setProcessingState(eventId, false); - } - } - - return ( -
- {/* Header */} -
-

- {view === "pending" ? `Pending Events (${events.length})` : `Event History (${events.length})`} -

- - {/* View toggle */} -
- - -
- - - - -
- - {/* Create Event modal */} - {showCreateForm && ( -
-
-

Create Event

-
-
- - setField("name", e.target.value)} - placeholder="Event name" - className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" - /> -
-
- - setField("organisation", e.target.value)} - placeholder="Organising body (optional)" - className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" - /> -
-
- - setField("address", e.target.value)} - placeholder="Full address for geocoding" - className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" - /> -
-
-
- - setField("start_time", e.target.value)} - className="w-full h-8 rounded-md border border-zinc-700 bg-zinc-800 px-3 text-sm text-zinc-200 focus:outline-none focus:ring-1 focus:ring-zinc-500" - /> -
-
- - setField("end_time", e.target.value)} - className="w-full h-8 rounded-md border border-zinc-700 bg-zinc-800 px-3 text-sm text-zinc-200 focus:outline-none focus:ring-1 focus:ring-zinc-500" - /> -
-
-
- - setField("link", e.target.value)} - placeholder="https://example.com/event" - className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" - /> -
-
- - -
-
- - -
-
-
-
- )} - - {loading ? ( -
- -
- ) : events.length === 0 ? ( -
- {view === "pending" ? ( - <> - -

All caught up — no pending events

- - ) : ( - <> - -

No reviewed events yet

- - )} -
- ) : ( -
- - - - - - - - - {view === "history" && } - {view === "pending" && } - - - - {events.map((event) => { - const busy = processing.has(event.id); - return ( - - {/* Name + editable display name */} - - - {/* Portal */} - - - {/* Type */} - - - {/* Date */} - - - {/* Location */} - - - {/* History: status badge */} - {view === "history" && ( - - )} - - {/* Pending: action buttons */} - {view === "pending" && ( - - )} - - ); - })} - -
Name / Display namePortalTypeDateLocationStatusActions
- - {event.name} - - {view === "pending" ? ( - - setDisplayNames((prev) => ({ ...prev, [event.id]: e.target.value })) - } - placeholder="Display name (leave blank to keep original)" - className="mt-1 h-7 text-xs bg-zinc-800 border-zinc-700 text-zinc-200 placeholder:text-zinc-600" - /> - ) : ( - event.display_name && event.display_name !== event.name && ( -

- → {event.display_name} -

- ) - )} -
- {event.portal ?? "—"} - - - {event.type ?? "—"} - - - {formatDate(event.start_time)} - - - {event.location?.name ?? event.address ?? "—"} - - {event.location?.locality?.hashtags?.[0] && ( - - #{event.location.locality.hashtags[0]} - - )} - - - {event.approval_status === "APPROVED" ? ( - - ) : ( - - )} - {event.approval_status} - - -
- - -
-
-
- )} -
- ); -} +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { adminFetch } from "../lib/api"; +import { ADMIN_API } from "../lib/constants"; +import { AdminEvent } from "../lib/types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Check, + X, + Loader2, + RefreshCw, + PartyPopper, + Calendar, + CheckCircle2, + XCircle, + Plus, + Pencil, +} from "lucide-react"; +import { toast } from "sonner"; + +function formatDate(dateStr: string | null) { + if (!dateStr) return "—"; + return new Date(dateStr).toLocaleDateString("en-IN", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +function toDatetimeLocal(iso: string | null): string { + if (!iso) return ""; + return iso.slice(0, 16); +} + +const STATUS_STYLES: Record = { + APPROVED: "bg-emerald-600 text-white", + REJECTED: "bg-red-600 text-white", + PENDING: "bg-amber-600 text-white", +}; + +const EVENT_TYPES = [ + "OTHER","CLEANLINESS_DRIVE","BEACH_CLEANUP","ROAD_CLEANUP","FOREST_CLEANUP", + "TREEPLANTATION","TREKANDPLOG","VOLUNTEERING","WORKSHOP", +]; + +export default function EventsPage() { + const [view, setView] = useState<"pending" | "history">("pending"); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [processing, setProcessing] = useState>(new Set()); + + // ── Create form ── + const [showCreateForm, setShowCreateForm] = useState(false); + const [creating, setCreating] = useState(false); + const [createForm, setCreateForm] = useState({ + name: "", organisation: "", address: "", + start_time: "", end_time: "", link: "", type: "OTHER", image_url: "", + }); + + // ── Edit form ── + const [editingEvent, setEditingEvent] = useState(null); + const [saving, setSaving] = useState(false); + const [editForm, setEditForm] = useState({ + name: "", organisation: "", address: "", + start_time: "", end_time: "", link: "", type: "OTHER", display_name: "", + }); + + + const loadEvents = useCallback(async (signal?: AbortSignal) => { + setLoading(true); + try { + const url = view === "pending" ? ADMIN_API.pendingEvents : ADMIN_API.eventHistory; + const res = await adminFetch(url, { signal }); + if (!res.ok) throw new Error(`Failed: ${res.status}`); + const json = await res.json(); + const list: AdminEvent[] = json.data?.events ?? []; + setEvents(list); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === "AbortError") return; + toast.error(`Failed to load events: ${err}`); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, [view]); + + useEffect(() => { + setEvents([]); + const controller = new AbortController(); + loadEvents(controller.signal); + return () => controller.abort(); + }, [loadEvents]); + + function setProcessingState(id: number, active: boolean) { + setProcessing((prev) => { + const next = new Set(prev); + if (active) { next.add(id); } else { next.delete(id); } + return next; + }); + } + + // datetime-local gives "YYYY-MM-DDTHH:mm" — backend needs seconds + function toLocalDateTime(val: string): string | null { + if (!val) return null; + return val.length === 16 ? val + ":00" : val; + } + + // ── Approve ── + async function handleApprove(event: AdminEvent) { + setProcessingState(event.id, true); + try { + const res = await adminFetch(ADMIN_API.approveEvent(event.id), { method: "PUT" }); + if (!res.ok) throw new Error(`${res.status}`); + toast.success(`Approved: ${event.display_name ?? event.name}`); + setEvents((prev) => prev.filter((e) => e.id !== event.id)); + } catch (err) { + toast.error(`Failed to approve: ${err}`); + } finally { + setProcessingState(event.id, false); + } + } + + // ── Reject ── + async function handleReject(eventId: number) { + setProcessingState(eventId, true); + try { + const res = await adminFetch(ADMIN_API.rejectEvent(eventId), { method: "PUT" }); + if (!res.ok) throw new Error(`${res.status}`); + toast.success("Event rejected"); + setEvents((prev) => prev.filter((e) => e.id !== eventId)); + } catch (err) { + toast.error(`Failed to reject: ${err}`); + } finally { + setProcessingState(eventId, false); + } + } + + // ── Create ── + function setCreateField(field: string, value: string) { + setCreateForm((prev) => ({ ...prev, [field]: value })); + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setCreating(true); + try { + const res = await adminFetch(ADMIN_API.createEvent, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: createForm.name, + organisation: createForm.organisation || null, + address: createForm.address, + start_time: toLocalDateTime(createForm.start_time), + end_time: toLocalDateTime(createForm.end_time) || null, + link: createForm.link, + type: createForm.type, + image_url: createForm.image_url || null, + }), + }); + if (!res.ok) { + let msg = `${res.status}`; + try { + const body = await res.json(); + if (body?.message) msg = body.message; + } catch { /* ignore */ } + throw new Error(msg); + } + toast.success("Event created and live"); + setShowCreateForm(false); + setCreateForm({ name: "", organisation: "", address: "", start_time: "", end_time: "", link: "", type: "OTHER", image_url: "" }); + setView("history"); + loadEvents(); + } catch (err) { + toast.error(`Failed to create event: ${err}`); + } finally { + setCreating(false); + } + } + + // ── Edit ── + function openEdit(event: AdminEvent) { + setEditForm({ + name: event.name, + organisation: event.organisation ?? "", + address: event.address ?? "", + start_time: toDatetimeLocal(event.start_time), + end_time: toDatetimeLocal(event.end_time), + link: event.link, + type: event.type ?? "OTHER", + display_name: event.display_name ?? "", + }); + setEditingEvent(event); + } + + function setEditField(field: string, value: string) { + setEditForm((prev) => ({ ...prev, [field]: value })); + } + + async function handleSaveEdit(e: React.FormEvent) { + e.preventDefault(); + if (!editingEvent) return; + setSaving(true); + try { + const res = await adminFetch(ADMIN_API.editEvent(editingEvent.id), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: editForm.name || null, + organisation: editForm.organisation || null, + address: editForm.address || null, + start_time: toLocalDateTime(editForm.start_time), + end_time: toLocalDateTime(editForm.end_time) || null, + link: editForm.link || null, + type: editForm.type || null, + display_name: editForm.display_name || null, + }), + }); + if (!res.ok) throw new Error(`${res.status}`); + toast.success("Event updated"); + setEditingEvent(null); + loadEvents(); + } catch (err) { + toast.error(`Failed to update event: ${err}`); + } finally { + setSaving(false); + } + } + + // ── History toggle (reject approved / approve rejected) ── + async function handleHistoryToggle(event: AdminEvent) { + const isApproved = event.approval_status === "APPROVED"; + setProcessingState(event.id, true); + try { + const url = isApproved ? ADMIN_API.rejectEvent(event.id) : ADMIN_API.approveEvent(event.id); + const res = await adminFetch(url, { method: "PUT" }); + if (!res.ok) throw new Error(`${res.status}`); + const newStatus = isApproved ? "REJECTED" : "APPROVED"; + toast.success(isApproved ? "Event rejected" : "Event approved"); + setEvents((prev) => + prev.map((e) => (e.id === event.id ? { ...e, approval_status: newStatus } : e)) + ); + } catch (err) { + toast.error(`Failed: ${err}`); + } finally { + setProcessingState(event.id, false); + } + } + + return ( +
+ {/* Header */} +
+

+ {view === "pending" + ? `Pending Events (${events.length})` + : `Event History (${events.length})`} +

+ + {/* View toggle */} +
+ + +
+ + + + +
+ + {/* ── Create Event modal ── */} + {showCreateForm && ( +
+
+

Create Event

+
+
+ + setCreateField("name", e.target.value)} + placeholder="Event name" className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" /> +
+
+ + setCreateField("organisation", e.target.value)} + placeholder="Organising body (optional)" className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" /> +
+
+ + setCreateField("address", e.target.value)} + placeholder="Full address for geocoding" className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" /> +
+
+
+ + setCreateField("start_time", e.target.value)} + className="w-full h-8 rounded-md border border-zinc-700 bg-zinc-800 px-3 text-sm text-zinc-200 focus:outline-none focus:ring-1 focus:ring-zinc-500" /> +
+
+ + setCreateField("end_time", e.target.value)} + className="w-full h-8 rounded-md border border-zinc-700 bg-zinc-800 px-3 text-sm text-zinc-200 focus:outline-none focus:ring-1 focus:ring-zinc-500" /> +
+
+
+ + setCreateField("link", e.target.value)} + placeholder="https://example.com/event" className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" /> +
+
+ + +
+
+ + setCreateField("image_url", e.target.value)} + placeholder="https://cdn.example.com/banner.jpg" className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" /> +
+
+ + +
+
+
+
+ )} + + {/* ── Edit Event modal ── */} + {editingEvent && ( +
+
+

Edit Event

+
+
+ + setEditField("name", e.target.value)} + placeholder="Event name" className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" /> +
+
+ + setEditField("display_name", e.target.value)} + placeholder="Override displayed to users (leave blank to use name)" className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" /> +
+
+ + setEditField("organisation", e.target.value)} + placeholder="Organising body" className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" /> +
+
+ + setEditField("address", e.target.value)} + placeholder="Full address" className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" /> +
+
+
+ + setEditField("start_time", e.target.value)} + className="w-full h-8 rounded-md border border-zinc-700 bg-zinc-800 px-3 text-sm text-zinc-200 focus:outline-none focus:ring-1 focus:ring-zinc-500" /> +
+
+ + setEditField("end_time", e.target.value)} + className="w-full h-8 rounded-md border border-zinc-700 bg-zinc-800 px-3 text-sm text-zinc-200 focus:outline-none focus:ring-1 focus:ring-zinc-500" /> +
+
+
+ + setEditField("link", e.target.value)} + placeholder="https://example.com/event" className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200" /> +
+
+ + +
+
+ + +
+
+
+
+ )} + + + {loading ? ( +
+ +

Loading events…

+
+ ) : events.length === 0 ? ( +
+ {view === "pending" ? ( + <> + +

All caught up — no pending events

+ + ) : ( + <> + +

No reviewed events yet

+ + )} +
+ ) : ( +
+ + + + + + + + + {view === "history" && } + + + + + {events.map((event) => { + const busy = processing.has(event.id); + return ( + + {/* Name */} + + + {/* Portal */} + + + {/* Type */} + + + {/* Date */} + + + {/* Location */} + + + {/* History: status badge */} + {view === "history" && ( + + )} + + {/* Actions */} + + + ); + })} + +
NamePortalTypeDateLocationStatusActions
+ + {event.display_name ?? event.name} + + {event.display_name && event.display_name !== event.name && ( +

({event.name})

+ )} +
+ {event.portal ?? "—"} + + + {event.type ?? "—"} + + + {formatDate(event.start_time)} + + + {event.location?.name ?? event.address ?? "—"} + + {event.location?.locality?.hashtags?.[0] && ( + #{event.location.locality.hashtags[0]} + )} + + + {event.approval_status === "APPROVED" ? ( + + ) : ( + + )} + {event.approval_status} + + +
+ {/* Edit — available in both views */} + + + {view === "pending" ? ( + <> + + + + ) : ( + event.approval_status === "APPROVED" ? ( + + ) : ( + + ) + )} +
+
+
+ )} +
+ ); +} diff --git a/code/src/app/ops/lib/constants.ts b/code/src/app/ops/lib/constants.ts index ee064f6..49f2bda 100644 --- a/code/src/app/ops/lib/constants.ts +++ b/code/src/app/ops/lib/constants.ts @@ -56,4 +56,7 @@ export const ADMIN_API = { /** Manually create an event via the ops portal */ createEvent: `${BASE_URL}/admin/event/manual`, + + /** Edit an existing event */ + editEvent: (eventId: number) => `${BASE_URL}/admin/event/${eventId}`, }; From cd4bfe627cf2aff72b57f5f1e399bcf55d5397c7 Mon Sep 17 00:00:00 2001 From: Prachi Nayak Date: Fri, 12 Jun 2026 11:23:01 +0530 Subject: [PATCH 2/5] City Bulletin Admin Panel (#79) --- code/src/app/ops/bulletins/page.tsx | 274 ++++++++++++++++ code/src/app/ops/layout.tsx | 24 ++ code/src/app/ops/lib/constants.ts | 41 +++ code/src/app/ops/lib/types.ts | 46 +++ code/src/app/ops/quizzes/page.tsx | 488 ++++++++++++++++++++++++++++ 5 files changed, 873 insertions(+) create mode 100644 code/src/app/ops/bulletins/page.tsx create mode 100644 code/src/app/ops/quizzes/page.tsx diff --git a/code/src/app/ops/bulletins/page.tsx b/code/src/app/ops/bulletins/page.tsx new file mode 100644 index 0000000..584eec1 --- /dev/null +++ b/code/src/app/ops/bulletins/page.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { adminFetch } from "../lib/api"; +import { ADMIN_API } from "../lib/constants"; +import { AdminBulletin, AdminLocalityOption } from "../lib/types"; +import { Button } from "@/components/ui/button"; +import { Loader2, RefreshCw, Newspaper, Pencil, Play, Check, X } from "lucide-react"; +import { toast } from "sonner"; + +function todayISO(): string { + return new Date().toISOString().slice(0, 10); +} + +function fmt(value: number | null | undefined, suffix = ""): string { + return value === null || value === undefined ? "—" : `${Math.round(value)}${suffix}`; +} + +export default function BulletinsPage() { + const [bulletins, setBulletins] = useState([]); + const [localities, setLocalities] = useState([]); + const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(false); + + // filters + const [filterLocality, setFilterLocality] = useState(""); + const [filterDate, setFilterDate] = useState(todayISO()); + + // inline summary editing + const [editingId, setEditingId] = useState(null); + const [summaryDraft, setSummaryDraft] = useState(""); + const [savingSummary, setSavingSummary] = useState(false); + + const loadBulletins = useCallback( + async (signal?: AbortSignal) => { + setLoading(true); + try { + const url = ADMIN_API.bulletins( + filterLocality ? Number(filterLocality) : null, + filterDate || null, + ); + const res = await adminFetch(url, { signal }); + if (!res.ok) throw new Error(`${res.status}`); + const json = await res.json(); + setBulletins(json.data ?? []); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === "AbortError") return; + toast.error(`Failed to load bulletins: ${err}`); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, + [filterLocality, filterDate], + ); + + useEffect(() => { + const controller = new AbortController(); + loadBulletins(controller.signal); + return () => controller.abort(); + }, [loadBulletins]); + + useEffect(() => { + (async () => { + try { + const res = await adminFetch(ADMIN_API.quizLocalities); + if (!res.ok) throw new Error(`${res.status}`); + const json = await res.json(); + setLocalities(json.data ?? []); + } catch (err) { + toast.error(`Failed to load localities: ${err}`); + } + })(); + }, []); + + /** Manual run of the daily weather generation (idempotent per locality+day). */ + async function handleGenerate() { + setGenerating(true); + try { + const res = await adminFetch(ADMIN_API.generateBulletins, { method: "POST" }); + if (!res.ok) throw new Error(`${res.status}`); + const json = await res.json(); + const r = json.data; + toast.success( + `Generation done: ${r?.generated ?? 0} generated, ${r?.skipped ?? 0} skipped, ${r?.failed ?? 0} failed`, + ); + loadBulletins(); + } catch (err) { + toast.error(`Generation failed: ${err}`); + } finally { + setGenerating(false); + } + } + + function openSummaryEdit(bulletin: AdminBulletin) { + setEditingId(bulletin.id); + setSummaryDraft(bulletin.summary ?? ""); + } + + async function handleSaveSummary(bulletinId: number) { + setSavingSummary(true); + try { + const res = await adminFetch(ADMIN_API.editBulletinSummary(bulletinId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ summary: summaryDraft }), + }); + if (!res.ok) throw new Error(`${res.status}`); + toast.success("Summary updated (feed post synced)"); + setBulletins((prev) => + prev.map((b) => (b.id === bulletinId ? { ...b, summary: summaryDraft } : b)), + ); + setEditingId(null); + } catch (err) { + toast.error(`Failed to update summary: ${err}`); + } finally { + setSavingSummary(false); + } + } + + return ( +
+ {/* Header + filters */} +
+

+ Bulletins ({bulletins.length}) +

+ + + + setFilterDate(e.target.value)} + className="h-8 rounded-md border border-zinc-700 bg-zinc-800 px-3 text-sm text-zinc-200 focus:outline-none focus:ring-1 focus:ring-zinc-500" + /> + + + + +
+ + {loading ? ( +
+ +

Loading bulletins…

+
+ ) : bulletins.length === 0 ? ( +
+ +

No bulletins for this filter

+

+ The weather job runs daily at 8 AM for saved-user localities — or use Generate Now. +

+
+ ) : ( +
+ + + + + + + + + + + + + + {bulletins.map((b) => ( + + + + + + + +
LocalityDateMin / MaxHumidityRainQuizAI Summary
+ {b.locality_name} +

{b.hashtag}

+
{b.date} + {fmt(b.weather?.min_temp, "°")} / {fmt(b.weather?.max_temp, "°")} + + {fmt(b.weather?.humidity, "%")} + + {fmt(b.weather?.rain_probability, "%")} + + {b.quiz_id ? ( + #{b.quiz_id} + ) : ( + missing + )} + + {editingId === b.id ? ( +
+