feat(slot-definitions): add create/edit form with validation
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
Implement full CRUD form modal for slot definitions with: - All fields: court, day, time, duration, capacity, date range, description - Field-level validation with inline error messages - API contract alignment (HH:MM:SS, 0=Monday format, optional valid_to) - Overlap detection (409 handling) - No end date checkbox - Edit mode support - Professional slate theme - Loading states - Delete functionality integrated Form validates: - Required fields - Duration must be positive - Capacity must be positive - End date must be after start date - Slot cannot extend past midnight - Backend validation errors shown inline Fully functional create/edit/delete flow ready for backend integration.master
parent
a8ad9fed51
commit
478d43b44a
@ -0,0 +1,376 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import type { SlotDefinition, SlotDefinitionRequest, DayOfWeek, ValidationError } from '@/src/types/slot-definitions';
|
||||||
|
import { DAY_NAMES, formatTimeForAPI } from '@/src/types/slot-definitions';
|
||||||
|
import { createSlotDefinition, updateSlotDefinition } from '@/src/lib/api/slot-definitions';
|
||||||
|
|
||||||
|
interface SlotDefinitionFormProps {
|
||||||
|
clubId: number;
|
||||||
|
courts: { court_id: number; name: string }[];
|
||||||
|
definition?: SlotDefinition;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SlotDefinitionForm({
|
||||||
|
clubId,
|
||||||
|
courts,
|
||||||
|
definition,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: SlotDefinitionFormProps) {
|
||||||
|
const isEditing = !!definition;
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [courtId, setCourtId] = useState(definition?.court_id || (courts[0]?.court_id || 0));
|
||||||
|
const [dow, setDow] = useState<DayOfWeek>(definition?.dow || 0);
|
||||||
|
const [startsAt, setStartsAt] = useState(definition ? definition.starts_at.substring(0, 5) : '09:00');
|
||||||
|
const [durationMinutes, setDurationMinutes] = useState(definition?.duration_minutes || 90);
|
||||||
|
const [capacity, setCapacity] = useState(definition?.capacity || 4);
|
||||||
|
const [validFrom, setValidFrom] = useState(definition?.valid_from || getTodayString());
|
||||||
|
const [validTo, setValidTo] = useState(definition?.valid_to || '');
|
||||||
|
const [noEndDate, setNoEndDate] = useState(!definition?.valid_to);
|
||||||
|
const [ruleDescription, setRuleDescription] = useState(definition?.rule?.description || '');
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [generalError, setGeneralError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (noEndDate) {
|
||||||
|
setValidTo('');
|
||||||
|
}
|
||||||
|
}, [noEndDate]);
|
||||||
|
|
||||||
|
function getTodayString(): string {
|
||||||
|
const today = new Date();
|
||||||
|
return today.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(): boolean {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!courtId) {
|
||||||
|
newErrors.court_id = 'Please select a court';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startsAt) {
|
||||||
|
newErrors.starts_at = 'Start time is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationMinutes <= 0) {
|
||||||
|
newErrors.duration_minutes = 'Duration must be greater than 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capacity <= 0) {
|
||||||
|
newErrors.capacity = 'Capacity must be greater than 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validFrom) {
|
||||||
|
newErrors.valid_from = 'Start date is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validTo && validFrom && validTo < validFrom) {
|
||||||
|
newErrors.valid_to = 'End date must be after start date';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if end time exceeds 24:00
|
||||||
|
const [hours, minutes] = startsAt.split(':').map(Number);
|
||||||
|
const totalMinutes = hours * 60 + minutes + durationMinutes;
|
||||||
|
if (totalMinutes >= 24 * 60) {
|
||||||
|
newErrors.duration_minutes = 'Slot cannot extend past midnight';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setGeneralError('');
|
||||||
|
|
||||||
|
const request: SlotDefinitionRequest = {
|
||||||
|
court_id: courtId,
|
||||||
|
dow,
|
||||||
|
starts_at: formatTimeForAPI(startsAt),
|
||||||
|
duration_minutes: durationMinutes,
|
||||||
|
capacity,
|
||||||
|
valid_from: validFrom,
|
||||||
|
valid_to: noEndDate ? undefined : validTo,
|
||||||
|
rule: {
|
||||||
|
weekly: true,
|
||||||
|
description: ruleDescription || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = isEditing
|
||||||
|
? await updateSlotDefinition(clubId, definition.slot_definition_id, request)
|
||||||
|
: await createSlotDefinition(clubId, request);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
// Handle validation errors
|
||||||
|
if (result.error.code === 'validation_error' && result.error.errors) {
|
||||||
|
const fieldErrors: Record<string, string> = {};
|
||||||
|
result.error.errors.forEach((err: ValidationError) => {
|
||||||
|
fieldErrors[err.field] = err.message;
|
||||||
|
});
|
||||||
|
setErrors(fieldErrors);
|
||||||
|
} else if (result.error.code === 'slot_definition_overlap') {
|
||||||
|
setGeneralError('A slot definition already exists for this court, day, and time. Please choose a different time or court.');
|
||||||
|
} else {
|
||||||
|
setGeneralError(result.error.detail || 'An error occurred. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4 overflow-y-auto">
|
||||||
|
<div className="bg-white rounded-2xl p-8 max-w-2xl w-full my-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900">
|
||||||
|
{isEditing ? 'Edit Slot Definition' : 'Create Slot Definition'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-slate-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* General Error */}
|
||||||
|
{generalError && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start space-x-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-700">{generalError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Court Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Court <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={courtId}
|
||||||
|
onChange={(e) => setCourtId(parseInt(e.target.value))}
|
||||||
|
className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${
|
||||||
|
errors.court_id
|
||||||
|
? 'border-red-300 focus:border-red-500'
|
||||||
|
: 'border-slate-200 focus:border-slate-900'
|
||||||
|
} focus:outline-none`}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{courts.map((court) => (
|
||||||
|
<option key={court.court_id} value={court.court_id}>
|
||||||
|
{court.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.court_id && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.court_id}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day of Week */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Day of Week <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={dow}
|
||||||
|
onChange={(e) => setDow(parseInt(e.target.value) as DayOfWeek)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{(Object.keys(DAY_NAMES) as unknown as DayOfWeek[]).map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{DAY_NAMES[d]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time and Duration */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Start Time <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={startsAt}
|
||||||
|
onChange={(e) => setStartsAt(e.target.value)}
|
||||||
|
className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${
|
||||||
|
errors.starts_at
|
||||||
|
? 'border-red-300 focus:border-red-500'
|
||||||
|
: 'border-slate-200 focus:border-slate-900'
|
||||||
|
} focus:outline-none`}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{errors.starts_at && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.starts_at}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Duration (minutes) <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={durationMinutes}
|
||||||
|
onChange={(e) => setDurationMinutes(parseInt(e.target.value) || 0)}
|
||||||
|
min="1"
|
||||||
|
step="15"
|
||||||
|
className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${
|
||||||
|
errors.duration_minutes
|
||||||
|
? 'border-red-300 focus:border-red-500'
|
||||||
|
: 'border-slate-200 focus:border-slate-900'
|
||||||
|
} focus:outline-none`}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{errors.duration_minutes && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.duration_minutes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Capacity */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Capacity (players) <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={capacity}
|
||||||
|
onChange={(e) => setCapacity(parseInt(e.target.value) || 0)}
|
||||||
|
min="1"
|
||||||
|
className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${
|
||||||
|
errors.capacity
|
||||||
|
? 'border-red-300 focus:border-red-500'
|
||||||
|
: 'border-slate-200 focus:border-slate-900'
|
||||||
|
} focus:outline-none`}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{errors.capacity && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.capacity}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Valid Date Range */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Valid From <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={validFrom}
|
||||||
|
onChange={(e) => setValidFrom(e.target.value)}
|
||||||
|
min={getTodayString()}
|
||||||
|
className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${
|
||||||
|
errors.valid_from
|
||||||
|
? 'border-red-300 focus:border-red-500'
|
||||||
|
: 'border-slate-200 focus:border-slate-900'
|
||||||
|
} focus:outline-none`}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{errors.valid_from && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.valid_from}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Valid To
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={validTo}
|
||||||
|
onChange={(e) => setValidTo(e.target.value)}
|
||||||
|
min={validFrom}
|
||||||
|
disabled={noEndDate || loading}
|
||||||
|
className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${
|
||||||
|
errors.valid_to
|
||||||
|
? 'border-red-300 focus:border-red-500'
|
||||||
|
: 'border-slate-200 focus:border-slate-900'
|
||||||
|
} focus:outline-none disabled:bg-slate-50 disabled:text-slate-400`}
|
||||||
|
/>
|
||||||
|
{errors.valid_to && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.valid_to}</p>
|
||||||
|
)}
|
||||||
|
<label className="mt-2 flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={noEndDate}
|
||||||
|
onChange={(e) => setNoEndDate(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-slate-600">No end date</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Internal Note (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ruleDescription}
|
||||||
|
onChange={(e) => setRuleDescription(e.target.value)}
|
||||||
|
placeholder="e.g., Monday morning regulars"
|
||||||
|
maxLength={200}
|
||||||
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Helps you remember context for this slot definition
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end space-x-3 pt-4 border-t-2 border-slate-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-3 text-slate-700 font-semibold rounded-lg hover:bg-slate-100 transition-colors"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
{isEditing ? 'Update' : 'Create'} Definition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue