From 889735fa7ea3c5b4da6c4335ec5506312d9fac7f Mon Sep 17 00:00:00 2001 From: BigBen-7 Date: Tue, 28 Apr 2026 21:42:11 +0000 Subject: [PATCH] feat(admin): queue snapshot card, report review list, activity section, manual entry form Closes #292 Closes #293 Closes #294 Closes #295 --- .../src/app/admin/locations/[id]/page.tsx | 8 +- .../app/admin/queue-reports/manual/page.tsx | 9 ++ .../src/app/admin/queue-reports/page.tsx | 13 ++ .../src/app/api/admin/queue-reports/route.ts | 80 +++++++++++ .../src/app/api/admin/queue-snapshot/route.ts | 40 ++++++ .../admin/ManualQueueReportForm.tsx | 131 ++++++++++++++++++ .../components/admin/QueueActivitySection.tsx | 60 ++++++++ .../admin/QueueReportReviewList.tsx | 100 +++++++++++++ .../components/admin/QueueSnapshotCard.tsx | 112 +++++++++++++++ 9 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 apps/admin-web/src/app/admin/queue-reports/manual/page.tsx create mode 100644 apps/admin-web/src/app/admin/queue-reports/page.tsx create mode 100644 apps/admin-web/src/app/api/admin/queue-reports/route.ts create mode 100644 apps/admin-web/src/app/api/admin/queue-snapshot/route.ts create mode 100644 apps/admin-web/src/components/admin/ManualQueueReportForm.tsx create mode 100644 apps/admin-web/src/components/admin/QueueActivitySection.tsx create mode 100644 apps/admin-web/src/components/admin/QueueReportReviewList.tsx create mode 100644 apps/admin-web/src/components/admin/QueueSnapshotCard.tsx diff --git a/apps/admin-web/src/app/admin/locations/[id]/page.tsx b/apps/admin-web/src/app/admin/locations/[id]/page.tsx index 6fb0413..0f03c19 100644 --- a/apps/admin-web/src/app/admin/locations/[id]/page.tsx +++ b/apps/admin-web/src/app/admin/locations/[id]/page.tsx @@ -1,5 +1,6 @@ import { cookies } from "next/headers"; import EditLocationForm from "@/components/admin/EditLocationForm"; +import QueueActivitySection from "@/components/admin/QueueActivitySection"; type LocationPayload = { id: string; @@ -33,6 +34,11 @@ export default async function EditLocationPage(props: { params: Promise<{ id: st } } - return ; + return ( + <> + + + + ); } diff --git a/apps/admin-web/src/app/admin/queue-reports/manual/page.tsx b/apps/admin-web/src/app/admin/queue-reports/manual/page.tsx new file mode 100644 index 0000000..97a6499 --- /dev/null +++ b/apps/admin-web/src/app/admin/queue-reports/manual/page.tsx @@ -0,0 +1,9 @@ +import ManualQueueReportForm from "@/components/admin/ManualQueueReportForm"; + +export default function ManualQueueReportPage() { + return ( +
+ +
+ ); +} diff --git a/apps/admin-web/src/app/admin/queue-reports/page.tsx b/apps/admin-web/src/app/admin/queue-reports/page.tsx new file mode 100644 index 0000000..7f49cb8 --- /dev/null +++ b/apps/admin-web/src/app/admin/queue-reports/page.tsx @@ -0,0 +1,13 @@ +import QueueReportReviewList from "@/components/admin/QueueReportReviewList"; + +export default function QueueReportsPage() { + return ( +
+

Queue Report Review

+

+ Incoming crowd-sourced queue reports from mobile contributors. +

+ +
+ ); +} diff --git a/apps/admin-web/src/app/api/admin/queue-reports/route.ts b/apps/admin-web/src/app/api/admin/queue-reports/route.ts new file mode 100644 index 0000000..d7160fe --- /dev/null +++ b/apps/admin-web/src/app/api/admin/queue-reports/route.ts @@ -0,0 +1,80 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import type { ApiResponse, QueueReport } from "@qyou/types"; + +export async function GET(req: NextRequest) { + const cookieStore = await cookies(); + const token = cookieStore.get(process.env.ADMIN_AUTH_COOKIE || "admin_token")?.value ?? ""; + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ""; + + if (!apiBase || !token) { + return NextResponse.json({ success: false, error: { code: "AUTH_ERROR", message: "Not authenticated" } }, { status: 401 }); + } + + const locationId = req.nextUrl.searchParams.get("locationId"); + const url = locationId + ? `${apiBase}/admin/queue-reports?locationId=${encodeURIComponent(locationId)}` + : `${apiBase}/admin/queue-reports`; + + try { + const res = await fetch(url, { + cache: "no-store", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + return NextResponse.json( + { success: false, error: { code: "UPSTREAM_ERROR", message: `Upstream returned ${res.status}` } }, + { status: res.status } + ); + } + + const payload = (await res.json()) as ApiResponse<{ reports: QueueReport[] }>; + return NextResponse.json(payload); + } catch { + return NextResponse.json( + { success: false, error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch queue reports" } }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + const cookieStore = await cookies(); + const token = cookieStore.get(process.env.ADMIN_AUTH_COOKIE || "admin_token")?.value ?? ""; + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ""; + + if (!apiBase || !token) { + return NextResponse.json({ success: false, error: { code: "AUTH_ERROR", message: "Not authenticated" } }, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + const res = await fetch(`${apiBase}/queues/report`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + return NextResponse.json( + { success: false, error: { code: "UPSTREAM_ERROR", message: `Upstream returned ${res.status}` } }, + { status: res.status } + ); + } + + const payload = (await res.json()) as ApiResponse; + return NextResponse.json(payload); + } catch { + return NextResponse.json( + { success: false, error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to submit queue report" } }, + { status: 500 } + ); + } +} diff --git a/apps/admin-web/src/app/api/admin/queue-snapshot/route.ts b/apps/admin-web/src/app/api/admin/queue-snapshot/route.ts new file mode 100644 index 0000000..a14bda9 --- /dev/null +++ b/apps/admin-web/src/app/api/admin/queue-snapshot/route.ts @@ -0,0 +1,40 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import type { ApiResponse, QueueSnapshot } from "@qyou/types"; + +export async function GET(req: NextRequest) { + const cookieStore = await cookies(); + const token = cookieStore.get(process.env.ADMIN_AUTH_COOKIE || "admin_token")?.value ?? ""; + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ""; + + if (!apiBase || !token) { + return NextResponse.json({ success: false, error: { code: "AUTH_ERROR", message: "Not authenticated" } }, { status: 401 }); + } + + const locationId = req.nextUrl.searchParams.get("locationId"); + if (!locationId) { + return NextResponse.json({ success: false, error: { code: "VALIDATION_ERROR", message: "locationId is required" } }, { status: 400 }); + } + + try { + const res = await fetch(`${apiBase}/queues/${encodeURIComponent(locationId)}`, { + cache: "no-store", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + return NextResponse.json( + { success: false, error: { code: "UPSTREAM_ERROR", message: `Upstream returned ${res.status}` } }, + { status: res.status } + ); + } + + const payload = (await res.json()) as ApiResponse<{ snapshot: QueueSnapshot }>; + return NextResponse.json(payload); + } catch { + return NextResponse.json( + { success: false, error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch queue snapshot" } }, + { status: 500 } + ); + } +} diff --git a/apps/admin-web/src/components/admin/ManualQueueReportForm.tsx b/apps/admin-web/src/components/admin/ManualQueueReportForm.tsx new file mode 100644 index 0000000..675ff50 --- /dev/null +++ b/apps/admin-web/src/components/admin/ManualQueueReportForm.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState } from "react"; +import type { QueueLevel } from "@qyou/types"; + +const LEVELS: QueueLevel[] = ["none", "low", "medium", "high", "unknown"]; + +type FormState = + | { status: "idle" } + | { status: "submitting" } + | { status: "success"; message: string } + | { status: "error"; message: string }; + +export default function ManualQueueReportForm() { + const [locationId, setLocationId] = useState(""); + const [level, setLevel] = useState("medium"); + const [waitTime, setWaitTime] = useState(""); + const [notes, setNotes] = useState(""); + const [formState, setFormState] = useState({ status: "idle" }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!locationId.trim()) { + setFormState({ status: "error", message: "Location ID is required." }); + return; + } + + setFormState({ status: "submitting" }); + + try { + const res = await fetch("/api/admin/queue-reports", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + locationId: locationId.trim(), + level, + waitTimeMinutes: waitTime ? Number(waitTime) : undefined, + notes: notes.trim() || undefined, + }), + }); + + const json = (await res.json()) as { success: boolean; error?: { message: string } }; + if (!json.success) throw new Error(json.error?.message ?? "Submission failed"); + + setFormState({ status: "success", message: "Report submitted successfully." }); + setLocationId(""); + setLevel("medium"); + setWaitTime(""); + setNotes(""); + } catch (err: unknown) { + setFormState({ status: "error", message: err instanceof Error ? err.message : "Submission failed" }); + } + }; + + return ( +
+

Manual Queue Report Entry

+

Seed queue activity for testing without mobile use.

+ + {formState.status === "success" && ( +
{formState.message}
+ )} + {formState.status === "error" && ( +
{formState.message}
+ )} + + + + + + + +