feat: Implement drag-and-drop in Month View with 'Day Snap' time slot overlay

This commit is contained in:
poduck
2025-11-27 20:14:48 -05:00
parent 373257469b
commit d7cc83ebdd

View File

@@ -89,6 +89,7 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [monthDropTarget, setMonthDropTarget] = useState<{ date: Date; rect: DOMRect } | null>(null);
// State for editing appointments
const [editDateTime, setEditDateTime] = useState('');
@@ -529,12 +530,17 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
e.dataTransfer.setData('appointmentId', appointmentId);
e.dataTransfer.effectAllowed = 'move';
// Calculate where on the appointment the drag started (relative to appointment, not timeline)
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const offsetX = e.clientX - rect.left; // Just the offset within the appointment itself
const offsetMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15;
setDragOffsetMinutes(offsetMinutes);
// In month view, we don't track minute offset
if (viewMode === 'month') {
setDragOffsetMinutes(0);
} else {
// Calculate where on the appointment the drag started (relative to appointment, not timeline)
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const offsetX = e.clientX - rect.left; // Just the offset within the appointment itself
const offsetMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15;
setDragOffsetMinutes(offsetMinutes);
}
setTimeout(() => setDraggedAppointmentId(appointmentId), 0);
};
@@ -542,10 +548,63 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const handleDragEnd = () => {
setDraggedAppointmentId(null);
setPreviewState(null);
setMonthDropTarget(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;
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 });
}
};
const handleMonthTimeDrop = (e: React.DragEvent, targetHour: number, targetMinute: number) => {
e.preventDefault();
e.stopPropagation();
if (!draggedAppointmentId || !monthDropTarget) return;
const appointment = appointments.find(a => a.id === draggedAppointmentId);
if (appointment) {
const newStartTime = new Date(monthDropTarget.date);
newStartTime.setHours(targetHour, targetMinute, 0, 0);
// Preserve duration, keep resource
// Add to history
addToHistory({
type: 'move',
appointmentId: appointment.id,
before: {
startTime: new Date(appointment.startTime),
resourceId: appointment.resourceId,
durationMinutes: appointment.durationMinutes
},
after: {
startTime: newStartTime,
resourceId: appointment.resourceId,
durationMinutes: appointment.durationMinutes
}
});
updateMutation.mutate({
id: appointment.id,
updates: {
startTime: newStartTime
}
});
}
setMonthDropTarget(null);
setDraggedAppointmentId(null);
};
const handleAppointmentClick = (appointment: Appointment) => {
// Only open modal if we didn't actually drag or resize
if (!isDragging && !isResizing) {
@@ -831,10 +890,11 @@ 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 ${
date ? 'hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer' : 'bg-gray-50 dark:bg-gray-800/50'
}`}
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'
} ${monthDropTarget?.date.getTime() === date?.getTime() ? '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)}
>
{date && (
<>
@@ -850,10 +910,15 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const service = services.find(s => s.id === apt.serviceId);
const resource = resources.find(r => r.id === apt.resourceId);
const startTime = new Date(apt.startTime);
const isDragged = apt.id === draggedAppointmentId;
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-pointer hover:bg-blue-200 dark:hover:bg-blue-800/50 transition-colors"
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' : ''}`}
draggable
onDragStart={(e) => handleDragStart(e, apt.id)}
onDragEnd={handleDragEnd}
onClick={(e) => { e.stopPropagation(); handleAppointmentClick(apt); }}
title={`${apt.customerName} - ${service?.name} with ${resource?.name}`}
>
@@ -878,6 +943,79 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
</div>
</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: monthDropTarget.rect.width + 20,
height: 300,
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
</div>
</div>
<div className="flex-1 overflow-y-auto p-1 custom-scrollbar">
{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 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'}`}
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>
<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>
</div>
{/* Half Hour Slot */}
<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'}`}
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>
</div>
</div>
);
})}
</div>
</div>
</Portal>
)}
{/* Day/Week View - Timeline */}
{viewMode !== 'month' && (