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
9 changes: 5 additions & 4 deletions src/backend/src/stats/stats.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { count, sum, sql, gte, inArray } from 'drizzle-orm';
import { count, sum, sql, gte, inArray, eq } from 'drizzle-orm';
import { DatabaseService } from '../database/database.service';
import { users, events, tickets, payments } from '../db/schema';

Expand Down Expand Up @@ -36,12 +36,13 @@ export class StatsService {
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),
// Calculate revenue from tickets × event price (covers tickets without payment records)
db
.select({
value: sql<number>`COALESCE(SUM(${payments.amountPaid} - COALESCE(${payments.refundedAmount}, 0)), 0)::int`,
value: sql<number>`COALESCE(SUM(${events.price}), 0)::int`,
})
.from(payments)
.where(inArray(payments.status, ['succeeded', 'partially_refunded'])),
.from(tickets)
.innerJoin(events, eq(tickets.eventId, events.id)),
]);

const userCount = Number(userCountResult[0]?.value ?? 0);
Expand Down
88 changes: 76 additions & 12 deletions src/frontend/web-admin/app/_lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
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(/\/$/, "");
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";
return "http://localhost:3000";
};

async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
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}`);
const url = `${base}${path}`;

try {
const res = await fetch(url, {
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();
} catch (error) {
if (error instanceof TypeError && error.message.includes("fetch")) {
throw new Error(`Failed to connect to backend at ${url}. Is the backend running?`);
}
throw error;
}
return res.json();
}

// ---------- Dashboard stats ----------
Expand Down Expand Up @@ -55,6 +66,16 @@ export function getUser(id: number): Promise<User> {
return apiFetch(`/users/${id}`);
}

export function updateUser(
id: number,
data: Partial<User>,
): Promise<User> {
return apiFetch(`/users/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}

