diff --git a/apps/web/app/(admin)/live/page.tsx b/apps/web/app/(admin)/live/page.tsx new file mode 100644 index 0000000..807ae02 --- /dev/null +++ b/apps/web/app/(admin)/live/page.tsx @@ -0,0 +1,589 @@ +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useSite } from "@/contexts/site-context"; +import { + Users, + UserPlus, + Clock, + RefreshCw, + MapPin, + X, + Search, +} from "lucide-react"; + +// --- Types --- + +interface Player { + id: string; + firstName?: string; + lastName?: string; + walkInName?: string; + checkedInAt: string; + sessionId: string; +} + +interface Court { + id: string; + name: string; + type: "INDOOR" | "OUTDOOR"; + isOpenPlay: boolean; + status: "available" | "active" | "booked" | "open_play"; + players: Player[]; + upcomingBooking?: { + startTime: string; + clientName: string; + }; +} + +interface ClientResult { + id: string; + firstName: string; + lastName: string; +} + +// --- Helpers --- + +function getStatusConfig(court: Court) { + if (court.status === "active") { + return { + dotColor: "bg-primary-500", + text: `Active (${court.players.length} player${court.players.length !== 1 ? "s" : ""})`, + bgColor: "bg-primary-50", + borderColor: "border-primary-200", + textColor: "text-primary-500", + }; + } + if (court.status === "open_play" && court.players.length === 0) { + return { + dotColor: "bg-amber-500", + text: "Open Play", + bgColor: "bg-amber-50", + borderColor: "border-amber-200", + textColor: "text-amber-500", + }; + } + if (court.status === "open_play" && court.players.length > 0) { + return { + dotColor: "bg-amber-500", + text: `Open Play (${court.players.length} player${court.players.length !== 1 ? "s" : ""})`, + bgColor: "bg-amber-50", + borderColor: "border-amber-200", + textColor: "text-amber-500", + }; + } + if (court.status === "booked") { + return { + dotColor: "bg-purple-500", + text: "Booked", + bgColor: "bg-purple-50", + borderColor: "border-purple-200", + textColor: "text-purple-500", + }; + } + return { + dotColor: "bg-green-500", + text: "Available", + bgColor: "bg-green-50", + borderColor: "border-green-200", + textColor: "text-green-500", + }; +} + +function formatTime(dateStr: string) { + return new Date(dateStr).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} + +function playerName(player: Player) { + if (player.walkInName) return player.walkInName; + return [player.firstName, player.lastName].filter(Boolean).join(" ") || "Unknown"; +} + +function playerInitials(player: Player) { + const name = playerName(player); + const parts = name.split(" "); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return name.slice(0, 2).toUpperCase(); +} + +// --- Main Page --- + +export default function LiveCourtsPage() { + const { selectedSiteId } = useSite(); + const [courts, setCourts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + const [checkInCourtId, setCheckInCourtId] = useState(null); + const [endSessionCourtId, setEndSessionCourtId] = useState(null); + const intervalRef = useRef(null); + + // --- Data fetching --- + + const fetchCourts = useCallback(async () => { + try { + setError(null); + const url = selectedSiteId + ? `/api/live?siteId=${selectedSiteId}` + : "/api/live"; + const response = await fetch(url); + if (!response.ok) throw new Error("Failed to load court data"); + const data = await response.json(); + setCourts(data.courts ?? data.data ?? []); + setLastUpdated(new Date()); + } catch (err) { + console.error("Live courts fetch error:", err); + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setIsLoading(false); + } + }, [selectedSiteId]); + + useEffect(() => { + fetchCourts(); + intervalRef.current = setInterval(fetchCourts, 30000); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [fetchCourts]); + + // --- End session handler --- + + const handleEndSession = async (court: Court) => { + try { + await Promise.all( + court.players.map((p) => + fetch(`/api/court-sessions/${p.sessionId}`, { method: "PUT" }) + ) + ); + setEndSessionCourtId(null); + fetchCourts(); + } catch (err) { + console.error("End session error:", err); + } + }; + + // --- Render --- + + const checkInCourt = courts.find((c) => c.id === checkInCourtId) ?? null; + const endSessionCourt = courts.find((c) => c.id === endSessionCourtId) ?? null; + + return ( +
+ {/* Header */} +
+
+

Live Courts

+

+ {lastUpdated + ? `Last updated: ${lastUpdated.toLocaleTimeString()}` + : "Loading..."} +

