175 lines
6.7 KiB
TypeScript
175 lines
6.7 KiB
TypeScript
'use client'
|
|
|
|
import { strokesReceived, scoreColorClass, formatVsPar } from '@/lib/handicap'
|
|
import type { Hole, Player } from '@/app/(app)/rounds/[id]/scorecard/page'
|
|
|
|
type Props = {
|
|
holes: Hole[]
|
|
players: Player[]
|
|
scores: Record<string, number>
|
|
onTapScore: (player: Player, hole: Hole) => void
|
|
isActive: boolean
|
|
}
|
|
|
|
export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props) {
|
|
const totalHoles = holes.length
|
|
const front = holes.filter((h) => h.hole_number <= 9)
|
|
const back = holes.filter((h) => h.hole_number > 9)
|
|
const hasBoth = front.length > 0 && back.length > 0
|
|
|
|
function getGross(playerId: string, hole: Hole) {
|
|
return scores[`${playerId}-${hole.id}`] ?? null
|
|
}
|
|
|
|
function getNet(playerId: string, hole: Hole) {
|
|
const gross = getGross(playerId, hole)
|
|
if (gross === null) return null
|
|
const ch = players.find((p) => p.user_id === playerId)?.course_handicap ?? 0
|
|
return gross - strokesReceived(ch, hole.stroke_index, totalHoles)
|
|
}
|
|
|
|
function subtotal(playerId: string, holeSet: Hole[], fn: (p: string, h: Hole) => number | null) {
|
|
const values = holeSet.map((h) => fn(playerId, h)).filter((v): v is number => v !== null)
|
|
return values.length === holeSet.length ? values.reduce((a, b) => a + b, 0) : null
|
|
}
|
|
|
|
const parFront = front.reduce((s, h) => s + h.par, 0)
|
|
const parBack = back.reduce((s, h) => s + h.par, 0)
|
|
const parTotal = parFront + parBack
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-xs border-collapse">
|
|
<thead>
|
|
<tr className="bg-muted/50 text-muted-foreground">
|
|
<th className="sticky left-0 z-10 bg-muted/50 px-3 py-2 text-left font-medium min-w-[100px]">
|
|
Player
|
|
</th>
|
|
{front.map((h) => (
|
|
<th key={h.id} className="px-2 py-2 text-center font-medium w-8">
|
|
{h.hole_number}
|
|
</th>
|
|
))}
|
|
{hasBoth && (
|
|
<th className="px-2 py-2 text-center font-semibold w-10 bg-muted">Out</th>
|
|
)}
|
|
{back.map((h) => (
|
|
<th key={h.id} className="px-2 py-2 text-center font-medium w-8">
|
|
{h.hole_number}
|
|
</th>
|
|
))}
|
|
{hasBoth && (
|
|
<th className="px-2 py-2 text-center font-semibold w-10 bg-muted">In</th>
|
|
)}
|
|
<th className="px-2 py-2 text-center font-semibold w-12 bg-muted">Tot</th>
|
|
<th className="px-2 py-2 text-center font-bold w-12 bg-muted">Net</th>
|
|
</tr>
|
|
{/* Par row */}
|
|
<tr className="bg-muted/20 text-muted-foreground border-b">
|
|
<td className="sticky left-0 z-10 bg-muted/20 px-3 py-1 text-xs italic">Par</td>
|
|
{front.map((h) => (
|
|
<td key={h.id} className="px-2 py-1 text-center">
|
|
{h.par}
|
|
</td>
|
|
))}
|
|
{hasBoth && (
|
|
<td className="px-2 py-1 text-center font-medium bg-muted">{parFront}</td>
|
|
)}
|
|
{back.map((h) => (
|
|
<td key={h.id} className="px-2 py-1 text-center">
|
|
{h.par}
|
|
</td>
|
|
))}
|
|
{hasBoth && (
|
|
<td className="px-2 py-1 text-center font-medium bg-muted">{parBack}</td>
|
|
)}
|
|
<td className="px-2 py-1 text-center font-medium bg-muted">{parTotal}</td>
|
|
<td className="px-2 py-1 text-center bg-muted" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{players.map((player, pi) => {
|
|
const frontOut = subtotal(player.user_id, front, getGross)
|
|
const backIn = subtotal(player.user_id, back, getGross)
|
|
const total =
|
|
frontOut !== null && backIn !== null
|
|
? frontOut + backIn
|
|
: hasBoth
|
|
? null
|
|
: subtotal(player.user_id, holes, getGross)
|
|
const netTotal = subtotal(player.user_id, holes, getNet)
|
|
const netVsPar = netTotal !== null ? netTotal - parTotal : null
|
|
|
|
return (
|
|
<tr key={player.user_id} className={pi % 2 === 0 ? '' : 'bg-muted/20'}>
|
|
<td className="sticky left-0 z-10 bg-background px-3 py-2 font-medium truncate max-w-[100px]">
|
|
{player.profile?.display_name}
|
|
</td>
|
|
|
|
{front.map((h) => {
|
|
const gross = getGross(player.user_id, h)
|
|
const grossVsPar = gross !== null ? gross - h.par : null
|
|
return (
|
|
<td
|
|
key={h.id}
|
|
className="px-1 py-1.5 text-center"
|
|
onClick={() => isActive && onTapScore(player, h)}
|
|
>
|
|
<span
|
|
className={`flex h-6 w-6 mx-auto items-center justify-center text-xs cursor-pointer ${
|
|
gross !== null ? scoreColorClass(grossVsPar) : 'text-muted-foreground'
|
|
}`}
|
|
>
|
|
{gross ?? '·'}
|
|
</span>
|
|
</td>
|
|
)
|
|
})}
|
|
|
|
{hasBoth && (
|
|
<td className="px-2 py-1.5 text-center font-semibold bg-muted/30">
|
|
{frontOut ?? '—'}
|
|
</td>
|
|
)}
|
|
|
|
{back.map((h) => {
|
|
const gross = getGross(player.user_id, h)
|
|
const grossVsPar = gross !== null ? gross - h.par : null
|
|
return (
|
|
<td
|
|
key={h.id}
|
|
className="px-1 py-1.5 text-center"
|
|
onClick={() => isActive && onTapScore(player, h)}
|
|
>
|
|
<span
|
|
className={`flex h-6 w-6 mx-auto items-center justify-center text-xs cursor-pointer ${
|
|
gross !== null ? scoreColorClass(grossVsPar) : 'text-muted-foreground'
|
|
}`}
|
|
>
|
|
{gross ?? '·'}
|
|
</span>
|
|
</td>
|
|
)
|
|
})}
|
|
|
|
{hasBoth && (
|
|
<td className="px-2 py-1.5 text-center font-semibold bg-muted/30">
|
|
{backIn ?? '—'}
|
|
</td>
|
|
)}
|
|
|
|
<td className="px-2 py-1.5 text-center font-semibold bg-muted/30">
|
|
{total ?? '—'}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-center font-bold bg-muted/30">
|
|
{netVsPar !== null ? formatVsPar(netVsPar) : '—'}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|