import { useState, useEffect, useRef, useCallback } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { Card, CardContent } from "@/components/ui/card"; import { useToast } from "@/hooks/use-toast"; import { Trash2, Plus, X, Pencil } from "lucide-react"; // ── Types ───────────────────────────────────────────────────────────────────── type DoctorColumn = "A" | "B" | "C"; interface ProcedureEntry { id: string; code: string; description: string; durationMin: number; } interface DoctorSlot { id: string; column: DoctorColumn; startTime: string; // "HH:MM" endTime: string; // "HH:MM" label: string; } interface HygienistSlot { id: string; description: string; durationMin: number; } interface TimeslotData { procedures: ProcedureEntry[]; doctorSlots: DoctorSlot[]; hygienistSlots: HygienistSlot[]; } // ── Time grid helpers ───────────────────────────────────────────────────────── function buildTimeSlots() { const slots: { time: string; display: string }[] = []; for (let h = 8; h <= 21; h++) { for (let m = 0; m < 60; m += 15) { if (h === 21 && m > 0) continue; const pad = (n: number) => n.toString().padStart(2, "0"); const time = `${pad(h)}:${pad(m)}`; const h12 = h > 12 ? h - 12 : h === 0 ? 12 : h; const period = h >= 12 ? "PM" : "AM"; const display = `${h12}:${pad(m)} ${period}`; slots.push({ time, display }); } } return slots; } const TIME_SLOTS = buildTimeSlots(); function timeToIdx(time: string): number { const [h, m] = time.split(":").map(Number); return (h - 8) * 4 + Math.floor(m / 15); } function idxToTime(idx: number): string { const totalMin = idx * 15 + 8 * 60; const h = Math.floor(totalMin / 60); const m = totalMin % 60; return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`; } function uid() { return Math.random().toString(36).slice(2, 10); } const DURATION_OPTIONS = [15, 30, 45, 60, 75, 90, 120]; const COL_COLORS: Record = { A: "bg-sky-500", B: "bg-teal-500", C: "bg-indigo-500", }; const COL_LIGHT: Record = { A: "bg-sky-100 border-sky-300 text-sky-800", B: "bg-teal-100 border-teal-300 text-teal-800", C: "bg-indigo-100 border-indigo-300 text-indigo-800", }; // ── Main component ──────────────────────────────────────────────────────────── export function ProcedureTimeslotCard() { const { toast } = useToast(); // ── Remote data ───────────────────────────────────────────────── const { data: remote, isLoading } = useQuery<{ data: TimeslotData } | null>({ queryKey: ["/api/procedure-timeslot"], queryFn: async () => { const res = await apiRequest("GET", "/api/procedure-timeslot"); if (!res.ok) throw new Error("Failed to load"); return res.json(); }, }); const saveMutation = useMutation({ mutationFn: async (payload: TimeslotData) => { const res = await apiRequest("PUT", "/api/procedure-timeslot", { data: payload }); if (!res.ok) throw new Error("Failed to save"); return res.json(); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/procedure-timeslot"] }); toast({ title: "Saved", description: "Settings saved successfully." }); }, onError: () => toast({ title: "Error", description: "Failed to save.", variant: "destructive" }), }); // ── Section 1 state: Procedure durations ───────────────────────── const [procedures, setProcedures] = useState([]); const [newCode, setNewCode] = useState(""); const [newDesc, setNewDesc] = useState(""); const [newDuration, setNewDuration] = useState(30); // ── Section 2 state: Doctor time slots ─────────────────────────── const [doctorSlots, setDoctorSlots] = useState([]); // Drag selection state const [dragCol, setDragCol] = useState(null); const [dragStartIdx, setDragStartIdx] = useState(null); const [dragEndIdx, setDragEndIdx] = useState(null); const mouseDownRef = useRef(false); // Pending slot confirmation const [pendingSlot, setPendingSlot] = useState<{ col: DoctorColumn; startIdx: number; endIdx: number } | null>(null); const [pendingLabel, setPendingLabel] = useState(""); // Edit existing slot const [editingSlot, setEditingSlot] = useState(null); const [editLabel, setEditLabel] = useState(""); const [editStart, setEditStart] = useState(""); const [editEnd, setEditEnd] = useState(""); const [editCol, setEditCol] = useState("A"); // ── Section 3 state: Hygienist slots ───────────────────────────── const [hygienistSlots, setHygienistSlots] = useState([]); const [newHygDesc, setNewHygDesc] = useState(""); const [newHygDuration, setNewHygDuration] = useState(30); // Populate from remote when loaded useEffect(() => { if (!remote?.data) return; const d = remote.data; setProcedures(d.procedures ?? []); setDoctorSlots(d.doctorSlots ?? []); setHygienistSlots(d.hygienistSlots ?? []); }, [remote]); // Global mouseup handler to end drag regardless of cursor position useEffect(() => { const onMouseUp = () => { if (!mouseDownRef.current) return; mouseDownRef.current = false; if (dragCol !== null && dragStartIdx !== null && dragEndIdx !== null) { const s = Math.min(dragStartIdx, dragEndIdx); const e = Math.max(dragStartIdx, dragEndIdx); if (e > s) { setPendingSlot({ col: dragCol, startIdx: s, endIdx: e }); setPendingLabel(""); } } setDragCol(null); setDragStartIdx(null); setDragEndIdx(null); }; window.addEventListener("mouseup", onMouseUp); return () => window.removeEventListener("mouseup", onMouseUp); }, [dragCol, dragStartIdx, dragEndIdx]); // ── Drag handlers ──────────────────────────────────────────────── const handleCellMouseDown = useCallback((col: DoctorColumn, idx: number) => { mouseDownRef.current = true; setDragCol(col); setDragStartIdx(idx); setDragEndIdx(idx); }, []); const handleCellMouseEnter = useCallback((col: DoctorColumn, idx: number) => { if (!mouseDownRef.current || dragCol !== col) return; setDragEndIdx(idx); }, [dragCol]); const isDragHighlighted = (col: DoctorColumn, idx: number) => { if (dragCol !== col || dragStartIdx === null || dragEndIdx === null) return false; const s = Math.min(dragStartIdx, dragEndIdx); const e = Math.max(dragStartIdx, dragEndIdx); return idx >= s && idx <= e; }; // ── Doctor slot helpers ────────────────────────────────────────── const getSlotOccupancy = (col: DoctorColumn, rowIdx: number) => { for (const slot of doctorSlots) { if (slot.column !== col) continue; const startIdx = timeToIdx(slot.startTime); const endIdx = timeToIdx(slot.endTime); if (rowIdx === startIdx) return { slot, isStart: true, rowSpan: endIdx - startIdx }; if (rowIdx > startIdx && rowIdx < endIdx) return { slot, isStart: false, rowSpan: 0 }; } return null; }; const confirmPendingSlot = () => { if (!pendingSlot) return; // Check for overlap with existing slots in same column const newStart = idxToTime(pendingSlot.startIdx); const newEnd = idxToTime(pendingSlot.endIdx); const overlap = doctorSlots.some((s) => { if (s.column !== pendingSlot.col) return false; return !(newEnd <= s.startTime || newStart >= s.endTime); }); if (overlap) { toast({ title: "Overlap", description: "This time range overlaps an existing slot.", variant: "destructive" }); setPendingSlot(null); return; } setDoctorSlots((prev) => [ ...prev, { id: uid(), column: pendingSlot.col, startTime: newStart, endTime: newEnd, label: pendingLabel.trim() }, ]); setPendingSlot(null); setPendingLabel(""); }; const deleteDocSlot = (id: string) => setDoctorSlots((prev) => prev.filter((s) => s.id !== id)); const openEditSlot = (slot: DoctorSlot) => { setEditingSlot(slot); setEditLabel(slot.label); setEditStart(slot.startTime); setEditEnd(slot.endTime); setEditCol(slot.column); setPendingSlot(null); }; const saveEditSlot = () => { if (!editingSlot) return; if (editStart >= editEnd) { toast({ title: "Invalid time range", description: "End time must be after start time.", variant: "destructive" }); return; } const overlap = doctorSlots.some((s) => { if (s.id === editingSlot.id || s.column !== editCol) return false; return !(editEnd <= s.startTime || editStart >= s.endTime); }); if (overlap) { toast({ title: "Overlap", description: "This time range overlaps an existing slot.", variant: "destructive" }); return; } setDoctorSlots((prev) => prev.map((s) => s.id === editingSlot.id ? { ...s, column: editCol, startTime: editStart, endTime: editEnd, label: editLabel.trim() } : s ) ); setEditingSlot(null); }; const confirmDeleteEditSlot = () => { if (!editingSlot) return; deleteDocSlot(editingSlot.id); setEditingSlot(null); }; // ── Save helpers ───────────────────────────────────────────────── const buildPayload = (): TimeslotData => ({ procedures, doctorSlots, hygienistSlots }); const handleSaveProcedures = () => saveMutation.mutate({ ...buildPayload(), procedures }); const handleSaveDoctorSlots = () => saveMutation.mutate({ ...buildPayload(), doctorSlots }); const handleSaveHygienistSlots = () => saveMutation.mutate({ ...buildPayload(), hygienistSlots }); // ── Section 1 actions ──────────────────────────────────────────── const addProcedure = () => { if (!newCode.trim()) return; setProcedures((prev) => [...prev, { id: uid(), code: newCode.trim().toUpperCase(), description: newDesc.trim(), durationMin: newDuration }]); setNewCode(""); setNewDesc(""); setNewDuration(30); }; const deleteProcedure = (id: string) => setProcedures((prev) => prev.filter((p) => p.id !== id)); const updateProcedure = (id: string, field: keyof ProcedureEntry, value: string | number) => { setProcedures((prev) => prev.map((p) => (p.id === id ? { ...p, [field]: value } : p))); }; // ── Section 3 actions ──────────────────────────────────────────── const addHygSlot = () => { if (!newHygDesc.trim()) return; setHygienistSlots((prev) => [...prev, { id: uid(), description: newHygDesc.trim(), durationMin: newHygDuration }]); setNewHygDesc(""); setNewHygDuration(30); }; const deleteHygSlot = (id: string) => setHygienistSlots((prev) => prev.filter((s) => s.id !== id)); const updateHygSlot = (id: string, field: keyof HygienistSlot, value: string | number) => { setHygienistSlots((prev) => prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))); }; // ── Render ─────────────────────────────────────────────────────── if (isLoading) return Loading...; return (
{/* ── Section 1: Procedure Duration ── */}

