feat: Implement overlapping lane layout for Month View drop overlay
This commit is contained in:
@@ -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 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 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>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user