feat: drag-to-resize appointment blocks on schedule to adjust duration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { addDays, addWeeks, startOfToday, addMinutes } from "date-fns";
|
||||
import {
|
||||
@@ -779,21 +779,26 @@ export default function AppointmentsPage() {
|
||||
const appointment = appointments.find((a) => a.id === appointmentId);
|
||||
if (!appointment) return;
|
||||
|
||||
// Calculate new end time (30 minutes from start)
|
||||
// Preserve existing duration when moving
|
||||
const apt = appointment as any;
|
||||
const oldStart = (typeof apt.startTime === "string" ? apt.startTime : formatLocalTime(apt.startTime)).substring(0, 5);
|
||||
const oldEnd = (typeof apt.endTime === "string" ? apt.endTime : formatLocalTime(apt.endTime)).substring(0, 5);
|
||||
const [osH, osM] = oldStart.split(":").map(Number);
|
||||
const [oeH, oeM] = oldEnd.split(":").map(Number);
|
||||
const durationMinutes = ((oeH ?? 0) * 60 + (oeM ?? 0)) - ((osH ?? 0) * 60 + (osM ?? 0));
|
||||
const effectiveDuration = durationMinutes > 0 ? durationMinutes : 30;
|
||||
|
||||
const startHour = parseInt(newTimeSlot.time.split(":")[0] as string);
|
||||
const startMinute = parseInt(newTimeSlot.time.split(":")[1] as string);
|
||||
const startDate = parseLocalDate(selectedDate);
|
||||
startDate.setHours(startHour, startMinute, 0);
|
||||
|
||||
const endDate = addMinutes(startDate, 30);
|
||||
const endDate = addMinutes(startDate, effectiveDuration);
|
||||
const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`;
|
||||
|
||||
// Find staff member
|
||||
const staff = staffMembers.find((s) => Number(s.id) === newStaffId);
|
||||
|
||||
// Send only the real DB fields — the appointment object may contain computed
|
||||
// fields (hasProcedures, hasClaimWithNumber, etc.) that the Zod schema rejects
|
||||
const apt = appointment as any;
|
||||
const updatedAppointment: UpdateAppointment = {
|
||||
patientId: apt.patientId,
|
||||
userId: apt.userId,
|
||||
@@ -861,15 +866,93 @@ export default function AppointmentsPage() {
|
||||
}),
|
||||
}));
|
||||
|
||||
const resizingRef = useRef(false);
|
||||
const startYRef = useRef(0);
|
||||
const originalSpanRef = useRef(1);
|
||||
const [resizeSpan, setResizeSpan] = useState<number | null>(null);
|
||||
|
||||
const currentSpan = getDisplaySpan(appointment);
|
||||
const SLOT_HEIGHT = 57; // h-14 = 56px + 1px border
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
resizingRef.current = true;
|
||||
startYRef.current = e.clientY;
|
||||
originalSpanRef.current = currentSpan;
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
const deltaY = ev.clientY - startYRef.current;
|
||||
const slotDelta = Math.round(deltaY / SLOT_HEIGHT);
|
||||
const newSpan = Math.max(1, originalSpanRef.current + slotDelta);
|
||||
setResizeSpan(newSpan);
|
||||
};
|
||||
|
||||
const onMouseUp = (ev: MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
resizingRef.current = false;
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
|
||||
const deltaY = ev.clientY - startYRef.current;
|
||||
const slotDelta = Math.round(deltaY / SLOT_HEIGHT);
|
||||
const newSpan = Math.max(1, originalSpanRef.current + slotDelta);
|
||||
setResizeSpan(null);
|
||||
|
||||
if (newSpan !== originalSpanRef.current && appointment.id) {
|
||||
const startStr = (typeof appointment.startTime === "string" ? appointment.startTime : formatLocalTime(appointment.startTime)).substring(0, 5);
|
||||
const [sH, sM] = startStr.split(":").map(Number);
|
||||
const newEndMinutes = ((sH ?? 0) * 60 + (sM ?? 0)) + newSpan * 15;
|
||||
const endH = Math.floor(newEndMinutes / 60).toString().padStart(2, "0");
|
||||
const endM = (newEndMinutes % 60).toString().padStart(2, "0");
|
||||
const newEndTime = `${endH}:${endM}`;
|
||||
|
||||
const fullApt = appointments.find((a) => a.id === appointment.id) as any;
|
||||
if (fullApt) {
|
||||
updateAppointmentMutation.mutate({
|
||||
id: appointment.id,
|
||||
appointment: {
|
||||
patientId: fullApt.patientId,
|
||||
userId: fullApt.userId,
|
||||
staffId: fullApt.staffId ?? appointment.staffId,
|
||||
title: fullApt.title,
|
||||
date: fullApt.date,
|
||||
type: fullApt.type,
|
||||
status: fullApt.status ?? undefined,
|
||||
startTime: startStr,
|
||||
endTime: newEndTime,
|
||||
notes: fullApt.notes ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.body.style.cursor = "ns-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
}, [appointment, currentSpan]);
|
||||
|
||||
const displayHeight = resizeSpan !== null
|
||||
? `${resizeSpan * SLOT_HEIGHT - 2}px`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drag as unknown as React.RefObject<HTMLDivElement>} // Type assertion to make TypeScript happy
|
||||
className={`${appointmentCardColor(appointment)} border shadow-md rounded p-1 text-xs h-full overflow-visible cursor-move relative ${
|
||||
ref={drag as unknown as React.RefObject<HTMLDivElement>}
|
||||
className={`${appointmentCardColor(appointment)} border shadow-md rounded p-1 text-xs overflow-visible cursor-move relative ${
|
||||
isDragging ? "opacity-50" : "opacity-100"
|
||||
}`}
|
||||
style={{ fontWeight: 500 }}
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
height: displayHeight ?? "100%",
|
||||
...(resizeSpan !== null ? { zIndex: 20 } : {}),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Only allow edit on click if we're not dragging
|
||||
if (!isDragging) {
|
||||
const fullAppointment = appointments.find(
|
||||
(a) => a.id === appointment.id
|
||||
@@ -884,8 +967,8 @@ export default function AppointmentsPage() {
|
||||
>
|
||||
<PatientStatusBadge
|
||||
status={resolveAppointmentBadgeStatus(appointment)}
|
||||
className="pointer-events-auto" // ensure tooltip works
|
||||
size={30} // bump size up from 10 → 14
|
||||
className="pointer-events-auto"
|
||||
size={30}
|
||||
/>
|
||||
|
||||
<div className="font-bold truncate flex items-center gap-1">
|
||||
@@ -914,6 +997,14 @@ export default function AppointmentsPage() {
|
||||
{appointment.notes}
|
||||
</div>
|
||||
)}
|
||||
{/* Resize handle at the bottom */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize flex items-center justify-center group hover:bg-black/10 rounded-b"
|
||||
onMouseDown={handleResizeStart}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-8 h-0.5 bg-current opacity-30 group-hover:opacity-70 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user