Procedure Duration

Set the standard time needed for common dental procedures by CDT code.

{/* Existing procedure rows */} {procedures.length > 0 && (
{procedures.map((proc) => ( ))}
CDT Code Description Duration
updateProcedure(proc.id, "code", e.target.value.toUpperCase())} className="w-full border rounded px-2 py-1 text-sm font-mono uppercase" placeholder="D1110" /> updateProcedure(proc.id, "description", e.target.value)} className="w-full border rounded px-2 py-1 text-sm" placeholder="Adult Prophy" />
)} {/* Add new procedure row */}
setNewCode(e.target.value.toUpperCase())} onKeyDown={(e) => e.key === "Enter" && addProcedure()} className="border rounded px-2 py-1.5 text-sm font-mono uppercase w-28" placeholder="D1110" />
setNewDesc(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addProcedure()} className="border rounded px-2 py-1.5 text-sm w-full" placeholder="e.g. Adult Prophy" />
{/* ── Section 2: Doctor Time Slot Settings ── */}

Doctor Time Slot Settings

Template reference for scheduling — drag to block time ranges in columns A, B, C. This does not affect the main schedule.

{(["A", "B", "C"] as DoctorColumn[]).map((col) => ( Column {col} ))} Drag cells to block a time range
{/* Pending slot confirmation dialog */} {pendingSlot && (

New slot in column {pendingSlot.col}:{" "} {TIME_SLOTS[pendingSlot.startIdx]?.display} → {TIME_SLOTS[pendingSlot.endIdx]?.display}

setPendingLabel(e.target.value)} onKeyDown={(e) => e.key === "Enter" && confirmPendingSlot()} className="border rounded px-2 py-1.5 text-sm flex-1" placeholder="Label (e.g. Root Canal D3330) — optional" autoFocus />
)} {/* Edit existing slot dialog */} {editingSlot && (

Edit Slot

setEditLabel(e.target.value)} onKeyDown={(e) => e.key === "Enter" && saveEditSlot()} className="border rounded px-2 py-1.5 text-sm w-full" placeholder="e.g. Root Canal D3330" autoFocus />
)} {/* Time grid */}
{ // don't cancel drag on table leave — global mouseup handles it }} > {(["A", "B", "C"] as DoctorColumn[]).map((col) => ( ))} {TIME_SLOTS.map(({ time, display }, rowIdx) => { const isHour = time.endsWith(":00"); return ( {/* Time label */} {/* Columns A / B / C */} {(["A", "B", "C"] as DoctorColumn[]).map((col) => { const occ = getSlotOccupancy(col, rowIdx); if (occ && !occ.isStart) return null; // spanned — skip td if (occ && occ.isStart) { const isBeingEdited = editingSlot?.id === occ.slot.id; return ( ); } // Empty / draggable cell const highlighted = isDragHighlighted(col, rowIdx); return ( ); })}
Time {col}
{isHour ? display : ""} { e.stopPropagation(); openEditSlot(occ.slot); }} >
{occ.slot.label || "Slot"}
{TIME_SLOTS[timeToIdx(occ.slot.startTime)]?.display} – {TIME_SLOTS[timeToIdx(occ.slot.endTime)]?.display}
handleCellMouseDown(col, rowIdx)} onMouseEnter={() => handleCellMouseEnter(col, rowIdx)} /> ); })}
{/* ── Section 3: Hygienist Time Slot Settings ── */}

Hygienist Time Slot Settings

Define standard procedure types for hygienists with typical durations.

{hygienistSlots.length > 0 && (
{hygienistSlots.map((slot) => ( ))}
Procedure Description Duration
updateHygSlot(slot.id, "description", e.target.value)} className="w-full border rounded px-2 py-1 text-sm" placeholder="e.g. Exam / Recalls / Teeth Cleaning" />
)}
setNewHygDesc(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addHygSlot()} className="border rounded px-2 py-1.5 text-sm w-full" placeholder="e.g. Exam, Recalls / Teeth Cleaning" />
); }