diff --git a/src/backend/src/app.module.ts b/src/backend/src/app.module.ts index f88e5db..db7ff94 100644 --- a/src/backend/src/app.module.ts +++ b/src/backend/src/app.module.ts @@ -6,6 +6,7 @@ import { UsersModule } from './users/users.module'; import { EventsModule } from './events/events.module'; import { WebhooksModule } from './webhooks/webhooks.module'; import { PaymentsModule } from './payments/payments.module'; +import { StatsModule } from './stats/stats.module'; @Module({ imports: [ @@ -14,6 +15,7 @@ import { PaymentsModule } from './payments/payments.module'; EventsModule, WebhooksModule, PaymentsModule, + StatsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/backend/src/stats/stats.controller.ts b/src/backend/src/stats/stats.controller.ts new file mode 100644 index 0000000..e628227 --- /dev/null +++ b/src/backend/src/stats/stats.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { StatsService, type DashboardStats } from './stats.service'; + +@Controller('admin') +export class StatsController { + constructor(private readonly statsService: StatsService) {} + + @Get('stats') + getDashboardStats(): Promise { + return this.statsService.getDashboardStats(); + } +} diff --git a/src/backend/src/stats/stats.module.ts b/src/backend/src/stats/stats.module.ts new file mode 100644 index 0000000..1dda32b --- /dev/null +++ b/src/backend/src/stats/stats.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../database/database.module'; +import { StatsController } from './stats.controller'; +import { StatsService } from './stats.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [StatsController], + providers: [StatsService], + exports: [StatsService], +}) +export class StatsModule {} diff --git a/src/backend/src/stats/stats.service.ts b/src/backend/src/stats/stats.service.ts new file mode 100644 index 0000000..e79ae59 --- /dev/null +++ b/src/backend/src/stats/stats.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { count, sum, sql, gte, inArray } from 'drizzle-orm'; +import { DatabaseService } from '../database/database.service'; +import { users, events, tickets, payments } from '../db/schema'; + +export interface DashboardStats { + userCount: number; + eventCount: number; + upcomingEventCount: number; + ticketsSold: number; + totalCapacity: number; + totalRevenue: number; + conversionRate: number; +} + +@Injectable() +export class StatsService { + constructor(private readonly dbService: DatabaseService) {} + + async getDashboardStats(): Promise { + const db = this.dbService.db; + + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + const [ + userCountResult, + eventCountResult, + upcomingResult, + ticketsResult, + capacityResult, + revenueResult, + ] = await Promise.all([ + db.select({ value: count() }).from(users), + db.select({ value: count() }).from(events), + db.select({ value: count() }).from(events).where(gte(events.date, today)), + db.select({ value: count() }).from(tickets), + db.select({ value: sum(events.capacity) }).from(events), + db + .select({ + value: sql`COALESCE(SUM(${payments.amountPaid} - COALESCE(${payments.refundedAmount}, 0)), 0)::int`, + }) + .from(payments) + .where(inArray(payments.status, ['succeeded', 'partially_refunded'])), + ]); + + const userCount = Number(userCountResult[0]?.value ?? 0); + const eventCount = Number(eventCountResult[0]?.value ?? 0); + const upcomingEventCount = Number(upcomingResult[0]?.value ?? 0); + const ticketsSold = Number(ticketsResult[0]?.value ?? 0); + const totalCapacity = Number(capacityResult[0]?.value ?? 0); + const totalRevenue = Number(revenueResult[0]?.value ?? 0); + + const conversionRate = + totalCapacity > 0 + ? Math.round((ticketsSold / totalCapacity) * 100 * 100) / 100 + : 0; + + return { + userCount, + eventCount, + upcomingEventCount, + ticketsSold, + totalCapacity, + totalRevenue, + conversionRate, + }; + } +} diff --git a/src/frontend/web-admin/app/_lib/api.ts b/src/frontend/web-admin/app/_lib/api.ts new file mode 100644 index 0000000..67c29a6 --- /dev/null +++ b/src/frontend/web-admin/app/_lib/api.ts @@ -0,0 +1,82 @@ +const getBaseUrl = (): string => { + if (typeof window !== "undefined") { + const url = process.env.NEXT_PUBLIC_API_URL; + if (url && typeof url === "string" && url.trim()) return url.replace(/\/$/, ""); + } + return process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") ?? "http://localhost:3000"; +}; + +async function apiFetch(path: string, options?: RequestInit): Promise { + const base = getBaseUrl(); + const res = await fetch(`${base}${path}`, { + headers: { "Content-Type": "application/json", ...options?.headers }, + ...options, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `API error: ${res.status} ${res.statusText}`); + } + return res.json(); +} + +// ---------- Dashboard stats ---------- +export interface DashboardStats { + userCount: number; + eventCount: number; + upcomingEventCount: number; + ticketsSold: number; + totalCapacity: number; + totalRevenue: number; + conversionRate: number; +} + +export function getDashboardStats(): Promise { + return apiFetch("/admin/stats"); +} + +// ---------- Users ---------- +export interface User { + id: number; + email: string; + name: string; + phoneNumber: string; + program: string | null; + isSystemAdmin: boolean; + roles?: string[]; + createdAt: string; + updatedAt: string; +} + +export function getUsers(): Promise { + return apiFetch("/users"); +} + +export function getUser(id: number): Promise { + return apiFetch(`/users/${id}`); +} + +// ---------- Events ---------- +export interface EventItem { + id: number; + name: string; + description: string | null; + date: string; + location: string | null; + capacity: number; + imageUrl: string | null; + price: number; + stripePriceId: string | null; + requiresTableSignup: boolean; + requiresBusSignup: boolean; + tableCount: number | null; + seatsPerTable: number | null; + busCount: number | null; + busCapacity: number | null; + registeredCount: number; + createdAt: string; + updatedAt: string; +} + +export function getEvents(): Promise { + return apiFetch("/events"); +} diff --git a/src/frontend/web-admin/app/components/DashboardShell.tsx b/src/frontend/web-admin/app/components/DashboardShell.tsx new file mode 100644 index 0000000..f3fd5e2 --- /dev/null +++ b/src/frontend/web-admin/app/components/DashboardShell.tsx @@ -0,0 +1,85 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const navItems = [ + { href: "/", label: "Dashboard" }, + { href: "/events", label: "Events" }, + { href: "/manage-roles", label: "Manage roles" }, +]; + +export default function DashboardShell({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + + return ( +
+ {/* Sidebar */} + + + {/* Main area */} +
+ {/* Top header */} +
+
+
+ + Create event + +
+ A +
+
+
+ + {/* Page content */} +
{children}
+
+
+ ); +} diff --git a/src/frontend/web-admin/app/events/[id]/page.tsx b/src/frontend/web-admin/app/events/[id]/page.tsx new file mode 100644 index 0000000..5b1a158 --- /dev/null +++ b/src/frontend/web-admin/app/events/[id]/page.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; + +export default async function EventDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + await params; + return ( +
+ + ← Back to events + +
+

Event detail

+

+ Full event editing and management coming soon. +

+
+
+ ); +} diff --git a/src/frontend/web-admin/app/events/page.tsx b/src/frontend/web-admin/app/events/page.tsx new file mode 100644 index 0000000..56bdba8 --- /dev/null +++ b/src/frontend/web-admin/app/events/page.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { getEvents, type EventItem } from "../_lib/api"; + +function formatDate(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function formatPrice(cents: number): string { + if (cents === 0) return "Free"; + return `$${(cents / 100).toFixed(2)}`; +} + +export default function EventsListPage() { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getEvents() + .then(setEvents) + .catch((e) => setError(e instanceof Error ? e.message : "Failed to load")) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+

Loading events…

+
+ ); + } + + if (error) { + return ( +
+

Could not load events

+

{error}

+
+ ); + } + + return ( +
+
+
+

Events

+

All events

+
+
+ +
+
+ {events.length === 0 ? ( +
+ No events yet +
+ ) : ( + events.map((event) => ( + +
+ {event.imageUrl ? ( + + ) : ( + 🖼️ + )} +
+
+

+ {event.name} +

+

+ {formatDate(event.date)} + {event.location ? ` • ${event.location}` : ""} +

+
+
+ {event.registeredCount} / {event.capacity} registered +
+
+ {formatPrice(event.price)} +
+ + )) + )} +
+
+
+ ); +} diff --git a/src/frontend/web-admin/app/layout.tsx b/src/frontend/web-admin/app/layout.tsx index 4a3154c..b26f039 100644 --- a/src/frontend/web-admin/app/layout.tsx +++ b/src/frontend/web-admin/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import DashboardShell from "./components/DashboardShell"; export const metadata: Metadata = { title: "Web Admin", @@ -13,8 +14,8 @@ export default function RootLayout({ }>) { return ( - - {children} + + {children} ); diff --git a/src/frontend/web-admin/app/manage-roles/page.tsx b/src/frontend/web-admin/app/manage-roles/page.tsx new file mode 100644 index 0000000..82fe1e1 --- /dev/null +++ b/src/frontend/web-admin/app/manage-roles/page.tsx @@ -0,0 +1,16 @@ +export default function ManageRolesPage() { + return ( +
+

Manage roles

+

+ Assign or revoke Administrator and Member roles for users. +

+
+

Coming soon

+

+ Role management will be available here after RBAC is implemented. +

+
+
+ ); +} diff --git a/src/frontend/web-admin/app/page.tsx b/src/frontend/web-admin/app/page.tsx index 0cd2020..eb982ad 100644 --- a/src/frontend/web-admin/app/page.tsx +++ b/src/frontend/web-admin/app/page.tsx @@ -1,8 +1,189 @@ -export default function Home() { +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { + getDashboardStats, + getEvents, + type DashboardStats as StatsType, + type EventItem, +} from "./_lib/api"; + +function formatRevenue(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +function formatDate(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function formatPrice(cents: number): string { + if (cents === 0) return "Free"; + return `$${(cents / 100).toFixed(2)}`; +} + +export default function DashboardHome() { + const [stats, setStats] = useState(null); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function load() { + try { + setError(null); + const [statsRes, eventsRes] = await Promise.all([ + getDashboardStats(), + getEvents(), + ]); + setStats(statsRes); + setEvents(eventsRes); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load dashboard"); + } finally { + setLoading(false); + } + } + load(); + }, []); + + if (loading) { + return ( +
+

Loading dashboard…

+
+ ); + } + + if (error) { + return ( +
+

Could not load dashboard

+

{error}

+

+ Ensure the backend is running and NEXT_PUBLIC_API_URL points to it. +

+
+ ); + } + + const statCards = [ + { + label: "Total users", + value: stats?.userCount ?? 0, + }, + { + label: "Total events", + value: stats?.eventCount ?? 0, + }, + { + label: "Upcoming events", + value: stats?.upcomingEventCount ?? 0, + }, + { + label: "Tickets sold", + value: stats?.ticketsSold ?? 0, + }, + { + label: "Total revenue", + value: stats != null ? formatRevenue(stats.totalRevenue) : "—", + highlight: true, + }, + { + label: "Conversion rate", + value: stats != null ? `${stats.conversionRate}%` : "—", + }, + ]; + + const upcomingEvents = events + .filter((e) => new Date(e.date) >= new Date()) + .slice(0, 5); + return ( -
-

Web Admin

-

Next.js admin dashboard

-
+
+
+

Dashboard

+

Overview of your events and metrics

+
+ + {/* Stat cards */} +
+ {statCards.map((card) => ( +
+

{card.label}

+

+ {card.value} +

+
+ ))} +
+ + {/* Event list card */} +
+
+

Upcoming events

+ + View all + +
+
+ {upcomingEvents.length === 0 ? ( +
+ No upcoming events +
+ ) : ( + upcomingEvents.map((event) => ( + +
+ {event.imageUrl ? ( + + ) : ( + 🖼️ + )} +
+
+

+ {event.name} +

+

+ {formatDate(event.date)} + {event.location ? ` • ${event.location}` : ""} +

+
+
+ {formatPrice(event.price)} / ticket +
+ + )) + )} +
+
+
); } diff --git a/src/frontend/web-admin/package-lock.json b/src/frontend/web-admin/package-lock.json index cbc2cd4..35152cb 100644 --- a/src/frontend/web-admin/package-lock.json +++ b/src/frontend/web-admin/package-lock.json @@ -955,6 +955,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1015,6 +1016,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1501,6 +1503,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2489,6 +2492,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2662,6 +2666,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3847,6 +3852,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4548,6 +4554,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4749,6 +4756,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4761,6 +4769,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5572,6 +5581,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5728,6 +5738,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/frontend/web-admin/tailwind.config.ts b/src/frontend/web-admin/tailwind.config.ts index 9117475..f9d0e05 100644 --- a/src/frontend/web-admin/tailwind.config.ts +++ b/src/frontend/web-admin/tailwind.config.ts @@ -7,7 +7,18 @@ export default { "./app/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { - extend: {}, + extend: { + colors: { + maroon: { + DEFAULT: "#7A1F3E", + dark: "#621832", + }, + gold: { + DEFAULT: "#D4A843", + }, + "app-gray": "#F5F5F7", + }, + }, }, plugins: [], } satisfies Config;