updated
This commit is contained in:
parent
8ae14cc2b9
commit
aff1f090c1
@ -13,9 +13,10 @@ import {
|
||||
Legend,
|
||||
Title,
|
||||
Filler,
|
||||
type Plugin,
|
||||
} from 'chart.js'
|
||||
import { Line, Bar, Radar, Doughnut, PolarArea, Bubble, Pie, Scatter } from 'react-chartjs-2'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
@ -31,7 +32,8 @@ ChartJS.register(
|
||||
Filler
|
||||
)
|
||||
|
||||
type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polararea' | 'bubble' | 'scatter'
|
||||
type ChartType =
|
||||
| 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polararea' | 'bubble' | 'scatter'
|
||||
|
||||
type BaseDataset = {
|
||||
label: string
|
||||
@ -52,35 +54,32 @@ type ChartProps = {
|
||||
|
||||
/** Fixe Höhe in px ODER 'auto' (Höhe aus aspectRatio) */
|
||||
height?: number | 'auto'
|
||||
|
||||
/** Wird genutzt, wenn height='auto'. Standard: 2 (Breite/Höhe = 2:1) */
|
||||
aspectRatio?: number
|
||||
|
||||
/** Legend & Achsen (nicht Radar) ausblenden */
|
||||
/** Legende & Achsen (nicht Radar) ausblenden */
|
||||
hideLabels?: boolean
|
||||
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
|
||||
/** Radar-spezifisch */
|
||||
/** Radar-Achse */
|
||||
radarMin?: number // default 0
|
||||
radarMax?: number // default 100
|
||||
radarStepSize?: number // default 20 (erster Ring)
|
||||
radarZeroToFirstTick?: boolean // default true – 0% auf ersten Tick zeichnen
|
||||
radarHideTicks?: boolean // default true – 100%, 80%, … ausblenden
|
||||
radarMax?: number // default 120 (wegen +20 Offset)
|
||||
radarStepSize?: number // default 20
|
||||
radarHideTicks?: boolean // default true
|
||||
|
||||
/** Feintuning Radar */
|
||||
/** Radar-Darstellung */
|
||||
radarFillMode?: boolean | number | 'origin' | 'start' | 'end' // default: true
|
||||
radarTension?: number // default: 0.2
|
||||
radarTension?: number // default: 0
|
||||
radarSpanGaps?: boolean // default: false
|
||||
|
||||
/**
|
||||
* Werte im Intervall (0..ersterTick) werden in
|
||||
* [ersterTick .. ersterTick+radarSubStepBand] gemappt,
|
||||
* damit sie sichtbar ÜBER 0% liegen.
|
||||
* Einheit: Prozentpunkte. default: 4
|
||||
*/
|
||||
radarSubStepBand?: number
|
||||
/** Werte auf den Ringen +stepSize nach außen schieben (Tooltips bleiben original) */
|
||||
radarAddRingOffset?: boolean
|
||||
|
||||
/** Icons statt Text-Labels */
|
||||
radarIcons?: string[] | Record<string, string>
|
||||
radarIconSize?: number // px
|
||||
radarIconOffset?: number // Abstand über dem äußersten Ring (Skaleneinheiten)
|
||||
}
|
||||
|
||||
export default function Chart({
|
||||
@ -94,55 +93,114 @@ export default function Chart({
|
||||
className = '',
|
||||
style,
|
||||
|
||||
// Radar defaults: 0..120 mit 20er Ringen (0/20/40/60/80/100/120)
|
||||
radarMin = 0,
|
||||
radarMax = 100,
|
||||
radarMax = 120,
|
||||
radarStepSize = 20,
|
||||
radarZeroToFirstTick = true,
|
||||
radarHideTicks = true,
|
||||
|
||||
radarFillMode = true,
|
||||
radarTension = 0.2,
|
||||
radarTension = 0,
|
||||
radarSpanGaps = false,
|
||||
radarSubStepBand = 4,
|
||||
|
||||
radarAddRingOffset = true,
|
||||
|
||||
radarIcons,
|
||||
radarIconSize = 28,
|
||||
radarIconOffset = 6,
|
||||
}: ChartProps) {
|
||||
const isRadar = type === 'radar'
|
||||
const isAutoHeight = height === 'auto'
|
||||
|
||||
// Original-Daten für Tooltip-Anzeige (echte Prozentwerte)
|
||||
// -------- Daten vorbereiten (Radar: +stepSize verschieben; Tooltips bleiben original) --------
|
||||
const originalDatasets = datasets
|
||||
|
||||
// Werte fürs Radar aufbereiten:
|
||||
// - 0% auf ersten Tick
|
||||
// - (0..ersterTick) leicht oberhalb des ersten Ticks verteilen
|
||||
const preparedDatasets = useMemo(() => {
|
||||
if (!isRadar) return originalDatasets
|
||||
const step = Math.max(radarStepSize, 1)
|
||||
const band = Math.max(0, radarSubStepBand)
|
||||
|
||||
const mapVal = (v: number) => {
|
||||
if (!radarZeroToFirstTick) return v
|
||||
if (v === 0) return step
|
||||
if (v > 0 && v < step) {
|
||||
const t = v / step // 0..1
|
||||
return step + t * band
|
||||
}
|
||||
return v
|
||||
}
|
||||
const ringOffset = radarAddRingOffset ? Math.max(1, radarStepSize) : 0
|
||||
const clamp = (v: number) => Math.min(v, radarMax)
|
||||
|
||||
return originalDatasets.map(ds => ({
|
||||
...ds,
|
||||
fill: ds.fill ?? radarFillMode,
|
||||
tension: ds.tension ?? radarTension,
|
||||
spanGaps: ds.spanGaps ?? radarSpanGaps,
|
||||
data: ds.data.map(mapVal),
|
||||
data: ds.data.map(v => clamp(v + ringOffset)),
|
||||
}))
|
||||
}, [originalDatasets, isRadar, radarZeroToFirstTick, radarStepSize, radarSubStepBand, radarFillMode, radarTension, radarSpanGaps])
|
||||
}, [
|
||||
originalDatasets,
|
||||
isRadar,
|
||||
radarAddRingOffset,
|
||||
radarStepSize,
|
||||
radarFillMode,
|
||||
radarTension,
|
||||
radarSpanGaps,
|
||||
radarMax,
|
||||
])
|
||||
|
||||
const data = useMemo(
|
||||
() => ({ labels, datasets: preparedDatasets }),
|
||||
[labels, preparedDatasets]
|
||||
)
|
||||
|
||||
// -------- Icon-Plugin (nur für Radar) --------
|
||||
const imageCacheRef = useRef<Map<string, HTMLImageElement>>(new Map())
|
||||
|
||||
const resolveIconUrl = (i: number, label: string): string | null => {
|
||||
if (!radarIcons) return null
|
||||
if (Array.isArray(radarIcons)) return radarIcons[i] ?? null
|
||||
return radarIcons[label] ?? null
|
||||
}
|
||||
|
||||
// exakt auf Radar typisieren
|
||||
const radarIconsPlugin: Plugin<'radar'> | undefined = useMemo(() => {
|
||||
if (!isRadar || !radarIcons) return undefined
|
||||
|
||||
const plugin: Plugin<'radar'> = {
|
||||
id: 'radar-icons',
|
||||
afterDraw: (chart) => {
|
||||
const scale: any = chart.scales?.r
|
||||
if (!scale) return
|
||||
const ctx = chart.ctx
|
||||
const n = (chart.data.labels ?? []).length
|
||||
if (!n) return
|
||||
|
||||
// Zeichnen knapp außerhalb des äußersten Rings
|
||||
const radiusValue = (scale.max ?? radarMax) + radarIconOffset
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const label = String(chart.data.labels?.[i] ?? '')
|
||||
const url = resolveIconUrl(i, label)
|
||||
if (!url) continue
|
||||
|
||||
let img = imageCacheRef.current.get(url)
|
||||
if (!img) {
|
||||
img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = url
|
||||
imageCacheRef.current.set(url, img)
|
||||
img.onload = () => { try { chart.draw() } catch {} }
|
||||
}
|
||||
if (!img.complete) continue
|
||||
|
||||
const pos = scale.getPointPositionForValue(i, radiusValue) // (index, value)
|
||||
const size = radarIconSize
|
||||
const x = pos.x - size / 2
|
||||
const y = pos.y - size / 2
|
||||
|
||||
ctx.save()
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.35)'
|
||||
ctx.shadowBlur = 4
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 1
|
||||
ctx.drawImage(img, x, y, size, size)
|
||||
ctx.restore()
|
||||
}
|
||||
},
|
||||
}
|
||||
return plugin
|
||||
}, [isRadar, radarIcons, radarIconOffset, radarIconSize, radarMax])
|
||||
|
||||
// -------- Optionen --------
|
||||
const options = useMemo(() => {
|
||||
const base: any = {
|
||||
responsive: true,
|
||||
@ -153,7 +211,7 @@ export default function Chart({
|
||||
title: { display: !!title, text: title },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
// Tooltip zeigt Originalwert (nicht den gesnappten) an
|
||||
// Tooltip zeigt Originalwerte (ohne +Offset)
|
||||
label: (ctx: any) => {
|
||||
const dsIdx = ctx.datasetIndex
|
||||
const i = ctx.dataIndex
|
||||
@ -181,7 +239,8 @@ export default function Chart({
|
||||
},
|
||||
angleLines: { color: 'rgba(255,255,255,0.08)' },
|
||||
grid: { color: 'rgba(255,255,255,0.08)' },
|
||||
pointLabels: { color: 'inherit' },
|
||||
// Text-Labels ausblenden – Icons übernehmen die Rolle
|
||||
pointLabels: { display: false },
|
||||
},
|
||||
}
|
||||
} else if (hideLabels) {
|
||||
@ -202,10 +261,25 @@ export default function Chart({
|
||||
originalDatasets,
|
||||
])
|
||||
|
||||
// -------- Render (typsicher) --------
|
||||
const wrapperStyle: React.CSSProperties = isAutoHeight
|
||||
? { width: '100%', ...style }
|
||||
: { height: typeof height === 'number' ? height : undefined, width: '100%', ...style }
|
||||
|
||||
if (isRadar) {
|
||||
// Nur hier das streng typisierte Radar-Plugin übergeben
|
||||
const radarPlugins = radarIconsPlugin ? [radarIconsPlugin] as Plugin<'radar'>[] : undefined
|
||||
return (
|
||||
<div className={className} style={wrapperStyle}>
|
||||
<Radar data={data} options={options} plugins={radarPlugins} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// andere Chart-Typen ohne Radar-Plugin
|
||||
const chartMap = {
|
||||
line: Line,
|
||||
bar: Bar,
|
||||
radar: Radar,
|
||||
doughnut: Doughnut,
|
||||
polararea: PolarArea,
|
||||
bubble: Bubble,
|
||||
@ -213,16 +287,11 @@ export default function Chart({
|
||||
scatter: Scatter,
|
||||
} as const
|
||||
|
||||
const ChartComponent = chartMap[type]
|
||||
|
||||
// Container: bei fixer Höhe -> px setzen; bei auto -> Höhe aus aspectRatio
|
||||
const wrapperStyle: React.CSSProperties = isAutoHeight
|
||||
? { width: '100%', ...style }
|
||||
: { height: typeof height === 'number' ? height : undefined, width: '100%', ...style }
|
||||
const NonRadar = chartMap[type as Exclude<ChartType, 'radar'>]
|
||||
|
||||
return (
|
||||
<div className={className} style={wrapperStyle}>
|
||||
<ChartComponent data={data} options={options} />
|
||||
<NonRadar data={data} options={options} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, useTransition } from 'react'
|
||||
import Modal from './Modal'
|
||||
import MiniCard from './MiniCard'
|
||||
import { useSession } from 'next-auth/react'
|
||||
@ -8,6 +8,7 @@ import LoadingSpinner from './LoadingSpinner'
|
||||
import { Player, Team } from '../../../types/team'
|
||||
import Pagination from './Pagination'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import Switch from './Switch'
|
||||
|
||||
type InviteStatus = 'sent' | 'failed' | 'added' | 'pending'
|
||||
|
||||
@ -25,19 +26,34 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
|
||||
const [allUsers, setAllUsers] = useState<Player[]>([])
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [knownUsers, setKnownUsers] = useState<Record<string, Player>>({})
|
||||
const [invitedIds, setInvitedIds] = useState<string[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isInviting, setIsInviting] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [sentCount, setSentCount] = useState(0)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
// Toggle: nur Spieler ohne Team
|
||||
const [onlyFree, setOnlyFree] = useState(false)
|
||||
|
||||
// Sanftes UI-Update
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
// 🔽 Anti-Flacker: verzögert anzeigen + Mindestdauer
|
||||
const SPINNER_DELAY_MS = 120
|
||||
const SPINNER_MIN_MS = 250
|
||||
const [spinnerVisible, setSpinnerVisible] = useState(false)
|
||||
const spinnerShowTimer = useRef<number | null>(null)
|
||||
const spinnerShownAt = useRef<number | null>(null)
|
||||
|
||||
// Dynamisch berechnete Items pro Seite
|
||||
const [usersPerPage, setUsersPerPage] = useState<number>(9)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [invitedStatus, setInvitedStatus] = useState<Record<string, InviteStatus>>({})
|
||||
|
||||
// Refs für die Messung des verfügbaren Platzes
|
||||
// Refs / Layout
|
||||
const descRef = useRef<HTMLParagraphElement>(null)
|
||||
const selectedWrapRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLDivElement>(null)
|
||||
@ -45,46 +61,132 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const firstCardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// aktuelle Grid-Höhe halten
|
||||
const [gridHoldHeight, setGridHoldHeight] = useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
fetchUsersNotInTeam()
|
||||
setIsSuccess(false)
|
||||
setInvitedIds([])
|
||||
setInvitedStatus({})
|
||||
}
|
||||
if (!show) return
|
||||
void fetchUsers({ resetLayout: true })
|
||||
setIsSuccess(false)
|
||||
setInvitedIds([])
|
||||
setInvitedStatus({})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [show])
|
||||
|
||||
useEffect(() => {
|
||||
if (!gridRef.current) return;
|
||||
if (!show) return
|
||||
setCurrentPage(1)
|
||||
void fetchUsers({ resetLayout: false })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onlyFree])
|
||||
|
||||
useEffect(() => {
|
||||
if (!gridRef.current) return
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!gridRef.current) return;
|
||||
const gridHeight = gridRef.current.clientHeight;
|
||||
const cardHeight = gridRef.current.querySelector('div')?.clientHeight || 0;
|
||||
const rows = cardHeight > 0 ? Math.floor(gridHeight / cardHeight) : 1;
|
||||
const cols = window.innerWidth >= 640 ? 3 : 2; // sm:grid-cols-3 vs grid-cols-2
|
||||
setUsersPerPage(rows * cols);
|
||||
});
|
||||
if (!gridRef.current) return
|
||||
const gridHeight = gridRef.current.clientHeight
|
||||
const cardHeight = gridRef.current.querySelector('div')?.clientHeight || 0
|
||||
const rows = cardHeight > 0 ? Math.floor(gridHeight / cardHeight) : 1
|
||||
const cols = window.innerWidth >= 640 ? 3 : 2
|
||||
setUsersPerPage(rows * cols)
|
||||
})
|
||||
observer.observe(gridRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
observer.observe(gridRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const fetchUsersNotInTeam = async () => {
|
||||
async function fetchUsers(opts: { resetLayout: boolean }) {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await fetch(`/api/team/available-users?teamId=${encodeURIComponent(team.id)}`)
|
||||
abortRef.current?.abort()
|
||||
const ctrl = new AbortController()
|
||||
abortRef.current = ctrl
|
||||
|
||||
// Höhe einfrieren
|
||||
if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight)
|
||||
|
||||
// Start Fetch
|
||||
setIsFetching(true)
|
||||
|
||||
// 🔽 Spinner NICHT sofort zeigen – erst nach kurzer Verzögerung
|
||||
if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current)
|
||||
spinnerShowTimer.current = window.setTimeout(() => {
|
||||
setSpinnerVisible(true)
|
||||
spinnerShownAt.current = Date.now()
|
||||
}, SPINNER_DELAY_MS)
|
||||
|
||||
const qs = new URLSearchParams({ teamId: team.id })
|
||||
if (onlyFree) qs.set('onlyFree', 'true')
|
||||
|
||||
const res = await fetch(`/api/team/available-users?${qs.toString()}`, {
|
||||
signal: ctrl.signal,
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!res.ok) throw new Error('load failed')
|
||||
|
||||
const data = await res.json()
|
||||
setAllUsers(data.users || [])
|
||||
startTransition(() => {
|
||||
setAllUsers(data.users || [])
|
||||
setKnownUsers(prev => {
|
||||
const next = { ...prev }
|
||||
for (const u of (data.users || [])) next[u.steamId] = u
|
||||
return next
|
||||
})
|
||||
if (opts.resetLayout) {
|
||||
setSelectedIds([])
|
||||
setInvitedIds([])
|
||||
setIsSuccess(false)
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') console.error('Fehler beim Laden der Benutzer:', e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsFetching(false)
|
||||
abortRef.current = null
|
||||
|
||||
// 🔽 Spinner Mindestdauer respektieren
|
||||
const hide = () => {
|
||||
setSpinnerVisible(false)
|
||||
spinnerShownAt.current = null
|
||||
setGridHoldHeight(0)
|
||||
}
|
||||
|
||||
if (spinnerShowTimer.current) {
|
||||
// Wenn der Delay-Timer noch nicht gefeuert hat: einfach abbrechen
|
||||
window.clearTimeout(spinnerShowTimer.current)
|
||||
spinnerShowTimer.current = null
|
||||
// Der Spinner wurde evtl. nie sichtbar -> direkt aufräumen
|
||||
if (!spinnerShownAt.current) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (spinnerShownAt.current) {
|
||||
const elapsed = Date.now() - spinnerShownAt.current
|
||||
const remain = Math.max(0, SPINNER_MIN_MS - elapsed)
|
||||
if (remain > 0) {
|
||||
window.setTimeout(hide, remain)
|
||||
} else {
|
||||
hide()
|
||||
}
|
||||
} else {
|
||||
// Falls er nie sichtbar war
|
||||
hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (steamId: string) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(steamId) ? prev.filter(id => id !== steamId) : [...prev, steamId]
|
||||
)
|
||||
setSelectedIds(prev => {
|
||||
const isSelected = prev.includes(steamId)
|
||||
// Beim Hinzufügen: sicherstellen, dass wir die Card-Daten im Cache haben
|
||||
if (!isSelected) {
|
||||
const u = allUsers.find(u => u.steamId === steamId)
|
||||
if (u) {
|
||||
setKnownUsers(map => (map[steamId] ? map : { ...map, [steamId]: u }))
|
||||
}
|
||||
}
|
||||
return isSelected ? prev.filter(id => id !== steamId) : [...prev, steamId]
|
||||
})
|
||||
}
|
||||
|
||||
const handleInvite = async () => {
|
||||
@ -108,7 +210,6 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
let json: any = null
|
||||
try { json = await res.clone().json() } catch {}
|
||||
|
||||
// --- Auswertung: bevorzugt 'results', fallback auf 'invitationIds' ---
|
||||
let results: { steamId: string; ok: boolean }[] = []
|
||||
if (json?.results && Array.isArray(json.results)) {
|
||||
results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok }))
|
||||
@ -116,7 +217,6 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
const okSet = new Set<string>(json.invitationIds)
|
||||
results = ids.map(id => ({ steamId: id, ok: okSet.has(id) }))
|
||||
} else {
|
||||
// Keine verwertbaren Details → alles als failed markieren
|
||||
results = ids.map(id => ({ steamId: id, ok: false }))
|
||||
}
|
||||
|
||||
@ -133,8 +233,6 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
setSentCount(okCount)
|
||||
setIsSuccess(true)
|
||||
setSelectedIds([])
|
||||
|
||||
// nur beim Erfolg wenigstens einer Einladung „onSuccess“ und Auto-Close
|
||||
if (okCount > 0) onSuccess()
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Einladen:', err)
|
||||
@ -149,34 +247,23 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuccess) return
|
||||
// nur automatisch schließen, wenn wirklich etwas versendet/ hinzugefügt wurde
|
||||
if (sentCount > 0) {
|
||||
const t = setTimeout(() => {
|
||||
const modalEl = document.getElementById('invite-members-modal')
|
||||
if (modalEl && (window as any).HSOverlay?.close) {
|
||||
(window as any).HSOverlay.close(modalEl)
|
||||
}
|
||||
if (modalEl && (window as any).HSOverlay?.close) (window as any).HSOverlay.close(modalEl)
|
||||
onClose()
|
||||
}, 2000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [isSuccess, sentCount, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [searchTerm])
|
||||
useEffect(() => { setCurrentPage(1) }, [searchTerm])
|
||||
|
||||
const filteredUsers = allUsers.filter(user =>
|
||||
user.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const filteredUsers = useMemo(
|
||||
() => allUsers.filter(u => u.name?.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
[allUsers, searchTerm]
|
||||
)
|
||||
|
||||
// Sortierung: ausgewählte nach vorne (nur für Anzeige der Selected-List)
|
||||
const sortedUsers = [...filteredUsers].sort((a, b) => {
|
||||
const aSelected = selectedIds.includes(a.steamId) ? -1 : 0
|
||||
const bSelected = selectedIds.includes(b.steamId) ? -1 : 0
|
||||
return aSelected - bSelected
|
||||
})
|
||||
|
||||
const unselectedUsers = filteredUsers.filter(user =>
|
||||
!selectedIds.includes(user.steamId) &&
|
||||
(!isSuccess || !invitedIds.includes(user.steamId))
|
||||
@ -186,31 +273,24 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
const startIdx = (currentPage - 1) * Math.max(1, usersPerPage)
|
||||
const paginatedUsers = unselectedUsers.slice(startIdx, startIdx + Math.max(1, usersPerPage))
|
||||
|
||||
// Seite einklemmen, wenn PerPage / Anzahl sich ändern
|
||||
useEffect(() => {
|
||||
if (totalPages === 0 && currentPage !== 1) setCurrentPage(1)
|
||||
else if (currentPage > totalPages) setCurrentPage(totalPages || 1)
|
||||
}, [totalPages, currentPage])
|
||||
|
||||
// ---- Dynamische Berechnung von usersPerPage (keine Scrollbars im Modal) ----
|
||||
const recalcUsersPerPage = useCallback(() => {
|
||||
const gridEl = gridRef.current
|
||||
if (!gridEl) return
|
||||
|
||||
// Modal-Body ist der Parent des Grids
|
||||
const bodyEl = gridEl.parentElement as HTMLElement | null
|
||||
if (!bodyEl) return
|
||||
|
||||
// bereits genutzte Höhe "oberhalb" des Grids
|
||||
const outer = (el: HTMLElement | null) => {
|
||||
if (!el) return 0
|
||||
const cs = getComputedStyle(el)
|
||||
return el.offsetHeight + parseFloat(cs.marginTop || '0') + parseFloat(cs.marginBottom || '0')
|
||||
}
|
||||
|
||||
// Abstand des Grids selbst (z.B. mt-4)
|
||||
const gridMT = parseFloat(getComputedStyle(gridEl).marginTop || '0')
|
||||
|
||||
const usedAbove =
|
||||
outer(descRef.current) +
|
||||
outer(selectedWrapRef.current) +
|
||||
@ -218,45 +298,27 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
outer(successRef.current) +
|
||||
gridMT
|
||||
|
||||
// Optional etwas Platz für Pagination reservieren (wenn sie sichtbar wäre)
|
||||
const reserveForPagination = (!isLoading && !isSuccess && totalPages > 1) ? 48 : 0
|
||||
|
||||
const reserveForPagination = (!isFetching && !isSuccess && totalPages > 1) ? 48 : 0
|
||||
const availableHeight = Math.max(0, bodyEl.clientHeight - usedAbove - reserveForPagination)
|
||||
|
||||
// Ermittel Spalten und Zeilenabstand aus dem Grid
|
||||
const csGrid = getComputedStyle(gridEl)
|
||||
const cols = Math.max(
|
||||
1,
|
||||
csGrid.gridTemplateColumns.split(' ').filter(Boolean).length
|
||||
)
|
||||
const cols = Math.max(1, csGrid.gridTemplateColumns.split(' ').filter(Boolean).length)
|
||||
const rowGap = parseFloat(csGrid.rowGap || '0')
|
||||
|
||||
// Kartenhöhe messen: nimm die erste echte Karte, sonst Fallback
|
||||
const cardEl = firstCardRef.current
|
||||
let cardHeight = cardEl?.offsetHeight || 0
|
||||
if (!cardHeight) {
|
||||
// vorsichtiger Fallback (angepasst an typische MiniCard)
|
||||
cardHeight = 140
|
||||
}
|
||||
if (!cardHeight) cardHeight = 140
|
||||
|
||||
// rows * cardHeight + (rows - 1) * rowGap <= availableHeight
|
||||
// => rows <= (availableHeight + rowGap) / (cardHeight + rowGap)
|
||||
const rows = Math.max(1, Math.floor((availableHeight + rowGap) / (cardHeight + rowGap)))
|
||||
const nextPerPage = Math.max(1, rows * cols)
|
||||
|
||||
if (nextPerPage !== usersPerPage) {
|
||||
setUsersPerPage(nextPerPage)
|
||||
}
|
||||
}, [isLoading, isSuccess, totalPages, usersPerPage])
|
||||
setUsersPerPage(prev => (prev !== nextPerPage ? nextPerPage : prev))
|
||||
}, [isFetching, isSuccess, totalPages])
|
||||
|
||||
// Recalc auf Resize/Content-Änderungen
|
||||
useEffect(() => {
|
||||
// Direkt versuchen
|
||||
recalcUsersPerPage()
|
||||
|
||||
const gridEl = gridRef.current
|
||||
if (!gridEl) return
|
||||
|
||||
const bodyEl = gridEl.parentElement as HTMLElement | null
|
||||
const cardEl = firstCardRef.current
|
||||
|
||||
@ -267,10 +329,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
|
||||
const onResize = () => recalcUsersPerPage()
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
// bei Show/Hide leichter Delay, bis Layout stabil ist
|
||||
const id = window.setTimeout(recalcUsersPerPage, 60)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(id)
|
||||
ro.disconnect()
|
||||
@ -288,11 +347,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
closeButtonColor={!isSuccess ? (isSuccess ? 'teal' : 'blue') : undefined}
|
||||
closeButtonTitle={
|
||||
!isSuccess
|
||||
? (
|
||||
isInviting
|
||||
? (directAdd ? 'Wird hinzugefügt...' : 'Wird eingeladen...')
|
||||
: (directAdd ? 'Hinzufügen' : 'Einladungen senden')
|
||||
)
|
||||
? (isInviting ? (directAdd ? 'Wird hinzugefügt...' : 'Wird eingeladen...') : (directAdd ? 'Hinzufügen' : 'Einladungen senden'))
|
||||
: undefined
|
||||
}
|
||||
closeButtonLoading={!isSuccess && isInviting}
|
||||
@ -304,37 +359,53 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
: 'Wähle Spieler aus, die du in dein Team einladen möchtest:'}
|
||||
</p>
|
||||
|
||||
{/* Ausgewählte Benutzer anzeigen */}
|
||||
{/* Filterleiste */}
|
||||
<div ref={searchRef} className="mt-1 grid grid-cols-[auto_1fr] items-center gap-x-3">
|
||||
{/* kompakteres Suchfeld */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-40 sm:w-56 rounded border px-2 py-1.5 text-[13px]
|
||||
focus:outline-none focus:ring focus:ring-blue-400
|
||||
dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100"
|
||||
/>
|
||||
|
||||
{/* Toggle bekommt die breite zweite Spalte */}
|
||||
<div className="justify-self-end min-w-[240px] sm:min-w-[300px]">
|
||||
<Switch
|
||||
id="only-free-switch"
|
||||
checked={onlyFree}
|
||||
onChange={(v) => startTransition(() => setOnlyFree(v))}
|
||||
labelLeft="Alle"
|
||||
labelRight="Nur ohne Team"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ausgewählte */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div ref={selectedWrapRef} className="col-span-full">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-neutral-300 mb-2">
|
||||
Ausgewählte Spieler:
|
||||
</h3>
|
||||
<div ref={selectedWrapRef} className="col-span-full mt-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-neutral-300 mb-2">Ausgewählte Spieler:</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-2">
|
||||
<AnimatePresence initial={false}>
|
||||
{selectedIds.map((id) => {
|
||||
const user = allUsers.find((u) => u.steamId === id)
|
||||
const user = knownUsers[id]
|
||||
if (!user) return null
|
||||
return (
|
||||
<motion.div
|
||||
key={user.steamId}
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<motion.div key={user.steamId} layout initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<MiniCard
|
||||
steamId={user.steamId}
|
||||
title={user.name}
|
||||
avatar={user.avatar}
|
||||
location={user.location}
|
||||
selected={true}
|
||||
selected
|
||||
onSelect={handleSelect}
|
||||
draggable={false}
|
||||
currentUserSteamId={steamId!}
|
||||
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader}
|
||||
hideActions={true}
|
||||
hideActions
|
||||
rank={user.premierRank}
|
||||
/>
|
||||
</motion.div>
|
||||
@ -345,43 +416,47 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={searchRef}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="mt-2 w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring focus:ring-blue-400 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSuccess && (
|
||||
<div ref={successRef} className="mt-2 px-4 py-2 text-sm rounded-lg border"
|
||||
style={{ background: sentCount ? '#dcfce7' : '#fee2e2', borderColor: sentCount ? '#bbf7d0' : '#fecaca', color: sentCount ? '#166534' : '#991b1b' }}>
|
||||
{directAdd
|
||||
? (sentCount === 0
|
||||
? 'Niemand konnte hinzugefügt werden.'
|
||||
: sentCount === invitedIds.length
|
||||
? `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt!`
|
||||
: `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt, andere fehlgeschlagen.`)
|
||||
: (sentCount === 0
|
||||
? 'Keine Einladungen versendet.'
|
||||
: sentCount === invitedIds.length
|
||||
? `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`
|
||||
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet, andere fehlgeschlagen.`)}
|
||||
<div
|
||||
ref={successRef}
|
||||
className={`mt-2 px-4 py-2 text-sm rounded-lg border transition-opacity duration-150 ${
|
||||
isSuccess ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
style={{
|
||||
minHeight: 40, // ~ Höhe reservieren, vermeidet Jump
|
||||
background: sentCount ? '#dcfce7' : '#fee2e2',
|
||||
borderColor: sentCount ? '#bbf7d0' : '#fecaca',
|
||||
color: sentCount ? '#166534' : '#991b1b'
|
||||
}}
|
||||
>
|
||||
{isSuccess && (
|
||||
<>
|
||||
{directAdd
|
||||
? (sentCount === 0
|
||||
? 'Niemand konnte hinzugefügt werden.'
|
||||
: sentCount === invitedIds.length
|
||||
? `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt!`
|
||||
: `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt, andere fehlgeschlagen.`)
|
||||
: (sentCount === 0
|
||||
? 'Keine Einladungen versendet.'
|
||||
: sentCount === invitedIds.length
|
||||
? `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`
|
||||
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet, andere fehlgeschlagen.`)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid der MiniCards — Höhe/Anzahl wird dynamisch berechnet */}
|
||||
<div ref={gridRef} className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : filteredUsers.length === 0 ? (
|
||||
{/* Grid */}
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="relative grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4 transition-[min-height] duration-150"
|
||||
style={gridHoldHeight ? { minHeight: gridHoldHeight } : undefined}
|
||||
>
|
||||
{unselectedUsers.length === 0 ? (
|
||||
<div className="col-span-full text-center text-gray-500 dark:text-neutral-400">
|
||||
{allUsers.length === 0
|
||||
? directAdd
|
||||
? 'Keine Benutzer verfügbar :('
|
||||
: 'Niemand zum Einladen verfügbar :('
|
||||
? directAdd ? 'Keine Benutzer verfügbar :(' : 'Niemand zum Einladen verfügbar :('
|
||||
: 'Keine Benutzer gefunden.'}
|
||||
</div>
|
||||
) : (
|
||||
@ -394,7 +469,6 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
// 👉 erste sichtbare Karte als Mess-Referenz verwenden
|
||||
ref={idx === 0 ? firstCardRef : undefined}
|
||||
>
|
||||
<MiniCard
|
||||
@ -414,7 +488,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
))}
|
||||
|
||||
{isSuccess && invitedIds.map((id, idx) => {
|
||||
const user = allUsers.find((u) => u.steamId === id)
|
||||
const user = knownUsers[id]
|
||||
if (!user) return null
|
||||
return (
|
||||
<motion.div
|
||||
@ -444,23 +518,29 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
})}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{/* Overlay-Spinner: erscheint verzögert und bleibt min. SPINNER_MIN_MS sichtbar */}
|
||||
{spinnerVisible && (
|
||||
<div className="absolute inset-0 bg-black/5 dark:bg-white/5 backdrop-blur-[1px] pointer-events-none flex items-center justify-center rounded transition-opacity duration-150">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!isLoading && !isSuccess && totalPages > 1 && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<div className="mt-3 min-h-[44px] flex justify-center items-center">
|
||||
{!isFetching && !isSuccess && totalPages > 1 ? (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={(p) => {
|
||||
const next = Math.max(1, Math.min(totalPages, p))
|
||||
setCurrentPage(next)
|
||||
// nach Page-Wechsel neu kalkulieren (Layout kann „springen“)
|
||||
setTimeout(() => recalcUsersPerPage(), 0)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@ -469,19 +469,6 @@ export default function MapVotePanel({ match }: Props) {
|
||||
[activeMapKeys]
|
||||
)
|
||||
|
||||
// Response-Struktur des /api/user/:id/winrate Endpoints (wir brauchen nur keys/values)
|
||||
type WinrateResponse = { keys: string[]; values: number[] }
|
||||
|
||||
// Helper: Winrate eines Spielers als Dictionary { [mapKey]: pct }
|
||||
async function fetchWinrate(steamId: string): Promise<Record<string, number>> {
|
||||
const r = await fetch(`/api/user/${steamId}/winrate?onlyActive=true`, { cache: 'no-store' })
|
||||
if (!r.ok) throw new Error('winrate fetch failed')
|
||||
const j: WinrateResponse = await r.json()
|
||||
const out: Record<string, number> = {}
|
||||
j.keys.forEach((key, i) => { out[key] = j.values[i] ?? 0 })
|
||||
return out
|
||||
}
|
||||
|
||||
// Helper: Durchschnitt (nur finite Werte)
|
||||
function avg(values: number[]) {
|
||||
const valid = values.filter(v => Number.isFinite(v))
|
||||
@ -905,20 +892,25 @@ export default function MapVotePanel({ match }: Props) {
|
||||
// Winrate-Tab
|
||||
<div className="w-full max-w-xl justify-self-center">
|
||||
<div className="rounded-lg border border-white/10 bg-neutral-900/40 backdrop-blur-sm p-3" style={{ height: 360 }}>
|
||||
<div className="w-full max-w-xl justify-self-center">
|
||||
<div className="h-full w-full max-w-xl justify-self-center">
|
||||
<Chart
|
||||
type="radar"
|
||||
labels={activeMapLabels}
|
||||
labels={activeMapLabels} // z.B. ['Ancient','Anubis', ...]
|
||||
height={"auto"}
|
||||
radarZeroToFirstTick // 0% auf ersten Tick
|
||||
radarSubStepBand={4} // (0..20%) wird in 20..24% gestreckt (sichtbar über 0)
|
||||
radarHideTicks // Ticks ausblenden (optional)
|
||||
datasets={[
|
||||
{ label: teamLeft?.name ?? 'Team Links', data: teamRadarLeft,
|
||||
{ label: teamLeft?.name ?? 'Links', data: teamRadarLeft,
|
||||
borderColor: 'rgba(54,162,235,0.9)', backgroundColor: 'rgba(54,162,235,0.20)', borderWidth: 2 },
|
||||
{ label: teamRight?.name ?? 'Team Rechts', data: teamRadarRight,
|
||||
{ label: teamRight?.name ?? 'Rechts', data: teamRadarRight,
|
||||
borderColor: 'rgba(255,99,132,0.9)', backgroundColor: 'rgba(255,99,132,0.20)', borderWidth: 2 },
|
||||
]}
|
||||
// Icons (array passt 1:1 zu labels) ODER als Mapping label->url
|
||||
radarIcons={activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`)}
|
||||
radarIconSize={28}
|
||||
radarIconOffset={24}
|
||||
// Skala + Offset (weil wir werte um +20 schieben)
|
||||
radarMax={120}
|
||||
radarStepSize={20}
|
||||
radarAddRingOffset
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -117,6 +117,8 @@ export default function Modal({
|
||||
rounded-xl
|
||||
/* -> Höhe des Panels auf den Viewport begrenzen */
|
||||
max-h-[calc(100vh-56px)]
|
||||
/* -> Fix: Verhindert Höhen-Jumps bei Filterwechsel */
|
||||
min-h-[620px]
|
||||
"
|
||||
>
|
||||
{/* Header (fixe Höhe) */}
|
||||
@ -156,6 +158,7 @@ export default function Modal({
|
||||
p-4 flex-1 min-h-0
|
||||
${scrollBody ? 'overflow-y-auto' : 'overflow-y-hidden'}
|
||||
`}
|
||||
style={{ scrollbarGutter: 'stable both-edges' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -5,11 +5,13 @@ export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const teamId = searchParams.get('teamId')
|
||||
const onlyFree = (searchParams.get('onlyFree') ?? '').toLowerCase() === 'true'
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 1) Team laden
|
||||
// 1) Team laden (für den Standard-Fall weiterhin nötig)
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: { activePlayers: true, inactivePlayers: true }
|
||||
@ -18,22 +20,41 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
const members = new Set([...team.activePlayers, ...team.inactivePlayers])
|
||||
let excludeIds: string[] = []
|
||||
|
||||
// 2) Pending-Invites dieses Teams
|
||||
const pendingInvites = await prisma.teamInvite.findMany({
|
||||
where: { teamId },
|
||||
select: { steamId: true }
|
||||
})
|
||||
const invited = new Set(pendingInvites.map(i => i.steamId))
|
||||
if (onlyFree) {
|
||||
// 2a) Nur Spieler ohne Team => ALLE Teammitglieder (aus allen Teams) ausschließen
|
||||
const allTeams = await prisma.team.findMany({
|
||||
select: { activePlayers: true, inactivePlayers: true }
|
||||
})
|
||||
const occupied = new Set<string>()
|
||||
for (const t of allTeams) {
|
||||
for (const id of (t.activePlayers ?? [])) occupied.add(id)
|
||||
for (const id of (t.inactivePlayers ?? [])) occupied.add(id)
|
||||
}
|
||||
excludeIds = Array.from(occupied)
|
||||
// Hinweis: Pending-Invites zählen nicht als "hat Team" – werden hier NICHT ausgeschlossen.
|
||||
} else {
|
||||
// 2b) Standard: Mitglieder + bereits eingeladene dieses Teams ausschließen
|
||||
const members = new Set<string>([
|
||||
...(team.activePlayers ?? []),
|
||||
...(team.inactivePlayers ?? [])
|
||||
])
|
||||
|
||||
// 3) Kandidaten: nur canBeInvited === true, nicht Mitglied, nicht eingeladen
|
||||
const excludeIds = Array.from(new Set([...members, ...invited]))
|
||||
const pendingInvites = await prisma.teamInvite.findMany({
|
||||
where: { teamId },
|
||||
select: { steamId: true }
|
||||
})
|
||||
const invited = new Set<string>(pendingInvites.map(i => i.steamId))
|
||||
|
||||
excludeIds = Array.from(new Set([...members, ...invited]))
|
||||
}
|
||||
|
||||
// 3) Kandidaten: nur einladbare, nicht in excludeIds
|
||||
const availableUsers = await prisma.user.findMany({
|
||||
where: {
|
||||
canBeInvited: true, // << nur einladbare
|
||||
steamId: { notIn: excludeIds } // << nicht schon Mitglied/geladen
|
||||
canBeInvited: true,
|
||||
steamId: { notIn: excludeIds }
|
||||
},
|
||||
select: {
|
||||
steamId: true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user