feat: add slot instances management UI
continuous-integration/drone/push Build is passing Details

Add complete admin UI for managing individual slot instances:

Components:
- SlotInstancesComponent: day view with date navigation and filters
- SlotInstanceEditModal: edit times, capacity, convert to manual
- ManualSlotModal: create one-off manual slots

Features:
- Date picker with prev/next day navigation
- Filter by court and show/hide cancelled slots
- Group slots by court in table view
- Status badges (available, pending, booked, cancelled)
- Origin badges (template, manual, maintenance)
- Convert definition-based slots to manual (detach from materializer)
- Create manual slots for special events
- Edit slot times/capacity (protected when has bookings)
- Cancel and delete slots with confirmation

API Client:
- getSlotInstances, createSlotInstance, updateSlotInstance, deleteSlotInstance
- cancelSlotInstance, convertToManualSlot helpers

Types:
- SlotInstance, SlotInstancesResponse
- CreateSlotInstanceRequest, UpdateSlotInstanceRequest
- Helper functions for formatting and status colors

Also adds "Slot Instances" tab to ClubDetailTabs navigation.
master
Guillermo Pages 3 weeks ago
parent 7055eb2f43
commit 308d9d70bf

@ -196,6 +196,12 @@ export default function ClubDetailTabs({ clubId }: ClubDetailTabsProps) {
>
Slot Definitions
</Link>
<Link
href={`/${locale}/admin/clubs/${clubId}/slot-instances`}
className="px-6 py-3 font-semibold text-slate-600 hover:text-slate-900 hover:border-slate-300 transition-colors border-b-2 border-transparent"
>
Slot Instances
</Link>
<Link
href={`/${locale}/admin/clubs/${clubId}/plans`}
className="px-6 py-3 font-semibold text-purple-600 hover:text-purple-700 hover:border-purple-300 transition-colors border-b-2 border-transparent"

@ -0,0 +1,329 @@
'use client';
import { useState } from 'react';
import { X, Loader2, AlertCircle, Calendar, Clock, Users, FileText, MapPin } from 'lucide-react';
import { createSlotInstance } from '@/src/lib/api/slot-instances';
import type { CreateSlotInstanceRequest, SlotInstanceError } from '@/src/types/slot-instances';
import type { Court } from '@/src/types/courts';
interface ManualSlotModalProps {
clubId: number;
courts: Court[];
initialDate: string;
onClose: () => void;
onSuccess: () => void;
}
export default function ManualSlotModal({
clubId,
courts,
initialDate,
onClose,
onSuccess,
}: ManualSlotModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<SlotInstanceError | null>(null);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
// Form state
const [courtId, setCourtId] = useState<number | null>(courts[0]?.court_id ?? null);
const [date, setDate] = useState(initialDate);
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:30');
const [capacity, setCapacity] = useState(4);
const [reason, setReason] = useState('');
function validateForm(): boolean {
const errors: Record<string, string> = {};
if (!courtId) {
errors.court_id = 'Please select a court';
}
if (!date) {
errors.date = 'Please select a date';
}
if (!startTime) {
errors.start_time = 'Please enter a start time';
}
if (!endTime) {
errors.end_time = 'Please enter an end time';
}
if (startTime && endTime && startTime >= endTime) {
errors.end_time = 'End time must be after start time';
}
if (capacity < 1) {
errors.capacity = 'Capacity must be at least 1';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validateForm()) {
return;
}
setLoading(true);
setError(null);
// Build the datetime strings
// Note: We're creating local datetime strings - the backend will handle timezone conversion
const startsAt = `${date}T${startTime}:00`;
const endsAt = `${date}T${endTime}:00`;
const request: CreateSlotInstanceRequest = {
court_id: courtId!,
starts_at: startsAt,
ends_at: endsAt,
capacity,
reason: reason.trim() || undefined,
};
const result = await createSlotInstance(clubId, request);
if (result.success) {
onSuccess();
} else {
setError(result.error);
// Handle field-level errors
if (result.error.errors) {
const fieldErrors: Record<string, string> = {};
for (const err of result.error.errors) {
fieldErrors[err.field] = err.message;
}
setValidationErrors(fieldErrors);
}
}
setLoading(false);
}
// Calculate duration for display
function getDuration(): string {
if (!startTime || !endTime) return '-';
const [startH, startM] = startTime.split(':').map(Number);
const [endH, endM] = endTime.split(':').map(Number);
const startMins = startH * 60 + startM;
const endMins = endH * 60 + endM;
const diffMins = endMins - startMins;
if (diffMins <= 0) return '-';
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (hours === 0) return `${mins}min`;
if (mins === 0) return `${hours}h`;
return `${hours}h ${mins}m`;
}
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl p-8 max-w-lg w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-900">Create Manual Slot</h2>
<p className="text-sm text-slate-600 mt-1">
Create a one-off booking slot
</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Info box */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<p className="text-sm text-blue-900">
Manual slots are independent of slot definitions and won&apos;t be affected
by the materializer&apos;s automatic slot generation or cleanup.
</p>
</div>
{/* Error display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-red-900 font-medium">{error.title}</p>
<p className="text-sm text-red-700 mt-1">{error.detail}</p>
</div>
</div>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Court selection */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
<MapPin className="w-4 h-4 inline mr-1" />
Court *
</label>
<select
value={courtId ?? ''}
onChange={(e) => setCourtId(parseInt(e.target.value))}
disabled={loading}
className={`w-full px-4 py-2 border-2 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 ${
validationErrors.court_id ? 'border-red-300' : 'border-slate-200'
}`}
>
<option value="">Select a court...</option>
{courts.map((court) => (
<option key={court.court_id} value={court.court_id}>
{court.name}
</option>
))}
</select>
{validationErrors.court_id && (
<p className="mt-1 text-sm text-red-600">{validationErrors.court_id}</p>
)}
</div>
{/* Date */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
<Calendar className="w-4 h-4 inline mr-1" />
Date *
</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
disabled={loading}
className={`w-full px-4 py-2 border-2 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 ${
validationErrors.date ? 'border-red-300' : 'border-slate-200'
}`}
/>
{validationErrors.date && (
<p className="mt-1 text-sm text-red-600">{validationErrors.date}</p>
)}
</div>
{/* Time range */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
<Clock className="w-4 h-4 inline mr-1" />
Start Time *
</label>
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
disabled={loading}
className={`w-full px-4 py-2 border-2 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 ${
validationErrors.start_time ? 'border-red-300' : 'border-slate-200'
}`}
/>
{validationErrors.start_time && (
<p className="mt-1 text-sm text-red-600">{validationErrors.start_time}</p>
)}
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
<Clock className="w-4 h-4 inline mr-1" />
End Time *
</label>
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
disabled={loading}
className={`w-full px-4 py-2 border-2 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 ${
validationErrors.end_time ? 'border-red-300' : 'border-slate-200'
}`}
/>
{validationErrors.end_time && (
<p className="mt-1 text-sm text-red-600">{validationErrors.end_time}</p>
)}
</div>
</div>
{/* Duration display */}
<div className="bg-slate-50 rounded-lg px-4 py-3 text-center">
<span className="text-sm text-slate-600">Duration: </span>
<span className="text-sm font-semibold text-slate-900">{getDuration()}</span>
</div>
{/* Capacity */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
<Users className="w-4 h-4 inline mr-1" />
Capacity *
</label>
<input
type="number"
min="1"
max="20"
value={capacity}
onChange={(e) => setCapacity(parseInt(e.target.value) || 1)}
disabled={loading}
className={`w-full px-4 py-2 border-2 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 ${
validationErrors.capacity ? 'border-red-300' : 'border-slate-200'
}`}
/>
{validationErrors.capacity && (
<p className="mt-1 text-sm text-red-600">{validationErrors.capacity}</p>
)}
<p className="mt-1 text-xs text-slate-500">
Number of players this slot can accommodate
</p>
</div>
{/* Reason/Notes */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
<FileText className="w-4 h-4 inline mr-1" />
Notes (optional)
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={loading}
rows={2}
placeholder="e.g., Tournament slot, Private event, etc."
className="w-full px-4 py-2 border-2 border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500"
/>
</div>
{/* Actions */}
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-6 py-2 text-slate-700 font-medium hover:bg-slate-100 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="inline-flex items-center px-6 py-2 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50"
>
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Create Slot
</button>
</div>
</form>
</div>
</div>
);
}

@ -0,0 +1,324 @@
'use client';
import { useState } from 'react';
import { X, Loader2, AlertCircle, Clock, Users, FileText, Shield } from 'lucide-react';
import { updateSlotInstance, convertToManualSlot } from '@/src/lib/api/slot-instances';
import type { SlotInstance, UpdateSlotInstanceRequest, SlotInstanceError } from '@/src/types/slot-instances';
import {
formatSlotTime,
getStatusColor,
getOriginColor,
getOriginLabel,
canEditSlotTimes,
} from '@/src/types/slot-instances';
interface SlotInstanceEditModalProps {
clubId: number;
slot: SlotInstance;
onClose: () => void;
onSuccess: () => void;
}
export default function SlotInstanceEditModal({
clubId,
slot,
onClose,
onSuccess,
}: SlotInstanceEditModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<SlotInstanceError | null>(null);
// Form state - parse times from ISO strings
const startDate = new Date(slot.starts_at);
const endDate = new Date(slot.ends_at);
const [startsAtTime, setStartsAtTime] = useState(
startDate.toTimeString().slice(0, 5) // HH:MM
);
const [endsAtTime, setEndsAtTime] = useState(
endDate.toTimeString().slice(0, 5) // HH:MM
);
const [capacity, setCapacity] = useState(slot.capacity);
const [reason, setReason] = useState(slot.reason ?? '');
const canEditTimes = canEditSlotTimes(slot);
const isFromDefinition = slot.origin_kind === 'definition';
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
// Build the request
const request: UpdateSlotInstanceRequest = {};
// Only include time changes if allowed
if (canEditTimes) {
// Parse date from the original slot and combine with new times
const dateStr = slot.starts_at.split('T')[0];
const newStartsAt = `${dateStr}T${startsAtTime}:00`;
const newEndsAt = `${dateStr}T${endsAtTime}:00`;
// Check if times changed
const originalStartTime = formatSlotTime(slot.starts_at);
const originalEndTime = formatSlotTime(slot.ends_at);
if (startsAtTime !== originalStartTime) {
request.starts_at = newStartsAt;
}
if (endsAtTime !== originalEndTime) {
request.ends_at = newEndsAt;
}
}
if (capacity !== slot.capacity) {
request.capacity = capacity;
}
if (reason !== (slot.reason ?? '')) {
request.reason = reason || undefined;
}
// Check if anything changed
if (Object.keys(request).length === 0) {
setError({
type: 'about:blank',
title: 'No Changes',
status: 400,
detail: 'No changes to save',
code: 'no_changes',
});
setLoading(false);
return;
}
const result = await updateSlotInstance(clubId, slot.slot_instance_id, request);
if (result.success) {
onSuccess();
} else {
setError(result.error);
}
setLoading(false);
}
async function handleConvertToManual() {
if (!confirm('Convert this slot to manual? This will detach it from its template definition, preventing automatic updates or cleanup.')) {
return;
}
setLoading(true);
setError(null);
const result = await convertToManualSlot(
clubId,
slot.slot_instance_id,
`Converted to manual by admin (was: definition ${slot.origin_definition_id})`
);
if (result.success) {
onSuccess();
} else {
setError(result.error);
}
setLoading(false);
}
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl p-8 max-w-lg w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-900">Edit Slot</h2>
<p className="text-sm text-slate-600 mt-1">{slot.court_name}</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Current slot info */}
<div className="bg-slate-50 rounded-xl p-4 mb-6">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500">Status:</span>
<span className={`ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(slot.status)}`}>
{slot.display_status}
</span>
</div>
<div>
<span className="text-slate-500">Origin:</span>
<span className={`ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getOriginColor(slot.origin_kind)}`}>
{getOriginLabel(slot.origin_kind)}
</span>
</div>
<div>
<span className="text-slate-500">Bookings:</span>
<span className="ml-2 font-medium text-slate-900">
{slot.booked_count} / {slot.capacity}
</span>
</div>
{slot.origin_definition_id && (
<div>
<span className="text-slate-500">Definition ID:</span>
<span className="ml-2 font-mono text-slate-900">
#{slot.origin_definition_id}
</span>
</div>
)}
</div>
</div>
{/* Warning for slots with bookings */}
{slot.booked_count > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-amber-900 font-medium">
This slot has {slot.booked_count} active booking(s)
</p>
<p className="text-sm text-amber-700 mt-1">
Time changes are disabled to protect existing bookings.
</p>
</div>
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-red-900 font-medium">{error.title}</p>
<p className="text-sm text-red-700 mt-1">{error.detail}</p>
</div>
</div>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Time fields */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
<Clock className="w-4 h-4 inline mr-1" />
Start Time
</label>
<input
type="time"
value={startsAtTime}
onChange={(e) => setStartsAtTime(e.target.value)}
disabled={!canEditTimes || loading}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 disabled:bg-slate-100 disabled:cursor-not-allowed"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
<Clock className="w-4 h-4 inline mr-1" />
End Time
</label>
<input
type="time"
value={endsAtTime}
onChange={(e) => setEndsAtTime(e.target.value)}
disabled={!canEditTimes || loading}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 disabled:bg-slate-100 disabled:cursor-not-allowed"
/>
</div>
</div>
{/* Capacity */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
<Users className="w-4 h-4 inline mr-1" />
Capacity
</label>
<input
type="number"
min="1"
max="20"
value={capacity}
onChange={(e) => setCapacity(parseInt(e.target.value) || 1)}
disabled={loading}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 disabled:bg-slate-100"
/>
</div>
{/* Reason/Notes */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
<FileText className="w-4 h-4 inline mr-1" />
Notes / Reason
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={loading}
rows={2}
placeholder="Optional notes about this slot..."
className="w-full px-4 py-2 border-2 border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 disabled:bg-slate-100"
/>
</div>
{/* Convert to Manual button (for definition slots) */}
{isFromDefinition && (
<div className="border-t border-slate-200 pt-6">
<div className="flex items-start space-x-3 mb-4">
<Shield className="w-5 h-5 text-purple-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-slate-900">
Detach from Template
</p>
<p className="text-sm text-slate-600 mt-1">
Convert this slot to manual to prevent it from being updated or
cleaned up by the materializer. Use this for special one-off
schedule changes.
</p>
</div>
</div>
<button
type="button"
onClick={handleConvertToManual}
disabled={loading}
className="w-full px-4 py-2 border-2 border-purple-200 text-purple-700 font-medium rounded-lg hover:bg-purple-50 transition-colors disabled:opacity-50"
>
Convert to Manual Slot
</button>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-6 py-2 text-slate-700 font-medium hover:bg-slate-100 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="inline-flex items-center px-6 py-2 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50"
>
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Save Changes
</button>
</div>
</form>
</div>
</div>
);
}

@ -0,0 +1,480 @@
'use client';
import { useState, useEffect } from 'react';
import {
Calendar,
Plus,
Loader2,
AlertCircle,
ArrowLeft,
ChevronLeft,
ChevronRight,
RefreshCw,
Filter,
} from 'lucide-react';
import Link from 'next/link';
import useTranslation from '@/src/hooks/useTranslation';
import { getSlotInstances, deleteSlotInstance, cancelSlotInstance } from '@/src/lib/api/slot-instances';
import { getAdminClubDetail } from '@/src/lib/api/admin-clubs';
import type { SlotInstance, SlotInstanceError } from '@/src/types/slot-instances';
import {
formatSlotTime,
formatSlotDuration,
getStatusColor,
getOriginColor,
getOriginLabel,
canDeleteSlot,
canCancelSlot,
} from '@/src/types/slot-instances';
import type { Court } from '@/src/types/courts';
import SlotInstanceEditModal from './SlotInstanceEditModal';
import ManualSlotModal from './ManualSlotModal';
interface SlotInstancesComponentProps {
clubId: number;
}
export default function SlotInstancesComponent({ clubId }: SlotInstancesComponentProps) {
const { t, locale } = useTranslation();
const [slots, setSlots] = useState<SlotInstance[]>([]);
const [courts, setCourts] = useState<Court[]>([]);
const [timezone, setTimezone] = useState<string>('UTC');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<SlotInstanceError | null>(null);
// Date navigation
const [selectedDate, setSelectedDate] = useState<string>(() => {
const today = new Date();
return today.toISOString().split('T')[0];
});
// Filters
const [selectedCourtId, setSelectedCourtId] = useState<number | null>(null);
const [showCancelled, setShowCancelled] = useState(false);
// Modals
const [editingSlot, setEditingSlot] = useState<SlotInstance | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
useEffect(() => {
loadCourts();
}, [clubId]);
useEffect(() => {
loadSlots();
}, [clubId, selectedDate, selectedCourtId, showCancelled]);
async function loadCourts() {
const result = await getAdminClubDetail(clubId);
if (result.success) {
setCourts(result.data.courts);
}
}
async function loadSlots() {
setLoading(true);
const result = await getSlotInstances(clubId, selectedDate, {
court_id: selectedCourtId ?? undefined,
include_cancelled: showCancelled,
});
if (result.success) {
setSlots(result.data.slots);
setTimezone(result.data.timezone);
setError(null);
} else {
setError(result.error);
setSlots([]);
}
setLoading(false);
}
function navigateDate(direction: 'prev' | 'next') {
const date = new Date(selectedDate);
date.setDate(date.getDate() + (direction === 'prev' ? -1 : 1));
setSelectedDate(date.toISOString().split('T')[0]);
}
function goToToday() {
setSelectedDate(new Date().toISOString().split('T')[0]);
}
async function handleDelete(slot: SlotInstance) {
if (!confirm(`Delete slot ${formatSlotTime(slot.starts_at)} - ${formatSlotTime(slot.ends_at)} on ${slot.court_name}?`)) {
return;
}
const result = await deleteSlotInstance(clubId, slot.slot_instance_id);
if (result.success) {
loadSlots();
} else {
alert(`Failed to delete: ${result.error.detail}`);
}
}
async function handleCancel(slot: SlotInstance) {
if (!confirm(`Cancel slot ${formatSlotTime(slot.starts_at)} - ${formatSlotTime(slot.ends_at)} on ${slot.court_name}?`)) {
return;
}
const result = await cancelSlotInstance(clubId, slot.slot_instance_id);
if (result.success) {
loadSlots();
} else {
alert(`Failed to cancel: ${result.error.detail}`);
}
}
// Format selected date for display
const displayDate = new Date(selectedDate + 'T00:00:00').toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
// Group slots by court for table display
const slotsByCourt = slots.reduce((acc, slot) => {
if (!acc[slot.court_name]) {
acc[slot.court_name] = [];
}
acc[slot.court_name].push(slot);
return acc;
}, {} as Record<string, SlotInstance[]>);
// Error state
if (error && !loading) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
<div className="max-w-2xl mx-auto">
<div className="bg-red-50 border-2 border-red-200 rounded-2xl p-8">
<div className="flex items-start space-x-4">
<AlertCircle className="w-8 h-8 text-red-600 flex-shrink-0 mt-1" />
<div className="flex-1">
<h2 className="text-2xl font-bold text-slate-900 mb-4">
Error Loading Slot Instances
</h2>
<p className="text-slate-700 mb-4 leading-relaxed">
{error.detail}
</p>
<p className="text-sm text-slate-600 font-mono mb-6">
Error code: {error.code}
</p>
<Link
href={`/${locale}/admin/clubs/${clubId}`}
className="inline-flex items-center px-4 py-2 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to club
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Header */}
<div className="mb-8">
<Link
href={`/${locale}/admin/clubs/${clubId}`}
className="inline-flex items-center text-slate-600 hover:text-slate-900 font-medium transition-colors mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to club
</Link>
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold text-slate-900 mb-2 tracking-tight">
Slot Instances
</h1>
<p className="text-lg text-slate-600 font-light">
View and manage individual booking slots
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors shadow-lg"
>
<Plus className="w-5 h-5 mr-2" />
Create Slot
</button>
</div>
</div>
{/* Date Navigation & Filters */}
<div className="bg-white border-2 border-slate-200 rounded-2xl p-6 mb-6">
<div className="flex flex-wrap items-center justify-between gap-4">
{/* Date picker */}
<div className="flex items-center space-x-3">
<button
onClick={() => navigateDate('prev')}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
title="Previous day"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="flex items-center space-x-2">
<Calendar className="w-5 h-5 text-slate-500" />
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500"
/>
</div>
<button
onClick={() => navigateDate('next')}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
title="Next day"
>
<ChevronRight className="w-5 h-5" />
</button>
<button
onClick={goToToday}
className="px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
>
Today
</button>
</div>
{/* Filters */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-slate-500" />
<select
value={selectedCourtId ?? ''}
onChange={(e) => setSelectedCourtId(e.target.value ? parseInt(e.target.value) : null)}
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500"
>
<option value="">All courts</option>
{courts.map((court) => (
<option key={court.court_id} value={court.court_id}>
{court.name}
</option>
))}
</select>
</div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={showCancelled}
onChange={(e) => setShowCancelled(e.target.checked)}
className="w-4 h-4 rounded border-slate-300 text-slate-600 focus:ring-slate-500"
/>
<span className="text-sm text-slate-700">Show cancelled</span>
</label>
<button
onClick={loadSlots}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
title="Refresh"
>
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
<div className="mt-4 text-center">
<h2 className="text-xl font-semibold text-slate-800">{displayDate}</h2>
<p className="text-sm text-slate-500">Timezone: {timezone}</p>
</div>
</div>
{/* Loading state */}
{loading && (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 className="w-12 h-12 text-slate-900 animate-spin" />
<p className="text-slate-600 font-medium mt-4">Loading slots...</p>
</div>
)}
{/* Empty state */}
{!loading && slots.length === 0 && (
<div className="bg-slate-50 border-2 border-slate-200 rounded-2xl p-12">
<div className="flex flex-col items-center text-center space-y-6">
<Calendar className="w-20 h-20 text-slate-400" />
<h2 className="text-2xl font-bold text-slate-900">
No Slots for This Day
</h2>
<p className="text-slate-600 max-w-md leading-relaxed">
There are no slot instances for {displayDate}.
{!showCancelled && ' Try showing cancelled slots or selecting a different date.'}
</p>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors"
>
<Plus className="w-5 h-5 mr-2" />
Create Manual Slot
</button>
</div>
</div>
)}
{/* Slots Table */}
{!loading && slots.length > 0 && (
<div className="space-y-6">
{Object.entries(slotsByCourt).map(([courtName, courtSlots]) => (
<div key={courtName} className="bg-white border-2 border-slate-200 rounded-2xl overflow-hidden shadow-sm">
<div className="bg-slate-50 border-b-2 border-slate-200 px-6 py-4">
<h3 className="text-lg font-bold text-slate-900">{courtName}</h3>
<p className="text-sm text-slate-600">{courtSlots.length} slot{courtSlots.length !== 1 ? 's' : ''}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-bold text-slate-700 uppercase tracking-wider">Time</th>
<th className="px-6 py-3 text-left text-xs font-bold text-slate-700 uppercase tracking-wider">Duration</th>
<th className="px-6 py-3 text-left text-xs font-bold text-slate-700 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-bold text-slate-700 uppercase tracking-wider">Bookings</th>
<th className="px-6 py-3 text-left text-xs font-bold text-slate-700 uppercase tracking-wider">Origin</th>
<th className="px-6 py-3 text-left text-xs font-bold text-slate-700 uppercase tracking-wider">Reason</th>
<th className="px-6 py-3 text-right text-xs font-bold text-slate-700 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{courtSlots.map((slot) => (
<tr
key={slot.slot_instance_id}
className={`hover:bg-slate-50 transition-colors ${slot.status === 'cancelled' ? 'opacity-60' : ''}`}
>
<td className="px-6 py-4">
<span className="font-semibold text-slate-900">
{formatSlotTime(slot.starts_at)} - {formatSlotTime(slot.ends_at)}
</span>
</td>
<td className="px-6 py-4">
<span className="text-slate-700">
{formatSlotDuration(slot.starts_at, slot.ends_at)}
</span>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(slot.status)}`}>
{slot.display_status}
</span>
</td>
<td className="px-6 py-4">
<span className={`font-medium ${slot.booked_count > 0 ? 'text-blue-600' : 'text-slate-500'}`}>
{slot.booked_count} / {slot.capacity}
</span>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getOriginColor(slot.origin_kind)}`}>
{getOriginLabel(slot.origin_kind)}
</span>
</td>
<td className="px-6 py-4">
{slot.reason ? (
<span className="text-sm text-slate-600 truncate max-w-[200px] block" title={slot.reason}>
{slot.reason}
</span>
) : (
<span className="text-sm text-slate-400 italic">-</span>
)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end space-x-2">
<button
onClick={() => setEditingSlot(slot)}
className="px-3 py-1.5 text-sm font-medium text-slate-700 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors"
>
Edit
</button>
{canCancelSlot(slot) && slot.status !== 'cancelled' && (
<button
onClick={() => handleCancel(slot)}
className="px-3 py-1.5 text-sm font-medium text-amber-700 hover:text-amber-900 hover:bg-amber-50 rounded-lg transition-colors"
>
Cancel
</button>
)}
{canDeleteSlot(slot) && (
<button
onClick={() => handleDelete(slot)}
className="px-3 py-1.5 text-sm font-medium text-red-700 hover:text-red-900 hover:bg-red-50 rounded-lg transition-colors"
>
Delete
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
{/* Summary */}
{!loading && slots.length > 0 && (
<div className="mt-6 bg-slate-50 border-2 border-slate-200 rounded-xl p-4">
<div className="flex flex-wrap items-center justify-center gap-6 text-sm">
<div className="flex items-center space-x-2">
<span className="w-3 h-3 rounded-full bg-green-500"></span>
<span className="text-slate-700">Open: {slots.filter(s => s.status === 'open').length}</span>
</div>
<div className="flex items-center space-x-2">
<span className="w-3 h-3 rounded-full bg-yellow-500"></span>
<span className="text-slate-700">Held: {slots.filter(s => s.status === 'held').length}</span>
</div>
<div className="flex items-center space-x-2">
<span className="w-3 h-3 rounded-full bg-blue-500"></span>
<span className="text-slate-700">Booked: {slots.filter(s => s.status === 'booked').length}</span>
</div>
{showCancelled && (
<div className="flex items-center space-x-2">
<span className="w-3 h-3 rounded-full bg-red-500"></span>
<span className="text-slate-700">Cancelled: {slots.filter(s => s.status === 'cancelled').length}</span>
</div>
)}
<div className="border-l border-slate-300 pl-6">
<span className="font-semibold text-slate-900">Total: {slots.length}</span>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingSlot && (
<SlotInstanceEditModal
clubId={clubId}
slot={editingSlot}
onClose={() => setEditingSlot(null)}
onSuccess={() => {
setEditingSlot(null);
loadSlots();
}}
/>
)}
{/* Create Modal */}
{showCreateModal && (
<ManualSlotModal
clubId={clubId}
courts={courts}
initialDate={selectedDate}
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false);
loadSlots();
}}
/>
)}
</div>
);
}

@ -0,0 +1,17 @@
import AdminAuthGuard from '@/src/components/AdminAuthGuard';
import SlotInstancesComponent from './SlotInstancesComponent';
export default async function SlotInstancesPage({
params
}: {
params: Promise<{ club_id: string }>;
}) {
const { club_id } = await params;
const clubId = parseInt(club_id, 10);
return (
<AdminAuthGuard>
<SlotInstancesComponent clubId={clubId} />
</AdminAuthGuard>
);
}

@ -0,0 +1,216 @@
/**
* Slot Instances API Client
*
* Handles CRUD operations for slot instances (materialized slots).
*/
import type {
SlotInstance,
SlotInstancesResponse,
CreateSlotInstanceRequest,
UpdateSlotInstanceRequest,
SlotInstanceMutationResponse,
SlotInstanceError,
} from '@/src/types/slot-instances';
import apiFetch from '@/src/utils/apiFetch';
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: SlotInstanceError };
/**
* List slot instances for a facility on a specific date
*/
export async function getSlotInstances(
clubId: number,
date: string,
filters?: {
court_id?: number;
status?: string;
include_cancelled?: boolean;
}
): Promise<ApiResult<SlotInstancesResponse>> {
try {
const params = new URLSearchParams({ date });
if (filters?.court_id) {
params.append('court_id', filters.court_id.toString());
}
if (filters?.status) {
params.append('status', filters.status);
}
if (filters?.include_cancelled) {
params.append('include_cancelled', 'true');
}
const response = await apiFetch(
`/admin/facilities/${clubId}/slot-instances?${params.toString()}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
const error: SlotInstanceError = await response.json();
return { success: false, error };
}
const data: SlotInstancesResponse = await response.json();
return { success: true, data };
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: 'Failed to fetch slot instances. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Create a new manual slot instance
*/
export async function createSlotInstance(
clubId: number,
request: CreateSlotInstanceRequest
): Promise<ApiResult<SlotInstanceMutationResponse>> {
try {
const response = await apiFetch(`/admin/facilities/${clubId}/slot-instances`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error: SlotInstanceError = await response.json();
return { success: false, error };
}
const data: SlotInstanceMutationResponse = await response.json();
return { success: true, data };
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: 'Failed to create slot instance. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Update an existing slot instance
*/
export async function updateSlotInstance(
clubId: number,
slotInstanceId: number,
request: UpdateSlotInstanceRequest
): Promise<ApiResult<SlotInstanceMutationResponse>> {
try {
const response = await apiFetch(
`/admin/facilities/${clubId}/slot-instances/${slotInstanceId}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
}
);
if (!response.ok) {
const error: SlotInstanceError = await response.json();
return { success: false, error };
}
const data: SlotInstanceMutationResponse = await response.json();
return { success: true, data };
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: 'Failed to update slot instance. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Delete a slot instance (hard delete - only if no bookings)
*/
export async function deleteSlotInstance(
clubId: number,
slotInstanceId: number
): Promise<ApiResult<void>> {
try {
const response = await apiFetch(
`/admin/facilities/${clubId}/slot-instances/${slotInstanceId}`,
{
method: 'DELETE',
}
);
if (!response.ok) {
const error: SlotInstanceError = await response.json();
return { success: false, error };
}
return { success: true, data: undefined };
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: 'Failed to delete slot instance. Please check your connection.',
code: 'network_error',
},
};
}
}
/**
* Cancel a slot instance (soft delete via status change)
*/
export async function cancelSlotInstance(
clubId: number,
slotInstanceId: number,
reason?: string
): Promise<ApiResult<SlotInstanceMutationResponse>> {
return updateSlotInstance(clubId, slotInstanceId, {
status: 'cancelled',
reason: reason ?? 'Cancelled by admin',
});
}
/**
* Convert a definition-based slot to manual (detach from template)
*/
export async function convertToManualSlot(
clubId: number,
slotInstanceId: number,
reason?: string
): Promise<ApiResult<SlotInstanceMutationResponse>> {
return updateSlotInstance(clubId, slotInstanceId, {
convert_to_manual: true,
reason: reason ?? 'Converted to manual slot by admin',
});
}

@ -0,0 +1,201 @@
/**
* Slot Instance Types
*
* Types for managing individual slot instances (materialized slots).
*/
// Slot origin types
export type SlotOriginKind = 'definition' | 'manual' | 'maintenance';
// Native slot status values
export type SlotStatus = 'open' | 'held' | 'booked' | 'cancelled';
// Frontend display status mapping
export type DisplayStatus = 'available' | 'pending' | 'booked' | 'cancelled';
/**
* Slot instance as returned by the API
*/
export interface SlotInstance {
slot_instance_id: number;
court_id: number;
court_name: string;
starts_at: string; // ISO 8601
ends_at: string; // ISO 8601
capacity: number;
status: SlotStatus;
display_status: DisplayStatus;
origin_kind: SlotOriginKind;
origin_definition_id: number | null;
reason: string | null;
booked_count: number;
created_at: string; // ISO 8601
updated_at: string; // ISO 8601
}
/**
* Response from GET /admin/facilities/{id}/slot-instances
*/
export interface SlotInstancesResponse {
facility_id: number;
date: string; // YYYY-MM-DD
timezone: string;
slots: SlotInstance[];
total: number;
}
/**
* Request body for POST /admin/facilities/{id}/slot-instances
*/
export interface CreateSlotInstanceRequest {
court_id: number;
starts_at: string; // ISO 8601
ends_at: string; // ISO 8601
capacity?: number;
reason?: string;
}
/**
* Request body for PATCH /admin/facilities/{id}/slot-instances/{id}
*/
export interface UpdateSlotInstanceRequest {
starts_at?: string; // ISO 8601
ends_at?: string; // ISO 8601
capacity?: number;
status?: 'open' | 'cancelled';
convert_to_manual?: boolean;
reason?: string;
}
/**
* Response from POST/PATCH slot instance endpoints
*/
export interface SlotInstanceMutationResponse {
slot_instance_id: number;
court_id: number;
starts_at: string;
ends_at: string;
capacity: number;
status: SlotStatus;
display_status?: DisplayStatus;
origin_kind: SlotOriginKind;
origin_definition_id?: number | null;
reason: string | null;
}
/**
* API error response
*/
export interface SlotInstanceError {
type: string;
title: string;
status: number;
detail: string;
code: string;
errors?: Array<{ field: string; message: string }>;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Format a datetime to local time string (HH:MM)
*/
export function formatSlotTime(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
/**
* Format slot duration in human-readable form
*/
export function formatSlotDuration(startsAt: string, endsAt: string): string {
const start = new Date(startsAt);
const end = new Date(endsAt);
const diffMs = end.getTime() - start.getTime();
const diffMins = Math.round(diffMs / 60000);
if (diffMins < 60) {
return `${diffMins}min`;
}
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
/**
* Get status badge color
*/
export function getStatusColor(status: SlotStatus): string {
switch (status) {
case 'open':
return 'bg-green-100 text-green-800';
case 'held':
return 'bg-yellow-100 text-yellow-800';
case 'booked':
return 'bg-blue-100 text-blue-800';
case 'cancelled':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
/**
* Get origin badge color
*/
export function getOriginColor(origin: SlotOriginKind): string {
switch (origin) {
case 'definition':
return 'bg-slate-100 text-slate-700';
case 'manual':
return 'bg-purple-100 text-purple-700';
case 'maintenance':
return 'bg-orange-100 text-orange-700';
default:
return 'bg-gray-100 text-gray-700';
}
}
/**
* Get human-readable origin label
*/
export function getOriginLabel(origin: SlotOriginKind): string {
switch (origin) {
case 'definition':
return 'Template';
case 'manual':
return 'Manual';
case 'maintenance':
return 'Maintenance';
default:
return origin;
}
}
/**
* Check if slot can be edited (time changes)
*/
export function canEditSlotTimes(slot: SlotInstance): boolean {
return slot.booked_count === 0 && slot.status !== 'booked';
}
/**
* Check if slot can be cancelled
*/
export function canCancelSlot(slot: SlotInstance): boolean {
return slot.booked_count === 0 && slot.status !== 'cancelled';
}
/**
* Check if slot can be deleted (hard delete)
*/
export function canDeleteSlot(slot: SlotInstance): boolean {
return slot.booked_count === 0;
}
Loading…
Cancel
Save