diff --git a/src/backend/src/stats/stats.service.ts b/src/backend/src/stats/stats.service.ts index e79ae59..18e21eb 100644 --- a/src/backend/src/stats/stats.service.ts +++ b/src/backend/src/stats/stats.service.ts @@ -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'; @@ -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`COALESCE(SUM(${payments.amountPaid} - COALESCE(${payments.refundedAmount}, 0)), 0)::int`, + value: sql`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); diff --git a/src/frontend/web-admin/app/_lib/api.ts b/src/frontend/web-admin/app/_lib/api.ts index 67c29a6..bab083d 100644 --- a/src/frontend/web-admin/app/_lib/api.ts +++ b/src/frontend/web-admin/app/_lib/api.ts @@ -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(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}`); + 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 ---------- @@ -55,6 +66,16 @@ export function getUser(id: number): Promise { return apiFetch(`/users/${id}`); } +export function updateUser( + id: number, + data: Partial, +): Promise { + return apiFetch(`/users/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + // ---------- Events ---------- export interface EventItem { id: number; @@ -80,3 +101,46 @@ export interface EventItem { export function getEvents(): Promise { 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 { + 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 { + return apiFetch(`/events/user/${userId}/tickets`); +} diff --git a/src/frontend/web-admin/app/components/DashboardShell.tsx b/src/frontend/web-admin/app/components/DashboardShell.tsx index f3fd5e2..4eaed5d 100644 --- a/src/frontend/web-admin/app/components/DashboardShell.tsx +++ b/src/frontend/web-admin/app/components/DashboardShell.tsx @@ -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({ @@ -66,7 +66,7 @@ export default function DashboardShell({
Create event diff --git a/src/frontend/web-admin/app/events/create/page.tsx b/src/frontend/web-admin/app/events/create/page.tsx new file mode 100644 index 0000000..fbc6237 --- /dev/null +++ b/src/frontend/web-admin/app/events/create/page.tsx @@ -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(null); + + function update(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 ( +
+

Create event

+

+ Add a new event. It will show up in the mobile app and dashboard. +

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + update("name", e.target.value)} + required + /> +
+ +
+ +