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