updated
This commit is contained in:
parent
534860a12f
commit
5d0150d903
@ -43,6 +43,8 @@ model User {
|
|||||||
|
|
||||||
createdSchedules Schedule[] @relation("CreatedSchedules")
|
createdSchedules Schedule[] @relation("CreatedSchedules")
|
||||||
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
|
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
|
||||||
|
|
||||||
|
mapVetoChoices MapVoteStep[] @relation("VetoStepChooser")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Team {
|
model Team {
|
||||||
@ -65,6 +67,8 @@ model Team {
|
|||||||
|
|
||||||
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
|
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
|
||||||
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
|
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
|
||||||
|
|
||||||
|
mapVetoSteps MapVoteStep[] @relation("VetoStepTeam")
|
||||||
}
|
}
|
||||||
|
|
||||||
model TeamInvite {
|
model TeamInvite {
|
||||||
@ -98,6 +102,10 @@ model Notification {
|
|||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 🎮 Matches
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
model Match {
|
model Match {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
@ -128,6 +136,10 @@ model Match {
|
|||||||
roundHistory Json?
|
roundHistory Json?
|
||||||
winnerTeam String?
|
winnerTeam String?
|
||||||
|
|
||||||
|
bestOf Int @default(3) // 1 | 3 | 5 – app-seitig validieren
|
||||||
|
matchDate DateTime? // geplante Startzeit (separat von demoDate)
|
||||||
|
mapVote MapVote? // 1:1 Map-Vote-Status
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@ -175,19 +187,19 @@ model PlayerStats {
|
|||||||
headshots Int @default(0)
|
headshots Int @default(0)
|
||||||
noScopes Int @default(0)
|
noScopes Int @default(0)
|
||||||
blindKills Int @default(0)
|
blindKills Int @default(0)
|
||||||
|
|
||||||
aim Int @default(0)
|
|
||||||
|
|
||||||
oneK Int @default(0)
|
aim Int @default(0)
|
||||||
twoK Int @default(0)
|
|
||||||
threeK Int @default(0)
|
|
||||||
fourK Int @default(0)
|
|
||||||
fiveK Int @default(0)
|
|
||||||
|
|
||||||
rankOld Int?
|
oneK Int @default(0)
|
||||||
rankNew Int?
|
twoK Int @default(0)
|
||||||
rankChange Int?
|
threeK Int @default(0)
|
||||||
winCount Int?
|
fourK Int @default(0)
|
||||||
|
fiveK Int @default(0)
|
||||||
|
|
||||||
|
rankOld Int?
|
||||||
|
rankNew Int?
|
||||||
|
rankChange Int?
|
||||||
|
winCount Int?
|
||||||
|
|
||||||
matchPlayer MatchPlayer @relation(fields: [matchId, steamId], references: [matchId, steamId])
|
matchPlayer MatchPlayer @relation(fields: [matchId, steamId], references: [matchId, steamId])
|
||||||
|
|
||||||
@ -280,3 +292,56 @@ model ServerRequest {
|
|||||||
|
|
||||||
@@unique([steamId, matchId])
|
@@unique([steamId, matchId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 🗺️ Map-Vote
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum MapVoteAction {
|
||||||
|
BAN
|
||||||
|
PICK
|
||||||
|
DECIDER
|
||||||
|
}
|
||||||
|
|
||||||
|
model MapVote {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
matchId String @unique
|
||||||
|
match Match @relation(fields: [matchId], references: [id])
|
||||||
|
|
||||||
|
// Basiszustand
|
||||||
|
bestOf Int @default(3)
|
||||||
|
mapPool String[] // z.B. ["de_inferno","de_mirage",...]
|
||||||
|
currentIdx Int @default(0)
|
||||||
|
locked Boolean @default(false)
|
||||||
|
|
||||||
|
// Optional: serverseitig speichern, statt im UI zu berechnen
|
||||||
|
opensAt DateTime?
|
||||||
|
|
||||||
|
steps MapVoteStep[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model MapVoteStep {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
vetoId String
|
||||||
|
order Int
|
||||||
|
action MapVoteAction
|
||||||
|
|
||||||
|
// Team, das am Zug ist (kann bei DECIDER null sein)
|
||||||
|
teamId String?
|
||||||
|
team Team? @relation("VetoStepTeam", fields: [teamId], references: [id])
|
||||||
|
|
||||||
|
// Ergebnis & wer gewählt hat
|
||||||
|
map String?
|
||||||
|
chosenAt DateTime?
|
||||||
|
chosenBy String?
|
||||||
|
chooser User? @relation("VetoStepChooser", fields: [chosenBy], references: [steamId])
|
||||||
|
|
||||||
|
veto MapVote @relation(fields: [vetoId], references: [id])
|
||||||
|
|
||||||
|
@@unique([vetoId, order])
|
||||||
|
@@index([teamId])
|
||||||
|
@@index([chosenBy])
|
||||||
|
}
|
||||||
|
|||||||
47
src/app/api/matches/[id]/delete/route.ts
Normal file
47
src/app/api/matches/[id]/delete/route.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// /app/api/matches/[id]/delete/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/app/lib/auth'
|
||||||
|
import { prisma } from '@/app/lib/prisma'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions(req))
|
||||||
|
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
|
||||||
|
if (!me?.isAdmin) {
|
||||||
|
return NextResponse.json({ message: 'Nur Admins dürfen löschen.' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchId = params?.id
|
||||||
|
if (!matchId) {
|
||||||
|
return NextResponse.json({ message: 'Match-ID fehlt.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = await prisma.match.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!match) {
|
||||||
|
return NextResponse.json({ message: 'Match nicht gefunden.' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.mapVetoStep.deleteMany({ where: { veto: { matchId } } })
|
||||||
|
await tx.mapVeto.deleteMany({ where: { matchId } })
|
||||||
|
await tx.playerStats.deleteMany({ where: { matchId } })
|
||||||
|
await tx.matchPlayer.deleteMany({ where: { matchId } })
|
||||||
|
await tx.rankHistory.deleteMany({ where: { matchId } })
|
||||||
|
await tx.demoFile.deleteMany({ where: { matchId } })
|
||||||
|
await tx.serverRequest.deleteMany({ where: { matchId } })
|
||||||
|
await tx.schedule.deleteMany({
|
||||||
|
where: { linkedMatchId: matchId },
|
||||||
|
})
|
||||||
|
await tx.match.delete({ where: { id: matchId } })
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[DELETE MATCH] error', e)
|
||||||
|
return NextResponse.json({ message: 'Löschen fehlgeschlagen.' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
273
src/app/api/matches/[id]/map-vote/route.ts
Normal file
273
src/app/api/matches/[id]/map-vote/route.ts
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
// /app/api/matches/[id]/map-vote/route.ts
|
||||||
|
import { NextResponse, NextRequest } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/app/lib/auth'
|
||||||
|
import { prisma } from '@/app/lib/prisma' // gemeinsame Prisma-Instanz nutzen
|
||||||
|
import { MapVetoAction } from '@/generated/prisma' // dein Prisma-Types-Output
|
||||||
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
|
||||||
|
/* -------------------- Konstanten -------------------- */
|
||||||
|
|
||||||
|
const ACTIVE_DUTY: string[] = [
|
||||||
|
'de_inferno','de_mirage','de_nuke','de_overpass','de_vertigo','de_ancient','de_anubis',
|
||||||
|
]
|
||||||
|
|
||||||
|
const ACTION_MAP: Record<MapVetoAction, 'ban'|'pick'|'decider'> = {
|
||||||
|
BAN: 'ban', PICK: 'pick', DECIDER: 'decider',
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- Helper -------------------- */
|
||||||
|
|
||||||
|
function vetoOpensAt(match: { matchDate: Date | null, demoDate: Date | null }) {
|
||||||
|
const base = match.matchDate ?? match.demoDate ?? new Date()
|
||||||
|
return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapActionToApi(a: MapVetoAction): 'ban'|'pick'|'decider' {
|
||||||
|
return ACTION_MAP[a]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
|
||||||
|
if (bestOf === 3) {
|
||||||
|
// 2x Ban, 2x Pick, 2x Ban, Decider
|
||||||
|
return [
|
||||||
|
{ order: 0, action: 'BAN', teamId: teamAId },
|
||||||
|
{ order: 1, action: 'BAN', teamId: teamBId },
|
||||||
|
{ order: 2, action: 'PICK', teamId: teamAId },
|
||||||
|
{ order: 3, action: 'PICK', teamId: teamBId },
|
||||||
|
{ order: 4, action: 'BAN', teamId: teamAId },
|
||||||
|
{ order: 5, action: 'BAN', teamId: teamBId },
|
||||||
|
{ order: 6, action: 'DECIDER', teamId: null },
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
// BO5: 2x Ban, dann 5 Picks (kein Decider)
|
||||||
|
return [
|
||||||
|
{ order: 0, action: 'BAN', teamId: teamAId },
|
||||||
|
{ order: 1, action: 'BAN', teamId: teamBId },
|
||||||
|
{ order: 2, action: 'PICK', teamId: teamAId },
|
||||||
|
{ order: 3, action: 'PICK', teamId: teamBId },
|
||||||
|
{ order: 4, action: 'PICK', teamId: teamAId },
|
||||||
|
{ order: 5, action: 'PICK', teamId: teamBId },
|
||||||
|
{ order: 6, action: 'PICK', teamId: teamAId },
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeState(veto: any) {
|
||||||
|
const steps = [...veto.steps]
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((s: any) => ({
|
||||||
|
order : s.order,
|
||||||
|
action : mapActionToApi(s.action),
|
||||||
|
teamId : s.teamId,
|
||||||
|
map : s.map,
|
||||||
|
chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null,
|
||||||
|
chosenBy: s.chosenBy ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
bestOf : veto.bestOf,
|
||||||
|
mapPool : veto.mapPool as string[],
|
||||||
|
currentIndex: veto.currentIdx,
|
||||||
|
locked : veto.locked as boolean,
|
||||||
|
opensAt : veto.opensAt ? new Date(veto.opensAt).toISOString() : null,
|
||||||
|
steps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureVeto(matchId: string) {
|
||||||
|
const match = await prisma.match.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: {
|
||||||
|
teamA : true,
|
||||||
|
teamB : true,
|
||||||
|
mapVeto: { include: { steps: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!match) return { match: null, veto: null }
|
||||||
|
|
||||||
|
// Bereits vorhanden?
|
||||||
|
if (match.mapVeto) return { match, veto: match.mapVeto }
|
||||||
|
|
||||||
|
// Neu anlegen
|
||||||
|
const bestOf = match.bestOf ?? 3
|
||||||
|
const mapPool = ACTIVE_DUTY
|
||||||
|
const opensAt = vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
|
||||||
|
const stepsDef = buildSteps(bestOf, match.teamA!.id, match.teamB!.id)
|
||||||
|
|
||||||
|
const created = await prisma.mapVeto.create({
|
||||||
|
data: {
|
||||||
|
matchId : match.id,
|
||||||
|
bestOf,
|
||||||
|
mapPool,
|
||||||
|
currentIdx: 0,
|
||||||
|
locked : false,
|
||||||
|
opensAt,
|
||||||
|
steps : {
|
||||||
|
create: stepsDef.map(s => ({
|
||||||
|
order : s.order,
|
||||||
|
action: s.action as MapVetoAction,
|
||||||
|
teamId: s.teamId,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { steps: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { match, veto: created }
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAvailableMaps(mapPool: string[], steps: Array<{ map: string | null }>) {
|
||||||
|
const used = new Set(steps.map(s => s.map).filter(Boolean) as string[])
|
||||||
|
return mapPool.filter(m => !used.has(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- GET -------------------- */
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const matchId = params.id
|
||||||
|
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
|
||||||
|
|
||||||
|
const { veto } = await ensureVeto(matchId)
|
||||||
|
if (!veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
shapeState(veto),
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[map-vote][GET] error', e)
|
||||||
|
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- POST ------------------- */
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
const session = await getServerSession(authOptions(req))
|
||||||
|
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
|
||||||
|
if (!me?.steamId) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
||||||
|
|
||||||
|
const matchId = params.id
|
||||||
|
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
|
||||||
|
|
||||||
|
let body: { map?: string } = {}
|
||||||
|
try { body = await req.json() } catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { match, veto } = await ensureVeto(matchId)
|
||||||
|
if (!match || !veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
|
||||||
|
|
||||||
|
// Öffnungsfenster (1h vor Match-/Demo-Beginn)
|
||||||
|
const opensAt = veto.opensAt ?? vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
|
||||||
|
const isOpen = new Date() >= new Date(opensAt)
|
||||||
|
if (!isOpen && !me.isAdmin) return NextResponse.json({ message: 'Veto ist noch nicht offen' }, { status: 403 })
|
||||||
|
|
||||||
|
// Schon abgeschlossen?
|
||||||
|
if (veto.locked) return NextResponse.json({ message: 'Veto bereits abgeschlossen' }, { status: 409 })
|
||||||
|
|
||||||
|
// Aktuellen Schritt bestimmen
|
||||||
|
const stepsSorted = [...veto.steps].sort((a: any, b: any) => a.order - b.order)
|
||||||
|
const current = stepsSorted.find((s: any) => s.order === veto.currentIdx)
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
// Kein Schritt mehr -> Veto abschließen
|
||||||
|
await prisma.mapVeto.update({ where: { id: veto.id }, data: { locked: true } })
|
||||||
|
const updated = await prisma.mapVeto.findUnique({ where: { id: veto.id }, include: { steps: true } })
|
||||||
|
await sendServerSSEMessage({ type: 'map-vote-updated', payload: { matchId } })
|
||||||
|
return NextResponse.json(shapeState(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = computeAvailableMaps(veto.mapPool, stepsSorted)
|
||||||
|
|
||||||
|
// DECIDER automatisch setzen, wenn nur noch 1 Map übrig
|
||||||
|
if (current.action === 'DECIDER') {
|
||||||
|
if (available.length !== 1) {
|
||||||
|
return NextResponse.json({ message: 'DECIDER noch nicht bestimmbar' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const lastMap = available[0]
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.mapVetoStep.update({
|
||||||
|
where: { id: current.id },
|
||||||
|
data : { map: lastMap, chosenAt: new Date(), chosenBy: me.steamId },
|
||||||
|
})
|
||||||
|
await tx.mapVeto.update({
|
||||||
|
where: { id: veto.id },
|
||||||
|
data : { currentIdx: veto.currentIdx + 1, locked: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const updated = await prisma.mapVeto.findUnique({ where: { id: veto.id }, include: { steps: true } })
|
||||||
|
await sendServerSSEMessage({ type: 'map-vote-updated', payload: { matchId } })
|
||||||
|
return NextResponse.json(shapeState(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rechte prüfen (Admin oder Leader des Teams am Zug)
|
||||||
|
const isLeaderA = !!match.teamA?.leaderId && match.teamA.leaderId === me.steamId
|
||||||
|
const isLeaderB = !!match.teamB?.leaderId && match.teamB.leaderId === me.steamId
|
||||||
|
const allowed = me.isAdmin || (current.teamId && (
|
||||||
|
(current.teamId === match.teamA?.id && isLeaderA) ||
|
||||||
|
(current.teamId === match.teamB?.id && isLeaderB)
|
||||||
|
))
|
||||||
|
if (!allowed) return NextResponse.json({ message: 'Keine Berechtigung für diesen Schritt' }, { status: 403 })
|
||||||
|
|
||||||
|
// Payload validieren
|
||||||
|
const map = body.map?.trim()
|
||||||
|
if (!map) return NextResponse.json({ message: 'map fehlt' }, { status: 400 })
|
||||||
|
if (!veto.mapPool.includes(map)) return NextResponse.json({ message: 'Map nicht im Pool' }, { status: 400 })
|
||||||
|
if (!available.includes(map)) return NextResponse.json({ message: 'Map bereits vergeben' }, { status: 409 })
|
||||||
|
|
||||||
|
// Schritt setzen & ggf. weiterdrehen (+ Decider evtl. auto)
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// aktuellen Schritt setzen
|
||||||
|
await tx.mapVetoStep.update({
|
||||||
|
where: { id: current.id },
|
||||||
|
data : { map, chosenAt: new Date(), chosenBy: me.steamId },
|
||||||
|
})
|
||||||
|
|
||||||
|
// neuen Zustand ermitteln
|
||||||
|
const after = await tx.mapVeto.findUnique({
|
||||||
|
where : { id: veto.id },
|
||||||
|
include: { steps: true },
|
||||||
|
})
|
||||||
|
if (!after) return
|
||||||
|
|
||||||
|
const stepsAfter = [...after.steps].sort((a: any, b: any) => a.order - b.order)
|
||||||
|
let idx = after.currentIdx + 1
|
||||||
|
let locked = false
|
||||||
|
|
||||||
|
// Falls nächster Schritt DECIDER und genau 1 Map übrig -> auto setzen & locken
|
||||||
|
const next = stepsAfter.find(s => s.order === idx)
|
||||||
|
if (next?.action === 'DECIDER') {
|
||||||
|
const avail = computeAvailableMaps(after.mapPool, stepsAfter)
|
||||||
|
if (avail.length === 1) {
|
||||||
|
await tx.mapVetoStep.update({
|
||||||
|
where: { id: next.id },
|
||||||
|
data : { map: avail[0], chosenAt: new Date(), chosenBy: me.steamId },
|
||||||
|
})
|
||||||
|
idx += 1
|
||||||
|
locked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ende erreicht?
|
||||||
|
const maxOrder = Math.max(...stepsAfter.map(s => s.order))
|
||||||
|
if (idx > maxOrder) locked = true
|
||||||
|
|
||||||
|
await tx.mapVeto.update({
|
||||||
|
where: { id: after.id },
|
||||||
|
data : { currentIdx: idx, locked },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const updated = await prisma.mapVeto.findUnique({
|
||||||
|
where : { id: veto.id },
|
||||||
|
include: { steps: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendServerSSEMessage({ type: 'map-vote-updated', payload: { matchId } })
|
||||||
|
return NextResponse.json(shapeState(updated))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[map-vote][POST] error', e)
|
||||||
|
return NextResponse.json({ message: 'Aktion fehlgeschlagen' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,8 +20,9 @@ export async function GET (
|
|||||||
_req: Request,
|
_req: Request,
|
||||||
{ params: { id } }: { params: { id: string } },
|
{ params: { id } }: { params: { id: string } },
|
||||||
) {
|
) {
|
||||||
if (!id)
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
|
return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
const match = await prisma.match.findUnique({
|
const match = await prisma.match.findUnique({
|
||||||
where : { id },
|
where : { id },
|
||||||
@ -31,14 +32,17 @@ export async function GET (
|
|||||||
teamAUsers : { include: { team: true } },
|
teamAUsers : { include: { team: true } },
|
||||||
teamBUsers : { include: { team: true } },
|
teamBUsers : { include: { team: true } },
|
||||||
players : { include: { user: true, stats: true, team: true } },
|
players : { include: { user: true, stats: true, team: true } },
|
||||||
|
mapVeto : { include: { steps: true } }, // ⬅️ wichtig
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!match)
|
if (!match) {
|
||||||
return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
|
return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Editierbarkeit bestimmen ---------- */
|
/* ---------- Editierbarkeit bestimmen ---------- */
|
||||||
const isFuture = !!match.demoDate && isAfter(match.demoDate, new Date())
|
const baseDate = match.matchDate ?? match.demoDate ?? null
|
||||||
|
const isFuture = !!baseDate && isAfter(baseDate, new Date())
|
||||||
const editable = match.matchType === 'community' && isFuture
|
const editable = match.matchType === 'community' && isFuture
|
||||||
|
|
||||||
/* ---------- Spielerlisten zusammenstellen --------------------------------- */
|
/* ---------- Spielerlisten zusammenstellen --------------------------------- */
|
||||||
@ -47,22 +51,20 @@ export async function GET (
|
|||||||
|
|
||||||
if (editable) {
|
if (editable) {
|
||||||
/* ───── Spieler kommen direkt aus der Match-Relation ───── */
|
/* ───── Spieler kommen direkt aus der Match-Relation ───── */
|
||||||
/* ▸ teamAUsers / teamBUsers enthalten bereits User-Objekte */
|
|
||||||
const mapUser = (u: any, fallbackTeam: string) => ({
|
const mapUser = (u: any, fallbackTeam: string) => ({
|
||||||
user : { // nur die Felder, die das Frontend braucht
|
user : {
|
||||||
steamId: u.steamId,
|
steamId: u.steamId,
|
||||||
name : u.name ?? 'Unbekannt',
|
name : u.name ?? 'Unbekannt',
|
||||||
avatar : u.avatar ?? null,
|
avatar : u.avatar ?? null,
|
||||||
},
|
},
|
||||||
stats: null, // noch keine Stats vorhanden
|
stats: null,
|
||||||
team : fallbackTeam,
|
team : fallbackTeam,
|
||||||
})
|
})
|
||||||
|
|
||||||
playersA = match.teamAUsers.map(u => mapUser(u, match.teamA?.name ?? 'CT'))
|
playersA = match.teamAUsers.map(u => mapUser(u, match.teamA?.name ?? 'CT'))
|
||||||
playersB = match.teamBUsers.map(u => mapUser(u, match.teamB?.name ?? 'T'))
|
playersB = match.teamBUsers.map(u => mapUser(u, match.teamB?.name ?? 'T'))
|
||||||
|
|
||||||
/* ► Falls beim Anlegen noch keine Spieler zugewiesen wurden,
|
/* ► Fallback: aktive Spieler, falls noch leer (z. B. nach Migration) */
|
||||||
(z. B. nach Migration) greifen wir auf activePlayers zurück */
|
|
||||||
if (playersA.length === 0 || playersB.length === 0) {
|
if (playersA.length === 0 || playersB.length === 0) {
|
||||||
const [aIds, bIds] = [
|
const [aIds, bIds] = [
|
||||||
match.teamA?.activePlayers ?? [],
|
match.teamA?.activePlayers ?? [],
|
||||||
@ -92,18 +94,50 @@ export async function GET (
|
|||||||
.map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'T' }))
|
.map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'T' }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Map-Vote ableiten (immer mitsenden) ---------- */
|
||||||
|
const computedOpensAt = baseDate
|
||||||
|
? new Date(new Date(baseDate).getTime() - 60 * 60 * 1000)
|
||||||
|
: null
|
||||||
|
|
||||||
|
let status: 'not_started' | 'in_progress' | 'completed' = 'not_started'
|
||||||
|
let opensAt = computedOpensAt?.toISOString() ?? null
|
||||||
|
let isOpen = opensAt ? (Date.now() >= new Date(opensAt).getTime()) : false
|
||||||
|
let currentIndex: number | null = null
|
||||||
|
let currentAction: 'BAN' | 'PICK' | 'DECIDER' | null = null
|
||||||
|
let decidedCount: number | null = null
|
||||||
|
let totalSteps: number | null = null
|
||||||
|
|
||||||
|
if (match.mapVeto) {
|
||||||
|
const stepsSorted = [...match.mapVeto.steps].sort((a, b) => a.order - b.order)
|
||||||
|
const anyChosen = stepsSorted.some(s => !!s.chosenAt)
|
||||||
|
status = match.mapVeto.locked ? 'completed' : (anyChosen ? 'in_progress' : 'not_started')
|
||||||
|
opensAt = (match.mapVeto.opensAt ?? computedOpensAt)?.toISOString() ?? null
|
||||||
|
isOpen = opensAt ? (Date.now() >= new Date(opensAt).getTime()) : false
|
||||||
|
currentIndex = match.mapVeto.currentIdx
|
||||||
|
currentAction = (stepsSorted.find(s => s.order === match.mapVeto.currentIdx)?.action ?? null) as any
|
||||||
|
decidedCount = stepsSorted.filter(s => !!s.chosenAt).length
|
||||||
|
totalSteps = stepsSorted.length
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Antwort ---------- */
|
/* ---------- Antwort ---------- */
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id : match.id,
|
id : match.id,
|
||||||
title : match.title,
|
title : match.title,
|
||||||
description: match.description,
|
description: match.description,
|
||||||
demoDate : match.demoDate,
|
demoDate : match.demoDate,
|
||||||
|
matchDate : match.matchDate, // ⬅️ nützlich fürs Frontend
|
||||||
matchType : match.matchType,
|
matchType : match.matchType,
|
||||||
roundCount : match.roundCount,
|
roundCount : match.roundCount,
|
||||||
map : match.map,
|
map : match.map,
|
||||||
scoreA : match.scoreA,
|
scoreA : match.scoreA,
|
||||||
scoreB : match.scoreB,
|
scoreB : match.scoreB,
|
||||||
editable, // <-- Frontend-Flag
|
editable,
|
||||||
|
|
||||||
|
// ⬇️ NEU: kompaktes Map-Vote-Objekt (virtuell, wenn kein DB-Eintrag)
|
||||||
|
mapVote: {
|
||||||
|
status, opensAt, isOpen, currentIndex, currentAction, decidedCount, totalSteps,
|
||||||
|
},
|
||||||
|
|
||||||
teamA: {
|
teamA: {
|
||||||
id : match.teamA?.id ?? null,
|
id : match.teamA?.id ?? null,
|
||||||
name : match.teamA?.name ?? 'CT',
|
name : match.teamA?.name ?? 'CT',
|
||||||
@ -130,18 +164,22 @@ export async function PUT (
|
|||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions(req))
|
const session = await getServerSession(authOptions(req))
|
||||||
const me = session?.user
|
const me = session?.user
|
||||||
if (!me?.steamId)
|
if (!me?.steamId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const match = await prisma.match.findUnique({ where: { id } })
|
const match = await prisma.match.findUnique({ where: { id } })
|
||||||
if (!match)
|
if (!match) {
|
||||||
return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- erneute Editierbarkeits-Prüfung ---------- */
|
/* ---------- erneute Editierbarkeits-Prüfung ---------- */
|
||||||
const isFuture = !!match.demoDate && isAfter(match.demoDate, new Date())
|
const baseDate = match.matchDate ?? match.demoDate ?? null
|
||||||
const editable = match.matchType === 'community' && isFuture
|
const isFuture = !!baseDate && isAfter(baseDate, new Date())
|
||||||
if (!editable)
|
const editable = match.matchType === 'community' && isFuture
|
||||||
|
if (!editable) {
|
||||||
return NextResponse.json({ error: 'Match kann nicht bearbeitet werden' }, { status: 403 })
|
return NextResponse.json({ error: 'Match kann nicht bearbeitet werden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Rollen-Check (Admin oder Team-Leader) ----- */
|
/* ---------- Rollen-Check (Admin oder Team-Leader) ----- */
|
||||||
const userData = await prisma.user.findUnique({
|
const userData = await prisma.user.findUnique({
|
||||||
@ -151,13 +189,13 @@ export async function PUT (
|
|||||||
const leaderOf = userData?.ledTeam?.id
|
const leaderOf = userData?.ledTeam?.id
|
||||||
const isLeader = leaderOf && (leaderOf === match.teamAId || leaderOf === match.teamBId)
|
const isLeader = leaderOf && (leaderOf === match.teamAId || leaderOf === match.teamBId)
|
||||||
|
|
||||||
if (!me.isAdmin && !isLeader)
|
if (!me.isAdmin && !isLeader) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Payload einlesen & validieren ------------- */
|
/* ---------- Payload einlesen & validieren ------------- */
|
||||||
const { players } = await req.json() // title / description etc. bei Bedarf ergänzen
|
const { players } = await req.json()
|
||||||
|
|
||||||
// wenn kein Admin: sicherstellen, dass nur Spieler des eigenen Teams gesetzt werden
|
|
||||||
if (!me.isAdmin && leaderOf) {
|
if (!me.isAdmin && leaderOf) {
|
||||||
const ownTeam = await prisma.team.findUnique({ where: { id: leaderOf } })
|
const ownTeam = await prisma.team.findUnique({ where: { id: leaderOf } })
|
||||||
const allowed = new Set([
|
const allowed = new Set([
|
||||||
@ -165,16 +203,16 @@ export async function PUT (
|
|||||||
...(ownTeam?.inactivePlayers ?? []),
|
...(ownTeam?.inactivePlayers ?? []),
|
||||||
])
|
])
|
||||||
|
|
||||||
const invalid = players.some((p: any) =>
|
const invalid = players.some((p: any) =>
|
||||||
p.teamId === leaderOf && !allowed.has(p.steamId),
|
p.teamId === leaderOf && !allowed.has(p.steamId),
|
||||||
)
|
)
|
||||||
if (invalid)
|
if (invalid) {
|
||||||
return NextResponse.json({ error: 'Ungültige Spielerzuweisung' }, { status: 403 })
|
return NextResponse.json({ error: 'Ungültige Spielerzuweisung' }, { status: 403 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Spieler-Mapping speichern ----------------- */
|
/* ---------- Spieler-Mapping speichern ----------------- */
|
||||||
try {
|
try {
|
||||||
/* ► Listen pro Team aus dem Payload aufteilen */
|
|
||||||
const teamAIds = players
|
const teamAIds = players
|
||||||
.filter((p: any) => p.teamId === match.teamAId)
|
.filter((p: any) => p.teamId === match.teamAId)
|
||||||
.map((p: any) => p.steamId)
|
.map((p: any) => p.steamId)
|
||||||
@ -184,10 +222,7 @@ export async function PUT (
|
|||||||
.map((p: any) => p.steamId)
|
.map((p: any) => p.steamId)
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
/* 1) alle alten Zuordnungen löschen … */
|
|
||||||
prisma.matchPlayer.deleteMany({ where: { matchId: id } }),
|
prisma.matchPlayer.deleteMany({ where: { matchId: id } }),
|
||||||
|
|
||||||
/* 2) … neue anlegen */
|
|
||||||
prisma.matchPlayer.createMany({
|
prisma.matchPlayer.createMany({
|
||||||
data: players.map((p: any) => ({
|
data: players.map((p: any) => ({
|
||||||
matchId: id,
|
matchId: id,
|
||||||
@ -196,8 +231,6 @@ export async function PUT (
|
|||||||
})),
|
})),
|
||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/* 3) M-N-Relationen an Match-Eintrag aktualisieren */
|
|
||||||
prisma.match.update({
|
prisma.match.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data : {
|
data : {
|
||||||
@ -212,7 +245,7 @@ export async function PUT (
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- neue Daten abrufen & zurückgeben ---------- */
|
/* ---------- neue Daten abrufen & zurückgeben ---------- */
|
||||||
return GET(req, { params: { id } }) // gleiche Antwort-Struktur wie oben
|
return GET(_req as any, { params: { id } }) // gleiche Antwort-Struktur wie oben
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─────────────────────────── DELETE ─────────────────────────── */
|
/* ─────────────────────────── DELETE ─────────────────────────── */
|
||||||
@ -221,8 +254,9 @@ export async function DELETE (
|
|||||||
{ params: { id } }: { params: { id: string } },
|
{ params: { id } }: { params: { id: string } },
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions(_req))
|
const session = await getServerSession(authOptions(_req))
|
||||||
if (!session?.user?.isAdmin)
|
if (!session?.user?.isAdmin) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
|
|||||||
@ -3,10 +3,40 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/app/lib/auth'
|
import { authOptions } from '@/app/lib/auth'
|
||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
|
import { MapVetoAction } from '@/generated/prisma'
|
||||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// Hilfsfunktion in der Datei (oder shared):
|
||||||
|
function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
|
||||||
|
if (bestOf === 5) {
|
||||||
|
return [
|
||||||
|
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId },
|
||||||
|
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId },
|
||||||
|
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId },
|
||||||
|
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId },
|
||||||
|
{ order: 4, action: MapVetoAction.PICK, teamId: teamAId },
|
||||||
|
{ order: 5, action: MapVetoAction.PICK, teamId: teamBId },
|
||||||
|
{ order: 6, action: MapVetoAction.PICK, teamId: teamAId },
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
// default BO3
|
||||||
|
return [
|
||||||
|
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId },
|
||||||
|
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId },
|
||||||
|
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId },
|
||||||
|
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId },
|
||||||
|
{ order: 4, action: MapVetoAction.BAN, teamId: teamAId },
|
||||||
|
{ order: 5, action: MapVetoAction.BAN, teamId: teamBId },
|
||||||
|
{ order: 6, action: MapVetoAction.DECIDER, teamId: null },
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE_DUTY = [
|
||||||
|
'de_inferno','de_mirage','de_nuke','de_overpass','de_vertigo','de_ancient','de_anubis',
|
||||||
|
]
|
||||||
|
|
||||||
export async function POST (req: NextRequest) {
|
export async function POST (req: NextRequest) {
|
||||||
// ── Auth: nur Admins
|
// ── Auth: nur Admins
|
||||||
const session = await getServerSession(authOptions(req))
|
const session = await getServerSession(authOptions(req))
|
||||||
@ -71,15 +101,14 @@ export async function POST (req: NextRequest) {
|
|||||||
const created = await prisma.$transaction(async (tx) => {
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
const newMatch = await tx.match.create({
|
const newMatch = await tx.match.create({
|
||||||
data: {
|
data: {
|
||||||
teamAId,
|
teamAId, teamBId,
|
||||||
teamBId,
|
title: safeTitle,
|
||||||
title : safeTitle,
|
description: safeDesc,
|
||||||
description : safeDesc,
|
map: safeMap,
|
||||||
map : safeMap,
|
demoDate: plannedAt, // du nutzt demoDate als geplante Zeit
|
||||||
demoDate : plannedAt,
|
bestOf: bestOfInt,
|
||||||
// ⚠ hier KEIN "type" setzen – existiert nicht im Schema
|
teamAUsers: { connect: (teamA.activePlayers ?? []).map(id => ({ steamId: id })) },
|
||||||
teamAUsers : { connect: (teamA.activePlayers ?? []).map(id => ({ steamId: id })) },
|
teamBUsers: { connect: (teamB.activePlayers ?? []).map(id => ({ steamId: id })) },
|
||||||
teamBUsers : { connect: (teamB.activePlayers ?? []).map(id => ({ steamId: id })) },
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -91,6 +120,28 @@ export async function POST (req: NextRequest) {
|
|||||||
await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true })
|
await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⬇️ MapVeto sofort anlegen
|
||||||
|
const opensAt = new Date((new Date(newMatch.matchDate ?? newMatch.demoDate ?? plannedAt)).getTime() - 60*60*1000)
|
||||||
|
const stepsDef = buildSteps(bestOfInt, teamAId, teamBId)
|
||||||
|
|
||||||
|
await tx.mapVeto.create({
|
||||||
|
data: {
|
||||||
|
matchId: newMatch.id,
|
||||||
|
bestOf : bestOfInt,
|
||||||
|
mapPool: ACTIVE_DUTY,
|
||||||
|
currentIdx: 0,
|
||||||
|
locked: false,
|
||||||
|
opensAt,
|
||||||
|
steps: {
|
||||||
|
create: stepsDef.map(s => ({
|
||||||
|
order: s.order,
|
||||||
|
action: s.action, // prisma.MapVetoAction.*
|
||||||
|
teamId: s.teamId ?? undefined,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return newMatch
|
return newMatch
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,113 +1,110 @@
|
|||||||
// /app/api/user/[steamId]/matches/route.ts
|
import { NextResponse } from 'next/server'
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
|
||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(req: Request) {
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: { steamId: string } },
|
|
||||||
) {
|
|
||||||
const steamId = params.steamId
|
|
||||||
if (!steamId) {
|
|
||||||
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url)
|
|
||||||
|
|
||||||
// Query-Parameter
|
|
||||||
const typesParam = searchParams.get('types') // z. B. "premier,competitive"
|
|
||||||
const types = typesParam
|
|
||||||
? typesParam.split(',').map(t => t.trim()).filter(Boolean)
|
|
||||||
: []
|
|
||||||
|
|
||||||
const limit = Math.min(
|
|
||||||
Math.max(parseInt(searchParams.get('limit') || '10', 10), 1),
|
|
||||||
50,
|
|
||||||
) // 1..50 (Default 10)
|
|
||||||
|
|
||||||
const cursor = searchParams.get('cursor') // letzte match.id der vorherigen Page
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Matches, in denen der Spieler vorkommt
|
const { searchParams } = new URL(req.url)
|
||||||
const matches = await prisma.match.findMany({
|
const matchType = searchParams.get('type') // z. B. "community"
|
||||||
where: {
|
|
||||||
players: { some: { steamId } },
|
|
||||||
...(types.length ? { matchType: { in: types } } : {}),
|
|
||||||
},
|
|
||||||
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
|
|
||||||
take: limit + 1, // eine extra zum Prüfen, ob es weiter geht
|
|
||||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
|
||||||
|
|
||||||
select: {
|
const matches = await prisma.match.findMany({
|
||||||
id: true,
|
where : matchType ? { matchType } : undefined,
|
||||||
demoDate: true,
|
orderBy: { demoDate: 'desc' },
|
||||||
map: true,
|
include: {
|
||||||
roundCount: true,
|
teamA : true,
|
||||||
scoreA: true,
|
teamB : true,
|
||||||
scoreB: true,
|
players: { include: { user: true, stats: true, team: true } },
|
||||||
matchType: true,
|
mapVeto: { include: { steps: true } },
|
||||||
teamAId: true,
|
|
||||||
teamBId: true,
|
|
||||||
teamAUsers: { select: { steamId: true } },
|
|
||||||
teamBUsers: { select: { steamId: true } },
|
|
||||||
winnerTeam: true,
|
|
||||||
// nur die MatchPlayer-Zeile des aktuellen Users (für Stats)
|
|
||||||
players: {
|
|
||||||
where: { steamId },
|
|
||||||
select: { stats: true },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasMore = matches.length > limit
|
const formatted = matches.map(m => {
|
||||||
const page = hasMore ? matches.slice(0, limit) : matches
|
let status: 'not_started' | 'in_progress' | 'completed' | null = null
|
||||||
|
let opensAtISO: string | null = null
|
||||||
|
let isOpen = false // <-- immer boolean
|
||||||
|
let currentIndex: number | null = null
|
||||||
|
let currentAction: 'BAN'|'PICK'|'DECIDER' | null = null
|
||||||
|
let decidedCount: number | null = null
|
||||||
|
let totalSteps: number | null = null
|
||||||
|
let opensInMinutes: number | null = null // <-- optional
|
||||||
|
|
||||||
const items = page.map(m => {
|
if (m.mapVeto) {
|
||||||
const stats = m.players[0]?.stats ?? null
|
const stepsSorted = [...m.mapVeto.steps].sort((a, b) => a.order - b.order)
|
||||||
const kills = stats?.kills ?? 0
|
const anyChosen = stepsSorted.some(s => !!s.chosenAt)
|
||||||
const deaths = stats?.deaths ?? 0
|
status = m.mapVeto.locked ? 'completed' : (anyChosen ? 'in_progress' : 'not_started')
|
||||||
const kdr = deaths ? (kills / deaths).toFixed(2) : '∞'
|
|
||||||
const rankOld = stats?.rankOld ?? null
|
|
||||||
const rankNew = stats?.rankNew ?? null
|
|
||||||
const aim = stats?.aim ?? null
|
|
||||||
const rankChange =
|
|
||||||
rankNew != null && rankOld != null ? rankNew - rankOld : null
|
|
||||||
|
|
||||||
// Teamzugehörigkeit aus Sicht des Spielers (CT/T)
|
const computedOpensAt =
|
||||||
const playerTeam = m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T'
|
m.mapVeto.opensAt ??
|
||||||
const score = `${m.scoreA ?? 0} : ${m.scoreB ?? 0}`
|
(() => {
|
||||||
|
const base = m.matchDate ?? m.demoDate ?? new Date()
|
||||||
|
return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher
|
||||||
|
})()
|
||||||
|
|
||||||
|
opensAtISO = computedOpensAt?.toISOString() ?? null
|
||||||
|
if (opensAtISO) {
|
||||||
|
const now = Date.now()
|
||||||
|
const oa = new Date(opensAtISO).getTime()
|
||||||
|
isOpen = now >= oa
|
||||||
|
opensInMinutes = Math.max(0, Math.ceil((oa - now) / 60000))
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIndex = m.mapVeto.currentIdx
|
||||||
|
const cur = stepsSorted.find(s => s.order === m.mapVeto.currentIdx)
|
||||||
|
currentAction = (cur?.action as 'BAN'|'PICK'|'DECIDER') ?? null
|
||||||
|
decidedCount = stepsSorted.filter(s => !!s.chosenAt).length
|
||||||
|
totalSteps = stepsSorted.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback für Anzeige-Datum im Frontend
|
||||||
|
const displayDate = m.demoDate ?? m.matchDate
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: m.id,
|
id : m.id,
|
||||||
map: m.map ?? 'Unknown',
|
map : m.map,
|
||||||
date: m.demoDate?.toISOString() ?? '',
|
demoDate : m.demoDate, // unverändert
|
||||||
matchType: m.matchType ?? 'community',
|
matchDate : m.matchDate, // falls du’s im UI brauchst
|
||||||
|
displayDate, // <-- neu (für sicheres Rendern)
|
||||||
score,
|
matchType : m.matchType,
|
||||||
roundCount: m.roundCount,
|
scoreA : m.scoreA,
|
||||||
|
scoreB : m.scoreB,
|
||||||
rankOld,
|
|
||||||
rankNew,
|
|
||||||
rankChange,
|
|
||||||
|
|
||||||
kills,
|
|
||||||
deaths,
|
|
||||||
kdr,
|
|
||||||
aim,
|
|
||||||
|
|
||||||
winnerTeam: m.winnerTeam ?? null,
|
winnerTeam: m.winnerTeam ?? null,
|
||||||
team: playerTeam, // 'CT' | 'T'
|
|
||||||
|
mapVote: m.mapVeto ? {
|
||||||
|
status,
|
||||||
|
opensAt: opensAtISO,
|
||||||
|
isOpen,
|
||||||
|
opensInMinutes, // <-- optional
|
||||||
|
currentIndex,
|
||||||
|
currentAction,
|
||||||
|
decidedCount,
|
||||||
|
totalSteps,
|
||||||
|
} : null,
|
||||||
|
|
||||||
|
teamA: {
|
||||||
|
id : m.teamA?.id ?? null,
|
||||||
|
name : m.teamA?.name ?? 'CT',
|
||||||
|
logo : m.teamA?.logo ?? null,
|
||||||
|
score: m.scoreA,
|
||||||
|
},
|
||||||
|
teamB: {
|
||||||
|
id : m.teamB?.id ?? null,
|
||||||
|
name : m.teamB?.name ?? 'T',
|
||||||
|
logo : m.teamB?.logo ?? null,
|
||||||
|
score: m.scoreB,
|
||||||
|
},
|
||||||
|
players: m.players.map(p => ({
|
||||||
|
steamId : p.steamId,
|
||||||
|
name : p.user?.name,
|
||||||
|
avatar : p.user?.avatar,
|
||||||
|
stats : p.stats,
|
||||||
|
teamId : p.teamId,
|
||||||
|
teamName: p.team?.name ?? null,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const nextCursor = hasMore ? page[page.length - 1].id : null
|
return NextResponse.json(formatted)
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
items,
|
|
||||||
nextCursor,
|
|
||||||
hasMore,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[API] /user/[steamId]/matches Fehler:', err)
|
console.error('GET /matches failed:', err)
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Failed to load matches' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,6 +69,15 @@ export default function CommunityMatchList({ matchType }: Props) {
|
|||||||
[teams]
|
[teams]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
// force re-render, damit isOpen (Vergleich mit Date.now) neu bewertet wird
|
||||||
|
setMatches(m => [...m])
|
||||||
|
}, 30_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
// Auto-Titel
|
// Auto-Titel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoTitle) return
|
if (!autoTitle) return
|
||||||
@ -240,6 +249,27 @@ export default function CommunityMatchList({ matchType }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Map-Vote Badge */}
|
||||||
|
{m.mapVote && (
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
px-2 py-0.5 rounded-full text-[11px] font-semibold
|
||||||
|
${m.mapVote.isOpen ? 'bg-green-300 dark:bg-green-600 text-white' : 'bg-neutral-200 dark:bg-neutral-700'}
|
||||||
|
`}
|
||||||
|
title={
|
||||||
|
m.mapVote.opensAt
|
||||||
|
? `Öffnet ${format(new Date(m.mapVote.opensAt), 'dd.MM.yyyy HH:mm', { locale: de })} Uhr`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{m.mapVote.isOpen
|
||||||
|
? (m.mapVote.status === 'completed' ? 'Map-Vote abgeschlossen' : 'Map-Vote offen')
|
||||||
|
: m.mapVote.opensAt
|
||||||
|
? `Map-Vote ab ${format(new Date(m.mapVote.opensAt), 'HH:mm', { locale: de })} Uhr`
|
||||||
|
: 'Map-Vote bald'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex w-full justify-around items-center">
|
<div className="flex w-full justify-around items-center">
|
||||||
<div className="flex flex-col items-center w-1/3">
|
<div className="flex flex-col items-center w-1/3">
|
||||||
<Image src={getTeamLogo(m.teamA.logo)} alt={m.teamA.name} width={48} height={48} className="rounded-full border bg-white" />
|
<Image src={getTeamLogo(m.teamA.logo)} alt={m.teamA.name} width={48} height={48} className="rounded-full border bg-white" />
|
||||||
@ -256,6 +286,7 @@ export default function CommunityMatchList({ matchType }: Props) {
|
|||||||
<span className={`px-3 py-0.5 rounded-full text-sm font-semibold ${isLive ? 'bg-red-300 dark:bg-red-500' : 'bg-yellow-300 dark:bg-yellow-500'}`}>
|
<span className={`px-3 py-0.5 rounded-full text-sm font-semibold ${isLive ? 'bg-red-300 dark:bg-red-500' : 'bg-yellow-300 dark:bg-yellow-500'}`}>
|
||||||
{format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })}
|
{format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="flex items-center gap-1 text-xs opacity-80">
|
<span className="flex items-center gap-1 text-xs opacity-80">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 512 512">
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 512 512">
|
||||||
<path d="M256 48a208 208 0 1 0 208 208A208.24 208.24 0 0 0 256 48Zm0 384a176 176 0 1 1 176-176 176.2 176.2 0 0 1-176 176Zm80-176h-64V144a16 16 0 0 0-32 0v120a16 16 0 0 0 16 16h80a16 16 0 0 0 0-32Z" />
|
<path d="M256 48a208 208 0 1 0 208 208A208.24 208.24 0 0 0 256 48Zm0 384a176 176 0 1 1 176-176 176.2 176.2 0 0 1-176 176Zm80-176h-64V144a16 16 0 0 0-32 0v120a16 16 0 0 0 16 16h80a16 16 0 0 0 0-32Z" />
|
||||||
|
|||||||
249
src/app/components/MapVotePanel.tsx
Normal file
249
src/app/components/MapVotePanel.tsx
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
// /app/components/MapVotePanel.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { mapNameMap } from '../lib/mapNameMap'
|
||||||
|
import Button from './Button'
|
||||||
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||||
|
import type { Match } from '../types/match'
|
||||||
|
import type { MapVetoState } from '../types/mapvote'
|
||||||
|
|
||||||
|
type Props = { match: Match }
|
||||||
|
|
||||||
|
export default function MapVotePanel({ match }: Props) {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
|
||||||
|
const [state, setState] = useState<MapVetoState | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// --- Zeitpunkt: 1h vor Matchbeginn ---
|
||||||
|
const opensAtTs = useMemo(() => {
|
||||||
|
const base = new Date(match.matchDate ?? match.demoDate ?? Date.now())
|
||||||
|
return base.getTime() - 60 * 60 * 1000
|
||||||
|
}, [match.matchDate, match.demoDate])
|
||||||
|
|
||||||
|
// Sauberer Countdown/„Jetzt offen“-Trigger
|
||||||
|
const [nowTs, setNowTs] = useState(() => Date.now())
|
||||||
|
const isOpen = nowTs >= opensAtTs
|
||||||
|
const msToOpen = Math.max(opensAtTs - nowTs, 0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) return
|
||||||
|
const t = setInterval(() => setNowTs(Date.now()), 1000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
// --- Berechtigungen: nur Leader des Teams, Admins immer ---
|
||||||
|
const me = session?.user
|
||||||
|
const isAdmin = !!me?.isAdmin
|
||||||
|
const isLeaderA = !!me?.steamId && match.teamA?.leader === me.steamId
|
||||||
|
const isLeaderB = !!me?.steamId && match.teamB?.leader === me.steamId
|
||||||
|
|
||||||
|
const canActForTeamId = useCallback((teamId?: string | null) => {
|
||||||
|
if (!teamId) return false
|
||||||
|
if (isAdmin) return true
|
||||||
|
return (teamId === match.teamA?.id && isLeaderA) ||
|
||||||
|
(teamId === match.teamB?.id && isLeaderB)
|
||||||
|
}, [isAdmin, isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id])
|
||||||
|
|
||||||
|
// --- Laden / Reload ---
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/matches/${match.id}/map-vote`, { cache: 'no-store' })
|
||||||
|
if (!r.ok) {
|
||||||
|
// Server-Fehlertext übernehmen, falls vorhanden
|
||||||
|
const j = await r.json().catch(() => ({}))
|
||||||
|
throw new Error(j?.message || 'Laden fehlgeschlagen')
|
||||||
|
}
|
||||||
|
const json = await r.json()
|
||||||
|
|
||||||
|
// ➜ harte Validierung der erwarteten Struktur
|
||||||
|
if (!json || !Array.isArray(json.steps)) {
|
||||||
|
// optional: console.debug('Unerwartete Antwort:', json)
|
||||||
|
throw new Error('Ungültige Serverantwort (steps fehlt)')
|
||||||
|
}
|
||||||
|
setState(json)
|
||||||
|
} catch (e: any) {
|
||||||
|
setState(null) // wichtig: kein halbgares Objekt rendern
|
||||||
|
setError(e?.message ?? 'Unbekannter Fehler')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [match.id])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
// --- SSE: bei Änderungen nachladen ---
|
||||||
|
const { lastEvent } = useSSEStore()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastEvent) return
|
||||||
|
if (lastEvent.type !== 'map-vote-updated') return
|
||||||
|
const matchId = lastEvent.payload?.matchId
|
||||||
|
if (matchId !== match.id) return
|
||||||
|
load()
|
||||||
|
}, [lastEvent, match.id, load])
|
||||||
|
|
||||||
|
// --- Abgeleitete Zustände ---
|
||||||
|
const currentStep = state?.steps?.[state.currentIndex]
|
||||||
|
const isMyTurn = Boolean(
|
||||||
|
isOpen && !state?.locked && currentStep?.teamId && canActForTeamId(currentStep.teamId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const mapPool = state?.mapPool ?? []
|
||||||
|
const pickedOrBanned = useMemo(
|
||||||
|
() => new Set((state?.steps ?? []).map(s => s.map).filter(Boolean) as string[]),
|
||||||
|
[state?.steps]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fmt = (k: string) => mapNameMap[k]?.name ?? k
|
||||||
|
|
||||||
|
// --- Aktionen ---
|
||||||
|
const handlePickOrBan = async (map: string) => {
|
||||||
|
if (!isMyTurn || !currentStep) return
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/matches/${match.id}/map-vote`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ map }),
|
||||||
|
})
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = await r.json().catch(() => ({}))
|
||||||
|
alert(j.message ?? 'Aktion fehlgeschlagen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Erfolg: Server triggert SSE → load() via SSE
|
||||||
|
} catch {
|
||||||
|
alert('Netzwerkfehler')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading && !state) return <div className="p-4">Lade Map-Voting…</div>
|
||||||
|
if (error && !state) return <div className="p-4 text-red-600">{error}</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8 border rounded-lg p-4 dark:border-neutral-700">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-lg font-semibold">Map-Vote</h3>
|
||||||
|
<div className="text-sm opacity-80">Modus: BO{match.bestOf ?? state?.bestOf ?? 3}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOpen && (
|
||||||
|
<div className="mb-4 text-sm">
|
||||||
|
<span className="inline-block px-2 py-1 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100">
|
||||||
|
Öffnet in {formatCountdown(msToOpen)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state && Array.isArray(state.steps) && (
|
||||||
|
<>
|
||||||
|
<ol className="mb-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{state.steps.map((s, i) => {
|
||||||
|
const done = !!s.map
|
||||||
|
const isCurrent = i === state.currentIndex && !state.locked
|
||||||
|
const actionLabel = s.action === 'ban' ? 'Ban' : s.action === 'pick' ? 'Pick' : 'Decider'
|
||||||
|
const teamLabel = s.teamId
|
||||||
|
? (s.teamId === match.teamA.id ? match.teamA.name : match.teamB.name)
|
||||||
|
: '—'
|
||||||
|
return (
|
||||||
|
<li key={i} className={`p-2 rounded border text-sm ${
|
||||||
|
done
|
||||||
|
? 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-900/40'
|
||||||
|
: isCurrent
|
||||||
|
? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-900/40'
|
||||||
|
: 'border-gray-200 dark:border-neutral-700'
|
||||||
|
}`}>
|
||||||
|
<div className="font-medium">
|
||||||
|
{actionLabel} {s.teamId ? `• ${teamLabel}` : ''}
|
||||||
|
</div>
|
||||||
|
<div className="opacity-80">
|
||||||
|
{s.map ? fmt(s.map) : (isCurrent ? 'am Zug…' : '—')}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{/* Karten-Grid */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||||
|
{mapPool.map((map) => {
|
||||||
|
const taken = pickedOrBanned.has(map)
|
||||||
|
const isAvailable = !taken && isMyTurn && isOpen && !state.locked
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={map}
|
||||||
|
type="button"
|
||||||
|
onClick={() => isAvailable && handlePickOrBan(map)}
|
||||||
|
className={[
|
||||||
|
'relative rounded-lg border px-3 py-2 text-left transition',
|
||||||
|
taken
|
||||||
|
? 'bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700 opacity-60 cursor-not-allowed'
|
||||||
|
: isAvailable
|
||||||
|
? 'bg-white dark:bg-neutral-900 border-blue-400 ring-1 ring-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950 cursor-pointer'
|
||||||
|
: 'bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700'
|
||||||
|
].join(' ')}
|
||||||
|
disabled={!isAvailable}
|
||||||
|
title={
|
||||||
|
taken
|
||||||
|
? 'Bereits gewählt/gestrichen'
|
||||||
|
: isAvailable
|
||||||
|
? 'Jetzt wählen'
|
||||||
|
: 'Nur der Team-Leader (oder Admin) darf wählen'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{fmt(map)}</div>
|
||||||
|
<div className="text-xs opacity-60">{map}</div>
|
||||||
|
|
||||||
|
{taken && (
|
||||||
|
<span className="absolute top-1 right-1 text-[10px] px-1.5 py-0.5 rounded bg-neutral-800 text-white dark:bg-neutral-200 dark:text-neutral-900">
|
||||||
|
vergeben
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-4 text-sm flex flex-wrap items-center gap-3">
|
||||||
|
{state.locked ? (
|
||||||
|
<span className="px-2 py-1 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
|
||||||
|
Veto abgeschlossen
|
||||||
|
</span>
|
||||||
|
) : isOpen ? (
|
||||||
|
isMyTurn ? (
|
||||||
|
<span className="px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200">
|
||||||
|
Du bist am Zug (Leader/Admin)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 rounded bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
|
||||||
|
Wartet auf
|
||||||
|
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
|
||||||
|
(Leader/Admin)
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button size="sm" variant="soft" onClick={load} disabled={isLoading}>
|
||||||
|
{isLoading ? 'Aktualisieren …' : 'Aktualisieren'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(ms: number) {
|
||||||
|
if (ms <= 0) return '0:00:00'
|
||||||
|
const totalSec = Math.floor(ms / 1000)
|
||||||
|
const h = Math.floor(totalSec / 3600)
|
||||||
|
const m = Math.floor((totalSec % 3600) / 60)
|
||||||
|
const s = totalSec % 60
|
||||||
|
const pad = (n:number)=>String(n).padStart(2,'0')
|
||||||
|
return `${h}:${pad(m)}:${pad(s)}`
|
||||||
|
}
|
||||||
@ -19,6 +19,8 @@ import type { EditSide } from './EditMatchPlayersModal' // 'A' | 'B'
|
|||||||
|
|
||||||
import type { Match, MatchPlayer } from '../types/match'
|
import type { Match, MatchPlayer } from '../types/match'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
|
import { mapNameMap } from '../lib/mapNameMap'
|
||||||
|
import MapVotePanel from './MapVotePanel'
|
||||||
|
|
||||||
/* ─────────────────── Hilfsfunktionen ────────────────────────── */
|
/* ─────────────────── Hilfsfunktionen ────────────────────────── */
|
||||||
const kdr = (k?: number, d?: number) =>
|
const kdr = (k?: number, d?: number) =>
|
||||||
@ -35,15 +37,29 @@ const adr = (dmg?: number, rounds?: number) =>
|
|||||||
export function MatchDetails ({ match }: { match: Match }) {
|
export function MatchDetails ({ match }: { match: Match }) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const isAdmin = !!session?.user?.isAdmin
|
||||||
|
|
||||||
/* ─── Rollen & Rechte ─────────────────────────────────────── */
|
/* ─── Rollen & Rechte ─────────────────────────────────────── */
|
||||||
const me = session?.user
|
const me = session?.user
|
||||||
const userId = me?.steamId
|
const userId = me?.steamId
|
||||||
const isAdmin = me?.isAdmin
|
|
||||||
const isLeaderA = !!userId && userId === match.teamA?.leader
|
const isLeaderA = !!userId && userId === match.teamA?.leader
|
||||||
const isLeaderB = !!userId && userId === match.teamB?.leader
|
const isLeaderB = !!userId && userId === match.teamB?.leader
|
||||||
const canEditA = isAdmin || isLeaderA
|
const canEditA = isAdmin || isLeaderA
|
||||||
const canEditB = isAdmin || isLeaderB
|
const canEditB = isAdmin || isLeaderB
|
||||||
|
|
||||||
|
const isMapVoteOpen = !!match.mapVote?.isOpen
|
||||||
|
|
||||||
|
console.log("Mapvote offen?: ", isMapVoteOpen);
|
||||||
|
|
||||||
|
/* ─── Map ─────────────────────────────────────────────────── */
|
||||||
|
const normalizeMapKey = (raw?: string) =>
|
||||||
|
(raw ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\.bsp$/,'')
|
||||||
|
.replace(/^.*\//,'')
|
||||||
|
|
||||||
|
const mapKey = normalizeMapKey(match.map)
|
||||||
|
const mapLabel = mapNameMap[mapKey]?.name ?? (match.map ?? 'Unbekannte Map')
|
||||||
|
|
||||||
/* ─── Match-Zeitpunkt ─────────────────────────────────────── */
|
/* ─── Match-Zeitpunkt ─────────────────────────────────────── */
|
||||||
const dateString = match.matchDate ?? match.demoDate
|
const dateString = match.matchDate ?? match.demoDate
|
||||||
@ -63,6 +79,24 @@ export function MatchDetails ({ match }: { match: Match }) {
|
|||||||
</colgroup>
|
</colgroup>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/* ─── Match löschen ────────────────────────────────────────── */
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm('Match wirklich löschen? Das kann nicht rückgängig gemacht werden.')) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/matches/${match.id}/delete`, { method: 'POST' })
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}))
|
||||||
|
alert(j.message ?? 'Löschen fehlgeschlagen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Zurück zur Matchliste
|
||||||
|
router.push('/schedule') // ggf. an deinen Pfad anpassen
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[MatchDetails] delete failed', e)
|
||||||
|
alert('Löschen fehlgeschlagen.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Spieler-Tabelle ─────────────────────────────────────── */
|
/* ─── Spieler-Tabelle ─────────────────────────────────────── */
|
||||||
const renderTable = (players: MatchPlayer[]) => {
|
const renderTable = (players: MatchPlayer[]) => {
|
||||||
const sorted = [...players].sort(
|
const sorted = [...players].sort(
|
||||||
@ -147,8 +181,17 @@ export function MatchDetails ({ match }: { match: Match }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold">
|
<h1 className="text-2xl font-bold">
|
||||||
Match auf {match.map} ({match.matchType})
|
Match auf {mapLabel} ({match.matchType})
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md"
|
||||||
|
>
|
||||||
|
Match löschen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
|
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
|
||||||
|
|
||||||
@ -161,6 +204,10 @@ export function MatchDetails ({ match }: { match: Match }) {
|
|||||||
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
|
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Map-Vote Panel nur anzeigen, wenn freigegeben */}
|
||||||
|
{isMapVoteOpen && <MapVotePanel match={match} />}
|
||||||
|
|
||||||
{/* ───────── Team-Blöcke ───────── */}
|
{/* ───────── Team-Blöcke ───────── */}
|
||||||
<div className="border-t pt-4 mt-4 space-y-10">
|
<div className="border-t pt-4 mt-4 space-y-10">
|
||||||
{/* Team A */}
|
{/* Team A */}
|
||||||
|
|||||||
21
src/app/types/mapvote.ts
Normal file
21
src/app/types/mapvote.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// z.B. /app/types/mapvote.ts
|
||||||
|
export type VetoAction = 'ban' | 'pick' | 'decider';
|
||||||
|
|
||||||
|
export type MapVetoStep = {
|
||||||
|
index: number;
|
||||||
|
action: VetoAction; // 'ban' | 'pick' | 'decider'
|
||||||
|
teamId?: string | null; // wer ist dran (bei 'decider' meist null)
|
||||||
|
map?: string | null; // gewählte Map (wenn ausgeführt)
|
||||||
|
byUser?: string | null; // steamId des Klickers
|
||||||
|
at?: string | null; // ISO-Zeitpunkt
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapVetoState = {
|
||||||
|
matchId: string;
|
||||||
|
bestOf: 3|5;
|
||||||
|
mapPool: string[]; // z.B. ['de_inferno','de_mirage',...]
|
||||||
|
steps: MapVetoStep[]; // komplette Reihenfolge inkl. 'decider'
|
||||||
|
currentIndex: number; // nächste auszuführende Stufe
|
||||||
|
opensAt: string; // ISO: matchDate - 60min
|
||||||
|
locked: boolean; // fertig / gesperrt
|
||||||
|
};
|
||||||
@ -6,21 +6,30 @@ export type Match = {
|
|||||||
/* Basis-Infos ---------------------------------------------------- */
|
/* Basis-Infos ---------------------------------------------------- */
|
||||||
id : string
|
id : string
|
||||||
title : string
|
title : string
|
||||||
demoDate : string // ⇐ Backend kommt als ISO-String
|
demoDate : string
|
||||||
description?: string
|
description?: string
|
||||||
map : string
|
map : string
|
||||||
matchType : 'premier' | 'competitive' | 'community' | string
|
matchType : 'premier' | 'competitive' | 'community' | string
|
||||||
roundCount : number
|
roundCount : number
|
||||||
|
|
||||||
|
// ⬇️ neu/optional, damit Alt-Daten weiter kompilen
|
||||||
|
bestOf? : 1 | 3 | 5
|
||||||
|
matchDate? : string
|
||||||
|
|
||||||
/* Ergebnis ------------------------------------------------------- */
|
/* Ergebnis ------------------------------------------------------- */
|
||||||
scoreA? : number | null
|
scoreA? : number | null
|
||||||
scoreB? : number | null
|
scoreB? : number | null
|
||||||
/** CT | T | Draw | null – null, solange noch kein Ergebnis vorliegt */
|
|
||||||
winnerTeam? : 'CT' | 'T' | 'Draw' | null
|
winnerTeam? : 'CT' | 'T' | 'Draw' | null
|
||||||
|
|
||||||
/* Teams ---------------------------------------------------------- */
|
/* Teams ---------------------------------------------------------- */
|
||||||
teamA: TeamMatches
|
teamA: TeamMatches
|
||||||
teamB: TeamMatches
|
teamB: TeamMatches
|
||||||
|
|
||||||
|
mapVote?: {
|
||||||
|
status: 'not_started' | 'in_progress' | 'completed' | null
|
||||||
|
opensAt: string | null
|
||||||
|
isOpen: boolean | null
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------- */
|
/* --------------------------------------------------------------- */
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -180,6 +180,8 @@ exports.Prisma.MatchScalarFieldEnum = {
|
|||||||
roundCount: 'roundCount',
|
roundCount: 'roundCount',
|
||||||
roundHistory: 'roundHistory',
|
roundHistory: 'roundHistory',
|
||||||
winnerTeam: 'winnerTeam',
|
winnerTeam: 'winnerTeam',
|
||||||
|
bestOf: 'bestOf',
|
||||||
|
matchDate: 'matchDate',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
@ -274,6 +276,29 @@ exports.Prisma.ServerRequestScalarFieldEnum = {
|
|||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.MapVetoScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
matchId: 'matchId',
|
||||||
|
bestOf: 'bestOf',
|
||||||
|
mapPool: 'mapPool',
|
||||||
|
currentIdx: 'currentIdx',
|
||||||
|
locked: 'locked',
|
||||||
|
opensAt: 'opensAt',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.MapVetoStepScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
vetoId: 'vetoId',
|
||||||
|
order: 'order',
|
||||||
|
action: 'action',
|
||||||
|
teamId: 'teamId',
|
||||||
|
map: 'map',
|
||||||
|
chosenAt: 'chosenAt',
|
||||||
|
chosenBy: 'chosenBy'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.SortOrder = {
|
exports.Prisma.SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@ -307,6 +332,12 @@ exports.ScheduleStatus = exports.$Enums.ScheduleStatus = {
|
|||||||
COMPLETED: 'COMPLETED'
|
COMPLETED: 'COMPLETED'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.MapVetoAction = exports.$Enums.MapVetoAction = {
|
||||||
|
BAN: 'BAN',
|
||||||
|
PICK: 'PICK',
|
||||||
|
DECIDER: 'DECIDER'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
User: 'User',
|
User: 'User',
|
||||||
Team: 'Team',
|
Team: 'Team',
|
||||||
@ -318,7 +349,9 @@ exports.Prisma.ModelName = {
|
|||||||
RankHistory: 'RankHistory',
|
RankHistory: 'RankHistory',
|
||||||
Schedule: 'Schedule',
|
Schedule: 'Schedule',
|
||||||
DemoFile: 'DemoFile',
|
DemoFile: 'DemoFile',
|
||||||
ServerRequest: 'ServerRequest'
|
ServerRequest: 'ServerRequest',
|
||||||
|
MapVeto: 'MapVeto',
|
||||||
|
MapVetoStep: 'MapVetoStep'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
4521
src/generated/prisma/index.d.ts
vendored
4521
src/generated/prisma/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "prisma-client-606d0d92f2bc1947c35b0ceba01327a22f26543cd49f6fab394887ba3b7b7804",
|
"name": "prisma-client-ccbcad66b35a04d2308e6d4492f46a36a927649c9d37c79c1ca8fa339e65016e",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "index-browser.js",
|
"browser": "index-browser.js",
|
||||||
|
|||||||
@ -43,6 +43,8 @@ model User {
|
|||||||
|
|
||||||
createdSchedules Schedule[] @relation("CreatedSchedules")
|
createdSchedules Schedule[] @relation("CreatedSchedules")
|
||||||
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
|
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
|
||||||
|
|
||||||
|
mapVetoChoices MapVoteStep[] @relation("VetoStepChooser")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Team {
|
model Team {
|
||||||
@ -65,6 +67,8 @@ model Team {
|
|||||||
|
|
||||||
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
|
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
|
||||||
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
|
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
|
||||||
|
|
||||||
|
mapVetoSteps MapVoteStep[] @relation("VetoStepTeam")
|
||||||
}
|
}
|
||||||
|
|
||||||
model TeamInvite {
|
model TeamInvite {
|
||||||
@ -98,6 +102,10 @@ model Notification {
|
|||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 🎮 Matches
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
model Match {
|
model Match {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
@ -128,6 +136,10 @@ model Match {
|
|||||||
roundHistory Json?
|
roundHistory Json?
|
||||||
winnerTeam String?
|
winnerTeam String?
|
||||||
|
|
||||||
|
bestOf Int @default(3) // 1 | 3 | 5 – app-seitig validieren
|
||||||
|
matchDate DateTime? // geplante Startzeit (separat von demoDate)
|
||||||
|
mapVeto MapVote? // 1:1 Map-Vote-Status
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@ -280,3 +292,56 @@ model ServerRequest {
|
|||||||
|
|
||||||
@@unique([steamId, matchId])
|
@@unique([steamId, matchId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 🗺️ Map-Vote
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum MapVoteAction {
|
||||||
|
BAN
|
||||||
|
PICK
|
||||||
|
DECIDER
|
||||||
|
}
|
||||||
|
|
||||||
|
model MapVote {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
matchId String @unique
|
||||||
|
match Match @relation(fields: [matchId], references: [id])
|
||||||
|
|
||||||
|
// Basiszustand
|
||||||
|
bestOf Int @default(3)
|
||||||
|
mapPool String[] // z.B. ["de_inferno","de_mirage",...]
|
||||||
|
currentIdx Int @default(0)
|
||||||
|
locked Boolean @default(false)
|
||||||
|
|
||||||
|
// Optional: serverseitig speichern, statt im UI zu berechnen
|
||||||
|
opensAt DateTime?
|
||||||
|
|
||||||
|
steps MapVoteStep[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model MapVoteStep {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
vetoId String
|
||||||
|
order Int
|
||||||
|
action MapVoteAction
|
||||||
|
|
||||||
|
// Team, das am Zug ist (kann bei DECIDER null sein)
|
||||||
|
teamId String?
|
||||||
|
team Team? @relation("VetoStepTeam", fields: [teamId], references: [id])
|
||||||
|
|
||||||
|
// Ergebnis & wer gewählt hat
|
||||||
|
map String?
|
||||||
|
chosenAt DateTime?
|
||||||
|
chosenBy String?
|
||||||
|
chooser User? @relation("VetoStepChooser", fields: [chosenBy], references: [steamId])
|
||||||
|
|
||||||
|
veto MapVote @relation(fields: [vetoId], references: [id])
|
||||||
|
|
||||||
|
@@unique([vetoId, order])
|
||||||
|
@@index([teamId])
|
||||||
|
@@index([chosenBy])
|
||||||
|
}
|
||||||
|
|||||||
@ -180,6 +180,8 @@ exports.Prisma.MatchScalarFieldEnum = {
|
|||||||
roundCount: 'roundCount',
|
roundCount: 'roundCount',
|
||||||
roundHistory: 'roundHistory',
|
roundHistory: 'roundHistory',
|
||||||
winnerTeam: 'winnerTeam',
|
winnerTeam: 'winnerTeam',
|
||||||
|
bestOf: 'bestOf',
|
||||||
|
matchDate: 'matchDate',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
@ -274,6 +276,29 @@ exports.Prisma.ServerRequestScalarFieldEnum = {
|
|||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.MapVetoScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
matchId: 'matchId',
|
||||||
|
bestOf: 'bestOf',
|
||||||
|
mapPool: 'mapPool',
|
||||||
|
currentIdx: 'currentIdx',
|
||||||
|
locked: 'locked',
|
||||||
|
opensAt: 'opensAt',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.MapVetoStepScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
vetoId: 'vetoId',
|
||||||
|
order: 'order',
|
||||||
|
action: 'action',
|
||||||
|
teamId: 'teamId',
|
||||||
|
map: 'map',
|
||||||
|
chosenAt: 'chosenAt',
|
||||||
|
chosenBy: 'chosenBy'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.SortOrder = {
|
exports.Prisma.SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@ -307,6 +332,12 @@ exports.ScheduleStatus = exports.$Enums.ScheduleStatus = {
|
|||||||
COMPLETED: 'COMPLETED'
|
COMPLETED: 'COMPLETED'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.MapVetoAction = exports.$Enums.MapVetoAction = {
|
||||||
|
BAN: 'BAN',
|
||||||
|
PICK: 'PICK',
|
||||||
|
DECIDER: 'DECIDER'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
User: 'User',
|
User: 'User',
|
||||||
Team: 'Team',
|
Team: 'Team',
|
||||||
@ -318,7 +349,9 @@ exports.Prisma.ModelName = {
|
|||||||
RankHistory: 'RankHistory',
|
RankHistory: 'RankHistory',
|
||||||
Schedule: 'Schedule',
|
Schedule: 'Schedule',
|
||||||
DemoFile: 'DemoFile',
|
DemoFile: 'DemoFile',
|
||||||
ServerRequest: 'ServerRequest'
|
ServerRequest: 'ServerRequest',
|
||||||
|
MapVeto: 'MapVeto',
|
||||||
|
MapVetoStep: 'MapVetoStep'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user