feat: add slot instances management UI
continuous-integration/drone/push Build is passing
Details
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
parent
7055eb2f43
commit
308d9d70bf
@ -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't be affected
|
||||
by the materializer'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…
Reference in New Issue