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 { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { addDays, addWeeks, startOfToday, addMinutes } from "date-fns";
|
import { addDays, addWeeks, startOfToday, addMinutes } from "date-fns";
|
||||||
import {
|
import {
|
||||||
@@ -779,21 +779,26 @@ export default function AppointmentsPage() {
|
|||||||
const appointment = appointments.find((a) => a.id === appointmentId);
|
const appointment = appointments.find((a) => a.id === appointmentId);
|
||||||
if (!appointment) return;
|
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 startHour = parseInt(newTimeSlot.time.split(":")[0] as string);
|
||||||
const startMinute = parseInt(newTimeSlot.time.split(":")[1] as string);
|
const startMinute = parseInt(newTimeSlot.time.split(":")[1] as string);
|
||||||
const startDate = parseLocalDate(selectedDate);
|
const startDate = parseLocalDate(selectedDate);
|
||||||
startDate.setHours(startHour, startMinute, 0);
|
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")}`;
|
const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`;
|
||||||
|
|
||||||
// Find staff member
|
// Find staff member
|
||||||
const staff = staffMembers.find((s) => Number(s.id) === newStaffId);
|
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 = {
|
const updatedAppointment: UpdateAppointment = {
|
||||||
patientId: apt.patientId,
|
patientId: apt.patientId,
|
||||||
userId: apt.userId,
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={drag as unknown as React.RefObject<HTMLDivElement>} // Type assertion to make TypeScript happy
|
ref={drag as unknown as React.RefObject<HTMLDivElement>}
|
||||||
className={`${appointmentCardColor(appointment)} border shadow-md rounded p-1 text-xs h-full overflow-visible cursor-move relative ${
|
className={`${appointmentCardColor(appointment)} border shadow-md rounded p-1 text-xs overflow-visible cursor-move relative ${
|
||||||
isDragging ? "opacity-50" : "opacity-100"
|
isDragging ? "opacity-50" : "opacity-100"
|
||||||
}`}
|
}`}
|
||||||
style={{ fontWeight: 500 }}
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
height: displayHeight ?? "100%",
|
||||||
|
...(resizeSpan !== null ? { zIndex: 20 } : {}),
|
||||||
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
// Only allow edit on click if we're not dragging
|
|
||||||
if (!isDragging) {
|
if (!isDragging) {
|
||||||
const fullAppointment = appointments.find(
|
const fullAppointment = appointments.find(
|
||||||
(a) => a.id === appointment.id
|
(a) => a.id === appointment.id
|
||||||
@@ -884,8 +967,8 @@ export default function AppointmentsPage() {
|
|||||||
>
|
>
|
||||||
<PatientStatusBadge
|
<PatientStatusBadge
|
||||||
status={resolveAppointmentBadgeStatus(appointment)}
|
status={resolveAppointmentBadgeStatus(appointment)}
|
||||||
className="pointer-events-auto" // ensure tooltip works
|
className="pointer-events-auto"
|
||||||
size={30} // bump size up from 10 → 14
|
size={30}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="font-bold truncate flex items-center gap-1">
|
<div className="font-bold truncate flex items-center gap-1">
|
||||||
@@ -914,6 +997,14 @@ export default function AppointmentsPage() {
|
|||||||
{appointment.notes}
|
{appointment.notes}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user