Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/admin-web/src/app/admin/locations/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -33,6 +34,11 @@ export default async function EditLocationPage(props: { params: Promise<{ id: st
}
}

return <EditLocationForm location={location} />;
return (
<>
<EditLocationForm location={location} />
<QueueActivitySection locationId={params.id} />
</>
);
}

9 changes: 9 additions & 0 deletions apps/admin-web/src/app/admin/queue-reports/manual/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import ManualQueueReportForm from "@/components/admin/ManualQueueReportForm";

export default function ManualQueueReportPage() {
return (
<div style={{ padding: "24px 32px" }}>
<ManualQueueReportForm />
</div>
);
}
13 changes: 13 additions & 0 deletions apps/admin-web/src/app/admin/queue-reports/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import QueueReportReviewList from "@/components/admin/QueueReportReviewList";

export default function QueueReportsPage() {
return (
<div style={{ padding: "24px 32px" }}>
<h1 style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>Queue Report Review</h1>
<p style={{ color: "#64748b", marginBottom: 24, fontSize: 14 }}>
Incoming crowd-sourced queue reports from mobile contributors.
</p>
<QueueReportReviewList />
</div>
);
}
80 changes: 80 additions & 0 deletions apps/admin-web/src/app/api/admin/queue-reports/route.ts
Original file line number Diff line number Diff line change
@@ -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<QueueReport>;
return NextResponse.json(payload);
} catch {
return NextResponse.json(
{ success: false, error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to submit queue report" } },
{ status: 500 }
);
}
}
40 changes: 40 additions & 0 deletions apps/admin-web/src/app/api/admin/queue-snapshot/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
131 changes: 131 additions & 0 deletions apps/admin-web/src/components/admin/ManualQueueReportForm.tsx
Original file line number Diff line number Diff line change
@@ -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<QueueLevel>("medium");
const [waitTime, setWaitTime] = useState("");
const [notes, setNotes] = useState("");
const [formState, setFormState] = useState<FormState>({ 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 (
<form onSubmit={handleSubmit} style={styles.form}>
<h2 style={styles.heading}>Manual Queue Report Entry</h2>
<p style={styles.description}>Seed queue activity for testing without mobile use.</p>

{formState.status === "success" && (
<div style={styles.successBanner}>{formState.message}</div>
)}
{formState.status === "error" && (
<div style={styles.errorBanner}>{formState.message}</div>
)}

<label style={styles.label}>
Location ID *
<input
style={styles.input}
value={locationId}
onChange={(e) => setLocationId(e.target.value)}
placeholder="e.g. 64a1b2c3d4e5f6a7b8c9d0e1"
required
/>
</label>

<label style={styles.label}>
Queue Level *
<select style={styles.input} value={level} onChange={(e) => setLevel(e.target.value as QueueLevel)}>
{LEVELS.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
</label>

<label style={styles.label}>
Estimated Wait (minutes)
<input
style={styles.input}
type="number"
min={0}
max={300}
value={waitTime}
onChange={(e) => setWaitTime(e.target.value)}
placeholder="Optional"
/>
</label>

<label style={styles.label}>
Notes
<textarea
style={{ ...styles.input, height: 72, resize: "vertical" }}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optional notes"
maxLength={280}
/>
</label>

<button
type="submit"
disabled={formState.status === "submitting"}
style={formState.status === "submitting" ? { ...styles.button, opacity: 0.6 } : styles.button}
>
{formState.status === "submitting" ? "Submitting…" : "Submit Report"}
</button>
</form>
);
}

const styles: Record<string, React.CSSProperties> = {
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 },
};
60 changes: 60 additions & 0 deletions apps/admin-web/src/components/admin/QueueActivitySection.tsx
Original file line number Diff line number Diff line change
@@ -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<QueueSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<section style={styles.section}>
<h2 style={styles.heading}>Queue Activity</h2>

<div style={styles.snapshotWrap}>
<h3 style={styles.subheading}>Current Snapshot</h3>
{error ? (
<p style={styles.error}>{error}</p>
) : (
<QueueSnapshotCard snapshot={snapshot} loading={loading} />
)}
</div>

<div style={styles.reportsWrap}>
<h3 style={styles.subheading}>Recent Reports</h3>
<QueueReportReviewList locationId={locationId} />
</div>
</section>
);
}

const styles: Record<string, React.CSSProperties> = {
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 },
};
Loading
Loading