Enhance month view overlay with preview, auto-scroll, and 1s delay
- Fix backend type comparison in AvailabilityService (int vs string) - Add durationMinutes to month overlay drop to fix end_time calculation - Add live preview of dragged appointment in overlay with lane splitting - Implement horizontal auto-scroll when dragging to overlay edges - Add 1-second delay before overlay appears for easier date navigation - Remove redundant drop zone highlight (preview shows position) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -136,7 +136,8 @@ export const useUpdateAppointment = () => {
|
||||
|
||||
if (updates.serviceId) backendData.service = parseInt(updates.serviceId);
|
||||
if (updates.resourceId !== undefined) {
|
||||
backendData.resource = updates.resourceId ? parseInt(updates.resourceId) : null;
|
||||
// Backend expects resource_ids as a list
|
||||
backendData.resource_ids = updates.resourceId ? [parseInt(updates.resourceId)] : [];
|
||||
}
|
||||
if (updates.startTime) {
|
||||
backendData.start_time = updates.startTime.toISOString();
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Appointment, User, Business } from '../types';
|
||||
import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, X, User as UserIcon, Mail, Phone, Undo, Redo, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Appointment, AppointmentStatus, User, Business } from '../types';
|
||||
import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, X, User as UserIcon, Mail, Phone, Undo, Redo, ChevronLeft, ChevronRight, Check } from 'lucide-react';
|
||||
import { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../hooks/useAppointments';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
@@ -16,6 +16,7 @@ const START_HOUR = 0; // Midnight
|
||||
const END_HOUR = 24; // Midnight next day
|
||||
const PIXELS_PER_MINUTE = 2.5;
|
||||
const OVERLAY_HOUR_HEIGHT = 60;
|
||||
const OVERLAY_RESOURCE_WIDTH = 150; // Width of each resource column in month overlay
|
||||
const HEADER_HEIGHT = 48;
|
||||
const SIDEBAR_WIDTH = 250;
|
||||
|
||||
@@ -91,12 +92,23 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [monthDropTarget, setMonthDropTarget] = useState<{ date: Date; rect: DOMRect } | null>(null);
|
||||
const [overlayPreview, setOverlayPreview] = useState<{ resourceId: string; hour: number; minute: number } | null>(null);
|
||||
const overlayAutoScrollRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const monthOverlayDelayRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pendingMonthDropRef = useRef<{ date: Date; rect: DOMRect } | null>(null);
|
||||
|
||||
// State for editing appointments
|
||||
const [editDateTime, setEditDateTime] = useState('');
|
||||
const [editResource, setEditResource] = useState('');
|
||||
const [editDuration, setEditDuration] = useState(0);
|
||||
|
||||
// Filter state
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
const [filterStatuses, setFilterStatuses] = useState<Set<AppointmentStatus>>(new Set(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW']));
|
||||
const [filterResources, setFilterResources] = useState<Set<string>>(new Set()); // Empty means all
|
||||
const [filterServices, setFilterServices] = useState<Set<string>>(new Set()); // Empty means all
|
||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Update edit state when selected appointment changes
|
||||
useEffect(() => {
|
||||
if (selectedAppointment) {
|
||||
@@ -118,6 +130,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const overlayScrollRef = useRef<HTMLDivElement>(null); // Ref for the MonthDropOverlay's scrollable area
|
||||
const overlayContainerRef = useRef<HTMLDivElement>(null); // Ref for the overlay container (for wheel events)
|
||||
const isOverOverlayRef = useRef(false); // Track if mouse is over the month drop overlay
|
||||
|
||||
// Keyboard shortcuts for undo/redo
|
||||
useEffect(() => {
|
||||
@@ -135,6 +149,87 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [historyIndex, history]);
|
||||
|
||||
// Handle wheel events on month drop overlay for horizontal scrolling
|
||||
// Use a callback ref pattern to attach the listener when the element is available
|
||||
const overlayWheelHandler = React.useCallback((e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (overlayScrollRef.current) {
|
||||
// Convert vertical scroll to horizontal: scroll up = scroll left, scroll down = scroll right
|
||||
overlayScrollRef.current.scrollLeft += e.deltaY;
|
||||
// Sync the header
|
||||
const header = overlayScrollRef.current.previousElementSibling as HTMLElement;
|
||||
if (header) header.scrollLeft = overlayScrollRef.current.scrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Callback ref that attaches the wheel listener when the overlay mounts
|
||||
const overlayContainerCallbackRef = React.useCallback((node: HTMLDivElement | null) => {
|
||||
// Remove listener from previous node if any
|
||||
if (overlayContainerRef.current) {
|
||||
overlayContainerRef.current.removeEventListener('wheel', overlayWheelHandler);
|
||||
}
|
||||
// Store the new ref
|
||||
overlayContainerRef.current = node;
|
||||
// Add listener to new node
|
||||
if (node) {
|
||||
node.addEventListener('wheel', overlayWheelHandler, { passive: false });
|
||||
}
|
||||
}, [overlayWheelHandler]);
|
||||
|
||||
// Close filter menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) {
|
||||
setShowFilterMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showFilterMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [showFilterMenu]);
|
||||
|
||||
// Filter toggle helpers
|
||||
const toggleStatusFilter = (status: AppointmentStatus) => {
|
||||
const newSet = new Set(filterStatuses);
|
||||
if (newSet.has(status)) {
|
||||
newSet.delete(status);
|
||||
} else {
|
||||
newSet.add(status);
|
||||
}
|
||||
setFilterStatuses(newSet);
|
||||
};
|
||||
|
||||
const toggleResourceFilter = (resourceId: string) => {
|
||||
const newSet = new Set(filterResources);
|
||||
if (newSet.has(resourceId)) {
|
||||
newSet.delete(resourceId);
|
||||
} else {
|
||||
newSet.add(resourceId);
|
||||
}
|
||||
setFilterResources(newSet);
|
||||
};
|
||||
|
||||
const toggleServiceFilter = (serviceId: string) => {
|
||||
const newSet = new Set(filterServices);
|
||||
if (newSet.has(serviceId)) {
|
||||
newSet.delete(serviceId);
|
||||
} else {
|
||||
newSet.add(serviceId);
|
||||
}
|
||||
setFilterServices(newSet);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setFilterStatuses(new Set(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW']));
|
||||
setFilterResources(new Set());
|
||||
setFilterServices(new Set());
|
||||
};
|
||||
|
||||
const hasActiveFilters = filterStatuses.size < 5 || filterResources.size > 0 || filterServices.size > 0;
|
||||
|
||||
// Scroll to current time on mount (centered in view)
|
||||
useEffect(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
@@ -454,7 +549,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
const getWidth = (durationMinutes: number) => durationMinutes * (PIXELS_PER_MINUTE * zoomLevel);
|
||||
|
||||
const getStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => {
|
||||
if (status === 'COMPLETED' || status === 'NO_SHOW') return 'bg-gray-100 border-gray-400 text-gray-600 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
|
||||
if (status === 'COMPLETED') return 'bg-green-100 border-green-500 text-green-900 dark:bg-green-900/50 dark:border-green-500 dark:text-green-200';
|
||||
if (status === 'NO_SHOW') return 'bg-gray-100 border-gray-400 text-gray-600 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
|
||||
if (status === 'CANCELLED') return 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
|
||||
const now = new Date();
|
||||
if (now > endTime) return 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200';
|
||||
@@ -462,18 +558,38 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
return 'bg-blue-100 border-blue-500 text-blue-900 dark:bg-blue-900/50 dark:border-blue-500 dark:text-blue-200';
|
||||
};
|
||||
|
||||
// Filter appointments by date range (but include all pending requests regardless of date)
|
||||
// Simplified status colors for month view (no border classes)
|
||||
const getMonthStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => {
|
||||
if (status === 'COMPLETED') return 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800/50';
|
||||
if (status === 'NO_SHOW') return 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600';
|
||||
if (status === 'CANCELLED') return 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 opacity-75 hover:bg-gray-200 dark:hover:bg-gray-600';
|
||||
const now = new Date();
|
||||
if (now > endTime) return 'bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-800/50';
|
||||
if (now >= startTime && now <= endTime) return 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-800 dark:text-yellow-200 hover:bg-yellow-200 dark:hover:bg-yellow-800/50';
|
||||
return 'bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800/50';
|
||||
};
|
||||
|
||||
// Filter appointments by date range and user filters
|
||||
const { start: rangeStart, end: rangeEnd } = getDateRange();
|
||||
const filteredAppointments = useMemo(() => {
|
||||
return appointments.filter(apt => {
|
||||
// Always include pending requests (no resourceId)
|
||||
// Apply status filter
|
||||
if (!filterStatuses.has(apt.status)) return false;
|
||||
|
||||
// Apply resource filter (empty set means show all)
|
||||
if (filterResources.size > 0 && apt.resourceId && !filterResources.has(apt.resourceId)) return false;
|
||||
|
||||
// Apply service filter (empty set means show all)
|
||||
if (filterServices.size > 0 && !filterServices.has(apt.serviceId)) return false;
|
||||
|
||||
// Always include pending requests (no resourceId) if they pass above filters
|
||||
if (!apt.resourceId) return true;
|
||||
|
||||
// Filter scheduled appointments by date range
|
||||
const aptDate = new Date(apt.startTime);
|
||||
return aptDate >= rangeStart && aptDate < rangeEnd;
|
||||
});
|
||||
}, [appointments, rangeStart, rangeEnd]);
|
||||
}, [appointments, rangeStart, rangeEnd, filterStatuses, filterResources, filterServices]);
|
||||
|
||||
const resourceLayouts = useMemo(() => {
|
||||
return resources.map(resource => {
|
||||
@@ -551,25 +667,63 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
setDraggedAppointmentId(null);
|
||||
setPreviewState(null);
|
||||
setMonthDropTarget(null);
|
||||
setOverlayPreview(null);
|
||||
isOverOverlayRef.current = false;
|
||||
pendingMonthDropRef.current = null;
|
||||
// Clear any pending overlay delay timeout
|
||||
if (monthOverlayDelayRef.current) {
|
||||
clearTimeout(monthOverlayDelayRef.current);
|
||||
monthOverlayDelayRef.current = null;
|
||||
}
|
||||
// Clear any auto-scroll interval
|
||||
if (overlayAutoScrollRef.current) {
|
||||
clearInterval(overlayAutoScrollRef.current);
|
||||
overlayAutoScrollRef.current = null;
|
||||
}
|
||||
// Reset isDragging after a short delay to allow click detection
|
||||
setTimeout(() => setIsDragging(false), 100);
|
||||
};
|
||||
|
||||
const handleMonthCellDragOver = (e: React.DragEvent, date: Date) => {
|
||||
if (!draggedAppointmentId) return;
|
||||
// Don't update target if mouse is over the overlay
|
||||
if (isOverOverlayRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Debounce the state update slightly or just check if changed
|
||||
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
|
||||
if (!monthDropTarget || monthDropTarget.date.getTime() !== date.getTime()) {
|
||||
setMonthDropTarget({ date, rect });
|
||||
|
||||
// If we're already showing the overlay for this date, do nothing
|
||||
if (monthDropTarget && monthDropTarget.date.getTime() === date.getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we moved to a different date, cancel any pending timeout and clear overlay
|
||||
if (pendingMonthDropRef.current && pendingMonthDropRef.current.date.getTime() !== date.getTime()) {
|
||||
if (monthOverlayDelayRef.current) {
|
||||
clearTimeout(monthOverlayDelayRef.current);
|
||||
monthOverlayDelayRef.current = null;
|
||||
}
|
||||
setMonthDropTarget(null);
|
||||
}
|
||||
|
||||
// Store the pending drop target
|
||||
pendingMonthDropRef.current = { date, rect };
|
||||
|
||||
// Start a new timeout if not already pending for this date
|
||||
if (!monthOverlayDelayRef.current) {
|
||||
monthOverlayDelayRef.current = setTimeout(() => {
|
||||
if (pendingMonthDropRef.current) {
|
||||
setMonthDropTarget(pendingMonthDropRef.current);
|
||||
}
|
||||
monthOverlayDelayRef.current = null;
|
||||
}, 1000); // 1 second delay
|
||||
}
|
||||
};
|
||||
|
||||
const handleMonthTimeDrop = (e: React.DragEvent, targetHour: number, targetMinute: number) => {
|
||||
const handleMonthTimeDrop = (e: React.DragEvent, targetHour: number, targetMinute: number, targetResourceId?: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!draggedAppointmentId || !monthDropTarget) return;
|
||||
@@ -578,8 +732,10 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
if (appointment) {
|
||||
const newStartTime = new Date(monthDropTarget.date);
|
||||
newStartTime.setHours(targetHour, targetMinute, 0, 0);
|
||||
|
||||
// Preserve duration, keep resource
|
||||
|
||||
// Use target resource if provided, otherwise keep existing
|
||||
const newResourceId = targetResourceId !== undefined ? targetResourceId : appointment.resourceId;
|
||||
|
||||
// Add to history
|
||||
addToHistory({
|
||||
type: 'move',
|
||||
@@ -591,7 +747,7 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
},
|
||||
after: {
|
||||
startTime: newStartTime,
|
||||
resourceId: appointment.resourceId,
|
||||
resourceId: newResourceId,
|
||||
durationMinutes: appointment.durationMinutes
|
||||
}
|
||||
});
|
||||
@@ -599,29 +755,54 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
updateMutation.mutate({
|
||||
id: appointment.id,
|
||||
updates: {
|
||||
startTime: newStartTime
|
||||
startTime: newStartTime,
|
||||
resourceId: newResourceId,
|
||||
durationMinutes: appointment.durationMinutes // Required to calculate end_time
|
||||
}
|
||||
});
|
||||
}
|
||||
setMonthDropTarget(null);
|
||||
setDraggedAppointmentId(null);
|
||||
setOverlayPreview(null);
|
||||
isOverOverlayRef.current = false;
|
||||
};
|
||||
|
||||
const handleOverlayScroll = (e: React.DragEvent) => {
|
||||
const handleOverlayAutoScroll = (e: React.DragEvent) => {
|
||||
if (!overlayScrollRef.current) return;
|
||||
|
||||
const rect = overlayScrollRef.current.getBoundingClientRect();
|
||||
const mouseY = e.clientY;
|
||||
const mouseX = e.clientX;
|
||||
const scrollThreshold = 50; // pixels from edge to start scrolling
|
||||
const scrollSpeed = 8; // pixels per scroll step
|
||||
|
||||
const scrollThreshold = 30; // pixels from edge to start scrolling
|
||||
const scrollSpeed = 10; // pixels per scroll step
|
||||
// Clear any existing auto-scroll
|
||||
if (overlayAutoScrollRef.current) {
|
||||
clearInterval(overlayAutoScrollRef.current);
|
||||
overlayAutoScrollRef.current = null;
|
||||
}
|
||||
|
||||
if (mouseY < rect.top + scrollThreshold && overlayScrollRef.current.scrollTop > 0) {
|
||||
// Scroll up
|
||||
overlayScrollRef.current.scrollTop -= scrollSpeed;
|
||||
} else if (mouseY > rect.bottom - scrollThreshold && overlayScrollRef.current.scrollTop < overlayScrollRef.current.scrollHeight - overlayScrollRef.current.clientHeight) {
|
||||
// Scroll down
|
||||
overlayScrollRef.current.scrollTop += scrollSpeed;
|
||||
// Start auto-scrolling if near left or right edge
|
||||
if (mouseX < rect.left + scrollThreshold && overlayScrollRef.current.scrollLeft > 0) {
|
||||
// Auto-scroll left
|
||||
overlayAutoScrollRef.current = setInterval(() => {
|
||||
if (overlayScrollRef.current) {
|
||||
overlayScrollRef.current.scrollLeft -= scrollSpeed;
|
||||
// Sync header
|
||||
const header = overlayScrollRef.current.previousElementSibling as HTMLElement;
|
||||
if (header) header.scrollLeft = overlayScrollRef.current.scrollLeft;
|
||||
}
|
||||
}, 16);
|
||||
} else if (mouseX > rect.right - scrollThreshold &&
|
||||
overlayScrollRef.current.scrollLeft < overlayScrollRef.current.scrollWidth - overlayScrollRef.current.clientWidth) {
|
||||
// Auto-scroll right
|
||||
overlayAutoScrollRef.current = setInterval(() => {
|
||||
if (overlayScrollRef.current) {
|
||||
overlayScrollRef.current.scrollLeft += scrollSpeed;
|
||||
// Sync header
|
||||
const header = overlayScrollRef.current.previousElementSibling as HTMLElement;
|
||||
if (header) header.scrollLeft = overlayScrollRef.current.scrollLeft;
|
||||
}
|
||||
}, 16);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -847,14 +1028,147 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
<Redo size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Status Legend */}
|
||||
<div className="flex items-center gap-3 border-l border-gray-300 dark:border-gray-600 pl-4">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Status:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-blue-100 border border-blue-500"></div>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Upcoming</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-yellow-100 border border-yellow-500"></div>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">In Progress</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-red-100 border border-red-500"></div>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Overdue</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-green-100 border border-green-500"></div>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Completed</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-gray-100 border border-gray-400"></div>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Cancelled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors shadow-sm">
|
||||
+ New Appointment
|
||||
</button>
|
||||
<button className="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 transition-colors">
|
||||
<Filter size={18} />
|
||||
</button>
|
||||
{/* Filter Dropdown */}
|
||||
<div className="relative" ref={filterMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowFilterMenu(!showFilterMenu)}
|
||||
className={`p-2 rounded-lg border transition-colors relative ${
|
||||
hasActiveFilters
|
||||
? 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 hover:bg-brand-100 dark:hover:bg-brand-900/50'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Filter size={18} />
|
||||
{hasActiveFilters && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-brand-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
{showFilterMenu && (
|
||||
<div className="absolute right-0 top-full mt-2 w-72 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 z-50 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white text-sm">Filters</h3>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{/* Status Filter */}
|
||||
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700">
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Status</h4>
|
||||
<div className="space-y-1">
|
||||
{(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW'] as AppointmentStatus[]).map(status => (
|
||||
<div
|
||||
key={status}
|
||||
onClick={() => toggleStatusFilter(status)}
|
||||
className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 px-2 -mx-2 rounded"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
|
||||
filterStatuses.has(status)
|
||||
? 'bg-brand-500 border-brand-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{filterStatuses.has(status) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 capitalize">
|
||||
{status.toLowerCase().replace('_', ' ')}
|
||||
</span>
|
||||
<div className={`w-2 h-2 rounded-full ml-auto ${
|
||||
status === 'COMPLETED' ? 'bg-green-500' :
|
||||
status === 'CANCELLED' ? 'bg-gray-400' :
|
||||
status === 'NO_SHOW' ? 'bg-gray-400' :
|
||||
status === 'CONFIRMED' ? 'bg-blue-500' :
|
||||
'bg-orange-400'
|
||||
}`}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Resource Filter */}
|
||||
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700">
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Resources</h4>
|
||||
<div className="space-y-1">
|
||||
{resources.map(resource => (
|
||||
<div
|
||||
key={resource.id}
|
||||
onClick={() => toggleResourceFilter(resource.id)}
|
||||
className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 px-2 -mx-2 rounded"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
|
||||
filterResources.size === 0 || filterResources.has(resource.id)
|
||||
? 'bg-brand-500 border-brand-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{(filterResources.size === 0 || filterResources.has(resource.id)) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">{resource.name}</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 capitalize ml-auto">{resource.type.toLowerCase()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Service Filter */}
|
||||
<div className="px-4 py-3">
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Services</h4>
|
||||
<div className="space-y-1">
|
||||
{services.map(service => (
|
||||
<div
|
||||
key={service.id}
|
||||
onClick={() => toggleServiceFilter(service.id)}
|
||||
className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 px-2 -mx-2 rounded"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
|
||||
filterServices.size === 0 || filterServices.has(service.id)
|
||||
? 'bg-brand-500 border-brand-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{(filterServices.size === 0 || filterServices.has(service.id)) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">{service.name}</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 ml-auto">{service.durationMinutes}min</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -910,9 +1224,13 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`bg-white dark:bg-gray-900 min-h-[120px] p-2 transition-colors relative ${
|
||||
date ? 'hover:bg-gray-50 dark:hover:bg-gray-800' : 'bg-gray-50 dark:bg-gray-800/50'
|
||||
} ${date && date.getMonth() !== viewDate.getMonth() ? 'opacity-50' : ''} ${monthDropTarget?.date.getTime() === date?.getTime() ? 'ring-2 ring-brand-500 ring-inset bg-brand-50 dark:bg-brand-900/20' : ''}`}
|
||||
className={`min-h-[120px] p-2 transition-colors relative ${
|
||||
date && date.getMonth() !== viewDate.getMonth()
|
||||
? 'bg-gray-100 dark:bg-gray-800/70 opacity-50'
|
||||
: date
|
||||
? 'bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800/50'
|
||||
} ${monthDropTarget?.date.getTime() === date?.getTime() && date?.getMonth() === viewDate.getMonth() ? 'ring-2 ring-brand-500 ring-inset bg-brand-50 dark:bg-brand-900/20' : ''}`}
|
||||
onClick={() => { if (date) { setViewDate(date); setViewMode('day'); } }}
|
||||
onDragOver={(e) => date && handleMonthCellDragOver(e, date)}
|
||||
>
|
||||
@@ -932,10 +1250,12 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
const startTime = new Date(apt.startTime);
|
||||
const isDragged = apt.id === draggedAppointmentId;
|
||||
|
||||
const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={apt.id}
|
||||
className={`text-xs p-1.5 rounded bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 truncate cursor-grab active:cursor-grabbing hover:bg-blue-200 dark:hover:bg-blue-800/50 transition-colors ${isDragged ? 'opacity-50' : ''}`}
|
||||
className={`text-xs p-1.5 rounded truncate cursor-grab active:cursor-grabbing transition-colors ${getMonthStatusColor(apt.status, startTime, endTime)} ${isDragged ? 'opacity-50' : ''}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, apt.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
@@ -964,135 +1284,269 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Month View Drop Overlay */}
|
||||
{monthDropTarget && draggedAppointmentId && (
|
||||
<Portal>
|
||||
<div
|
||||
className="fixed z-50 bg-white dark:bg-gray-800 shadow-xl rounded-lg border border-brand-200 dark:border-brand-800 overflow-hidden flex flex-col"
|
||||
style={{
|
||||
top: Math.min(window.innerHeight - 320, Math.max(10, monthDropTarget.rect.top - 20)), // Try to center vertically or keep on screen
|
||||
left: monthDropTarget.rect.left - 10,
|
||||
width: Math.max(300, monthDropTarget.rect.width + 150), // Wider to show lanes
|
||||
height: 400,
|
||||
animation: 'fadeIn 0.2s ease-out'
|
||||
}}
|
||||
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }} // Consume drag over
|
||||
>
|
||||
<div className="bg-brand-50 dark:bg-brand-900/30 px-3 py-2 border-b border-brand-100 dark:border-brand-800 flex items-center justify-between shrink-0">
|
||||
<span className="text-xs font-bold text-brand-700 dark:text-brand-300 uppercase tracking-wider">
|
||||
Move to {monthDropTarget.date.getDate()}
|
||||
</span>
|
||||
<div className="text-[10px] text-brand-500 dark:text-brand-400 bg-brand-100 dark:bg-brand-800/50 px-1.5 py-0.5 rounded-full">
|
||||
Drop in slot
|
||||
{/* Month View Drop Overlay - Mini Day Scheduler with Resource Rows */}
|
||||
{monthDropTarget && draggedAppointmentId && (() => {
|
||||
// Pre-calculate resource layouts with lanes for this day
|
||||
const dayStart = new Date(monthDropTarget.date);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(dayStart);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const OVERLAY_ROW_HEIGHT = 50; // Height per lane in overlay
|
||||
const OVERLAY_LANE_GAP = 2;
|
||||
const OVERLAY_PIXELS_PER_MINUTE = 1.5;
|
||||
const overlayTimelineWidth = (END_HOUR - START_HOUR) * 60 * OVERLAY_PIXELS_PER_MINUTE;
|
||||
|
||||
// Get the dragged appointment for preview calculations
|
||||
const draggedApt = appointments.find(a => a.id === draggedAppointmentId);
|
||||
|
||||
const overlayResourceLayouts = resources.map(resource => {
|
||||
// Get existing appointments for this resource on this day (excluding the dragged one)
|
||||
let resourceApps = appointments.filter(apt => {
|
||||
if (apt.resourceId !== resource.id || apt.id === draggedAppointmentId) return false;
|
||||
const t = new Date(apt.startTime);
|
||||
return t >= dayStart && t <= dayEnd;
|
||||
});
|
||||
|
||||
// Add preview appointment if hovering over this resource
|
||||
if (overlayPreview?.resourceId === resource.id && draggedApt) {
|
||||
const previewStartMinutes = (overlayPreview.hour - START_HOUR) * 60 + overlayPreview.minute;
|
||||
resourceApps = [...resourceApps, {
|
||||
...draggedApt,
|
||||
id: 'PREVIEW',
|
||||
startTime: new Date(dayStart.getTime() + previewStartMinutes * 60000),
|
||||
}];
|
||||
}
|
||||
|
||||
// Sort by start time, then by duration (longer first)
|
||||
resourceApps.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() || b.durationMinutes - a.durationMinutes);
|
||||
|
||||
// Calculate lanes (respecting maxConcurrentEvents)
|
||||
const lanes: number[] = [];
|
||||
const maxLanes = resource.maxConcurrentEvents === 0 ? Infinity : resource.maxConcurrentEvents;
|
||||
const laidOutApps = resourceApps.map(apt => {
|
||||
const start = new Date(apt.startTime);
|
||||
const startMinutes = (start.getHours() - START_HOUR) * 60 + start.getMinutes();
|
||||
const endMinutes = startMinutes + apt.durationMinutes;
|
||||
|
||||
let laneIndex = -1;
|
||||
for (let i = 0; i < Math.min(lanes.length, maxLanes); i++) {
|
||||
if (lanes[i] <= startMinutes) {
|
||||
laneIndex = i;
|
||||
lanes[i] = endMinutes;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (laneIndex === -1 && lanes.length < maxLanes) {
|
||||
lanes.push(endMinutes);
|
||||
laneIndex = lanes.length - 1;
|
||||
} else if (laneIndex === -1) {
|
||||
laneIndex = Math.min(lanes.length - 1, maxLanes - 1);
|
||||
}
|
||||
|
||||
return { ...apt, laneIndex, startMinutes, endMinutes, isPreview: apt.id === 'PREVIEW' };
|
||||
});
|
||||
|
||||
const laneCount = Math.max(1, Math.min(lanes.length, maxLanes === Infinity ? lanes.length : maxLanes));
|
||||
const rowHeight = laneCount * OVERLAY_ROW_HEIGHT + OVERLAY_LANE_GAP;
|
||||
|
||||
return { resource, appointments: laidOutApps, laneCount, rowHeight };
|
||||
});
|
||||
|
||||
const totalRowsHeight = overlayResourceLayouts.reduce((sum, r) => sum + r.rowHeight, 0);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
ref={overlayContainerCallbackRef}
|
||||
className="fixed z-50 bg-white dark:bg-gray-800 shadow-xl rounded-lg border border-brand-200 dark:border-brand-800 overflow-hidden flex flex-col"
|
||||
style={{
|
||||
top: Math.min(window.innerHeight - 400, Math.max(10, monthDropTarget.rect.top - 50)),
|
||||
left: Math.max(10, monthDropTarget.rect.left - 200),
|
||||
width: Math.min(900, window.innerWidth - 40),
|
||||
height: Math.min(400, totalRowsHeight + 80),
|
||||
animation: 'fadeIn 0.2s ease-out'
|
||||
}}
|
||||
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
onDragEnter={() => { isOverOverlayRef.current = true; }}
|
||||
onDragLeave={(e) => {
|
||||
// Only set to false if we're actually leaving the overlay (not entering a child)
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) {
|
||||
isOverOverlayRef.current = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-brand-50 dark:bg-brand-900/30 px-3 py-2 border-b border-brand-100 dark:border-brand-800 flex items-center justify-between shrink-0">
|
||||
<span className="text-xs font-bold text-brand-700 dark:text-brand-300 uppercase tracking-wider">
|
||||
{monthDropTarget.date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<div className="text-[10px] text-brand-500 dark:text-brand-400 bg-brand-100 dark:bg-brand-800/50 px-1.5 py-0.5 rounded-full">
|
||||
Drop on resource row
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar relative" ref={overlayScrollRef} onDragOver={handleOverlayScroll}>
|
||||
{/* Background Slots */}
|
||||
<div className="relative pb-4">
|
||||
{Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i).map(hour => (
|
||||
<div key={hour} className="flex flex-col border-b border-gray-100 dark:border-gray-800" style={{ height: OVERLAY_HOUR_HEIGHT }}>
|
||||
{/* Hour Slot (Top Half) */}
|
||||
<div
|
||||
className="flex-1 flex items-start group"
|
||||
onDrop={(e) => handleMonthTimeDrop(e, hour, 0)}
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||
>
|
||||
<div className="w-12 text-right text-xs font-medium text-gray-400 dark:text-gray-500 pr-2 -mt-2 select-none">
|
||||
{hour === 0 ? '12 AM' : hour > 12 ? `${hour - 12} PM` : `${hour} ${hour === 12 ? 'PM' : 'AM'}`}
|
||||
</div>
|
||||
<div className="flex-1 h-full border-l border-gray-200 dark:border-gray-700 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors relative">
|
||||
<div className="absolute left-0 right-0 top-0 h-px bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Half Hour Slot (Bottom Half) */}
|
||||
<div
|
||||
className="flex-1 flex items-start group"
|
||||
onDrop={(e) => handleMonthTimeDrop(e, hour, 30)}
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||
>
|
||||
<div className="w-12"></div>
|
||||
<div className="flex-1 h-full border-l border-gray-200 dark:border-gray-700 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors relative">
|
||||
<div className="absolute left-0 right-0 top-0 h-px bg-gray-100 dark:bg-gray-800 border-dashed"></div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Resource names sidebar - fixed */}
|
||||
<div className="w-28 shrink-0 bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
{/* Corner cell above resource names */}
|
||||
<div className="h-6 border-b border-gray-200 dark:border-gray-700 shrink-0"></div>
|
||||
{/* Resource names - synced scroll */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden" style={{ scrollbarWidth: 'none' }} ref={(el) => {
|
||||
if (el && overlayScrollRef.current) {
|
||||
el.scrollTop = overlayScrollRef.current.scrollTop;
|
||||
}
|
||||
}}>
|
||||
{overlayResourceLayouts.map(layout => (
|
||||
<div
|
||||
key={layout.resource.id}
|
||||
className="border-b border-gray-100 dark:border-gray-800 px-2 flex items-center"
|
||||
style={{ height: layout.rowHeight }}
|
||||
>
|
||||
<div className="truncate">
|
||||
<span className="text-[10px] font-semibold text-gray-700 dark:text-gray-300 block truncate">
|
||||
{layout.resource.name}
|
||||
</span>
|
||||
{layout.laneCount > 1 && (
|
||||
<span className="text-[8px] text-brand-500 dark:text-brand-400">
|
||||
{layout.laneCount} lanes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline area - scrollable both ways */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Time header - horizontal scroll only */}
|
||||
<div className="h-6 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 overflow-x-auto overflow-y-hidden shrink-0" style={{ scrollbarWidth: 'none' }}>
|
||||
<div className="flex" style={{ width: overlayTimelineWidth }}>
|
||||
{Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i).map(hour => (
|
||||
<div
|
||||
key={hour}
|
||||
className="border-r border-gray-200 dark:border-gray-700 text-[9px] font-medium text-gray-500 dark:text-gray-400 px-1 flex items-center"
|
||||
style={{ width: 60 * OVERLAY_PIXELS_PER_MINUTE }}
|
||||
>
|
||||
{hour === 0 ? '12a' : hour === 12 ? '12p' : hour > 12 ? `${hour - 12}p` : `${hour}a`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Existing Appointments Layer */}
|
||||
<div className="absolute inset-0 left-12 pointer-events-none">
|
||||
{(() => {
|
||||
// Calculate layout for this day
|
||||
const dayStart = new Date(monthDropTarget.date);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(dayStart);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const dayApps = appointments.filter(apt => {
|
||||
if (!apt.resourceId || apt.id === draggedAppointmentId) return false;
|
||||
const t = new Date(apt.startTime);
|
||||
return t >= dayStart && t <= dayEnd;
|
||||
}).sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() || b.durationMinutes - a.durationMinutes);
|
||||
{/* Timeline grid with appointments */}
|
||||
<div className="flex-1 overflow-auto custom-scrollbar" ref={overlayScrollRef} onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
handleOverlayAutoScroll(e);
|
||||
}} onScroll={(e) => {
|
||||
// Sync horizontal scroll with header
|
||||
const header = e.currentTarget.previousElementSibling as HTMLElement;
|
||||
if (header) header.scrollLeft = e.currentTarget.scrollLeft;
|
||||
}}>
|
||||
<div style={{ width: overlayTimelineWidth, minHeight: totalRowsHeight }}>
|
||||
{overlayResourceLayouts.map((layout) => (
|
||||
<div
|
||||
key={layout.resource.id}
|
||||
className="relative border-b border-gray-100 dark:border-gray-800"
|
||||
style={{ height: layout.rowHeight }}
|
||||
>
|
||||
{/* Hour grid lines */}
|
||||
{Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i).map(hour => (
|
||||
<React.Fragment key={hour}>
|
||||
{/* Hour line */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ left: hour * 60 * OVERLAY_PIXELS_PER_MINUTE }}
|
||||
></div>
|
||||
{/* Half-hour line */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 border-r border-dashed border-gray-100 dark:border-gray-800"
|
||||
style={{ left: (hour * 60 + 30) * OVERLAY_PIXELS_PER_MINUTE }}
|
||||
></div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
// Calculate lanes
|
||||
const lanes: number[] = [];
|
||||
const laidOutApps = dayApps.map(apt => {
|
||||
const start = new Date(apt.startTime);
|
||||
const startMinutes = (start.getHours() * 60) + start.getMinutes();
|
||||
const endMinutes = startMinutes + apt.durationMinutes;
|
||||
|
||||
let laneIndex = -1;
|
||||
for (let i = 0; i < lanes.length; i++) {
|
||||
if (lanes[i] <= startMinutes) {
|
||||
laneIndex = i;
|
||||
lanes[i] = endMinutes;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (laneIndex === -1) {
|
||||
lanes.push(endMinutes);
|
||||
laneIndex = lanes.length - 1;
|
||||
}
|
||||
|
||||
return { ...apt, laneIndex, startMinutes, endMinutes };
|
||||
});
|
||||
{/* Drop zones for each half-hour */}
|
||||
{Array.from({ length: (END_HOUR - START_HOUR) * 2 }, (_, i) => {
|
||||
const hour = START_HOUR + Math.floor(i / 2);
|
||||
const minute = (i % 2) * 30;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{
|
||||
left: (hour * 60 + minute - START_HOUR * 60) * OVERLAY_PIXELS_PER_MINUTE,
|
||||
width: 30 * OVERLAY_PIXELS_PER_MINUTE
|
||||
}}
|
||||
onDrop={(e) => handleMonthTimeDrop(e, hour, minute, layout.resource.id)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
// Set preview state
|
||||
if (!overlayPreview || overlayPreview.resourceId !== layout.resource.id ||
|
||||
overlayPreview.hour !== hour || overlayPreview.minute !== minute) {
|
||||
setOverlayPreview({ resourceId: layout.resource.id, hour, minute });
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
// Only clear if leaving this specific zone
|
||||
if (overlayPreview?.resourceId === layout.resource.id &&
|
||||
overlayPreview?.hour === hour && overlayPreview?.minute === minute) {
|
||||
// Don't clear immediately - let dragover on another zone set it
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
|
||||
const totalLanes = Math.max(1, lanes.length);
|
||||
{/* Appointments (including preview) */}
|
||||
{layout.appointments.map(apt => {
|
||||
const left = apt.startMinutes * OVERLAY_PIXELS_PER_MINUTE;
|
||||
const width = apt.durationMinutes * OVERLAY_PIXELS_PER_MINUTE;
|
||||
const top = apt.laneIndex * OVERLAY_ROW_HEIGHT + OVERLAY_LANE_GAP;
|
||||
const service = services.find(s => s.id === apt.serviceId);
|
||||
const startTime = new Date(apt.startTime);
|
||||
const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000);
|
||||
const isPreview = apt.isPreview;
|
||||
|
||||
return laidOutApps.map(apt => {
|
||||
const top = (apt.startMinutes / 60) * OVERLAY_HOUR_HEIGHT;
|
||||
const height = (apt.durationMinutes / 60) * OVERLAY_HOUR_HEIGHT;
|
||||
const widthPercent = 100 / totalLanes;
|
||||
const leftPercent = apt.laneIndex * widthPercent;
|
||||
const service = services.find(s => s.id === apt.serviceId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={apt.id}
|
||||
className="absolute rounded border border-gray-200 dark:border-gray-600 bg-gray-100/90 dark:bg-gray-700/90 p-1 overflow-hidden shadow-sm"
|
||||
style={{
|
||||
top: `${top}px`,
|
||||
height: `${Math.max(20, height - 2)}px`,
|
||||
left: `${leftPercent}%`,
|
||||
width: `${widthPercent - 2}%`,
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] font-bold text-gray-700 dark:text-gray-300 truncate leading-tight">
|
||||
{apt.customerName}
|
||||
</div>
|
||||
<div className="text-[9px] text-gray-500 dark:text-gray-400 truncate leading-tight">
|
||||
{service?.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={apt.id}
|
||||
className={`absolute rounded border-l-2 p-1 overflow-hidden pointer-events-none ${
|
||||
isPreview
|
||||
? 'border-dashed border-brand-400 dark:border-brand-600 bg-brand-50 dark:bg-brand-900/40 shadow-md opacity-80'
|
||||
: `shadow-sm ${getMonthStatusColor(apt.status, startTime, endTime)}`
|
||||
}`}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
width: `${Math.max(30, width - 2)}px`,
|
||||
top: `${top}px`,
|
||||
height: `${OVERLAY_ROW_HEIGHT - OVERLAY_LANE_GAP * 2}px`,
|
||||
zIndex: isPreview ? 50 : 10
|
||||
}}
|
||||
>
|
||||
<div className={`text-[9px] font-bold truncate leading-tight ${isPreview ? 'text-brand-700 dark:text-brand-300' : ''}`}>
|
||||
{apt.customerName}
|
||||
</div>
|
||||
<div className={`text-[8px] truncate leading-tight ${isPreview ? 'text-brand-600 dark:text-brand-400' : 'opacity-80'}`}>
|
||||
{service?.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Day/Week View - Timeline */}
|
||||
{viewMode !== 'month' && (
|
||||
|
||||
Reference in New Issue
Block a user