diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 74cca35..cf169ec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,6 +41,8 @@ const Payments = React.lazy(() => import('./pages/Payments')); const Resources = React.lazy(() => import('./pages/Resources')); const Services = React.lazy(() => import('./pages/Services')); const Staff = React.lazy(() => import('./pages/Staff')); +const TimeBlocks = React.lazy(() => import('./pages/TimeBlocks')); +const MyAvailability = React.lazy(() => import('./pages/MyAvailability')); const CustomerDashboard = React.lazy(() => import('./pages/customer/CustomerDashboard')); const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport')); const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard')); @@ -78,8 +80,10 @@ const HelpCustomers = React.lazy(() => import('./pages/help/HelpCustomers')); const HelpServices = React.lazy(() => import('./pages/help/HelpServices')); const HelpResources = React.lazy(() => import('./pages/help/HelpResources')); const HelpStaff = React.lazy(() => import('./pages/help/HelpStaff')); +const HelpTimeBlocks = React.lazy(() => import('./pages/HelpTimeBlocks')); const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages')); const HelpPayments = React.lazy(() => import('./pages/help/HelpPayments')); +const HelpContracts = React.lazy(() => import('./pages/help/HelpContracts')); const HelpPlugins = React.lazy(() => import('./pages/help/HelpPlugins')); const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral')); const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes')); @@ -98,6 +102,9 @@ const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Pl const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin')); // Import Create Plugin page const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Import Email Templates page +const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page +const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page +const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public) // Settings pages const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout')); @@ -307,6 +314,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> @@ -340,6 +348,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> @@ -367,6 +376,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> @@ -671,8 +681,10 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> } /> + } /> } /> } /> } /> @@ -775,6 +787,46 @@ const AppContent: React.FC = () => { ) } /> + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> = { '/services': '/help/services', '/resources': '/help/resources', '/staff': '/help/staff', + '/time-blocks': '/help/time-blocks', + '/my-availability': '/help/time-blocks', '/messages': '/help/messages', '/tickets': '/help/ticketing', '/payments': '/help/payments', + '/contracts': '/help/contracts', + '/contracts/templates': '/help/contracts', '/plugins': '/help/plugins', '/plugins/marketplace': '/help/plugins', '/plugins/my-plugins': '/help/plugins', diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 16846a3..ef8bcd8 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -15,6 +15,8 @@ import { HelpCircle, Clock, Plug, + FileSignature, + CalendarOff, } from 'lucide-react'; import { Business, User } from '../types'; import { useLogout } from '../hooks/useAuth'; @@ -121,6 +123,14 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo isCollapsed={isCollapsed} locked={!canUse('plugins') || !canUse('tasks')} /> + {(role === 'staff' || role === 'resource') && ( + + )} {/* Manage Section - Staff+ */} @@ -145,12 +155,26 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo isCollapsed={isCollapsed} /> {canViewAdminPages && ( - + <> + + + + )} )} diff --git a/frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx b/frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx new file mode 100644 index 0000000..0227347 --- /dev/null +++ b/frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx @@ -0,0 +1,247 @@ +/** + * TimeBlockCalendarOverlay - Renders time block overlays on the scheduler calendar + * + * Shows blocked time periods with visual styling: + * - Hard blocks: Red with diagonal stripes, 50% opacity + * - Soft blocks: Yellow with dotted border, 30% opacity + * - Business blocks: Full-width across the lane + * - Resource blocks: Only on matching resource lane + */ + +import React, { useMemo, useState } from 'react'; +import { BlockedDate, BlockType } from '../../types'; + +interface TimeBlockCalendarOverlayProps { + blockedDates: BlockedDate[]; + resourceId: string; + viewDate: Date; + zoomLevel: number; + pixelsPerMinute: number; + startHour: number; + dayWidth: number; + laneHeight: number; + days: Date[]; + onDayClick?: (day: Date) => void; +} + +interface TimeBlockTooltipProps { + block: BlockedDate; + position: { x: number; y: number }; +} + +const TimeBlockTooltip: React.FC = ({ block, position }) => { + return ( +
+
{block.title}
+
+ {block.block_type === 'HARD' ? 'Hard Block' : 'Soft Block'} + {block.all_day ? ' (All Day)' : ` (${block.start_time} - ${block.end_time})`} +
+
+ ); +}; + +const TimeBlockCalendarOverlay: React.FC = ({ + blockedDates, + resourceId, + viewDate, + zoomLevel, + pixelsPerMinute, + startHour, + dayWidth, + laneHeight, + days, + onDayClick, +}) => { + const [hoveredBlock, setHoveredBlock] = useState<{ block: BlockedDate; position: { x: number; y: number } } | null>(null); + + // Filter blocks for this resource (includes business-level blocks where resource_id is null) + const relevantBlocks = useMemo(() => { + return blockedDates.filter( + (block) => block.resource_id === null || block.resource_id === resourceId + ); + }, [blockedDates, resourceId]); + + // Calculate block positions for each day + const blockOverlays = useMemo(() => { + const overlays: Array<{ + block: BlockedDate; + left: number; + width: number; + dayIndex: number; + }> = []; + + relevantBlocks.forEach((block) => { + // Parse date string as local date, not UTC + // "2025-12-06" should be Dec 6 in local timezone, not UTC + const [year, month, dayNum] = block.date.split('-').map(Number); + const blockDate = new Date(year, month - 1, dayNum); + blockDate.setHours(0, 0, 0, 0); + + // Find which day this block falls on + days.forEach((day, dayIndex) => { + const dayStart = new Date(day); + dayStart.setHours(0, 0, 0, 0); + + if (blockDate.getTime() === dayStart.getTime()) { + let left: number; + let width: number; + + if (block.all_day) { + // Full day block + left = dayIndex * dayWidth; + width = dayWidth; + } else if (block.start_time && block.end_time) { + // Partial day block + const [startHours, startMins] = block.start_time.split(':').map(Number); + const [endHours, endMins] = block.end_time.split(':').map(Number); + + const startMinutes = (startHours - startHour) * 60 + startMins; + const endMinutes = (endHours - startHour) * 60 + endMins; + + left = dayIndex * dayWidth + startMinutes * pixelsPerMinute * zoomLevel; + width = (endMinutes - startMinutes) * pixelsPerMinute * zoomLevel; + } else { + // Default to full day if no times specified + left = dayIndex * dayWidth; + width = dayWidth; + } + + overlays.push({ + block, + left, + width, + dayIndex, + }); + } + }); + }); + + return overlays; + }, [relevantBlocks, days, dayWidth, pixelsPerMinute, zoomLevel, startHour]); + + const getBlockStyle = (blockType: BlockType, isBusinessLevel: boolean): React.CSSProperties => { + const baseStyle: React.CSSProperties = { + position: 'absolute', + top: 0, + height: '100%', + pointerEvents: 'auto', + cursor: 'default', + }; + + if (isBusinessLevel) { + // Business blocks: Red (hard) / Amber (soft) + if (blockType === 'HARD') { + return { + ...baseStyle, + background: `repeating-linear-gradient( + -45deg, + rgba(239, 68, 68, 0.3), + rgba(239, 68, 68, 0.3) 5px, + rgba(239, 68, 68, 0.5) 5px, + rgba(239, 68, 68, 0.5) 10px + )`, + borderTop: '2px solid rgba(239, 68, 68, 0.7)', + borderBottom: '2px solid rgba(239, 68, 68, 0.7)', + }; + } else { + return { + ...baseStyle, + background: 'rgba(251, 191, 36, 0.2)', + borderTop: '2px dashed rgba(251, 191, 36, 0.8)', + borderBottom: '2px dashed rgba(251, 191, 36, 0.8)', + }; + } + } else { + // Resource blocks: Purple (hard) / Cyan (soft) + if (blockType === 'HARD') { + return { + ...baseStyle, + background: `repeating-linear-gradient( + -45deg, + rgba(147, 51, 234, 0.25), + rgba(147, 51, 234, 0.25) 5px, + rgba(147, 51, 234, 0.4) 5px, + rgba(147, 51, 234, 0.4) 10px + )`, + borderTop: '2px solid rgba(147, 51, 234, 0.7)', + borderBottom: '2px solid rgba(147, 51, 234, 0.7)', + }; + } else { + return { + ...baseStyle, + background: 'rgba(6, 182, 212, 0.15)', + borderTop: '2px dashed rgba(6, 182, 212, 0.7)', + borderBottom: '2px dashed rgba(6, 182, 212, 0.7)', + }; + } + } + }; + + const handleMouseEnter = (e: React.MouseEvent, block: BlockedDate) => { + setHoveredBlock({ + block, + position: { x: e.clientX, y: e.clientY }, + }); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (hoveredBlock) { + setHoveredBlock({ + ...hoveredBlock, + position: { x: e.clientX, y: e.clientY }, + }); + } + }; + + const handleMouseLeave = () => { + setHoveredBlock(null); + }; + + return ( + <> + {blockOverlays.map((overlay, index) => { + const isBusinessLevel = overlay.block.resource_id === null; + const style = getBlockStyle(overlay.block.block_type, isBusinessLevel); + + return ( +
handleMouseEnter(e, overlay.block)} + onMouseMove={handleMouseMove} + onMouseLeave={handleMouseLeave} + onClick={() => onDayClick?.(days[overlay.dayIndex])} + > + {/* Block level indicator */} +
+ {isBusinessLevel ? 'B' : 'R'} +
+
+ ); + })} + + {/* Tooltip */} + {hoveredBlock && ( + + )} + + ); +}; + +export default TimeBlockCalendarOverlay; diff --git a/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx b/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx new file mode 100644 index 0000000..6e085a2 --- /dev/null +++ b/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx @@ -0,0 +1,1263 @@ +/** + * 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 = ({ + isOpen, + onClose, + onSubmit, + isSubmitting, + editingBlock, + holidays, + resources, + isResourceLevel: initialIsResourceLevel = false, +}) => { + const { t } = useTranslation(); + const [step, setStep] = useState(editingBlock ? 'details' : 'preset'); + const [selectedPreset, setSelectedPreset] = useState(null); + const [isResourceLevel, setIsResourceLevel] = useState(initialIsResourceLevel); + + // Form state + const [title, setTitle] = useState(editingBlock?.title || ''); + const [description, setDescription] = useState(editingBlock?.description || ''); + const [blockType, setBlockType] = useState(editingBlock?.block_type || 'HARD'); + const [recurrenceType, setRecurrenceType] = useState(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(editingBlock?.resource || null); + + // Date selection + const [selectedDates, setSelectedDates] = useState([]); + const [calendarMonth, setCalendarMonth] = useState(new Date()); + const [dragStart, setDragStart] = useState(null); + + // Recurrence patterns + const [daysOfWeek, setDaysOfWeek] = useState( + editingBlock?.recurrence_pattern?.days_of_week || [] + ); + const [daysOfMonth, setDaysOfMonth] = useState( + editingBlock?.recurrence_pattern?.days_of_month || [] + ); + const [yearlyMonth, setYearlyMonth] = useState( + editingBlock?.recurrence_pattern?.month || 1 + ); + const [yearlyDay, setYearlyDay] = useState( + editingBlock?.recurrence_pattern?.day || 1 + ); + const [holidayCodes, setHolidayCodes] = useState( + 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 ( + +
+
+ {/* Header */} +
+
+
+ +
+
+

+ {editingBlock ? 'Edit Time Block' : 'Create Time Block'} +

+

+ {step === 'preset' && 'Choose a quick preset or start custom'} + {step === 'details' && 'Configure block details'} + {step === 'schedule' && 'Set the schedule'} + {step === 'review' && 'Review and create'} +

+
+
+ +
+ + {/* Progress Steps */} +
+
+ {(['preset', 'details', 'schedule', 'review'] as Step[]).map((s, i) => ( + +
+ {i < ['preset', 'details', 'schedule', 'review'].indexOf(step) ? ( + + ) : ( + + {i + 1} + + )} + {s} +
+ {i < 3 && ( +
+ )} + + ))} +
+
+ + {/* Content */} +
+ {/* Step 1: Preset Selection */} + {step === 'preset' && ( +
+
+ {PRESETS.map((preset) => { + const Icon = preset.icon; + return ( + + ); + })} +
+
+ )} + + {/* Step 2: Details */} + {step === 'details' && ( +
+ {/* Block Level Selector */} +
+ +
+ + +
+
+ + {/* Title */} +
+ + 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" + /> +
+ + {/* Description */} +
+ +