+
+ +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Loading skeleton */} + {isLoading && courts.length === 0 && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + +
+
+
+
+ + +
+
+
+
+ + + ))} +
+ )} + + {/* Court grid */} + {!isLoading && courts.length === 0 && !error && ( +
+ +

No courts found

+

Courts will appear here once configured.

+
+ )} + +
+ {courts.map((court) => { + const cfg = getStatusConfig(court); + const earliestCheckIn = court.players.length > 0 + ? court.players.reduce((earliest, p) => + new Date(p.checkedInAt) < new Date(earliest.checkedInAt) ? p : earliest + ) + : null; + + return ( + + +
+ + {court.name} + +
+ + {court.type} + + {court.isOpenPlay && ( + + Open Play + + )} +
+
+ {/* Status indicator */} +
+ + + {cfg.text} + +
+
+ + + {/* Upcoming booking info for booked courts */} + {court.status === "booked" && court.upcomingBooking && ( +
+ + {court.upcomingBooking.clientName} at{" "} + {formatTime(court.upcomingBooking.startTime)} +
+ )} + + {/* Player list */} + {court.players.length > 0 && ( +
+ {court.players.map((player) => ( +
+
+ {playerInitials(player)} +
+ + {playerName(player)} + +
+ ))} +
+ )} + + {/* Time since first check-in */} + {earliestCheckIn && ( +
+ + Since {formatTime(earliestCheckIn.checkedInAt)} +
+ )} + + {/* Actions */} +
+ {(court.status === "available" || + court.status === "active" || + court.status === "open_play") && ( + + )} + {(court.status === "active" || (court.status === "open_play" && court.players.length > 0)) && ( + + )} + {court.isOpenPlay && ( + + )} +
+
+
+ ); + })} +
+ + {/* Check In Modal */} + {checkInCourt && ( + setCheckInCourtId(null)} + onSuccess={() => { + setCheckInCourtId(null); + fetchCourts(); + }} + /> + )} + + {/* End Session Confirm Dialog */} + {endSessionCourt && ( +
+
+

+ End Session +

+

+ End session on {endSessionCourt.name}? This will + check out all players. +

+
+ + +
+
+
+ )} +
+ ); +} + +// --- Check In Modal Component --- + +function CheckInModal({ + court, + onClose, + onSuccess, +}: { + court: Court; + onClose: () => void; + onSuccess: () => void; +}) { + const [mode, setMode] = useState<"search" | "walkin">("search"); + const [searchQuery, setSearchQuery] = useState(""); + const [walkInName, setWalkInName] = useState(""); + const [clients, setClients] = useState([]); + const [selectedClient, setSelectedClient] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const debounceRef = useRef(null); + + // Debounced client search + useEffect(() => { + if (searchQuery.length < 2) { + setClients([]); + return; + } + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + setIsSearching(true); + try { + const res = await fetch( + `/api/clients?search=${encodeURIComponent(searchQuery)}` + ); + if (res.ok) { + const data = await res.json(); + setClients(data.data ?? data ?? []); + } + } catch { + console.error("Client search failed"); + } finally { + setIsSearching(false); + } + }, 300); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [searchQuery]); + + const handleSubmit = async () => { + setIsSubmitting(true); + try { + const body: Record = { courtId: court.id }; + if (mode === "search" && selectedClient) { + body.clientId = selectedClient.id; + } else if (mode === "walkin" && walkInName.trim()) { + body.walkInName = walkInName.trim(); + } else { + return; + } + + const res = await fetch("/api/court-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) throw new Error("Check-in failed"); + onSuccess(); + } catch (err) { + console.error("Check-in error:", err); + } finally { + setIsSubmitting(false); + } + }; + + const canSubmit = + (mode === "search" && selectedClient !== null) || + (mode === "walkin" && walkInName.trim().length > 0); + + return ( +
+
+ {/* Modal header */} +
+

+ Check In — {court.name} +

+ +
+ + {/* Mode toggle */} +
+ + +
+ + {/* Search mode */} + {mode === "search" && ( +
+ { + setSearchQuery(e.target.value); + setSelectedClient(null); + }} + autoFocus + /> + {isSearching && ( +

Searching...

+ )} + {clients.length > 0 && ( +
+ {clients.map((client) => ( + + ))} +
+ )} + {searchQuery.length >= 2 && + !isSearching && + clients.length === 0 && ( +

No clients found.

+ )} + {selectedClient && ( +
+ + Selected: {selectedClient.firstName} {selectedClient.lastName} + +
+ )} +
+ )} + + {/* Walk-in mode */} + {mode === "walkin" && ( + setWalkInName(e.target.value)} + autoFocus + /> + )} + + {/* Submit */} +
+ + +
+
+
+ ); +}