feat: Implement tournament schedule tab and fix E2E tests #27
@@ -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
|
||||||
@@ -395,3 +395,57 @@ Given('a tournament has a generated schedule', async function () {
|
|||||||
// 2. Add teams/participants
|
// 2. Add teams/participants
|
||||||
// 3. Generate schedule via API or UI
|
// 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')
|
||||||
|
})
|
||||||
|
|||||||
@@ -475,3 +475,79 @@ Then('I should see {string} error', async function (errorMessage: string) {
|
|||||||
expect(content).toMatch(new RegExp(errorMessage, 'i'));
|
expect(content).toMatch(new RegExp(errorMessage, 'i'));
|
||||||
console.log(`🌍 Verified error message: ${errorMessage}`);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -27,6 +27,7 @@ model Player {
|
|||||||
partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2")
|
partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2")
|
||||||
partnershipStats PartnershipStat[] @relation("StatPlayer1")
|
partnershipStats PartnershipStat[] @relation("StatPlayer1")
|
||||||
partnershipStats2 PartnershipStat[] @relation("StatPlayer2")
|
partnershipStats2 PartnershipStat[] @relation("StatPlayer2")
|
||||||
|
activities Activity[]
|
||||||
user User?
|
user User?
|
||||||
eloRating EloRating?
|
eloRating EloRating?
|
||||||
glicko2Rating Glicko2Rating?
|
glicko2Rating Glicko2Rating?
|
||||||
@@ -53,6 +54,7 @@ model User {
|
|||||||
ownedTournaments Event[] @relation("TournamentOwner")
|
ownedTournaments Event[] @relation("TournamentOwner")
|
||||||
createdMatches Match[] @relation("MatchCreator")
|
createdMatches Match[] @relation("MatchCreator")
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
activities Activity[]
|
||||||
player Player? @relation(fields: [playerId], references: [id])
|
player Player? @relation(fields: [playerId], references: [id])
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -79,6 +81,7 @@ model Event {
|
|||||||
owner User? @relation("TournamentOwner", fields: [ownerId], references: [id])
|
owner User? @relation("TournamentOwner", fields: [ownerId], references: [id])
|
||||||
matches Match[]
|
matches Match[]
|
||||||
rounds TournamentRound[]
|
rounds TournamentRound[]
|
||||||
|
activities Activity[]
|
||||||
|
|
||||||
// Team configuration fields
|
// Team configuration fields
|
||||||
teamDurability String @default("permanent") // permanent, variable, per_round
|
teamDurability String @default("permanent") // permanent, variable, per_round
|
||||||
@@ -167,6 +170,7 @@ model Match {
|
|||||||
isCasual Boolean @default(false)
|
isCasual Boolean @default(false)
|
||||||
bracketMatchups BracketMatchup[]
|
bracketMatchups BracketMatchup[]
|
||||||
eloSnapshots EloSnapshot[]
|
eloSnapshots EloSnapshot[]
|
||||||
|
activities Activity[]
|
||||||
createdBy User? @relation("MatchCreator", fields: [createdById], references: [id])
|
createdBy User? @relation("MatchCreator", fields: [createdById], references: [id])
|
||||||
event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||||
player1P1 Player? @relation("MatchPlayer1", fields: [player1P1Id], references: [id])
|
player1P1 Player? @relation("MatchPlayer1", fields: [player1P1Id], references: [id])
|
||||||
@@ -325,3 +329,34 @@ model OpenSkillRating {
|
|||||||
|
|
||||||
@@map("open_skill_ratings")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ export default async function AdminDashboard() {
|
|||||||
}),
|
}),
|
||||||
]) as [number, number, number, EventModel[]]
|
]) 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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
@@ -231,6 +242,41 @@ export default async function AdminDashboard() {
|
|||||||
</Link> in the rankings page.
|
</Link> in the rankings page.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity Feed */}
|
||||||
|
<div className="bg-white shadow rounded-lg p-6 mt-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-lg font-medium text-gray-900">Recent Activity</h2>
|
||||||
|
<Link
|
||||||
|
href="/admin/activity"
|
||||||
|
className="text-green-600 hover:text-green-900 text-sm font-medium"
|
||||||
|
>
|
||||||
|
View All
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{recentActivities.length > 0 ? (
|
||||||
|
<ul className="divide-y divide-gray-200">
|
||||||
|
{recentActivities.map((activity: any) => (
|
||||||
|
<li key={activity.id} className="py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-900">{activity.description}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(activity.createdAt).toLocaleDateString()} at{' '}
|
||||||
|
{new Date(activity.createdAt).toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
{activity.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No recent activities.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -231,6 +231,32 @@ export default function AdminPlayersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Search and Filter Controls */}
|
||||||
|
<div className="bg-white shadow rounded-lg p-4 mb-4">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
placeholder="Search players by name..."
|
||||||
|
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"
|
||||||
|
onChange={async (e) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Player Table */}
|
{/* Player Table */}
|
||||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
|||||||
@@ -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<ClubSettings | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [success, setSuccess] = useState("")
|
||||||
|
|
||||||
|
const [formSettings, setFormSettings] = useState<Partial<ClubSettings>>({})
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navigation />
|
||||||
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div className="px-4 py-6 sm:px-0">
|
||||||
|
<p className="text-gray-500">Loading settings...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navigation />
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div className="px-4 py-6 sm:px-0">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Club Settings</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
Configure your club's default settings and preferences.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Form */}
|
||||||
|
<div className="bg-white shadow rounded-lg p-6">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="mb-4 p-4 bg-green-100 text-green-700 rounded">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Club Name */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="clubName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Club Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="clubName"
|
||||||
|
value={formSettings.clubName || ""}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Default Elo Rating */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="defaultEloRating" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Default Elo Rating
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="defaultEloRating"
|
||||||
|
value={formSettings.defaultEloRating || 1000}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Partnership Tracking */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700">Partnership Tracking</label>
|
||||||
|
<p className="text-sm text-gray-500">Enable partnership performance analytics</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormSettings({ ...formSettings, partnershipEnabled: !formSettings.partnershipEnabled })}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||||
|
formSettings.partnershipEnabled ? 'bg-green-600' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
formSettings.partnershipEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700">Notifications</label>
|
||||||
|
<p className="text-sm text-gray-500">Send email notifications for updates</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormSettings({ ...formSettings, notificationsEnabled: !formSettings.notificationsEnabled })}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||||
|
formSettings.notificationsEnabled ? 'bg-green-600' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
formSettings.notificationsEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Match Verification */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700">Match Verification</label>
|
||||||
|
<p className="text-sm text-gray-500">Require admin verification for match results</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormSettings({ ...formSettings, matchVerification: !formSettings.matchVerification })}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||||
|
formSettings.matchVerification ? 'bg-green-600' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
formSettings.matchVerification ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="mt-8 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6,10 +6,22 @@ import { prisma } from "@/lib/prisma";
|
|||||||
*
|
*
|
||||||
* Get all players with their user associations
|
* Get all players with their user associations
|
||||||
* This is a public endpoint (no authentication required)
|
* This is a public endpoint (no authentication required)
|
||||||
|
* Supports search query parameter
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
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({
|
const players = await prisma.player.findMany({
|
||||||
|
where,
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
@@ -17,7 +29,9 @@ export async function GET() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { name: "asc" },
|
orderBy: { currentElo: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(players);
|
return NextResponse.json(players);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user