From 31fc26478d7f6b876703ac5872d813a93fd8329a Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 26 Apr 2026 17:17:52 -0700 Subject: [PATCH] feat: implement club admin dashboard with activity feed and settings --- e2e/cucumber/features/admin-dashboard.feature | 36 +++ e2e/cucumber/step-definitions/auth-steps.ts | 54 +++++ e2e/cucumber/step-definitions/common-steps.ts | 76 ++++++ .../migration.sql | 63 +++++ prisma/schema.prisma | 35 +++ src/app/admin/page.tsx | 46 ++++ src/app/admin/players/page.tsx | 26 ++ src/app/admin/settings/page.tsx | 224 ++++++++++++++++++ src/app/api/admin/activity/route.ts | 34 +++ src/app/api/admin/settings/route.ts | 46 ++++ src/app/api/players/route.ts | 20 +- src/lib/activity-logger.ts | 29 +++ 12 files changed, 686 insertions(+), 3 deletions(-) create mode 100644 e2e/cucumber/features/admin-dashboard.feature create mode 100644 prisma/migrations/20260426235230_add_activity_and_settings_models/migration.sql create mode 100644 src/app/admin/settings/page.tsx create mode 100644 src/app/api/admin/activity/route.ts create mode 100644 src/app/api/admin/settings/route.ts create mode 100644 src/lib/activity-logger.ts diff --git a/e2e/cucumber/features/admin-dashboard.feature b/e2e/cucumber/features/admin-dashboard.feature new file mode 100644 index 0000000..ff8082d --- /dev/null +++ b/e2e/cucumber/features/admin-dashboard.feature @@ -0,0 +1,36 @@ +Feature: Club Admin Dashboard + As a club admin + I want to view club-wide statistics and manage club operations + So that I can effectively oversee the club + + @happy-path @admin @issue-11 + Scenario: Club admin views dashboard with statistics + Given I am logged in as a club admin + When I go to the admin dashboard + Then I should see "Admin Dashboard" + And I should see total player count + And I should see active tournament count + + @happy-path @admin @issue-11 + Scenario: Club admin views recent activity feed + Given I am logged in as a club admin + And there are recent activities in the system + When I go to the admin dashboard + Then I should see the activity feed section + And I should see recent player registrations + + @happy-path @admin @issue-11 + Scenario: Club admin searches player directory + Given I am logged in as a club admin + And there are multiple players in the system + When I go to the player management page + And I search for "Player 1" + Then I should see search results + + @happy-path @admin @issue-11 + Scenario: Club admin updates club settings + Given I am logged in as a club admin + When I go to the club settings page + And I update the club name + And I save the settings + Then the settings should be saved successfully diff --git a/e2e/cucumber/step-definitions/auth-steps.ts b/e2e/cucumber/step-definitions/auth-steps.ts index 88db51f..3b4b975 100644 --- a/e2e/cucumber/step-definitions/auth-steps.ts +++ b/e2e/cucumber/step-definitions/auth-steps.ts @@ -395,3 +395,57 @@ Given('a tournament has a generated schedule', async function () { // 2. Add teams/participants // 3. Generate schedule via API or UI }); + +Given('there are recent activities in the system', async function () { + // Create test activities using the activity logger + const prisma = await world.getPrisma() + + // Use timestamp to ensure unique names + const timestamp = Date.now() + + // Create a test player first + const player = await prisma.player.create({ + data: { + name: `Test Activity Player ${timestamp}`, + normalizedName: `test activity player ${timestamp}`, + currentElo: 1000, + gamesPlayed: 0, + wins: 0, + losses: 0, + }, + }) + + // Create an activity + await (prisma as any).activity.create({ + data: { + type: 'player_registration', + description: `Test Activity Player ${timestamp} registered`, + playerId: player.id, + }, + }) + + console.log('🌍 Created test activity for player:', player.name) +}) + +Given('there are multiple players in the system', async function () { + const prisma = await world.getPrisma() + + // Use timestamp to ensure unique names + const timestamp = Date.now() + + // Create multiple test players + for (let i = 1; i <= 5; i++) { + await prisma.player.create({ + data: { + name: `Test Player ${i} ${timestamp}`, + normalizedName: `test player ${i} ${timestamp}`, + currentElo: 1000 + i * 10, + gamesPlayed: 0, + wins: 0, + losses: 0, + }, + }) + } + + console.log('🌍 Created 5 test players') +}) diff --git a/e2e/cucumber/step-definitions/common-steps.ts b/e2e/cucumber/step-definitions/common-steps.ts index cb5e8f5..953a5be 100644 --- a/e2e/cucumber/step-definitions/common-steps.ts +++ b/e2e/cucumber/step-definitions/common-steps.ts @@ -475,3 +475,79 @@ Then('I should see {string} error', async function (errorMessage: string) { expect(content).toMatch(new RegExp(errorMessage, 'i')); console.log(`🌍 Verified error message: ${errorMessage}`); }); + +// Admin Dashboard Steps +When('I go to the admin dashboard', async function () { + console.log('🌍 Going to admin dashboard'); + await world.page.goto(`${world.baseURL}/admin`); + await world.page.waitForLoadState('domcontentloaded'); +}); + +Then('I should see total player count', async function () { + await expect(world.page.locator('text=Total Players')).toBeVisible(); + console.log('🌍 Verified total players section is visible'); +}); + +Then('I should see active tournament count', async function () { + // Use more specific locator to find the stats card + await expect(world.page.locator('dt:has-text("Tournaments")').first()).toBeVisible(); + console.log('🌍 Verified tournaments section is visible'); +}); + +Then('I should see the activity feed section', async function () { + await expect(world.page.locator('text=Recent Activity')).toBeVisible(); + console.log('🌍 Verified activity feed section is visible'); +}); + +Then('I should see recent player registrations', async function () { + // Check if there are any activities in the feed + const activityItems = await world.page.locator('.divide-y.divide-gray-200 li').count(); + console.log(`🌍 Found ${activityItems} activity items`); + + // Also check for the activity text + const content = await world.page.content(); + const hasActivityText = content.includes('Test Activity Player'); + console.log(`🌍 Activity text found in page: ${hasActivityText}`); + + expect(activityItems).toBeGreaterThan(0); +}); + +When('I go to the player management page', async function () { + console.log('🌍 Going to player management page'); + await world.page.goto(`${world.baseURL}/admin/players`); + await world.page.waitForLoadState('domcontentloaded'); +}); + +When('I search for {string}', async function (searchTerm: string) { + console.log(`🌍 Searching for: ${searchTerm}`); + await world.page.fill('input[name="search"]', searchTerm); + await world.page.waitForTimeout(500); // Wait for search to execute +}); + +Then('I should see search results', async function () { + // Check if player table is visible + await expect(world.page.locator('table')).toBeVisible(); + console.log('🌍 Verified search results are displayed'); +}); + +When('I go to the club settings page', async function () { + console.log('🌍 Going to club settings page'); + await world.page.goto(`${world.baseURL}/admin/settings`); + await world.page.waitForLoadState('domcontentloaded'); +}); + +When('I update the club name', async function () { + console.log('🌍 Updating club name'); + await world.page.fill('input[id="clubName"]', 'Test Club Updated'); +}); + +When('I save the settings', async function () { + console.log('🌍 Saving settings'); + await world.page.click('button:has-text("Save Settings")'); + await world.page.waitForTimeout(1000); // Wait for save to complete +}); + +Then('the settings should be saved successfully', async function () { + await expect(world.page.locator('text=Settings saved successfully')).toBeVisible(); + console.log('🌍 Verified settings were saved successfully'); +}); diff --git a/prisma/migrations/20260426235230_add_activity_and_settings_models/migration.sql b/prisma/migrations/20260426235230_add_activity_and_settings_models/migration.sql new file mode 100644 index 0000000..fbff61e --- /dev/null +++ b/prisma/migrations/20260426235230_add_activity_and_settings_models/migration.sql @@ -0,0 +1,63 @@ +-- DropForeignKey +ALTER TABLE "matches" DROP CONSTRAINT "matches_player1P1Id_fkey"; + +-- DropForeignKey +ALTER TABLE "matches" DROP CONSTRAINT "matches_player1P2Id_fkey"; + +-- DropForeignKey +ALTER TABLE "matches" DROP CONSTRAINT "matches_player2P1Id_fkey"; + +-- DropForeignKey +ALTER TABLE "matches" DROP CONSTRAINT "matches_player2P2Id_fkey"; + +-- CreateTable +CREATE TABLE "activities" ( + "id" SERIAL NOT NULL, + "type" TEXT NOT NULL, + "description" TEXT NOT NULL, + "userId" TEXT, + "playerId" INTEGER, + "eventId" INTEGER, + "matchId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "activities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "club_settings" ( + "id" SERIAL NOT NULL, + "clubName" TEXT NOT NULL DEFAULT 'Euchre Club', + "defaultEloRating" INTEGER NOT NULL DEFAULT 1200, + "partnershipEnabled" BOOLEAN NOT NULL DEFAULT true, + "notificationsEnabled" BOOLEAN NOT NULL DEFAULT true, + "matchVerification" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "club_settings_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "matches" ADD CONSTRAINT "matches_player1P1Id_fkey" FOREIGN KEY ("player1P1Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "matches" ADD CONSTRAINT "matches_player1P2Id_fkey" FOREIGN KEY ("player1P2Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "matches" ADD CONSTRAINT "matches_player2P1Id_fkey" FOREIGN KEY ("player2P1Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "matches" ADD CONSTRAINT "matches_player2P2Id_fkey" FOREIGN KEY ("player2P2Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "activities" ADD CONSTRAINT "activities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "activities" ADD CONSTRAINT "activities_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "activities" ADD CONSTRAINT "activities_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "activities" ADD CONSTRAINT "activities_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "matches"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0f2074..4468396 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ model Player { partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2") partnershipStats PartnershipStat[] @relation("StatPlayer1") partnershipStats2 PartnershipStat[] @relation("StatPlayer2") + activities Activity[] user User? eloRating EloRating? glicko2Rating Glicko2Rating? @@ -53,6 +54,7 @@ model User { ownedTournaments Event[] @relation("TournamentOwner") createdMatches Match[] @relation("MatchCreator") sessions Session[] + activities Activity[] player Player? @relation(fields: [playerId], references: [id]) @@map("users") @@ -79,6 +81,7 @@ model Event { owner User? @relation("TournamentOwner", fields: [ownerId], references: [id]) matches Match[] rounds TournamentRound[] + activities Activity[] // Team configuration fields teamDurability String @default("permanent") // permanent, variable, per_round @@ -167,6 +170,7 @@ model Match { isCasual Boolean @default(false) bracketMatchups BracketMatchup[] eloSnapshots EloSnapshot[] + activities Activity[] createdBy User? @relation("MatchCreator", fields: [createdById], references: [id]) event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade) player1P1 Player? @relation("MatchPlayer1", fields: [player1P1Id], references: [id]) @@ -325,3 +329,34 @@ model OpenSkillRating { @@map("open_skill_ratings") } + +model Activity { + id Int @id @default(autoincrement()) + type String // "player_registration", "tournament_created", "match_completed", "partnership_recorded" + description String + userId String? + playerId Int? + eventId Int? + matchId Int? + createdAt DateTime @default(now()) + + user User? @relation(fields: [userId], references: [id]) + player Player? @relation(fields: [playerId], references: [id]) + event Event? @relation(fields: [eventId], references: [id]) + match Match? @relation(fields: [matchId], references: [id]) + + @@map("activities") +} + +model ClubSettings { + id Int @id @default(autoincrement()) + clubName String @default("Euchre Club") + defaultEloRating Int @default(1200) + partnershipEnabled Boolean @default(true) + notificationsEnabled Boolean @default(true) + matchVerification Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("club_settings") +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 3f7e3e9..1b34fe8 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -59,6 +59,17 @@ export default async function AdminDashboard() { }), ]) as [number, number, number, EventModel[]] + // Get recent activities (using any type to bypass TypeScript error for now) + const recentActivities = await (prisma as any).activity.findMany({ + take: 10, + orderBy: { createdAt: "desc" }, + include: { + user: { select: { name: true } }, + player: { select: { name: true } }, + event: { select: { name: true } }, + }, + }) + return (
@@ -231,6 +242,41 @@ export default async function AdminDashboard() { in the rankings page.

+ + {/* Recent Activity Feed */} +
+
+

Recent Activity

+ + View All + +
+ {recentActivities.length > 0 ? ( + + ) : ( +

No recent activities.

+ )} +
diff --git a/src/app/admin/players/page.tsx b/src/app/admin/players/page.tsx index ec0563f..cc3520e 100644 --- a/src/app/admin/players/page.tsx +++ b/src/app/admin/players/page.tsx @@ -231,6 +231,32 @@ export default function AdminPlayersPage() { )} + {/* Search and Filter Controls */} +
+
+
+ { + const search = e.target.value + try { + const response = await fetch(`/api/players?search=${encodeURIComponent(search)}`) + if (response.ok) { + const data = await response.json() + setPlayers(data) + } + } catch (err) { + console.error('Search failed:', err) + } + }} + /> +
+
+
+ {/* Player Table */}
diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..0006aea --- /dev/null +++ b/src/app/admin/settings/page.tsx @@ -0,0 +1,224 @@ +"use client" + +import { useState, useEffect } from "react" +import Navigation from "@/components/Navigation" +import { redirect } from "next/navigation" +import { getSession } from "@/lib/auth-simple" + +interface ClubSettings { + id: number + clubName: string + defaultEloRating: number + partnershipEnabled: boolean + notificationsEnabled: boolean + matchVerification: boolean +} + +export default function ClubSettingsPage() { + const [settings, setSettings] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") + const [success, setSuccess] = useState("") + + const [formSettings, setFormSettings] = useState>({}) + + useEffect(() => { + fetchSettings() + }, []) + + const fetchSettings = async () => { + try { + const response = await fetch("/api/admin/settings") + if (!response.ok) { + throw new Error("Failed to fetch settings") + } + const data = await response.json() + setSettings(data) + setFormSettings(data || {}) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to fetch settings") + } finally { + setLoading(false) + } + } + + const handleSave = async () => { + setSaving(true) + setError("") + setSuccess("") + + try { + const response = await fetch("/api/admin/settings", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formSettings), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || "Failed to save settings") + } + + const updatedSettings = await response.json() + setSettings(updatedSettings) + setSuccess("Settings saved successfully!") + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to save settings") + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+ +
+
+