// ---------- Events ----------
export interface EventItem {
id: number;
Expand All @@ -80,3 +101,46 @@ export interface EventItem {
export function getEvents(): Promise<EventItem[]> {
return apiFetch("/events");
}

export interface CreateEventPayload {
name: string;
description?: string;
date: string;
location?: string;
capacity: number;
imageUrl?: string;
price: number;
stripePriceId?: string;
requiresTableSignup?: boolean;
requiresBusSignup?: boolean;
tableCount?: number;
seatsPerTable?: number;
busCount?: number;
busCapacity?: number;
}

export function createEvent(data: CreateEventPayload): Promise<EventItem> {
return apiFetch("/events", {
method: "POST",
body: JSON.stringify(data),
});
}

// ---------- Tickets ----------
export interface Ticket {
ticketId: number;
eventId: number;
checkedIn: boolean;
busSeat: string | null;
tableSeat: string | null;
createdAt: string;
eventName: string;
eventDate: string;
eventLocation: string | null;
eventPrice: number;
eventImageUrl: string | null;
}

export function getUserTickets(userId: number): Promise<Ticket[]> {
return apiFetch(`/events/user/${userId}/tickets`);
}
4 changes: 2 additions & 2 deletions src/frontend/web-admin/app/components/DashboardShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { usePathname } from "next/navigation";
const navItems = [
{ href: "/", label: "Dashboard" },
{ href: "/events", label: "Events" },
{ href: "/manage-roles", label: "Manage roles" },
{ href: "/users", label: "Users" },
];

export default function DashboardShell({
Expand Down Expand Up @@ -66,7 +66,7 @@ export default function DashboardShell({
<div />
<div className="flex items-center gap-4">
<Link
href="/events"
href="/events/create"
className="px-4 py-2 bg-maroon hover:bg-maroon-dark text-white text-sm font-semibold rounded-xl transition-colors"
>
Create event
Expand Down
193 changes: 193 additions & 0 deletions src/frontend/web-admin/app/events/create/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { createEvent, type CreateEventPayload } from "../../_lib/api";

export default function CreateEventPage() {
const router = useRouter();
const [form, setForm] = useState({
name: "",
description: "",
date: "",
location: "",
capacity: "",
priceDollars: "",
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

function update<K extends keyof typeof form>(key: K, value: string) {
setForm((prev) => ({ ...prev, [key]: value }));
}

async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);

if (!form.name.trim()) {
setError("Name is required");
return;
}
if (!form.date) {
setError("Date is required");
return;
}

const capacity = Number(form.capacity);
if (!Number.isFinite(capacity) || capacity <= 0) {
setError("Capacity must be a positive number");
return;
}

const priceDollars = form.priceDollars.trim()
? Number(form.priceDollars)
: 0;
if (!Number.isFinite(priceDollars) || priceDollars < 0) {
setError("Price must be a non‑negative number");
return;
}

const payload: CreateEventPayload = {
name: form.name.trim(),
description: form.description.trim() || undefined,
date: new Date(form.date).toISOString(),
location: form.location.trim() || undefined,
capacity,
imageUrl: undefined,
price: Math.round(priceDollars * 100),
};

try {
setSubmitting(true);
await createEvent(payload);
router.push("/events");
router.refresh();
} catch (err) {
console.error("Create event error:", err);
setError(
err instanceof Error ? err.message : "Failed to create event",
);
} finally {
setSubmitting(false);
}
}

return (
<div className="max-w-xl">
<h1 className="text-2xl font-bold text-gray-900">Create event</h1>
<p className="text-gray-500 mt-1">
Add a new event. It will show up in the mobile app and dashboard.
</p>

<form onSubmit={onSubmit} className="mt-6 space-y-4">
{error && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
{error}
</div>
)}

<div>
<label className="block text-sm font-medium text-gray-700">
Name
</label>
<input
type="text"
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-maroon"
value={form.name}
onChange={(e) => update("name", e.target.value)}
required
/>
</div>

<div>
<label className="block text-sm font-medium text-gray-700">
Description
</label>
<textarea
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-maroon"
rows={3}
value={form.description}
onChange={(e) => update("description", e.target.value)}
/>
</div>

<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Date &amp; time
</label>
<input
type="datetime-local"
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-maroon"
value={form.date}
onChange={(e) => update("date", e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Capacity
</label>
<input
type="number"
min={1}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-maroon"
value={form.capacity}
onChange={(e) => update("capacity", e.target.value)}
required
/>
</div>
</div>

<div>
<label className="block text-sm font-medium text-gray-700">
Location
</label>
<input
type="text"
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-maroon"
value={form.location}
onChange={(e) => update("location", e.target.value)}
/>
</div>

<div>
<label className="block text-sm font-medium text-gray-700">
Price (USD)
</label>
<input
type="number"
min={0}
step="0.01"
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-maroon"
value={form.priceDollars}
onChange={(e) => update("priceDollars", e.target.value)}
/>
<p className="mt-1 text-xs text-gray-500">
Leave empty or 0 for a free event.
</p>
</div>

<div className="flex justify-end gap-3 pt-3">
<button
type="button"
className="px-4 py-2 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => router.back()}
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 rounded-lg bg-maroon text-white text-sm font-semibold hover:bg-maroon-dark disabled:opacity-60"
disabled={submitting}
>
{submitting ? "Creating…" : "Create event"}
</button>
</div>
</form>
</div>
);
}

10 changes: 8 additions & 2 deletions src/frontend/web-admin/app/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,14 @@ export default function EventsListPage() {

useEffect(() => {
getEvents()
.then(setEvents)
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load"))
.then((events) => {
console.log("Events loaded:", events.length);
setEvents(events);
})
.catch((e) => {
console.error("Events load error:", e);
setError(e instanceof Error ? e.message : "Failed to load");
})
.finally(() => setLoading(false));
}, []);

Expand Down
3 changes: 3 additions & 0 deletions src/frontend/web-admin/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,16 @@ export default function DashboardHome() {
async function load() {
try {
setError(null);
console.log("Loading dashboard data...");
const [statsRes, eventsRes] = await Promise.all([
getDashboardStats(),
getEvents(),
]);
console.log("Dashboard data loaded:", { stats: statsRes, eventsCount: eventsRes.length });
setStats(statsRes);
setEvents(eventsRes);
} catch (e) {
console.error("Dashboard load error:", e);
setError(e instanceof Error ? e.message : "Failed to load dashboard");
} finally {
setLoading(false);
Expand Down
Loading
Loading