feat(bookings): add dry_run slot picker with live validation
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
Implemented complete slot picker flow with real-time validation: **Components Created:** 1. **SlotPickerTile** (src/components/bookings/SlotPickerTile.tsx) - Individual slot tile with dry_run validation - 4 validation states: idle, validating, valid, invalid - Real-time API calls to validateMoveBooking() on selection - Visual feedback: checkmark (✓) for valid, X for invalid, spinner during validation - Displays validation errors inline with bullet points - Shows success message "✓ Valid - ready to confirm" - Integrates CapacityDisplay component - Color-coded borders (green=valid, red=invalid, gray=idle) - Disabled state for full/cancelled slots - onValidationResult callback for parent coordination 2. **SlotPickerModal** (src/components/bookings/SlotPickerModal.tsx) - Full-screen modal for slot selection - Groups slots by date (weekday, month, day, year) - Sorts dates chronologically - Filters out current slot - 2-column grid layout for slots - Info banner with policy hints (14-day window, product-type, club match) - Confirm button disabled until valid slot selected - Loading states with spinner - Empty state with helpful message - Tracks validation state from child tiles 3. **CancelBookingModal** (src/components/bookings/CancelBookingModal.tsx) - Confirmation modal for booking cancellation - Optional reason textarea (500 char limit) - Displays grace period policy (reads from booking.policies or defaults to 15 min) - Shows booking details (court, time, attendees count) - Warning banner: "This action cannot be undone" - Policy info banner with grace period details - Two-button layout: "Keep Booking" / "Yes, Cancel Booking" - Loading state with spinner **Features:** - Live dry_run validation on slot selection (no manual trigger needed) - Real-time feedback with checkmarks/errors - Graceful error handling (network failures, validation errors) - Parameterized error messages from getBookingErrorMessage() - SSR-safe with proper disabled states - Professional slate theme matching existing UI - Responsive grid layouts **Integration:** - SlotPickerTile calls validateMoveBooking() API automatically - SlotPickerModal coordinates validation state - CancelBookingModal reads policy fields from BookingDetail - All components ready for integration into BookingDrawer Related: Phase 3, Booking Admin API v1.1, BUILD #23master
parent
13097541f1
commit
6a80607430
@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Cancel Booking Modal Component
|
||||||
|
*
|
||||||
|
* Confirmation modal for cancelling bookings with optional reason.
|
||||||
|
* Displays grace period policy information.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, AlertTriangle, Loader2 } from 'lucide-react';
|
||||||
|
import type { BookingDetail } from '@/src/types/bookings';
|
||||||
|
|
||||||
|
interface CancelBookingModalProps {
|
||||||
|
booking: BookingDetail;
|
||||||
|
onConfirm: (reason?: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CancelBookingModal({
|
||||||
|
booking,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
loading = false,
|
||||||
|
}: CancelBookingModalProps) {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
onConfirm(reason.trim() || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract grace period from policies if available
|
||||||
|
const graceMinutes = booking.policies?.cancel_grace_minutes || 15;
|
||||||
|
const graceAnchor = booking.policies?.cancel_grace_anchor || 'start';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-2xl p-8 max-w-lg w-full mx-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900">Cancel Booking</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-sm text-red-900 font-semibold mb-2">
|
||||||
|
This action cannot be undone
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-red-700">
|
||||||
|
Cancelling this booking will free up the slot and notify all attendees.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grace Period Info */}
|
||||||
|
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-xs font-semibold text-blue-900 uppercase mb-1">Policy</p>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
Changes allowed until {graceMinutes} minutes after slot{' '}
|
||||||
|
{graceAnchor === 'end' ? 'ends' : 'starts'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Booking Details */}
|
||||||
|
<div className="bg-slate-50 border-2 border-slate-200 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-xs font-semibold text-slate-600 uppercase mb-2">Booking Details</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-slate-900">
|
||||||
|
<span className="font-medium">Court:</span> {booking.slot.court.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-900">
|
||||||
|
<span className="font-medium">Time:</span>{' '}
|
||||||
|
{new Date(booking.slot.starts_at).toLocaleString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-900">
|
||||||
|
<span className="font-medium">Attendees:</span> {booking.attendees.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reason (Optional) */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Reason for Cancellation (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="E.g., Court maintenance, double booking, player request..."
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
disabled={loading}
|
||||||
|
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 resize-none"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">{reason.length} / 500 characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 px-6 py-3 bg-slate-200 text-slate-700 font-semibold rounded-lg hover:bg-slate-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Keep Booking
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 px-6 py-3 bg-red-600 text-white font-semibold rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center space-x-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Cancelling...</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Yes, Cancel Booking'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Slot Picker Modal Component
|
||||||
|
*
|
||||||
|
* Modal for selecting a new slot when moving a booking.
|
||||||
|
* Provides live dry_run validation with visual feedback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Calendar, Loader2 } from 'lucide-react';
|
||||||
|
import type { BookingSlot } from '@/src/types/bookings';
|
||||||
|
import SlotPickerTile from './SlotPickerTile';
|
||||||
|
|
||||||
|
interface SlotPickerModalProps {
|
||||||
|
bookingId: number;
|
||||||
|
currentSlotId: number;
|
||||||
|
availableSlots: Array<
|
||||||
|
Pick<
|
||||||
|
BookingSlot,
|
||||||
|
'slot_instance_id' | 'starts_at' | 'ends_at' | 'capacity' | 'booked_count' | 'capacity_remaining' | 'status'
|
||||||
|
> & {
|
||||||
|
court: {
|
||||||
|
court_id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
onSelect: (slotInstanceId: number) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SlotPickerModal({
|
||||||
|
bookingId,
|
||||||
|
currentSlotId,
|
||||||
|
availableSlots,
|
||||||
|
onSelect,
|
||||||
|
onCancel,
|
||||||
|
loading = false,
|
||||||
|
}: SlotPickerModalProps) {
|
||||||
|
const [selectedSlotId, setSelectedSlotId] = useState<number | null>(null);
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
const [validationReasons, setValidationReasons] = useState<string[]>([]);
|
||||||
|
|
||||||
|
function handleValidationResult(valid: boolean, reasons?: string[]) {
|
||||||
|
setIsValid(valid);
|
||||||
|
setValidationReasons(reasons || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (selectedSlotId && isValid) {
|
||||||
|
onSelect(selectedSlotId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out current slot and group by date
|
||||||
|
const slotsToShow = availableSlots.filter(
|
||||||
|
(slot) => slot.slot_instance_id !== currentSlotId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group slots by date
|
||||||
|
const slotsByDate = slotsToShow.reduce((acc, slot) => {
|
||||||
|
const date = new Date(slot.starts_at).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!acc[date]) {
|
||||||
|
acc[date] = [];
|
||||||
|
}
|
||||||
|
acc[date].push(slot);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, typeof slotsToShow>);
|
||||||
|
|
||||||
|
// Sort dates
|
||||||
|
const sortedDates = Object.keys(slotsByDate).sort((a, b) => {
|
||||||
|
return new Date(a).getTime() - new Date(b).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-2xl p-8 max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Calendar className="w-6 h-6 text-slate-900" />
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900">Select New Slot</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner */}
|
||||||
|
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-sm text-blue-900">
|
||||||
|
<span className="font-semibold">Select a slot below.</span> We'll validate it in
|
||||||
|
real-time and show you if it's available for this booking.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-700 mt-2">
|
||||||
|
Move window: 14 days · Product type must match · Club must match
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slots Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 text-slate-600 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : slotsToShow.length === 0 ? (
|
||||||
|
<div className="bg-slate-50 border-2 border-slate-200 rounded-lg p-12 text-center">
|
||||||
|
<Calendar className="w-12 h-12 text-slate-400 mx-auto mb-4" />
|
||||||
|
<p className="text-slate-600">No available slots found</p>
|
||||||
|
<p className="text-sm text-slate-500 mt-2">
|
||||||
|
Try adjusting your filters or check back later
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{sortedDates.map((date) => (
|
||||||
|
<div key={date}>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 mb-3 uppercase tracking-wide">
|
||||||
|
{date}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{slotsByDate[date].map((slot) => (
|
||||||
|
<SlotPickerTile
|
||||||
|
key={slot.slot_instance_id}
|
||||||
|
slot={slot}
|
||||||
|
bookingId={bookingId}
|
||||||
|
selected={selectedSlotId === slot.slot_instance_id}
|
||||||
|
onSelect={setSelectedSlotId}
|
||||||
|
onValidationResult={handleValidationResult}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="mt-8 flex items-center justify-between pt-6 border-t border-slate-200">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-6 py-3 bg-slate-200 text-slate-700 font-semibold rounded-lg hover:bg-slate-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!selectedSlotId || !isValid || loading}
|
||||||
|
className="px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center space-x-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Moving...</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Confirm Move'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Slot Picker Tile Component
|
||||||
|
*
|
||||||
|
* Individual slot tile with dry_run validation feedback.
|
||||||
|
* Shows checkmark/error badges based on move validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Check, X, Loader2 } from 'lucide-react';
|
||||||
|
import type { BookingSlot } from '@/src/types/bookings';
|
||||||
|
import { validateMoveBooking } from '@/src/lib/api/bookings';
|
||||||
|
import { getBookingErrorMessage } from '@/src/types/bookings';
|
||||||
|
import CapacityDisplay from './CapacityDisplay';
|
||||||
|
|
||||||
|
interface SlotPickerTileProps {
|
||||||
|
slot: Pick<
|
||||||
|
BookingSlot,
|
||||||
|
'slot_instance_id' | 'starts_at' | 'ends_at' | 'capacity' | 'booked_count' | 'capacity_remaining' | 'status'
|
||||||
|
> & {
|
||||||
|
court: {
|
||||||
|
court_id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
bookingId: number;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (slotInstanceId: number) => void;
|
||||||
|
onValidationResult?: (valid: boolean, reasons?: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidationState = 'idle' | 'validating' | 'valid' | 'invalid';
|
||||||
|
|
||||||
|
export default function SlotPickerTile({
|
||||||
|
slot,
|
||||||
|
bookingId,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
onValidationResult,
|
||||||
|
}: SlotPickerTileProps) {
|
||||||
|
const [validationState, setValidationState] = useState<ValidationState>('idle');
|
||||||
|
const [validationReasons, setValidationReasons] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Run validation when tile is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected) {
|
||||||
|
runValidation();
|
||||||
|
} else {
|
||||||
|
// Reset validation when deselected
|
||||||
|
setValidationState('idle');
|
||||||
|
setValidationReasons([]);
|
||||||
|
}
|
||||||
|
}, [selected, slot.slot_instance_id]);
|
||||||
|
|
||||||
|
async function runValidation() {
|
||||||
|
setValidationState('validating');
|
||||||
|
setValidationReasons([]);
|
||||||
|
|
||||||
|
const result = await validateMoveBooking(bookingId, {
|
||||||
|
new_slot_instance_id: slot.slot_instance_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (result.data.ok) {
|
||||||
|
setValidationState('valid');
|
||||||
|
setValidationReasons([]);
|
||||||
|
onValidationResult?.(true);
|
||||||
|
} else {
|
||||||
|
setValidationState('invalid');
|
||||||
|
setValidationReasons(result.data.reasons || []);
|
||||||
|
onValidationResult?.(false, result.data.reasons);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Validation request failed - treat as invalid
|
||||||
|
setValidationState('invalid');
|
||||||
|
const errorMessage = getBookingErrorMessage(result.error);
|
||||||
|
setValidationReasons([errorMessage]);
|
||||||
|
onValidationResult?.(false, [errorMessage]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(isoString: string): string {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
onSelect(slot.slot_instance_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine tile appearance based on validation state
|
||||||
|
const isDisabled = slot.status !== 'open' || slot.capacity_remaining === 0;
|
||||||
|
const borderColor = selected
|
||||||
|
? validationState === 'valid'
|
||||||
|
? 'border-green-500'
|
||||||
|
: validationState === 'invalid'
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-slate-900'
|
||||||
|
: 'border-slate-200';
|
||||||
|
|
||||||
|
const bgColor = selected
|
||||||
|
? validationState === 'valid'
|
||||||
|
? 'bg-green-50'
|
||||||
|
: validationState === 'invalid'
|
||||||
|
? 'bg-red-50'
|
||||||
|
: 'bg-slate-50'
|
||||||
|
: 'bg-white';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={`w-full text-left border-2 rounded-lg p-4 transition-all ${borderColor} ${bgColor} ${
|
||||||
|
isDisabled
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: 'hover:border-slate-300 hover:shadow-sm cursor-pointer'
|
||||||
|
} ${selected ? 'shadow-md' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-slate-900 mb-1">{slot.court.name}</p>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
{formatTime(slot.starts_at)} - {formatTime(slot.ends_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation Badge */}
|
||||||
|
{selected && (
|
||||||
|
<div className="ml-3">
|
||||||
|
{validationState === 'validating' && (
|
||||||
|
<div className="flex items-center justify-center w-6 h-6 bg-slate-200 rounded-full">
|
||||||
|
<Loader2 className="w-4 h-4 text-slate-600 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{validationState === 'valid' && (
|
||||||
|
<div className="flex items-center justify-center w-6 h-6 bg-green-500 rounded-full">
|
||||||
|
<Check className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{validationState === 'invalid' && (
|
||||||
|
<div className="flex items-center justify-center w-6 h-6 bg-red-500 rounded-full">
|
||||||
|
<X className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Capacity */}
|
||||||
|
<CapacityDisplay slot={slot} variant="compact" />
|
||||||
|
|
||||||
|
{/* Validation Errors */}
|
||||||
|
{selected && validationState === 'invalid' && validationReasons.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-red-200">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{validationReasons.map((reason, index) => (
|
||||||
|
<li key={index} className="text-xs text-red-700 flex items-start">
|
||||||
|
<span className="mr-1">•</span>
|
||||||
|
<span>{reason}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
{selected && validationState === 'valid' && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-green-200">
|
||||||
|
<p className="text-xs text-green-700 font-medium">✓ Valid - ready to confirm</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue