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 [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const [monthDropTarget, setMonthDropTarget] = useState<{ date: Date; rect: DOMRect } | null>(null);
|
||||||
|
|
||||||
// State for editing appointments
|
// State for editing appointments
|
||||||
const [editDateTime, setEditDateTime] = useState('');
|
const [editDateTime, setEditDateTime] = useState('');
|
||||||
@@ -529,12 +530,17 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
e.dataTransfer.setData('appointmentId', appointmentId);
|
e.dataTransfer.setData('appointmentId', appointmentId);
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
|
||||||
// Calculate where on the appointment the drag started (relative to appointment, not timeline)
|
// In month view, we don't track minute offset
|
||||||
const target = e.currentTarget as HTMLElement;
|
if (viewMode === 'month') {
|
||||||
const rect = target.getBoundingClientRect();
|
setDragOffsetMinutes(0);
|
||||||
const offsetX = e.clientX - rect.left; // Just the offset within the appointment itself
|
} else {
|
||||||
const offsetMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15;
|
// Calculate where on the appointment the drag started (relative to appointment, not timeline)
|
||||||
setDragOffsetMinutes(offsetMinutes);
|
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);
|
setTimeout(() => setDraggedAppointmentId(appointmentId), 0);
|
||||||
};
|
};
|
||||||
@@ -542,10 +548,63 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
setDraggedAppointmentId(null);
|
setDraggedAppointmentId(null);
|
||||||
setPreviewState(null);
|
setPreviewState(null);
|
||||||
|
setMonthDropTarget(null);
|
||||||
// Reset isDragging after a short delay to allow click detection
|
// Reset isDragging after a short delay to allow click detection
|
||||||
setTimeout(() => setIsDragging(false), 100);
|
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) => {
|
const handleAppointmentClick = (appointment: Appointment) => {
|
||||||
// Only open modal if we didn't actually drag or resize
|
// Only open modal if we didn't actually drag or resize
|
||||||
if (!isDragging && !isResizing) {
|
if (!isDragging && !isResizing) {
|
||||||
@@ -831,10 +890,11 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`bg-white dark:bg-gray-900 min-h-[120px] p-2 transition-colors ${
|
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 cursor-pointer' : 'bg-gray-50 dark:bg-gray-800/50'
|
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'); } }}
|
onClick={() => { if (date) { setViewDate(date); setViewMode('day'); } }}
|
||||||
|
onDragOver={(e) => date && handleMonthCellDragOver(e, date)}
|
||||||
>
|
>
|
||||||
{date && (
|
{date && (
|
||||||
<>
|
<>
|
||||||
@@ -850,10 +910,15 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
const service = services.find(s => s.id === apt.serviceId);
|
const service = services.find(s => s.id === apt.serviceId);
|
||||||
const resource = resources.find(r => r.id === apt.resourceId);
|
const resource = resources.find(r => r.id === apt.resourceId);
|
||||||
const startTime = new Date(apt.startTime);
|
const startTime = new Date(apt.startTime);
|
||||||
|
const isDragged = apt.id === draggedAppointmentId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={apt.id}
|
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); }}
|
onClick={(e) => { e.stopPropagation(); handleAppointmentClick(apt); }}
|
||||||
title={`${apt.customerName} - ${service?.name} with ${resource?.name}`}
|
title={`${apt.customerName} - ${service?.name} with ${resource?.name}`}
|
||||||
>
|
>
|
||||||
@@ -878,6 +943,79 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Day/Week View - Timeline */}
|
||||||
{viewMode !== 'month' && (
|
{viewMode !== 'month' && (
|
||||||
|
|||||||
Reference in New Issue
Block a user