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
246 changes: 246 additions & 0 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
"use client";

import { useEffect, useState } from "react";
import Link from "next/link";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";

interface RequestUser {
id: string;
name: string | null;
email: string;
}

interface AdminRequest {
id: string;
location: string;
pointsRequested: number;
status: string;
message: string | null;
inPersonAllowed: boolean;
qrCodeAllowed: boolean;
selectedFulfillmentMode: string | null;
completedAt: string | null;
createdAt: string;
updatedAt: string;
requester: RequestUser;
donor: RequestUser | null;
}

interface Stats {
total: number;
pending: number;
accepted: number;
completed: number;
declined: number;
}

export default function AdminPage() {
const [requests, setRequests] = useState<AdminRequest[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");

useEffect(() => {
fetchData();
}, []);

const fetchData = async () => {
try {
setIsLoading(true);
const res = await fetch("/api/admin/requests");
const data = await res.json();

if (!res.ok) {
setError(data.error || "Failed to load admin data");
return;
}

setRequests(data.requests);
setStats(data.stats);
setError("");
} catch {
setError("An error occurred. Please try again.");
} finally {
setIsLoading(false);
}
};

// helper to get the color for each status
const getStatusColor = (status: string) => {
if (status === "pending") return "text-yellow-600";
if (status === "accepted") return "text-green-600";
if (status === "completed") return "text-blue-600";
if (status === "declined") return "text-red-600";
return "text-gray-600";
};

if (isLoading) {
return (
<div className="min-h-full bg-[radial-gradient(circle_at_top,#fef3c7,#f8fafc_40%)] p-8 dark:bg-[radial-gradient(circle_at_top,#1f2937,#0b1220_45%)]">
<div className="mx-auto max-w-5xl">
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
);
}

if (error) {
return (
<div className="min-h-full bg-[radial-gradient(circle_at_top,#fef3c7,#f8fafc_40%)] p-8 dark:bg-[radial-gradient(circle_at_top,#1f2937,#0b1220_45%)]">
<div className="mx-auto max-w-5xl">
<Link href="/dashboard" className="text-sm text-muted-foreground hover:text-foreground">
Back to Dashboard
</Link>
<div className="mt-6 rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{error === "Forbidden" ? "You do not have admin access." : error}
</div>
</div>
</div>
);
}

return (
<div className="min-h-full bg-[radial-gradient(circle_at_top,#fef3c7,#f8fafc_40%)] p-8 dark:bg-[radial-gradient(circle_at_top,#1f2937,#0b1220_45%)]">
<div className="mx-auto max-w-5xl">

{/* header */}
<div className="mb-8 flex items-start justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-muted-foreground">
Admin
</p>
<h1 className="mt-2 text-4xl font-black tracking-tight text-foreground">
All Requests
</h1>
</div>
<div className="flex gap-3 pt-3">
<button
onClick={fetchData}
className="rounded-xl border border-border bg-card/90 px-3 py-2 text-sm font-medium shadow-sm transition hover:bg-accent"
>
Refresh
</button>
<Link
href="/dashboard"
className="rounded-xl border border-border bg-card/90 px-3 py-2 text-sm font-medium shadow-sm transition hover:bg-accent"
>
Back To Dashboard
</Link>
</div>
</div>

{/* stats section */}
{stats && (
<div className="mb-8">
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Overview
</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div className="rounded-2xl border border-border bg-card/90 p-4 shadow-lg shadow-black/5">
<p className="text-2xl font-black">{stats.total}</p>
<p className="text-xs text-muted-foreground">Total</p>
</div>
<div className="rounded-2xl border border-border bg-card/90 p-4 shadow-lg shadow-black/5">
<p className="text-2xl font-black text-yellow-600">{stats.pending}</p>
<p className="text-xs text-muted-foreground">Pending</p>
</div>
<div className="rounded-2xl border border-border bg-card/90 p-4 shadow-lg shadow-black/5">
<p className="text-2xl font-black text-green-600">{stats.accepted}</p>
<p className="text-xs text-muted-foreground">Accepted</p>
</div>
<div className="rounded-2xl border border-border bg-card/90 p-4 shadow-lg shadow-black/5">
<p className="text-2xl font-black text-blue-600">{stats.completed}</p>
<p className="text-xs text-muted-foreground">Completed</p>
</div>
<div className="rounded-2xl border border-border bg-card/90 p-4 shadow-lg shadow-black/5">
<p className="text-2xl font-black text-red-500">{stats.declined}</p>
<p className="text-xs text-muted-foreground">Declined</p>
</div>
</div>
</div>
)}

{/* requests list */}
<div>
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
All requests ({requests.length})
</p>

{requests.length === 0 ? (
<Card className="rounded-2xl border border-border bg-card/90 shadow-lg shadow-black/5">
<CardContent className="py-12 text-center text-muted-foreground">
No requests found.
</CardContent>
</Card>
) : (
<div className="space-y-3">
{requests.map((req) => (
<Card
key={req.id}
className="rounded-2xl border border-border bg-card/90 shadow-lg shadow-black/5"
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<div>
<CardTitle className="text-base font-bold">{req.location}</CardTitle>
<CardDescription className="text-xs">ID: {req.id}</CardDescription>
</div>
<span className={`text-sm font-semibold capitalize ${getStatusColor(req.status)}`}>
{req.status}
</span>
</div>
</CardHeader>

<CardContent className="space-y-1.5 text-sm">
<p className="text-2xl font-black text-blue-600">
{req.pointsRequested}{" "}
<span className="text-sm font-normal text-muted-foreground">points</span>
</p>

<p className="text-muted-foreground">
<span className="font-medium text-foreground">Requester:</span>{" "}
{req.requester.name ?? req.requester.email} ({req.requester.email})
</p>

{req.donor && (
<p className="text-muted-foreground">
<span className="font-medium text-foreground">Donor:</span>{" "}
{req.donor.name ?? req.donor.email} ({req.donor.email})
</p>
)}

<p className="text-muted-foreground">
<span className="font-medium text-foreground">Fulfillment:</span>{" "}
{req.inPersonAllowed && "In person"}
{req.inPersonAllowed && req.qrCodeAllowed && " & "}
{req.qrCodeAllowed && "QR code"}
{req.selectedFulfillmentMode && ` — selected: ${req.selectedFulfillmentMode.replace("_", " ")}`}
</p>

{req.message && (
<p className="rounded-lg bg-muted/50 px-3 py-2 italic text-muted-foreground">
&ldquo;{req.message}&rdquo;
</p>
)}

<p className="text-xs text-muted-foreground">
Created: {new Date(req.createdAt).toLocaleString()}
{req.completedAt && ` · Completed: ${new Date(req.completedAt).toLocaleString()}`}
</p>
</CardContent>
</Card>
))}
</div>
)}
</div>

</div>
</div>
);
}
71 changes: 71 additions & 0 deletions app/api/admin/requests/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";

export async function GET() {
try {
const user = await getCurrentUser();

if (!user || !user.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

// get the user from the database to check if they are an admin
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
});

if (!dbUser || !dbUser.isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

// get all requests with requester and donor info
const requests = await prisma.request.findMany({
include: {
requester: {
select: {
id: true,
name: true,
email: true,
},
},
donor: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});

// count requests by status for the stats section
let pendingCount = 0;
let acceptedCount = 0;
let completedCount = 0;
let declinedCount = 0;

for (const req of requests) {
if (req.status === "pending") pendingCount++;
if (req.status === "accepted") acceptedCount++;
if (req.status === "completed") completedCount++;
if (req.status === "declined") declinedCount++;
}

const stats = {
total: requests.length,
pending: pendingCount,
accepted: acceptedCount,
completed: completedCount,
declined: declinedCount,
};

return NextResponse.json({ requests, stats });
} catch (error) {
console.error("Error fetching admin requests:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
16 changes: 14 additions & 2 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Calculator,
BookOpen,
Plug,
ShieldCheck,
} from "lucide-react";

export default async function DashboardPage() {
Expand All @@ -20,13 +21,15 @@ export default async function DashboardPage() {

const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { id: true, isAdmin: true },
});

if (!dbUser) {
redirect("/auth/logout-stale");
}

const actions = [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actions: { href: string; title: string; description: string; icon: any }[] = [
{
href: "/requests/create",
title: "Create Request",
Expand Down Expand Up @@ -57,7 +60,16 @@ export default async function DashboardPage() {
description: "Link and monitor your GET session",
icon: Plug,
},
] as const;
];

if (dbUser?.isAdmin) {
actions.push({
href: "/admin",
title: "Admin Panel",
description: "Monitor all system requests",
icon: ShieldCheck,
});
}

return (
<div className="min-h-full bg-[radial-gradient(circle_at_top,#fef3c7,#f8fafc_40%)] p-8 dark:bg-[radial-gradient(circle_at_top,#1f2937,#0b1220_45%)]">
Expand Down
23 changes: 6 additions & 17 deletions app/requests/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,13 @@ const getDayKey = () => {
const getMealPeriod = () => {
const now = new Date();
const time = now.getHours() + now.getMinutes() / 60;

if (time >= 7 && time < 11){ //7AM-2PM
return 'breakfast';
}

if (time >= 11.5 && time < 14){ //11:30AM-2PM
return 'lunch';
}

if (time >= 17 && time < 20){ //5PM-8PM
return 'dinner';
}

if (time >= 20 && time < 23){ //8PM-11PM
return 'lateNight';
}

return 'continuousDining';
if (time >= 7 && time < 11) return 'breakfast'; // 7:00–11:00 AM
if (time >= 11 && time < 14) return 'lunch'; // 11:00 AM–2:00 PM (covers 11–11:30 gap)
if (time >= 14 && time < 17) return 'dinner'; // 2:00–5:00 PM (show upcoming dinner price)
if (time >= 17 && time < 20) return 'dinner'; // 5:00–8:00 PM
if (time >= 20 && time < 24) return 'lateNight'; // 8:00 PM–midnight
return 'breakfast'; // midnight–7 AM (dining halls closed anyway)
};

const isCurrentlyOpen = (schedule: any) => {
Expand Down
Loading
Loading