Loading settings...

+
+
+
+ ) + } + + return ( +
+ + +
+
+ {/* Page Header */} +
+

Club Settings

+

+ Configure your club's default settings and preferences. +

+
+ + {/* Settings Form */} +
+ {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + +
+ {/* Club Name */} +
+ + setFormSettings({ ...formSettings, clubName: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-green-500 focus:border-green-500" + /> +
+ + {/* Default Elo Rating */} +
+ + setFormSettings({ ...formSettings, defaultEloRating: parseInt(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-green-500 focus:border-green-500" + /> +
+ + {/* Partnership Tracking */} +
+
+ +

Enable partnership performance analytics

+
+ +
+ + {/* Notifications */} +
+
+ +

Send email notifications for updates

+
+ +
+ + {/* Match Verification */} +
+
+ +

Require admin verification for match results

+
+ +
+
+ + {/* Save Button */} +
+ +
+
+
+
+
+ ) +} diff --git a/src/app/api/admin/activity/route.ts b/src/app/api/admin/activity/route.ts new file mode 100644 index 0000000..cdf9730 --- /dev/null +++ b/src/app/api/admin/activity/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth-simple' + +export async function GET(request: NextRequest) { + const session = await getSession() + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const limit = parseInt(searchParams.get('limit') || '20') + const offset = parseInt(searchParams.get('offset') || '0') + const type = searchParams.get('type') + + const where: any = {} + if (type) { + where.type = type + } + + const activities = await prisma.activity.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + include: { + user: { select: { name: true } }, + player: { select: { name: true } }, + event: { select: { name: true } }, + }, + }) + + return NextResponse.json(activities) +} diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts new file mode 100644 index 0000000..7638d92 --- /dev/null +++ b/src/app/api/admin/settings/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth-simple' + +export async function GET() { + const session = await getSession() + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const settings = await (prisma as any).clubSettings.findFirst() + return NextResponse.json(settings) +} + +export async function PATCH(request: NextRequest) { + const session = await getSession() + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const data = await request.json() + + // Get the existing settings record (should be id: 1 or first record) + let settings = await (prisma as any).clubSettings.findFirst() + + if (!settings) { + // Create default settings if none exist + settings = await (prisma as any).clubSettings.create({ + data: { + clubName: 'Euchre Club', + defaultEloRating: 1200, + partnershipEnabled: true, + notificationsEnabled: true, + matchVerification: false, + }, + }) + } + + // Update the settings + const updatedSettings = await (prisma as any).clubSettings.update({ + where: { id: settings.id }, + data, + }) + + return NextResponse.json(updatedSettings) +} diff --git a/src/app/api/players/route.ts b/src/app/api/players/route.ts index 9df4a3b..afa17db 100644 --- a/src/app/api/players/route.ts +++ b/src/app/api/players/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; /** @@ -6,10 +6,22 @@ import { prisma } from "@/lib/prisma"; * * Get all players with their user associations * This is a public endpoint (no authentication required) + * Supports search query parameter */ -export async function GET() { +export async function GET(request: NextRequest) { try { + const { searchParams } = new URL(request.url) + const search = searchParams.get('search') || '' + const limit = parseInt(searchParams.get('limit') || '50') + const offset = parseInt(searchParams.get('offset') || '0') + + const where: any = {} + if (search) { + where.name = { contains: search, mode: 'insensitive' } + } + const players = await prisma.player.findMany({ + where, include: { user: { select: { @@ -17,7 +29,9 @@ export async function GET() { }, }, }, - orderBy: { name: "asc" }, + orderBy: { currentElo: 'desc' }, + take: limit, + skip: offset, }); return NextResponse.json(players); diff --git a/src/lib/activity-logger.ts b/src/lib/activity-logger.ts new file mode 100644 index 0000000..92f3345 --- /dev/null +++ b/src/lib/activity-logger.ts @@ -0,0 +1,29 @@ +import { prisma } from './prisma' + +export type ActivityType = + | 'player_registration' + | 'tournament_created' + | 'match_completed' + | 'partnership_recorded' + +export interface ActivityData { + type: ActivityType + description: string + userId?: string + playerId?: number + eventId?: number + matchId?: number +} + +export async function logActivity(data: ActivityData) { + return prisma.activity.create({ + data: { + type: data.type, + description: data.description, + userId: data.userId, + playerId: data.playerId, + eventId: data.eventId, + matchId: data.matchId, + }, + }) +}