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
2 changes: 2 additions & 0 deletions src/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -14,6 +15,7 @@ import { PaymentsModule } from './payments/payments.module';
EventsModule,
WebhooksModule,
PaymentsModule,
StatsModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
12 changes: 12 additions & 0 deletions src/backend/src/stats/stats.controller.ts
Original file line number Diff line number Diff line change
@@ -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<DashboardStats> {
return this.statsService.getDashboardStats();
}
}
12 changes: 12 additions & 0 deletions src/backend/src/stats/stats.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
69 changes: 69 additions & 0 deletions src/backend/src/stats/stats.service.ts
Original file line number Diff line number Diff line change
@@ -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<DashboardStats> {
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<number>`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,
};
}
}
82 changes: 82 additions & 0 deletions src/frontend/web-admin/app/_lib/api.ts
Original file line number Diff line number Diff line change
@@ -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<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}`);
}
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<DashboardStats> {
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<User[]> {
return apiFetch("/users");
}

export function getUser(id: number): Promise<User> {
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<EventItem[]> {
return apiFetch("/events");
}
85 changes: 85 additions & 0 deletions src/frontend/web-admin/app/components/DashboardShell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen flex bg-app-gray">
{/* Sidebar */}
<aside className="w-56 flex-shrink-0 bg-white border-r border-gray-200 flex flex-col">
<div className="p-4 border-b border-gray-100">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 bg-maroon rounded-lg flex items-center justify-center">
<span className="text-white text-lg font-bold">M</span>
</div>
<div>
<p className="text-base font-bold text-gray-900 leading-tight">
MacSync
</p>
<p className="text-[11px] text-gray-500 leading-tight">
Admin
</p>
</div>
</div>
</div>
<nav className="flex-1 p-3 space-y-0.5">
{navItems.map(({ href, label }) => {
const isActive =
href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-semibold transition-colors ${
isActive
? "bg-maroon text-white"
: "text-gray-700 hover:bg-gray-100"
}`}
>
{label}
</Link>
);
})}
</nav>
<div className="p-3 border-t border-gray-100">
<p className="text-xs text-gray-500 px-3">Admin portal</p>
</div>
</aside>

{/* Main area */}
<div className="flex-1 flex flex-col min-w-0">
{/* Top header */}
<header className="h-14 flex-shrink-0 bg-white border-b border-gray-200 flex items-center justify-between px-6">
<div />
<div className="flex items-center gap-4">
<Link
href="/events"
className="px-4 py-2 bg-maroon hover:bg-maroon-dark text-white text-sm font-semibold rounded-xl transition-colors"
>
Create event
</Link>
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-xs font-bold text-gray-600">A</span>
</div>
</div>
</header>

{/* Page content */}
<main className="flex-1 p-6 overflow-auto">{children}</main>
</div>
</div>
);
}
25 changes: 25 additions & 0 deletions src/frontend/web-admin/app/events/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Link from "next/link";

export default async function EventDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await params;
return (
<div className="max-w-xl">
<Link
href="/events"
className="text-sm font-medium text-maroon hover:text-maroon-dark"
>
← Back to events
</Link>
<div className="mt-6 rounded-xl border border-gray-200 bg-gray-50 p-8 text-center">
<p className="text-gray-600 font-medium">Event detail</p>
<p className="text-sm text-gray-500 mt-1">
Full event editing and management coming soon.
</p>
</div>
</div>
);
}
Loading