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>
732 lines
34 KiB
TypeScript
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;
|