feat: Implement overlapping lane layout for Month View drop overlay

This commit is contained in:
poduck
2025-11-27 20:22:57 -05:00
parent d54d9eee6b
commit 2a95b007e2

View File

@@ -15,6 +15,7 @@ import Portal from '../components/Portal';
const START_HOUR = 0; // Midnight
const END_HOUR = 24; // Midnight next day
const PIXELS_PER_MINUTE = 2.5;
const OVERLAY_HOUR_HEIGHT = 60;
const HEADER_HEIGHT = 48;
const SIDEBAR_WIDTH = 250;
@@ -971,8 +972,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
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: monthDropTarget.rect.width + 20,
height: 300,
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
@@ -985,52 +986,109 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
Drop in slot
</div>
</div>
<div className="flex-1 overflow-y-auto p-1 custom-scrollbar" ref={overlayScrollRef} onDragOver={handleOverlayScroll}>
{Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i).map(hour => {
// Check if we have conflicts (simple check)
const dayStart = new Date(monthDropTarget.date);
dayStart.setHours(hour, 0, 0, 0);
const dayEnd = new Date(dayStart);
dayEnd.setMinutes(59);
const hasAppt = filteredAppointments.some(a => {
if (a.id === draggedAppointmentId) return false; // Don't count self
const t = new Date(a.startTime);
return t >= dayStart && t <= dayEnd;
});
return (
<div key={hour} className="flex flex-col gap-1 mb-1">
{/* Hour Slot */}
<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 items-center gap-2 p-2 rounded cursor-pointer transition-colors border border-transparent hover:border-brand-300 dark:hover:border-brand-600 ${hasAppt ? 'bg-gray-50 dark:bg-gray-700/30' : 'hover:bg-brand-50 dark:hover:bg-brand-900/20'}`}
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-500 dark:text-gray-400">
{hour > 12 ? `${hour - 12} PM` : `${hour} ${hour === 12 ? 'PM' : 'AM'}`}
<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-6 border-l border-gray-200 dark:border-gray-700 pl-2 flex items-center">
{hasAppt && <div className="h-1.5 w-1.5 rounded-full bg-gray-400 dark:bg-gray-500 mr-2" title="Existing appointment"></div>}
<div className="h-px flex-1 bg-gray-100 dark:bg-gray-800"></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 */}
{/* Half Hour Slot (Bottom Half) */}
<div
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors border border-transparent hover:border-brand-300 dark:hover:border-brand-600 ${hasAppt ? 'bg-gray-50 dark:bg-gray-700/30' : 'hover:bg-brand-50 dark:hover:bg-brand-900/20'}`}
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 text-right text-[10px] text-gray-400 dark:text-gray-500">
:30
</div>
<div className="flex-1 h-4 border-l border-dashed border-gray-200 dark:border-gray-700 pl-2 flex items-center">
<div className="h-px flex-1 bg-gray-50 dark:bg-gray-800"></div>
<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>
</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);
// 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 };
});
const totalLanes = Math.max(1, lanes.length);
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>
</div>
</div>
</div>
</Portal>