572 lines
22 KiB
TypeScript
572 lines
22 KiB
TypeScript
// /src/app/components/MatchDetails.tsx
|
|
'use client'
|
|
|
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useSession } from 'next-auth/react'
|
|
import { format } from 'date-fns'
|
|
import { de } from 'date-fns/locale'
|
|
|
|
import Table from './Table'
|
|
import PremierRankBadge from './PremierRankBadge'
|
|
import CompRankBadge from './CompRankBadge'
|
|
import EditMatchMetaModal from './EditMatchMetaModal'
|
|
import EditMatchPlayersModal from './EditMatchPlayersModal'
|
|
import type { EditSide } from './EditMatchPlayersModal'
|
|
|
|
import type { Match, MatchPlayer } from '../types/match'
|
|
import Button from './Button'
|
|
import { MAP_OPTIONS } from '../lib/mapOptions'
|
|
import MapVoteBanner from './MapVoteBanner'
|
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
|
import { Team } from '../types/team'
|
|
import Alert from './Alert'
|
|
import Image from 'next/image'
|
|
import Link from 'next/link'
|
|
|
|
type TeamWithPlayers = Team & { players?: MatchPlayer[] }
|
|
|
|
/* ─────────────────── Hilfsfunktionen ────────────────────────── */
|
|
const kdr = (k?: number, d?: number) =>
|
|
typeof k === 'number' && typeof d === 'number'
|
|
? d === 0
|
|
? '∞'
|
|
: (k / d).toFixed(2)
|
|
: '-'
|
|
|
|
const adr = (dmg?: number, rounds?: number) =>
|
|
typeof dmg === 'number' && typeof rounds === 'number' && rounds > 0
|
|
? (dmg / rounds).toFixed(1)
|
|
: '-'
|
|
|
|
const normalizeMapKey = (raw?: string) =>
|
|
(raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
|
|
|
|
type VoteAction = 'BAN' | 'PICK' | 'DECIDER'
|
|
type VoteStep = { order: number; action: VoteAction; map?: string | null }
|
|
|
|
const mapLabelFromKey = (key?: string) => {
|
|
const k = (key ?? '').toLowerCase().replace(/\.bsp$/,'').replace(/^.*\//,'')
|
|
return (
|
|
MAP_OPTIONS.find(o => o.key === k)?.label ?? (k ? k : 'TBD')
|
|
)
|
|
}
|
|
|
|
// Maps aus dem MapVote (nur PICK/DECIDER, sortiert)
|
|
function extractSeriesMaps(match: Match): string[] {
|
|
const steps = (match.mapVote?.steps ?? []) as unknown as VoteStep[]
|
|
const picks = steps
|
|
.filter(s => s && (s.action === 'PICK' || s.action === 'DECIDER'))
|
|
.sort((a,b) => (a.order ?? 0) - (b.order ?? 0))
|
|
.map(s => s.map ?? '')
|
|
const n = Math.max(1, match.bestOf ?? 1)
|
|
return picks.slice(0, n)
|
|
}
|
|
|
|
function SeriesStrip({
|
|
bestOf,
|
|
scoreA = 0,
|
|
scoreB = 0,
|
|
maps,
|
|
}: {
|
|
bestOf: number
|
|
scoreA?: number | null
|
|
scoreB?: number | null
|
|
maps: string[]
|
|
}) {
|
|
const winsA = Math.max(0, scoreA ?? 0)
|
|
const winsB = Math.max(0, scoreB ?? 0)
|
|
const needed = Math.ceil(bestOf / 2)
|
|
const total = Math.max(bestOf, maps.length || 1)
|
|
|
|
const finished = winsA >= needed || winsB >= needed
|
|
const currentIdx = finished ? -1 : Math.min(winsA + winsB, total - 1)
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="text-sm text-gray-400">
|
|
Best of {bestOf} • First to {needed}
|
|
</div>
|
|
<div className="text-sm font-semibold">
|
|
{winsA}:{winsB}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
|
{Array.from({ length: total }).map((_, i) => {
|
|
const key = maps[i] ?? ''
|
|
const label = mapLabelFromKey(key)
|
|
|
|
const isDone = i < winsA + winsB
|
|
const isCurrent = i === currentIdx
|
|
const isFuture = i > winsA + winsB
|
|
|
|
return (
|
|
<div
|
|
key={`series-map-${i}`}
|
|
className={[
|
|
'rounded-md px-3 py-2 border flex items-center justify-between',
|
|
isCurrent
|
|
? 'border-blue-500 ring-2 ring-blue-300/50 bg-blue-500/10'
|
|
: isDone
|
|
? 'border-emerald-500 bg-emerald-500/10'
|
|
: 'border-gray-600 bg-neutral-800/40',
|
|
].join(' ')}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="text-xs opacity-70 shrink-0">Map {i + 1}</span>
|
|
<span className="font-medium truncate">{label}</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{isDone && (
|
|
<span className="text-emerald-400 text-xs font-semibold">✔</span>
|
|
)}
|
|
{isCurrent && !isDone && !isFuture && (
|
|
<span className="text-blue-400 text-[11px] font-semibold">LIVE / als Nächstes</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ─────────────────── Komponente ─────────────────────────────── */
|
|
export function MatchDetails({ match, initialNow }: { match: Match; initialNow: number }) {
|
|
const { data: session } = useSession()
|
|
const { lastEvent } = useSSEStore()
|
|
const router = useRouter()
|
|
const isAdmin = !!session?.user?.isAdmin
|
|
|
|
// Hydration-sicher: keine sich ändernden Werte im SSR rendern
|
|
// Wir brauchen "now" nur, um zu entscheiden, ob Mapvote schon gestartet ist
|
|
const [now, setNow] = useState(initialNow)
|
|
const [editMetaOpen, setEditMetaOpen] = useState(false)
|
|
|
|
// Lokale Overrides (analog MapVoteBanner), damit die Clients sofort reagieren
|
|
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null)
|
|
const [leadOverride, setLeadOverride] = useState<number | null>(null)
|
|
const lastHandledKeyRef = useRef<string>('')
|
|
|
|
/* ─── Rollen & Rechte ─────────────────────────────────────── */
|
|
const me = session?.user
|
|
const userId = me?.steamId
|
|
const isLeaderA = !!userId && userId === match.teamA?.leader?.steamId
|
|
const isLeaderB = !!userId && userId === match.teamB?.leader?.steamId
|
|
const canEditA = isAdmin || isLeaderA
|
|
const canEditB = isAdmin || isLeaderB
|
|
|
|
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
|
|
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
|
|
|
|
/* ─── Map-Label ───────────────────────────────────────────── */
|
|
const mapKey = normalizeMapKey(match.map)
|
|
const mapLabel =
|
|
MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ??
|
|
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapvote')?.label ??
|
|
'Unbekannte Map'
|
|
|
|
/* ─── Match-Zeitpunkt (vom Server; ändert sich via router.refresh) ─── */
|
|
const dateString = match.matchDate ?? match.demoDate
|
|
const readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt'
|
|
|
|
const seriesMaps = useMemo(() => {
|
|
const fromVote = extractSeriesMaps(match)
|
|
const n = Math.max(1, match.bestOf ?? 1)
|
|
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({ length: n - fromVote.length }, () => '')]
|
|
}, [match.bestOf, match.mapVote?.steps?.length])
|
|
|
|
/* ─── Modal-State ─────────────────────────────────────────── */
|
|
const [editSide, setEditSide] = useState<EditSide | null>(null)
|
|
|
|
/* ─── Live-Uhr für Mapvote-Startfenster ───────────────────── */
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
return () => clearInterval(id)
|
|
}, [])
|
|
|
|
// Basiszeit des Matches (stabil; für Berechnung von opensAt-Fallback)
|
|
const matchBaseTs = useMemo(() => {
|
|
const raw = match.matchDate ?? match.demoDate ?? initialNow
|
|
return new Date(raw).getTime()
|
|
}, [match.matchDate, match.demoDate, initialNow])
|
|
|
|
// Zeitpunkt, wann der Mapvote öffnet (Parent errechnet und an Banner gereicht)
|
|
const voteOpensAtTs = useMemo(() => {
|
|
if (opensAtOverride != null) return opensAtOverride
|
|
if (match.mapVote?.opensAt) return new Date(match.mapVote.opensAt).getTime()
|
|
const lead = (leadOverride != null)
|
|
? leadOverride
|
|
: (Number.isFinite(match.mapVote?.leadMinutes ?? NaN) ? (match.mapVote!.leadMinutes as number) : 60)
|
|
return matchBaseTs - lead * 60_000
|
|
}, [opensAtOverride, match.mapVote?.opensAt, match.mapVote?.leadMinutes, matchBaseTs, leadOverride])
|
|
|
|
const sseOpensAtTs = voteOpensAtTs
|
|
const sseLeadMinutes = leadOverride
|
|
|
|
const endDate = new Date(voteOpensAtTs)
|
|
const mapvoteStarted = (match.mapVote?.isOpen ?? false) || now >= voteOpensAtTs
|
|
|
|
const showEditA = canEditA && !mapvoteStarted
|
|
const showEditB = canEditB && !mapvoteStarted
|
|
|
|
/* ─── SSE-Listener (nur map-vote-updated & Co.) ───────────── */
|
|
useEffect(() => {
|
|
if (!lastEvent) return
|
|
|
|
// robustes Unwrap
|
|
const outer = lastEvent as any
|
|
const maybeInner = outer?.payload
|
|
const base = (maybeInner && typeof maybeInner === 'object' && 'type' in maybeInner && 'payload' in maybeInner)
|
|
? maybeInner
|
|
: outer
|
|
|
|
const type = base?.type
|
|
const evt = base?.payload ?? base
|
|
if (!evt?.matchId || evt.matchId !== match.id) return
|
|
|
|
// Dedupe-Key
|
|
const key = `${type}|${evt.matchId}|${evt.opensAt ?? ''}|${Number.isFinite(evt.leadMinutes) ? evt.leadMinutes : ''}`
|
|
if (key === lastHandledKeyRef.current) {
|
|
// identisches Event bereits verarbeitet → ignorieren
|
|
return
|
|
}
|
|
lastHandledKeyRef.current = key
|
|
|
|
// eigentliche Verarbeitung
|
|
if (type === 'map-vote-updated') {
|
|
if (evt?.opensAt) {
|
|
const ts = new Date(evt.opensAt).getTime()
|
|
setOpensAtOverride(ts)
|
|
}
|
|
if (Number.isFinite(evt?.leadMinutes)) {
|
|
const lead = Number(evt.leadMinutes)
|
|
setLeadOverride(lead)
|
|
if (!evt?.opensAt) {
|
|
const baseTs = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime()
|
|
setOpensAtOverride(baseTs - lead * 60_000)
|
|
}
|
|
}
|
|
// damit match.matchDate & Co. neu vom Server kommen
|
|
router.refresh()
|
|
return
|
|
}
|
|
|
|
const REFRESH_TYPES = new Set(['map-vote-reset', 'map-vote-locked', 'map-vote-unlocked', 'match-lineup-updated'])
|
|
if (REFRESH_TYPES.has(type) && evt?.matchId === match.id) {
|
|
router.refresh()
|
|
}
|
|
}, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow])
|
|
|
|
/* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */
|
|
const ColGroup = () => (
|
|
<colgroup>
|
|
<col style={{ width: '24%' }} />
|
|
<col style={{ width: '8%' }} />
|
|
{Array.from({ length: 13 }).map((_, i) => (
|
|
<col key={i} style={{ width: '5.666%' }} />
|
|
))}
|
|
</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
|
|
}
|
|
router.push('/schedule')
|
|
} catch (e) {
|
|
console.error('[MatchDetails] delete failed', e)
|
|
alert('Löschen fehlgeschlagen.')
|
|
}
|
|
}
|
|
|
|
/* ─── Spieler-Tabelle (pure; keine Hooks hier drin!) ──────── */
|
|
const renderTable = (players: MatchPlayer[]) => {
|
|
const sorted = [...players].sort(
|
|
(a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0),
|
|
)
|
|
|
|
return (
|
|
<Table>
|
|
<ColGroup />
|
|
<Table.Head>
|
|
<Table.Row>
|
|
{[
|
|
'Spieler', 'Rank', 'Aim', 'K', 'A', 'D',
|
|
'1K', '2K', '3K', '4K', '5K',
|
|
'K/D', 'ADR', 'HS%', 'Damage',
|
|
].map((h) => (
|
|
<Table.Cell key={h} as="th">
|
|
{h}
|
|
</Table.Cell>
|
|
))}
|
|
</Table.Row>
|
|
</Table.Head>
|
|
|
|
<Table.Body>
|
|
{sorted.map((p) => (
|
|
<Table.Row key={p.user.steamId}>
|
|
<Table.Cell
|
|
className="py-1 flex items-center gap-2"
|
|
hoverable
|
|
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
|
>
|
|
<img
|
|
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
|
|
alt={p.user.name}
|
|
className="w-8 h-8 rounded-full mr-3"
|
|
/>
|
|
<div className="font-semibold text-base">{p.user.name ?? 'Unbekannt'}</div>
|
|
</Table.Cell>
|
|
|
|
<Table.Cell>
|
|
<div className="flex items-center gap-[6px]">
|
|
{match.matchType === 'premier'
|
|
? <PremierRankBadge rank={p.stats?.rankNew ?? 0} />
|
|
: <CompRankBadge rank={p.stats?.rankNew ?? 0} />}
|
|
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
|
|
<span
|
|
className={`text-sm ${
|
|
p.stats.rankChange > 0
|
|
? 'text-green-500'
|
|
: p.stats.rankChange < 0
|
|
? 'text-red-500'
|
|
: ''
|
|
}`}
|
|
>
|
|
{p.stats.rankChange > 0 ? '+' : ''}
|
|
{p.stats.rankChange}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</Table.Cell>
|
|
|
|
<Table.Cell>
|
|
{Number.isFinite(Number(p.stats?.aim))
|
|
? `${Number(p.stats?.aim).toFixed(0)} %`
|
|
: '-'}
|
|
</Table.Cell>
|
|
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
|
|
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
|
|
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
|
|
<Table.Cell>{p.stats?.oneK ?? '-'}</Table.Cell>
|
|
<Table.Cell>{p.stats?.twoK ?? '-'}</Table.Cell>
|
|
<Table.Cell>{p.stats?.threeK ?? '-'}</Table.Cell>
|
|
<Table.Cell>{p.stats?.fourK ?? '-'}</Table.Cell>
|
|
<Table.Cell>{p.stats?.fiveK ?? '-'}</Table.Cell>
|
|
<Table.Cell>{kdr(p.stats?.kills, p.stats?.deaths)}</Table.Cell>
|
|
<Table.Cell>{adr(p.stats?.totalDamage, match.roundCount)}</Table.Cell>
|
|
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}%</Table.Cell>
|
|
<Table.Cell>{p.stats?.totalDamage?.toFixed(0) ?? '-'}</Table.Cell>
|
|
</Table.Row>
|
|
))}
|
|
</Table.Body>
|
|
</Table>
|
|
)
|
|
}
|
|
|
|
/* ─── Render ─────────────────────────────────────────────── */
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Kopfzeile: Zurück + Admin-Buttons */}
|
|
<div className="flex items-center justify-between">
|
|
<Link href="/schedule">
|
|
<Button color="gray" variant="outline">
|
|
← Zurück
|
|
</Button>
|
|
</Link>
|
|
|
|
{isAdmin && (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={() => setEditMetaOpen(true)}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-md"
|
|
>
|
|
Match bearbeiten
|
|
</Button>
|
|
<Button
|
|
onClick={handleDelete}
|
|
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md"
|
|
>
|
|
Match löschen
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<h1 className="text-2xl font-bold">
|
|
Match auf {mapLabel} ({match.matchType})
|
|
</h1>
|
|
|
|
{/* Hydration-sicher: Datum kommt vom Server und ändert sich nach SSE via router.refresh() */}
|
|
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
|
|
|
|
{(match.bestOf ?? 1) > 1 && (
|
|
<div className="mt-3">
|
|
<SeriesStrip
|
|
bestOf={match.bestOf ?? 3}
|
|
scoreA={match.scoreA}
|
|
scoreB={match.scoreB}
|
|
maps={extractSeriesMaps(match)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-md mt-2">
|
|
<strong>Teams:</strong> {match.teamA?.name ?? 'Unbekannt'} vs. {match.teamB?.name ?? 'Unbekannt'}
|
|
</div>
|
|
|
|
<div className="text-md">
|
|
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
|
|
</div>
|
|
|
|
{/* MapVote-Banner erhält die aktuell berechneten (SSE-konformen) Werte */}
|
|
{(match.matchType === 'community' &&
|
|
<MapVoteBanner
|
|
match={match}
|
|
initialNow={initialNow}
|
|
matchBaseTs={matchBaseTs}
|
|
sseOpensAtTs={sseOpensAtTs}
|
|
sseLeadMinutes={sseLeadMinutes}
|
|
/>
|
|
)}
|
|
|
|
{/* ───────── Team-Blöcke ───────── */}
|
|
<div className="border-t pt-4 mt-4 space-y-10">
|
|
{/* Team A */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h2 className="text-xl font-semibold">
|
|
{match.teamA?.logo && (
|
|
<span className="relative inline-block w-8 h-8 mr-2 align-middle">
|
|
<Image
|
|
src={match.teamA.logo ? `/assets/img/logos/${match.teamA.logo}` : `/assets/img/logos/cs2.webp`}
|
|
alt="Teamlogo"
|
|
fill
|
|
sizes="96px"
|
|
quality={75}
|
|
priority={false}
|
|
/>
|
|
</span>
|
|
)}
|
|
{match.teamB?.name ?? 'Team B'}
|
|
</h2>
|
|
|
|
{canEditA && !mapvoteStarted && (
|
|
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
|
|
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
|
|
</svg>
|
|
<span className="text-gray-300">
|
|
Du kannst die Aufstellung noch bis{' '}
|
|
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
|
</span>
|
|
</div>
|
|
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setEditSide('A')}
|
|
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
Spieler bearbeiten
|
|
</Button>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
|
|
{renderTable(teamAPlayers)}
|
|
</div>
|
|
|
|
{/* Team B */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h2 className="text-xl font-semibold">
|
|
{match.teamB?.logo && (
|
|
<span className="relative inline-block w-8 h-8 mr-2 align-middle">
|
|
<Image
|
|
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
|
|
alt="Teamlogo"
|
|
fill
|
|
sizes="96px"
|
|
quality={75}
|
|
priority={false}
|
|
/>
|
|
</span>
|
|
)}
|
|
{match.teamB?.name ?? 'Team B'}
|
|
</h2>
|
|
|
|
{canEditB && !mapvoteStarted && (
|
|
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
|
|
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
|
|
</svg>
|
|
<span className="text-gray-300">
|
|
Du kannst die Aufstellung noch bis{' '}
|
|
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
|
</span>
|
|
</div>
|
|
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setEditSide('B')}
|
|
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
Spieler bearbeiten
|
|
</Button>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
|
|
{renderTable(teamBPlayers)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ───────── Modal ───────── */}
|
|
{editSide && (
|
|
<EditMatchPlayersModal
|
|
show
|
|
onClose={() => setEditSide(null)}
|
|
matchId={match.id}
|
|
teamA={match.teamA}
|
|
teamB={match.teamB}
|
|
side={editSide}
|
|
initialA={teamAPlayers.map((mp) => mp.user.steamId)}
|
|
initialB={teamBPlayers.map((mp) => mp.user.steamId)}
|
|
onSaved={() => router.refresh()}
|
|
/>
|
|
)}
|
|
|
|
{editMetaOpen && (
|
|
<EditMatchMetaModal
|
|
show
|
|
onClose={() => setEditMetaOpen(false)}
|
|
matchId={match.id}
|
|
defaultTitle={match.title}
|
|
defaultTeamAId={match.teamA?.id ?? null}
|
|
defaultTeamBId={match.teamB?.id ?? null}
|
|
defaultTeamAName={match.teamA?.name ?? null}
|
|
defaultTeamBName={match.teamB?.name ?? null}
|
|
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
|
|
defaultMap={match.map ?? null}
|
|
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
|
|
defaultBestOf={Number(match.bestOf) === 5 ? 5 : 3}
|
|
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|