- 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>
1264 lines
56 KiB
TypeScript
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;
|