Files
smoothschedule/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx
poduck 8d0cc1e90a feat(time-blocks): Add comprehensive time blocking system with contracts
- Add TimeBlock and Holiday models with recurrence support (one-time, weekly, monthly, yearly, holiday)
- Implement business-level and resource-level blocking with hard/soft block types
- Add multi-select holiday picker for bulk holiday blocking
- Add calendar overlay visualization with distinct colors:
  - Business blocks: Red (hard) / Yellow (soft)
  - Resource blocks: Purple (hard) / Cyan (soft)
- Add month view resource indicators showing 1/n width per resource
- Add yearly calendar view for block overview
- Add My Availability page for staff self-service
- Add contracts module with templates, signing flow, and PDF generation
- Update scheduler with click-to-day navigation in week view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:19:12 -05:00

1264 lines
56 KiB
TypeScript

/**
* TimeBlockCreatorModal - A visual, intuitive time block creation experience
*
* Features:
* - Quick presets for common blocks (holidays, weekends, lunch)
* - Visual calendar for date range selection
* - Step-by-step flow
* - Large, clear UI elements
*/
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
X,
Calendar,
Clock,
Sun,
Moon,
Coffee,
Palmtree,
PartyPopper,
Building2,
User,
Ban,
AlertCircle,
ChevronLeft,
ChevronRight,
Check,
Sparkles,
Repeat,
CalendarDays,
CalendarRange,
Loader2,
} from 'lucide-react';
import Portal from '../Portal';
import {
BlockType,
RecurrenceType,
RecurrencePattern,
Holiday,
Resource,
TimeBlockListItem,
} from '../../types';
// Preset block types
const PRESETS = [
{
id: 'weekend',
name: 'Block Weekends',
description: 'Every Saturday & Sunday',
icon: Sun,
color: 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400',
config: {
title: 'Weekend',
recurrence_type: 'WEEKLY' as RecurrenceType,
recurrence_pattern: { days_of_week: [5, 6] },
all_day: true,
block_type: 'HARD' as BlockType,
},
},
{
id: 'lunch',
name: 'Daily Lunch Break',
description: '12:00 PM - 1:00 PM',
icon: Coffee,
color: 'bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400',
config: {
title: 'Lunch Break',
recurrence_type: 'WEEKLY' as RecurrenceType,
recurrence_pattern: { days_of_week: [0, 1, 2, 3, 4] },
all_day: false,
start_time: '12:00',
end_time: '13:00',
block_type: 'SOFT' as BlockType,
},
},
{
id: 'vacation',
name: 'Vacation / Time Off',
description: 'Block a date range',
icon: Palmtree,
color: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400',
config: {
title: 'Vacation',
recurrence_type: 'NONE' as RecurrenceType,
all_day: true,
block_type: 'HARD' as BlockType,
},
},
{
id: 'holiday',
name: 'Holiday',
description: 'Annual recurring holiday',
icon: PartyPopper,
color: 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400',
config: {
title: '',
recurrence_type: 'HOLIDAY' as RecurrenceType,
all_day: true,
block_type: 'HARD' as BlockType,
},
},
{
id: 'monthly',
name: 'Monthly Closure',
description: 'Same day(s) each month',
icon: CalendarDays,
color: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
config: {
title: 'Monthly Block',
recurrence_type: 'MONTHLY' as RecurrenceType,
all_day: true,
block_type: 'HARD' as BlockType,
},
},
{
id: 'custom',
name: 'Custom Block',
description: 'Full control over settings',
icon: Sparkles,
color: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
config: {
title: '',
recurrence_type: 'NONE' as RecurrenceType,
all_day: true,
block_type: 'HARD' as BlockType,
},
},
];
// Popular US Holidays quick picks
const POPULAR_HOLIDAYS = [
{ code: 'new_years_day', name: "New Year's Day", date: 'Jan 1' },
{ code: 'mlk_day', name: 'MLK Day', date: '3rd Mon Jan' },
{ code: 'presidents_day', name: "Presidents' Day", date: '3rd Mon Feb' },
{ code: 'memorial_day', name: 'Memorial Day', date: 'Last Mon May' },
{ code: 'independence_day', name: 'Independence Day', date: 'Jul 4' },
{ code: 'labor_day', name: 'Labor Day', date: '1st Mon Sep' },
{ code: 'thanksgiving', name: 'Thanksgiving', date: '4th Thu Nov' },
{ code: 'christmas', name: 'Christmas Day', date: 'Dec 25' },
];
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
interface TimeBlockCreatorModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: any) => void;
isSubmitting: boolean;
editingBlock?: TimeBlockListItem | null;
holidays: Holiday[];
resources: Resource[];
isResourceLevel?: boolean;
}
type Step = 'preset' | 'details' | 'schedule' | 'review';
const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
isOpen,
onClose,
onSubmit,
isSubmitting,
editingBlock,
holidays,
resources,
isResourceLevel: initialIsResourceLevel = false,
}) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>(editingBlock ? 'details' : 'preset');
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [isResourceLevel, setIsResourceLevel] = useState(initialIsResourceLevel);
// Form state
const [title, setTitle] = useState(editingBlock?.title || '');
const [description, setDescription] = useState(editingBlock?.description || '');
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || 'HARD');
const [recurrenceType, setRecurrenceType] = useState<RecurrenceType>(editingBlock?.recurrence_type || 'NONE');
const [allDay, setAllDay] = useState(editingBlock?.all_day ?? true);
const [startTime, setStartTime] = useState(editingBlock?.start_time || '09:00');
const [endTime, setEndTime] = useState(editingBlock?.end_time || '17:00');
const [resourceId, setResourceId] = useState<string | null>(editingBlock?.resource || null);
// Date selection
const [selectedDates, setSelectedDates] = useState<Date[]>([]);
const [calendarMonth, setCalendarMonth] = useState(new Date());
const [dragStart, setDragStart] = useState<Date | null>(null);
// Recurrence patterns
const [daysOfWeek, setDaysOfWeek] = useState<number[]>(
editingBlock?.recurrence_pattern?.days_of_week || []
);
const [daysOfMonth, setDaysOfMonth] = useState<number[]>(
editingBlock?.recurrence_pattern?.days_of_month || []
);
const [yearlyMonth, setYearlyMonth] = useState<number>(
editingBlock?.recurrence_pattern?.month || 1
);
const [yearlyDay, setYearlyDay] = useState<number>(
editingBlock?.recurrence_pattern?.day || 1
);
const [holidayCodes, setHolidayCodes] = useState<string[]>(
editingBlock?.recurrence_pattern?.holiday_code ? [editingBlock.recurrence_pattern.holiday_code] : []
);
// Active period for recurring
const [recurrenceStart, setRecurrenceStart] = useState(editingBlock?.recurrence_start || '');
const [recurrenceEnd, setRecurrenceEnd] = useState(editingBlock?.recurrence_end || '');
// Reset or populate form when modal opens
React.useEffect(() => {
if (isOpen) {
if (editingBlock) {
// Populate form with existing block data
setStep('details'); // Skip preset step when editing
setSelectedPreset(null);
setTitle(editingBlock.title || '');
setDescription(editingBlock.description || '');
setBlockType(editingBlock.block_type || 'HARD');
setRecurrenceType(editingBlock.recurrence_type || 'NONE');
setAllDay(editingBlock.all_day ?? true);
setStartTime(editingBlock.start_time || '09:00');
setEndTime(editingBlock.end_time || '17:00');
setResourceId(editingBlock.resource || null);
setIsResourceLevel(!!editingBlock.resource); // Set level based on whether block has a resource
// Parse dates if available
if (editingBlock.start_date) {
const startDate = new Date(editingBlock.start_date);
const endDate = editingBlock.end_date ? new Date(editingBlock.end_date) : startDate;
const dates: Date[] = [];
const current = new Date(startDate);
while (current <= endDate) {
dates.push(new Date(current));
current.setDate(current.getDate() + 1);
}
setSelectedDates(dates);
setCalendarMonth(startDate);
} else {
setSelectedDates([]);
}
// Parse recurrence_pattern - handle both string and object formats
let pattern = editingBlock.recurrence_pattern;
if (typeof pattern === 'string') {
try {
pattern = JSON.parse(pattern);
} catch {
pattern = {};
}
}
pattern = pattern || {};
// Recurrence patterns
setDaysOfWeek(pattern.days_of_week || []);
setDaysOfMonth(pattern.days_of_month || []);
setYearlyMonth(pattern.month || 1);
setYearlyDay(pattern.day || 1);
setHolidayCodes(pattern.holiday_code ? [pattern.holiday_code] : []);
setRecurrenceStart(editingBlock.recurrence_start || '');
setRecurrenceEnd(editingBlock.recurrence_end || '');
} else {
// Reset form for new block
setStep('preset');
setSelectedPreset(null);
setTitle('');
setDescription('');
setBlockType('HARD');
setRecurrenceType('NONE');
setAllDay(true);
setStartTime('09:00');
setEndTime('17:00');
setResourceId(null);
setSelectedDates([]);
setDaysOfWeek([]);
setDaysOfMonth([]);
setYearlyMonth(1);
setYearlyDay(1);
setHolidayCodes([]);
setRecurrenceStart('');
setRecurrenceEnd('');
setIsResourceLevel(initialIsResourceLevel);
}
}
}, [isOpen, editingBlock, initialIsResourceLevel]);
// Apply preset configuration
const applyPreset = (presetId: string) => {
const preset = PRESETS.find(p => p.id === presetId);
if (!preset) return;
setSelectedPreset(presetId);
setTitle(preset.config.title);
setRecurrenceType(preset.config.recurrence_type);
setAllDay(preset.config.all_day);
setBlockType(preset.config.block_type);
if (preset.config.start_time) setStartTime(preset.config.start_time);
if (preset.config.end_time) setEndTime(preset.config.end_time);
if (preset.config.recurrence_pattern?.days_of_week) {
setDaysOfWeek(preset.config.recurrence_pattern.days_of_week);
}
setStep('details');
};
// Calendar helpers
const getDaysInMonth = (date: Date): (Date | null)[] => {
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const days: (Date | null)[] = [];
// Padding for days before first of month
for (let i = 0; i < firstDay.getDay(); i++) {
days.push(null);
}
// Days of the month
for (let d = 1; d <= lastDay.getDate(); d++) {
days.push(new Date(year, month, d));
}
return days;
};
const isDateSelected = (date: Date): boolean => {
return selectedDates.some(d =>
d.getFullYear() === date.getFullYear() &&
d.getMonth() === date.getMonth() &&
d.getDate() === date.getDate()
);
};
const isDateInRange = (date: Date): boolean => {
if (!dragStart || selectedDates.length === 0) return false;
const start = dragStart < date ? dragStart : date;
const end = dragStart < date ? date : dragStart;
return date >= start && date <= end;
};
const handleDateClick = (date: Date) => {
if (recurrenceType === 'NONE') {
// Single date or range selection
if (selectedDates.length === 0) {
setSelectedDates([date]);
setDragStart(date);
} else if (selectedDates.length === 1) {
// Create range
const start = selectedDates[0] < date ? selectedDates[0] : date;
const end = selectedDates[0] < date ? date : selectedDates[0];
const range: Date[] = [];
const current = new Date(start);
while (current <= end) {
range.push(new Date(current));
current.setDate(current.getDate() + 1);
}
setSelectedDates(range);
setDragStart(null);
} else {
// Start new selection
setSelectedDates([date]);
setDragStart(date);
}
}
};
const handleSubmit = () => {
const baseData: any = {
description: description || undefined,
block_type: blockType,
recurrence_type: recurrenceType,
all_day: allDay,
resource: isResourceLevel ? resourceId : null,
};
if (!allDay) {
baseData.start_time = startTime;
baseData.end_time = endTime;
}
if (recurrenceType !== 'NONE') {
if (recurrenceStart) baseData.recurrence_start = recurrenceStart;
if (recurrenceEnd) baseData.recurrence_end = recurrenceEnd;
}
// For holidays with multiple selections, create separate blocks
if (recurrenceType === 'HOLIDAY' && holidayCodes.length > 0) {
const blocks = holidayCodes.map(code => {
const holiday = holidays.find(h => h.code === code);
return {
...baseData,
title: holiday?.name || code,
recurrence_pattern: { holiday_code: code },
};
});
onSubmit(blocks);
return;
}
// Single block for other recurrence types
const data = { ...baseData, title };
if (recurrenceType === 'NONE') {
if (selectedDates.length > 0) {
const sorted = [...selectedDates].sort((a, b) => a.getTime() - b.getTime());
data.start_date = sorted[0].toISOString().split('T')[0];
data.end_date = sorted[sorted.length - 1].toISOString().split('T')[0];
}
} else if (recurrenceType === 'WEEKLY') {
data.recurrence_pattern = { days_of_week: daysOfWeek };
} else if (recurrenceType === 'MONTHLY') {
data.recurrence_pattern = { days_of_month: daysOfMonth };
} else if (recurrenceType === 'YEARLY') {
data.recurrence_pattern = { month: yearlyMonth, day: yearlyDay };
}
onSubmit(data);
};
const canProceed = (): boolean => {
switch (step) {
case 'preset':
return true;
case 'details':
if (!title.trim()) return false;
if (isResourceLevel && !resourceId) return false;
return true;
case 'schedule':
if (recurrenceType === 'NONE' && selectedDates.length === 0) return false;
if (recurrenceType === 'WEEKLY' && daysOfWeek.length === 0) return false;
if (recurrenceType === 'MONTHLY' && daysOfMonth.length === 0) return false;
if (recurrenceType === 'HOLIDAY' && holidayCodes.length === 0) return false;
return true;
case 'review':
return true;
default:
return false;
}
};
const nextStep = () => {
if (step === 'preset') setStep('details');
else if (step === 'details') setStep('schedule');
else if (step === 'schedule') setStep('review');
};
const prevStep = () => {
if (step === 'details' && !editingBlock) setStep('preset');
else if (step === 'schedule') setStep('details');
else if (step === 'review') setStep('schedule');
};
if (!isOpen) return null;
return (
<Portal>
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-brand-50 to-blue-50 dark:from-gray-800 dark:to-gray-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
<Calendar className="w-5 h-5 text-brand-600 dark:text-brand-400" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{editingBlock ? 'Edit Time Block' : 'Create Time Block'}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{step === 'preset' && 'Choose a quick preset or start custom'}
{step === 'details' && 'Configure block details'}
{step === 'schedule' && 'Set the schedule'}
{step === 'review' && 'Review and create'}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X size={24} />
</button>
</div>
{/* Progress Steps */}
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<div className="flex items-center justify-center gap-2">
{(['preset', 'details', 'schedule', 'review'] as Step[]).map((s, i) => (
<React.Fragment key={s}>
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
step === s
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-300'
: i < ['preset', 'details', 'schedule', 'review'].indexOf(step)
? 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{i < ['preset', 'details', 'schedule', 'review'].indexOf(step) ? (
<Check size={14} />
) : (
<span className="w-5 h-5 rounded-full bg-current/20 flex items-center justify-center text-xs">
{i + 1}
</span>
)}
<span className="hidden sm:inline capitalize">{s}</span>
</div>
{i < 3 && (
<div className={`w-8 h-0.5 ${
i < ['preset', 'details', 'schedule', 'review'].indexOf(step)
? 'bg-green-300 dark:bg-green-700'
: 'bg-gray-200 dark:bg-gray-700'
}`} />
)}
</React.Fragment>
))}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{/* Step 1: Preset Selection */}
{step === 'preset' && (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{PRESETS.map((preset) => {
const Icon = preset.icon;
return (
<button
key={preset.id}
onClick={() => applyPreset(preset.id)}
className={`p-4 rounded-xl border-2 text-left transition-all hover:scale-[1.02] hover:shadow-lg ${
selectedPreset === preset.id
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-brand-300'
}`}
>
<div className={`w-12 h-12 rounded-xl ${preset.color} flex items-center justify-center mb-3`}>
<Icon size={24} />
</div>
<h3 className="font-semibold text-gray-900 dark:text-white text-base">
{preset.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{preset.description}
</p>
</button>
);
})}
</div>
</div>
)}
{/* Step 2: Details */}
{step === 'details' && (
<div className="space-y-6">
{/* Block Level Selector */}
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Block Level
</label>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => {
setIsResourceLevel(false);
setResourceId(null);
}}
className={`p-4 rounded-xl border-2 transition-all text-left ${
!isResourceLevel
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${!isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
<Building2 size={20} className={!isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
</div>
<div>
<p className={`font-semibold ${!isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
Business-wide
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Affects all resources
</p>
</div>
</div>
</button>
<button
type="button"
onClick={() => setIsResourceLevel(true)}
className={`p-4 rounded-xl border-2 transition-all text-left ${
isResourceLevel
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
<User size={20} className={isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
</div>
<div>
<p className={`font-semibold ${isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
Specific Resource
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Affects one resource
</p>
</div>
</div>
</button>
</div>
</div>
{/* Title */}
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
Block Name
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-4 py-3 text-lg border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
placeholder="e.g., Christmas Day, Team Meeting, Vacation"
/>
</div>
{/* Description */}
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
Description <span className="text-gray-400 font-normal">(optional)</span>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
placeholder="Add any notes about this time block..."
/>
</div>
{/* Resource (if resource-level) */}
{isResourceLevel && (
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
Resource
</label>
<select
value={resourceId || ''}
onChange={(e) => setResourceId(e.target.value || null)}
className="w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
>
<option value="">Select a resource...</option>
{resources.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</div>
)}
{/* Block Type */}
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Block Type
</label>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => setBlockType('HARD')}
className={`p-4 rounded-xl border-2 text-left transition-all ${
blockType === 'HARD'
? 'border-red-500 bg-red-50 dark:bg-red-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-red-300'
}`}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Ban className="w-5 h-5 text-red-600 dark:text-red-400" />
</div>
<span className="font-semibold text-gray-900 dark:text-white">Hard Block</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Completely prevents bookings. Cannot be overridden.
</p>
</button>
<button
type="button"
onClick={() => setBlockType('SOFT')}
className={`p-4 rounded-xl border-2 text-left transition-all ${
blockType === 'SOFT'
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-yellow-300'
}`}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
</div>
<span className="font-semibold text-gray-900 dark:text-white">Soft Block</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Shows a warning but allows bookings with override.
</p>
</button>
</div>
</div>
{/* All Day Toggle & Time */}
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Duration
</label>
<div className="flex items-center gap-4 mb-4">
<button
type="button"
onClick={() => setAllDay(true)}
className={`flex-1 p-3 rounded-xl border-2 flex items-center justify-center gap-2 transition-all ${
allDay
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-brand-300'
}`}
>
<Sun size={20} />
<span className="font-medium">All Day</span>
</button>
<button
type="button"
onClick={() => setAllDay(false)}
className={`flex-1 p-3 rounded-xl border-2 flex items-center justify-center gap-2 transition-all ${
!allDay
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-brand-300'
}`}
>
<Clock size={20} />
<span className="font-medium">Specific Hours</span>
</button>
</div>
{!allDay && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">Start Time</label>
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">End Time</label>
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
)}
</div>
</div>
)}
{/* Step 3: Schedule */}
{step === 'schedule' && (
<div className="space-y-6">
{/* Recurrence Type Selector */}
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
How often?
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{[
{ type: 'NONE', label: 'One-time', icon: Calendar, desc: 'Specific date(s)' },
{ type: 'WEEKLY', label: 'Weekly', icon: Repeat, desc: 'Days of week' },
{ type: 'MONTHLY', label: 'Monthly', icon: CalendarDays, desc: 'Days of month' },
{ type: 'YEARLY', label: 'Yearly', icon: CalendarRange, desc: 'Same date each year' },
{ type: 'HOLIDAY', label: 'Holiday', icon: PartyPopper, desc: 'Floating holiday' },
].map(({ type, label, icon: Icon, desc }) => (
<button
key={type}
type="button"
onClick={() => setRecurrenceType(type as RecurrenceType)}
className={`p-3 rounded-xl border-2 text-left transition-all ${
recurrenceType === type
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-brand-300'
}`}
>
<Icon size={20} className={recurrenceType === type ? 'text-brand-600' : 'text-gray-400'} />
<div className="font-medium text-gray-900 dark:text-white mt-1">{label}</div>
<div className="text-xs text-gray-500">{desc}</div>
</button>
))}
</div>
</div>
{/* One-time: Calendar */}
{recurrenceType === 'NONE' && (
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Select Date(s)
<span className="text-sm font-normal text-gray-500 ml-2">Click to select, click another to create range</span>
</label>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-xl p-4">
{/* Calendar Header */}
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={() => setCalendarMonth(new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() - 1))}
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ChevronLeft size={20} />
</button>
<span className="text-lg font-semibold text-gray-900 dark:text-white">
{MONTHS[calendarMonth.getMonth()]} {calendarMonth.getFullYear()}
</span>
<button
type="button"
onClick={() => setCalendarMonth(new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + 1))}
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ChevronRight size={20} />
</button>
</div>
{/* Weekday Headers */}
<div className="grid grid-cols-7 gap-1 mb-2">
{WEEKDAYS.map(day => (
<div key={day} className="text-center text-sm font-medium text-gray-500 py-2">
{day}
</div>
))}
</div>
{/* Calendar Days */}
<div className="grid grid-cols-7 gap-1">
{getDaysInMonth(calendarMonth).map((date, i) => (
<div key={i} className="aspect-square">
{date ? (
<button
type="button"
onClick={() => handleDateClick(date)}
className={`w-full h-full rounded-lg text-sm font-medium transition-all ${
isDateSelected(date)
? 'bg-brand-500 text-white hover:bg-brand-600'
: date.toDateString() === new Date().toDateString()
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-brand-100'
: 'hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{date.getDate()}
</button>
) : null}
</div>
))}
</div>
{/* Selected dates summary */}
{selectedDates.length > 0 && (
<div className="mt-4 p-3 bg-brand-50 dark:bg-brand-900/20 rounded-lg">
<div className="text-sm font-medium text-brand-700 dark:text-brand-300">
{selectedDates.length === 1 ? (
`Selected: ${selectedDates[0].toLocaleDateString()}`
) : (
`Selected: ${selectedDates[0].toLocaleDateString()} - ${selectedDates[selectedDates.length - 1].toLocaleDateString()} (${selectedDates.length} days)`
)}
</div>
</div>
)}
</div>
</div>
)}
{/* Weekly: Day of Week Selector */}
{recurrenceType === 'WEEKLY' && (
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Select Days
</label>
<div className="flex flex-wrap gap-2">
{/* Days ordered to match Python's weekday(): Mon=0 ... Sun=6 */}
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, i) => (
<button
key={day}
type="button"
onClick={() => {
setDaysOfWeek(prev =>
prev.includes(i) ? prev.filter(d => d !== i) : [...prev, i].sort()
);
}}
className={`px-4 py-3 rounded-xl text-base font-medium transition-all ${
daysOfWeek.includes(i)
? 'bg-brand-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{day}
</button>
))}
</div>
<div className="flex gap-2 mt-3">
<button
type="button"
onClick={() => setDaysOfWeek([5, 6])}
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
Weekends only
</button>
<span className="text-gray-300">|</span>
<button
type="button"
onClick={() => setDaysOfWeek([0, 1, 2, 3, 4])}
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
Weekdays only
</button>
</div>
</div>
)}
{/* Monthly: Day of Month Selector */}
{recurrenceType === 'MONTHLY' && (
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Select Days of Month
</label>
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
<button
key={day}
type="button"
onClick={() => {
setDaysOfMonth(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day].sort((a, b) => a - b)
);
}}
className={`w-10 h-10 rounded-lg text-sm font-medium transition-all ${
daysOfMonth.includes(day)
? 'bg-brand-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{day}
</button>
))}
</div>
<div className="flex gap-2 mt-3">
<button
type="button"
onClick={() => setDaysOfMonth([1])}
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
1st only
</button>
<span className="text-gray-300">|</span>
<button
type="button"
onClick={() => setDaysOfMonth([1, 15])}
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
1st & 15th
</button>
</div>
</div>
)}
{/* Yearly: Month & Day */}
{recurrenceType === 'YEARLY' && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">Month</label>
<select
value={yearlyMonth}
onChange={(e) => setYearlyMonth(parseInt(e.target.value))}
className="w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
{MONTHS.map((month, i) => (
<option key={month} value={i + 1}>{month}</option>
))}
</select>
</div>
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">Day</label>
<input
type="number"
min={1}
max={31}
value={yearlyDay}
onChange={(e) => setYearlyDay(parseInt(e.target.value) || 1)}
className="w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
)}
{/* Holiday Picker - Multi-select */}
{recurrenceType === 'HOLIDAY' && (
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Select Holidays <span className="text-sm font-normal text-gray-500">(click to toggle)</span>
</label>
{/* Popular Holidays Grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
{POPULAR_HOLIDAYS.map(holiday => {
const isSelected = holidayCodes.includes(holiday.code);
return (
<button
key={holiday.code}
type="button"
onClick={() => {
setHolidayCodes(prev =>
isSelected
? prev.filter(c => c !== holiday.code)
: [...prev, holiday.code]
);
}}
className={`p-3 rounded-xl border-2 text-left transition-all relative ${
isSelected
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-brand-300'
}`}
>
{isSelected && (
<div className="absolute top-1 right-1 w-5 h-5 bg-brand-500 rounded-full flex items-center justify-center">
<Check size={12} className="text-white" />
</div>
)}
<div className="font-medium text-gray-900 dark:text-white text-sm">{holiday.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{holiday.date}</div>
</button>
);
})}
</div>
{/* Selected Holidays List */}
{holidayCodes.length > 0 && (
<div className="mb-4 p-3 bg-brand-50 dark:bg-brand-900/20 rounded-xl">
<div className="text-sm font-medium text-brand-700 dark:text-brand-300 mb-2">
Selected ({holidayCodes.length}):
</div>
<div className="flex flex-wrap gap-2">
{holidayCodes.map(code => {
const holiday = POPULAR_HOLIDAYS.find(h => h.code === code) || holidays.find(h => h.code === code);
return (
<span
key={code}
className="inline-flex items-center gap-1 px-2 py-1 bg-white dark:bg-gray-700 rounded-lg text-sm"
>
{holiday?.name || code}
<button
type="button"
onClick={() => setHolidayCodes(prev => prev.filter(c => c !== code))}
className="text-gray-400 hover:text-red-500"
>
<X size={14} />
</button>
</span>
);
})}
</div>
</div>
)}
{/* Quick Actions */}
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => setHolidayCodes(POPULAR_HOLIDAYS.map(h => h.code))}
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
Select all popular
</button>
<span className="text-gray-300">|</span>
<button
type="button"
onClick={() => setHolidayCodes([])}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400"
>
Clear all
</button>
</div>
</div>
)}
{/* Active Period for Recurring */}
{recurrenceType !== 'NONE' && (
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Active Period <span className="text-sm font-normal text-gray-500">(optional)</span>
</label>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">Start Date</label>
<input
type="date"
value={recurrenceStart}
onChange={(e) => setRecurrenceStart(e.target.value)}
className="w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">End Date</label>
<input
type="date"
value={recurrenceEnd}
onChange={(e) => setRecurrenceEnd(e.target.value)}
className="w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
</div>
)}
</div>
)}
{/* Step 4: Review */}
{step === 'review' && (
<div className="space-y-6">
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-xl p-6">
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Summary</h3>
<dl className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<dt className="text-gray-500 dark:text-gray-400">Name</dt>
<dd className="font-medium text-gray-900 dark:text-white">{title || '—'}</dd>
</div>
<div className="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<dt className="text-gray-500 dark:text-gray-400">Type</dt>
<dd className="font-medium">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-sm ${
blockType === 'HARD'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300'
}`}>
{blockType === 'HARD' ? <Ban size={14} /> : <AlertCircle size={14} />}
{blockType === 'HARD' ? 'Hard Block' : 'Soft Block'}
</span>
</dd>
</div>
<div className="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<dt className="text-gray-500 dark:text-gray-400">Duration</dt>
<dd className="font-medium text-gray-900 dark:text-white">
{allDay ? 'All Day' : `${startTime} - ${endTime}`}
</dd>
</div>
<div className="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<dt className="text-gray-500 dark:text-gray-400">Schedule</dt>
<dd className="font-medium text-gray-900 dark:text-white text-right">
{recurrenceType === 'NONE' && selectedDates.length > 0 && (
selectedDates.length === 1
? selectedDates[0].toLocaleDateString()
: `${selectedDates[0].toLocaleDateString()} - ${selectedDates[selectedDates.length - 1].toLocaleDateString()}`
)}
{recurrenceType === 'WEEKLY' && (
`Every ${daysOfWeek.map(d => ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][d]).join(', ')}`
)}
{recurrenceType === 'MONTHLY' && (
`${daysOfMonth.join(', ')}${daysOfMonth.length === 1 ? 'st/nd/rd/th' : ''} of each month`
)}
{recurrenceType === 'YEARLY' && (
`${MONTHS[yearlyMonth - 1]} ${yearlyDay} each year`
)}
{recurrenceType === 'HOLIDAY' && holidayCodes.length > 0 && (
<div className="text-right">
{holidayCodes.length === 1 ? (
POPULAR_HOLIDAYS.find(h => h.code === holidayCodes[0])?.name ||
holidays.find(h => h.code === holidayCodes[0])?.name ||
holidayCodes[0]
) : (
<div className="space-y-1">
<div>{holidayCodes.length} holidays:</div>
{holidayCodes.slice(0, 4).map(code => (
<div key={code} className="text-sm text-gray-600 dark:text-gray-400">
{POPULAR_HOLIDAYS.find(h => h.code === code)?.name ||
holidays.find(h => h.code === code)?.name ||
code}
</div>
))}
{holidayCodes.length > 4 && (
<div className="text-sm text-gray-500">
+{holidayCodes.length - 4} more
</div>
)}
</div>
)}
</div>
)}
</dd>
</div>
{isResourceLevel && resourceId && (
<div className="flex justify-between py-2">
<dt className="text-gray-500 dark:text-gray-400">Resource</dt>
<dd className="font-medium text-gray-900 dark:text-white">
{resources.find(r => r.id === resourceId)?.name || resourceId}
</dd>
</div>
)}
</dl>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<div>
{step !== 'preset' && (
<button
type="button"
onClick={prevStep}
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ChevronLeft size={18} />
Back
</button>
)}
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
{step === 'review' ? (
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="flex items-center gap-2 px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
>
{isSubmitting ? (
<>
<Loader2 size={18} className="animate-spin" />
Creating...
</>
) : (
<>
<Check size={18} />
{editingBlock ? 'Save Changes' : 'Create Block'}
</>
)}
</button>
) : (
<button
onClick={nextStep}
disabled={!canProceed()}
className="flex items-center gap-2 px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue
<ChevronRight size={18} />
</button>
)}
</div>
</div>
</div>
</div>
</Portal>
);
};
export default TimeBlockCreatorModal;