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:
ff
2026-06-21 00:39:38 -04:00
parent 20b478a7a9
commit 0139f89e2f

View File

@@ -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>
);
}