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 (
+
+ );
+}
+
+const styles: Record = {
+ form: { maxWidth: 480, display: "flex", flexDirection: "column", gap: 16 },
+ heading: { fontSize: 18, fontWeight: 700, margin: 0 },
+ description: { color: "#64748b", fontSize: 13, margin: 0 },
+ label: { display: "flex", flexDirection: "column", gap: 4, fontSize: 13, fontWeight: 500, color: "#334155" },
+ input: { padding: "8px 10px", border: "1px solid #cbd5e1", borderRadius: 6, fontSize: 14, outline: "none" },
+ button: { padding: "10px 20px", background: "#3b82f6", color: "#fff", border: "none", borderRadius: 6, fontWeight: 600, cursor: "pointer", fontSize: 14 },
+ successBanner: { background: "#dcfce7", color: "#166534", borderRadius: 6, padding: "8px 12px", fontSize: 13 },
+ errorBanner: { background: "#fee2e2", color: "#991b1b", borderRadius: 6, padding: "8px 12px", fontSize: 13 },
+};
diff --git a/apps/admin-web/src/components/admin/QueueActivitySection.tsx b/apps/admin-web/src/components/admin/QueueActivitySection.tsx
new file mode 100644
index 0000000..7934885
--- /dev/null
+++ b/apps/admin-web/src/components/admin/QueueActivitySection.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { QueueSnapshot } from "@qyou/types";
+import QueueSnapshotCard from "./QueueSnapshotCard";
+import QueueReportReviewList from "./QueueReportReviewList";
+
+type Props = {
+ locationId: string;
+};
+
+export default function QueueActivitySection({ locationId }: Props) {
+ const [snapshot, setSnapshot] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setLoading(true);
+ fetch(`/api/admin/queue-snapshot?locationId=${encodeURIComponent(locationId)}`)
+ .then(async (res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const json = (await res.json()) as { success: boolean; data?: { snapshot: QueueSnapshot }; error?: { message: string } };
+ if (!json.success) throw new Error(json.error?.message ?? "Unknown error");
+ setSnapshot(json.data?.snapshot ?? null);
+ })
+ .catch((err: unknown) => {
+ setError(err instanceof Error ? err.message : "Failed to load snapshot");
+ })
+ .finally(() => setLoading(false));
+ }, [locationId]);
+
+ return (
+
+ Queue Activity
+
+
+
Current Snapshot
+ {error ? (
+
{error}
+ ) : (
+
+ )}
+
+
+
+
Recent Reports
+
+
+
+ );
+}
+
+const styles: Record = {
+ section: { marginTop: 32 },
+ heading: { fontSize: 18, fontWeight: 700, marginBottom: 16 },
+ subheading: { fontSize: 14, fontWeight: 600, color: "#475569", marginBottom: 8 },
+ snapshotWrap: { marginBottom: 24 },
+ reportsWrap: {},
+ error: { color: "#ef4444", fontSize: 13 },
+};
diff --git a/apps/admin-web/src/components/admin/QueueReportReviewList.tsx b/apps/admin-web/src/components/admin/QueueReportReviewList.tsx
new file mode 100644
index 0000000..c64d000
--- /dev/null
+++ b/apps/admin-web/src/components/admin/QueueReportReviewList.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { QueueReport } from "@qyou/types";
+
+type Props = {
+ locationId?: string;
+};
+
+type FetchState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | { status: "success"; reports: QueueReport[] }
+ | { status: "error"; message: string };
+
+const LEVEL_COLORS: Record = {
+ none: "#22c55e",
+ low: "#84cc16",
+ medium: "#f59e0b",
+ high: "#ef4444",
+ unknown: "#94a3b8",
+};
+
+export default function QueueReportReviewList({ locationId }: Props) {
+ const [state, setState] = useState({ status: "idle" });
+
+ useEffect(() => {
+ setState({ status: "loading" });
+ const url = locationId
+ ? `/api/admin/queue-reports?locationId=${encodeURIComponent(locationId)}`
+ : `/api/admin/queue-reports`;
+
+ fetch(url)
+ .then(async (res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const json = (await res.json()) as { success: boolean; data?: { reports: QueueReport[] }; error?: { message: string } };
+ if (!json.success) throw new Error(json.error?.message ?? "Unknown error");
+ setState({ status: "success", reports: json.data?.reports ?? [] });
+ })
+ .catch((err: unknown) => {
+ setState({ status: "error", message: err instanceof Error ? err.message : "Failed to load reports" });
+ });
+ }, [locationId]);
+
+ if (state.status === "loading" || state.status === "idle") {
+ return Loading reports…
;
+ }
+
+ if (state.status === "error") {
+ return Error: {state.message}
;
+ }
+
+ const { reports } = state;
+
+ if (reports.length === 0) {
+ return No queue reports found.
;
+ }
+
+ return (
+
+
+
+
+ | Level |
+ Wait (min) |
+ Notes |
+ Reported At |
+ User |
+
+
+
+ {reports.map((r) => (
+
+ |
+
+ {r.level}
+
+ |
+ {r.waitTimeMinutes ?? "—"} |
+ {r.notes || "—"} |
+ {new Date(r.reportedAt).toLocaleString()} |
+ {r.userId} |
+
+ ))}
+
+
+
+ );
+}
+
+const styles: Record = {
+ container: { overflowX: "auto" },
+ table: { width: "100%", borderCollapse: "collapse", fontSize: 13 },
+ th: { textAlign: "left", padding: "8px 12px", borderBottom: "2px solid #e2e8f0", color: "#475569", fontWeight: 600 },
+ tr: { borderBottom: "1px solid #f1f5f9" },
+ td: { padding: "8px 12px", color: "#334155" },
+ badge: { color: "#fff", borderRadius: 4, padding: "2px 8px", fontWeight: 600, fontSize: 12 },
+ muted: { color: "#94a3b8", margin: 0 },
+ error: { color: "#ef4444", margin: 0 },
+};
diff --git a/apps/admin-web/src/components/admin/QueueSnapshotCard.tsx b/apps/admin-web/src/components/admin/QueueSnapshotCard.tsx
new file mode 100644
index 0000000..34f4f54
--- /dev/null
+++ b/apps/admin-web/src/components/admin/QueueSnapshotCard.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import type { QueueSnapshot } from "@qyou/types";
+
+const LEVEL_COLORS: Record = {
+ none: "#22c55e",
+ low: "#84cc16",
+ medium: "#f59e0b",
+ high: "#ef4444",
+ unknown: "#94a3b8",
+};
+
+const LEVEL_LABELS: Record = {
+ none: "No Queue",
+ low: "Low",
+ medium: "Medium",
+ high: "High",
+ unknown: "Unknown",
+};
+
+type Props = {
+ snapshot: QueueSnapshot | null | undefined;
+ loading?: boolean;
+};
+
+export default function QueueSnapshotCard({ snapshot, loading }: Props) {
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!snapshot) {
+ return (
+
+
No queue data available.
+
+ );
+ }
+
+ const color = LEVEL_COLORS[snapshot.level] ?? LEVEL_COLORS.unknown;
+ const label = LEVEL_LABELS[snapshot.level] ?? snapshot.level;
+ const updatedAt = snapshot.lastUpdatedAt
+ ? new Date(snapshot.lastUpdatedAt).toLocaleString()
+ : "—";
+
+ return (
+
+ {snapshot.isStale && (
+
⚠ Stale data — last update may be outdated
+ )}
+
+ {label}
+ {snapshot.estimatedWaitMinutes != null && (
+ ~{snapshot.estimatedWaitMinutes} min wait
+ )}
+
+
+ Reports: {snapshot.reportCount}
+ Confidence: {Math.round(snapshot.confidence * 100)}%
+ Updated: {updatedAt}
+
+
+ );
+}
+
+const styles: Record = {
+ card: {
+ border: "1px solid #e2e8f0",
+ borderRadius: 8,
+ padding: "12px 16px",
+ background: "#fff",
+ fontSize: 14,
+ },
+ staleBanner: {
+ background: "#fef3c7",
+ color: "#92400e",
+ borderRadius: 4,
+ padding: "4px 8px",
+ marginBottom: 8,
+ fontSize: 12,
+ },
+ row: {
+ display: "flex",
+ alignItems: "center",
+ gap: 12,
+ marginBottom: 8,
+ },
+ levelBadge: {
+ color: "#fff",
+ borderRadius: 4,
+ padding: "2px 10px",
+ fontWeight: 600,
+ fontSize: 13,
+ },
+ wait: {
+ color: "#475569",
+ fontWeight: 500,
+ },
+ meta: {
+ display: "flex",
+ gap: 16,
+ color: "#64748b",
+ fontSize: 12,
+ },
+ muted: {
+ color: "#94a3b8",
+ margin: 0,
+ },
+};