feat: Implement drag-and-drop in Month View with 'Day Snap' time slot overlay
This commit is contained in:
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user