From 0139f89e2f0f1822270b72f1d590980bf01c8b59 Mon Sep 17 00:00:00 2001 From: ff Date: Sun, 21 Jun 2026 00:39:38 -0400 Subject: [PATCH] feat: drag-to-resize appointment blocks on schedule to adjust duration Co-Authored-By: Claude Sonnet 4.6 --- apps/Frontend/src/pages/appointments-page.tsx | 115 ++++++++++++++++-- 1 file changed, 103 insertions(+), 12 deletions(-) diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index 03ab01b9..0ab6a983 100755 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -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(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 (
} // 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} + 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() { >
@@ -914,6 +997,14 @@ export default function AppointmentsPage() { {appointment.notes}
)} + {/* Resize handle at the bottom */} +
e.stopPropagation()} + > +
+
); }