Files
smoothschedule/frontend/src/components/ResourceCalendar.tsx
poduck c7f241b30a feat(i18n): Comprehensive internationalization of frontend components and pages
Translate all hardcoded English strings to use i18n translation keys:

Components:
- TransactionDetailModal: payment details, refunds, technical info
- ConnectOnboarding/ConnectOnboardingEmbed: Stripe Connect setup
- StripeApiKeysForm: API key management
- DomainPurchase: domain registration flow
- Sidebar: navigation labels
- Schedule/Sidebar, PendingSidebar: scheduler UI
- MasqueradeBanner: masquerade status
- Dashboard widgets: metrics, capacity, customers, tickets
- Marketing: PricingTable, PluginShowcase, BenefitsSection
- ConfirmationModal, ServiceList: common UI

Pages:
- Staff: invitation flow, role management
- Customers: form placeholders
- Payments: transactions, payouts, billing
- BookingSettings: URL and redirect configuration
- TrialExpired: upgrade prompts and features
- PlatformSettings, PlatformBusinesses: admin UI
- HelpApiDocs: API documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 21:40:54 -05:00

732 lines
34 KiB
TypeScript

import React, { useState, useMemo, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import { format, addDays, addWeeks, addMonths, startOfDay, startOfWeek, startOfMonth, endOfDay, endOfWeek, endOfMonth, eachDayOfInterval, eachHourOfInterval, isToday, isSameDay, getDay } from 'date-fns';
import { useAppointments, useUpdateAppointment } from '../hooks/useAppointments';
import { Appointment } from '../types';
import Portal from './Portal';
type ViewMode = 'day' | 'week' | 'month';
// Format duration as hours and minutes when >= 60 min
const formatDuration = (minutes: number): string => {
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
return `${minutes} min`;
};
// Constants for timeline rendering
const PIXELS_PER_HOUR = 64;
const PIXELS_PER_MINUTE = PIXELS_PER_HOUR / 60;
interface ResourceCalendarProps {
resourceId: string;
resourceName: string;
onClose: () => void;
}
const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourceName, onClose }) => {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>('day');
const [currentDate, setCurrentDate] = useState(new Date());
const timelineRef = useRef<HTMLDivElement>(null);
const timeLabelsRef = useRef<HTMLDivElement>(null);
// Drag state
const [dragState, setDragState] = useState<{
appointmentId: string;
startY: number;
originalStartTime: Date;
originalDuration: number;
} | null>(null);
const [dragPreview, setDragPreview] = useState<Date | null>(null);
// Resize state
const [resizeState, setResizeState] = useState<{
appointmentId: string;
direction: 'top' | 'bottom';
startY: number;
originalStartTime: Date;
originalDuration: number;
} | null>(null);
const [resizePreview, setResizePreview] = useState<{ startTime: Date; duration: number } | null>(null);
const updateMutation = useUpdateAppointment();
// Auto-scroll to current time or 8 AM when switching to day/week view
useEffect(() => {
if ((viewMode === 'day' || viewMode === 'week') && timelineRef.current) {
const now = new Date();
const scrollToHour = isToday(currentDate)
? Math.max(now.getHours() - 1, 0) // Scroll to an hour before current time
: 8; // Default to 8 AM for other days
timelineRef.current.scrollTop = scrollToHour * PIXELS_PER_HOUR;
// Sync time labels scroll
if (timeLabelsRef.current) {
timeLabelsRef.current.scrollTop = scrollToHour * PIXELS_PER_HOUR;
}
}
}, [viewMode, currentDate]);
// Sync scroll between timeline and time labels (for week view)
useEffect(() => {
const timeline = timelineRef.current;
const timeLabels = timeLabelsRef.current;
if (!timeline || !timeLabels) return;
const handleTimelineScroll = () => {
if (timeLabels) {
timeLabels.scrollTop = timeline.scrollTop;
}
};
timeline.addEventListener('scroll', handleTimelineScroll);
return () => timeline.removeEventListener('scroll', handleTimelineScroll);
}, [viewMode]);
// Helper to get Monday of the week containing the given date
const getMonday = (date: Date) => {
return startOfWeek(date, { weekStartsOn: 1 }); // 1 = Monday
};
// Helper to get Friday of the week (4 days after Monday)
const getFriday = (date: Date) => {
return addDays(getMonday(date), 4);
};
// Calculate date range based on view mode
const dateRange = useMemo(() => {
switch (viewMode) {
case 'day':
return { startDate: startOfDay(currentDate), endDate: addDays(startOfDay(currentDate), 1) };
case 'week':
// Full week (Monday to Sunday)
return { startDate: getMonday(currentDate), endDate: addDays(getMonday(currentDate), 7) };
case 'month':
return { startDate: startOfMonth(currentDate), endDate: addDays(endOfMonth(currentDate), 1) };
}
}, [viewMode, currentDate]);
// Fetch appointments for this resource within the date range
const { data: allAppointments = [], isLoading } = useAppointments({
resource: resourceId,
...dateRange
});
// Filter appointments for this specific resource
const appointments = useMemo(() => {
const resourceIdStr = String(resourceId);
return allAppointments.filter(apt => apt.resourceId === resourceIdStr);
}, [allAppointments, resourceId]);
const navigatePrevious = () => {
switch (viewMode) {
case 'day':
setCurrentDate(addDays(currentDate, -1));
break;
case 'week':
setCurrentDate(addWeeks(currentDate, -1));
break;
case 'month':
setCurrentDate(addMonths(currentDate, -1));
break;
}
};
const navigateNext = () => {
switch (viewMode) {
case 'day':
setCurrentDate(addDays(currentDate, 1));
break;
case 'week':
setCurrentDate(addWeeks(currentDate, 1));
break;
case 'month':
setCurrentDate(addMonths(currentDate, 1));
break;
}
};
const goToToday = () => {
setCurrentDate(new Date());
};
const getTitle = () => {
switch (viewMode) {
case 'day':
return format(currentDate, 'EEEE, MMMM d, yyyy');
case 'week':
const weekStart = getMonday(currentDate);
const weekEnd = addDays(weekStart, 6); // Sunday
return `${format(weekStart, 'MMM d')} - ${format(weekEnd, 'MMM d, yyyy')}`;
case 'month':
return format(currentDate, 'MMMM yyyy');
}
};
// Get appointments for a specific day
const getAppointmentsForDay = (day: Date) => {
return appointments.filter(apt => isSameDay(new Date(apt.startTime), day));
};
// Convert Y position to time
const yToTime = (y: number, baseDate: Date): Date => {
const minutes = Math.round((y / PIXELS_PER_MINUTE) / 15) * 15; // Snap to 15 min
const result = new Date(baseDate);
result.setHours(0, 0, 0, 0);
result.setMinutes(minutes);
return result;
};
// Handle drag start
const handleDragStart = (e: React.MouseEvent, apt: Appointment) => {
e.preventDefault();
const rect = timelineRef.current?.getBoundingClientRect();
if (!rect) return;
setDragState({
appointmentId: apt.id,
startY: e.clientY,
originalStartTime: new Date(apt.startTime),
originalDuration: apt.durationMinutes,
});
};
// Handle resize start
const handleResizeStart = (e: React.MouseEvent, apt: Appointment, direction: 'top' | 'bottom') => {
e.preventDefault();
e.stopPropagation();
setResizeState({
appointmentId: apt.id,
direction,
startY: e.clientY,
originalStartTime: new Date(apt.startTime),
originalDuration: apt.durationMinutes,
});
};
// Mouse move handler for drag and resize
useEffect(() => {
if (!dragState && !resizeState) return;
const handleMouseMove = (e: MouseEvent) => {
if (dragState) {
const deltaY = e.clientY - dragState.startY;
const deltaMinutes = Math.round((deltaY / PIXELS_PER_MINUTE) / 15) * 15;
const newStartTime = new Date(dragState.originalStartTime.getTime() + deltaMinutes * 60000);
// Keep within same day
const dayStart = startOfDay(dragState.originalStartTime);
const dayEnd = endOfDay(dragState.originalStartTime);
if (newStartTime >= dayStart && newStartTime <= dayEnd) {
setDragPreview(newStartTime);
}
}
if (resizeState) {
const deltaY = e.clientY - resizeState.startY;
const deltaMinutes = Math.round((deltaY / PIXELS_PER_MINUTE) / 15) * 15;
if (resizeState.direction === 'bottom') {
// Resize from bottom - change duration
const newDuration = Math.max(15, resizeState.originalDuration + deltaMinutes);
setResizePreview({
startTime: resizeState.originalStartTime,
duration: newDuration,
});
} else {
// Resize from top - change start time and duration
const newStartTime = new Date(resizeState.originalStartTime.getTime() + deltaMinutes * 60000);
const newDuration = Math.max(15, resizeState.originalDuration - deltaMinutes);
// Keep within same day
const dayStart = startOfDay(resizeState.originalStartTime);
if (newStartTime >= dayStart) {
setResizePreview({
startTime: newStartTime,
duration: newDuration,
});
}
}
}
};
const handleMouseUp = () => {
if (dragState && dragPreview) {
updateMutation.mutate({
id: dragState.appointmentId,
updates: {
startTime: dragPreview,
durationMinutes: dragState.originalDuration, // Preserve duration when dragging
}
});
}
if (resizeState && resizePreview) {
updateMutation.mutate({
id: resizeState.appointmentId,
updates: {
startTime: resizePreview.startTime,
durationMinutes: resizePreview.duration,
}
});
}
setDragState(null);
setDragPreview(null);
setResizeState(null);
setResizePreview(null);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragState, dragPreview, resizeState, resizePreview, updateMutation]);
// Calculate lanes for overlapping appointments
const calculateLanes = (appts: Appointment[]): Map<string, { lane: number; totalLanes: number }> => {
const laneMap = new Map<string, { lane: number; totalLanes: number }>();
if (appts.length === 0) return laneMap;
// Sort by start time
const sorted = [...appts].sort((a, b) =>
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
);
// Get end time for an appointment
const getEndTime = (apt: Appointment) => {
return new Date(apt.startTime).getTime() + apt.durationMinutes * 60000;
};
// Find overlapping groups
const groups: Appointment[][] = [];
let currentGroup: Appointment[] = [];
let groupEndTime = 0;
for (const apt of sorted) {
const aptStart = new Date(apt.startTime).getTime();
const aptEnd = getEndTime(apt);
if (currentGroup.length === 0 || aptStart < groupEndTime) {
// Overlaps with current group
currentGroup.push(apt);
groupEndTime = Math.max(groupEndTime, aptEnd);
} else {
// Start new group
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
currentGroup = [apt];
groupEndTime = aptEnd;
}
}
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
// Assign lanes within each group
for (const group of groups) {
const totalLanes = group.length;
// Sort by start time within group
group.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
group.forEach((apt, index) => {
laneMap.set(apt.id, { lane: index, totalLanes });
});
}
return laneMap;
};
const renderDayView = () => {
const dayStart = startOfDay(currentDate);
const hours = eachHourOfInterval({
start: dayStart,
end: new Date(dayStart.getTime() + 23 * 60 * 60 * 1000)
});
const dayAppointments = getAppointmentsForDay(currentDate);
const laneAssignments = calculateLanes(dayAppointments);
return (
<div className="flex-1 overflow-y-auto min-h-0" ref={timelineRef}>
<div className="relative ml-16" style={{ height: hours.length * PIXELS_PER_HOUR }}>
{/* Hour grid lines */}
{hours.map((hour) => (
<div key={hour.toISOString()} className="border-b border-gray-200 dark:border-gray-700 relative" style={{ height: PIXELS_PER_HOUR }}>
<div className="absolute -left-16 top-0 w-14 text-xs text-gray-500 dark:text-gray-400 pr-2 text-right">
{format(hour, 'h a')}
</div>
{/* Half-hour line */}
<div className="absolute left-0 right-0 top-1/2 border-t border-dashed border-gray-100 dark:border-gray-800" />
</div>
))}
{/* Render appointments */}
{dayAppointments.map((apt) => {
const isDragging = dragState?.appointmentId === apt.id;
const isResizing = resizeState?.appointmentId === apt.id;
// Use preview values if dragging/resizing this appointment
let displayStartTime = new Date(apt.startTime);
let displayDuration = apt.durationMinutes;
if (isDragging && dragPreview) {
displayStartTime = dragPreview;
}
if (isResizing && resizePreview) {
displayStartTime = resizePreview.startTime;
displayDuration = resizePreview.duration;
}
const startHour = displayStartTime.getHours() + displayStartTime.getMinutes() / 60;
const durationHours = displayDuration / 60;
const top = startHour * PIXELS_PER_HOUR;
const height = Math.max(durationHours * PIXELS_PER_HOUR, 30);
// Get lane info for overlapping appointments
const laneInfo = laneAssignments.get(apt.id) || { lane: 0, totalLanes: 1 };
const widthPercent = 100 / laneInfo.totalLanes;
const leftPercent = laneInfo.lane * widthPercent;
return (
<div
key={apt.id}
className={`absolute bg-brand-100 dark:bg-brand-900/50 border-t-4 border-brand-500 rounded-b px-2 py-1 overflow-hidden cursor-move select-none group transition-shadow ${
isDragging || isResizing ? 'shadow-lg ring-2 ring-brand-500 z-20' : 'hover:shadow-md z-10'
}`}
style={{
top: `${top}px`,
height: `${height}px`,
left: `${leftPercent}%`,
width: `calc(${widthPercent}% - 8px)`,
}}
onMouseDown={(e) => handleDragStart(e, apt)}
>
{/* Top resize handle */}
<div
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-brand-300/50 dark:hover:bg-brand-700/50"
onMouseDown={(e) => handleResizeStart(e, apt, 'top')}
/>
<div className="text-sm font-medium text-gray-900 dark:text-white truncate pointer-events-none mt-2">
{apt.customerName}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 pointer-events-none">
<Clock size={10} />
{format(displayStartTime, 'h:mm a')} {formatDuration(displayDuration)}
</div>
{/* Bottom resize handle */}
<div
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-brand-300/50 dark:hover:bg-brand-700/50"
onMouseDown={(e) => handleResizeStart(e, apt, 'bottom')}
/>
</div>
);
})}
{/* Current time indicator */}
{isToday(currentDate) && (
<div
className="absolute left-0 right-0 border-t-2 border-red-500 z-30 pointer-events-none"
style={{
top: `${(new Date().getHours() + new Date().getMinutes() / 60) * PIXELS_PER_HOUR}px`
}}
>
<div className="absolute -left-1.5 -top-1.5 w-3 h-3 bg-red-500 rounded-full" />
</div>
)}
</div>
</div>
);
};
const renderWeekView = () => {
// Full week Monday to Sunday
const days = eachDayOfInterval({
start: getMonday(currentDate),
end: addDays(getMonday(currentDate), 6)
});
const dayStart = startOfDay(days[0]);
const hours = eachHourOfInterval({
start: dayStart,
end: new Date(dayStart.getTime() + 23 * 60 * 60 * 1000)
});
const DAY_COLUMN_WIDTH = 200; // pixels per day column
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Day headers - fixed at top */}
<div className="flex border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex-shrink-0">
<div className="w-16 flex-shrink-0" /> {/* Spacer for time column */}
<div className="flex overflow-hidden">
{days.map((day) => (
<div
key={day.toISOString()}
className={`flex-shrink-0 text-center py-2 font-medium text-sm border-l border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 ${
isToday(day) ? 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20' : 'text-gray-900 dark:text-white'
}`}
style={{ width: DAY_COLUMN_WIDTH }}
onClick={() => {
setCurrentDate(day);
setViewMode('day');
}}
>
{format(day, 'EEE, MMM d')}
</div>
))}
</div>
</div>
{/* Scrollable timeline grid */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Time labels - fixed left column */}
<div ref={timeLabelsRef} className="w-16 flex-shrink-0 overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
<div style={{ height: hours.length * PIXELS_PER_HOUR }}>
{hours.map((hour) => (
<div key={hour.toISOString()} className="relative" style={{ height: PIXELS_PER_HOUR }}>
<div className="absolute top-0 right-2 text-xs text-gray-500 dark:text-gray-400">
{format(hour, 'h a')}
</div>
</div>
))}
</div>
</div>
{/* Day columns with appointments - scrollable both ways */}
<div className="flex-1 overflow-auto" ref={timelineRef}>
<div className="flex" style={{ height: hours.length * PIXELS_PER_HOUR, width: days.length * DAY_COLUMN_WIDTH }}>
{days.map((day) => {
const dayAppointments = getAppointmentsForDay(day);
const laneAssignments = calculateLanes(dayAppointments);
return (
<div
key={day.toISOString()}
className="relative flex-shrink-0 border-l border-gray-200 dark:border-gray-700"
style={{ width: DAY_COLUMN_WIDTH }}
onClick={() => {
setCurrentDate(day);
setViewMode('day');
}}
>
{/* Hour grid lines */}
{hours.map((hour) => (
<div
key={hour.toISOString()}
className="border-b border-gray-100 dark:border-gray-800"
style={{ height: PIXELS_PER_HOUR }}
>
<div className="absolute left-0 right-0 border-t border-dashed border-gray-100 dark:border-gray-800" style={{ top: PIXELS_PER_HOUR / 2 }} />
</div>
))}
{/* Appointments for this day */}
{dayAppointments.map((apt) => {
const aptStartTime = new Date(apt.startTime);
const startHour = aptStartTime.getHours() + aptStartTime.getMinutes() / 60;
const durationHours = apt.durationMinutes / 60;
const top = startHour * PIXELS_PER_HOUR;
const height = Math.max(durationHours * PIXELS_PER_HOUR, 24);
const laneInfo = laneAssignments.get(apt.id) || { lane: 0, totalLanes: 1 };
const widthPercent = 100 / laneInfo.totalLanes;
const leftPercent = laneInfo.lane * widthPercent;
return (
<div
key={apt.id}
className="absolute bg-brand-100 dark:bg-brand-900/50 border-t-2 border-brand-500 rounded-b px-1 py-0.5 overflow-hidden cursor-pointer hover:shadow-md hover:z-10 text-xs"
style={{
top: `${top}px`,
height: `${height}px`,
left: `${leftPercent}%`,
width: `calc(${widthPercent}% - 4px)`,
}}
onClick={(e) => {
e.stopPropagation();
setCurrentDate(day);
setViewMode('day');
}}
>
<div className="font-medium text-gray-900 dark:text-white truncate">
{apt.customerName}
</div>
<div className="text-gray-500 dark:text-gray-400 truncate">
{format(aptStartTime, 'h:mm a')}
</div>
</div>
);
})}
{/* Current time indicator for today */}
{isToday(day) && (
<div
className="absolute left-0 right-0 border-t-2 border-red-500 z-20 pointer-events-none"
style={{
top: `${(new Date().getHours() + new Date().getMinutes() / 60) * PIXELS_PER_HOUR}px`
}}
/>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
);
};
const renderMonthView = () => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
// Start padding from Monday (weekStartsOn: 1)
const startDayOfWeek = getDay(monthStart);
// Adjust for Monday start: if Sunday (0), it's 6 days from Monday; otherwise subtract 1
const paddingDays = Array(startDayOfWeek === 0 ? 6 : startDayOfWeek - 1).fill(null);
return (
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-7 gap-2">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day) => (
<div key={day} className="text-center text-xs font-medium text-gray-500 dark:text-gray-400 py-2">
{day}
</div>
))}
{paddingDays.map((_, index) => (
<div key={`padding-${index}`} className="min-h-20" />
))}
{days.map((day) => {
const dayAppointments = getAppointmentsForDay(day);
const dayOfWeek = getDay(day);
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
return (
<div
key={day.toISOString()}
className={`min-h-20 p-2 border border-gray-200 dark:border-gray-700 rounded cursor-pointer hover:border-brand-300 dark:hover:border-brand-700 transition-colors ${
isToday(day) ? 'bg-brand-50 dark:bg-brand-900/20' : isWeekend ? 'bg-gray-50 dark:bg-gray-900/30' : 'bg-white dark:bg-gray-800'
}`}
onClick={() => {
// Drill down to week view showing the week containing this day
setCurrentDate(day);
setViewMode('week');
}}
>
<div className={`text-sm font-medium mb-1 ${isToday(day) ? 'text-brand-600 dark:text-brand-400' : isWeekend ? 'text-gray-400 dark:text-gray-500' : 'text-gray-900 dark:text-white'}`}>
{format(day, 'd')}
</div>
{dayAppointments.length > 0 && (
<div className="text-xs">
<div className="text-brand-600 dark:text-brand-400 font-medium">
{dayAppointments.length} appt{dayAppointments.length > 1 ? 's' : ''}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
};
return (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-6xl h-[80vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{resourceName} Calendar</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{viewMode === 'day' ? 'Drag to move, drag edges to resize' : 'Click a day to view details'}
</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
<X size={24} />
</button>
</div>
{/* Toolbar */}
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={navigatePrevious}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
>
<ChevronLeft size={20} />
</button>
<button
onClick={goToToday}
className="px-3 py-1 text-sm font-medium bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Today
</button>
<button
onClick={navigateNext}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
>
<ChevronRight size={20} />
</button>
<div className="ml-4 text-lg font-semibold text-gray-900 dark:text-white">
{getTitle()}
</div>
</div>
{/* View Mode Selector */}
<div className="flex gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
{(['day', 'week', 'month'] as ViewMode[]).map((mode) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`px-4 py-1.5 text-sm font-medium rounded transition-colors capitalize ${viewMode === mode
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{mode}
</button>
))}
</div>
</div>
{/* Calendar Content */}
<div className="flex-1 min-h-0 flex flex-col relative">
{viewMode === 'day' && renderDayView()}
{viewMode === 'week' && renderWeekView()}
{viewMode === 'month' && renderMonthView()}
</div>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-gray-400 dark:text-gray-500">{t('scheduler.loadingAppointments')}</p>
</div>
)}
{!isLoading && appointments.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-gray-400 dark:text-gray-500">{t('scheduler.noAppointmentsScheduled')}</p>
</div>
)}
</div>
</div>
</Portal>
);
};
export default ResourceCalendar;