- Add Settings > Advanced > Procedure Duration/Time Slot page with three sections: 1. Procedure Duration: CDT codes with durations (editable table, save per section) 2. Doctor Time Slot: drag-to-block visual grid (A/B/C columns, 8 AM–9 PM, edit/delete slots) 3. Hygienist Time Slot: procedure descriptions with durations - Backend: ProcedureTimeslot Prisma model, storage, and GET/PUT /api/procedure-timeslot route - DB migration: procedure_timeslot table - Appointment form: when type is "Other", show a free-text input for custom description; saved as other:<description> and decoded for display on the schedule card Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
782 lines
34 KiB
TypeScript
782 lines
34 KiB
TypeScript
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<DoctorColumn, string> = {
|
||
A: "bg-sky-500",
|
||
B: "bg-teal-500",
|
||
C: "bg-indigo-500",
|
||
};
|
||
const COL_LIGHT: Record<DoctorColumn, string> = {
|
||
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<ProcedureEntry[]>([]);
|
||
const [newCode, setNewCode] = useState("");
|
||
const [newDesc, setNewDesc] = useState("");
|
||
const [newDuration, setNewDuration] = useState(30);
|
||
|
||
// ── Section 2 state: Doctor time slots ───────────────────────────
|
||
const [doctorSlots, setDoctorSlots] = useState<DoctorSlot[]>([]);
|
||
|
||
// Drag selection state
|
||
const [dragCol, setDragCol] = useState<DoctorColumn | null>(null);
|
||
const [dragStartIdx, setDragStartIdx] = useState<number | null>(null);
|
||
const [dragEndIdx, setDragEndIdx] = useState<number | null>(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<DoctorSlot | null>(null);
|
||
const [editLabel, setEditLabel] = useState("");
|
||
const [editStart, setEditStart] = useState("");
|
||
const [editEnd, setEditEnd] = useState("");
|
||
const [editCol, setEditCol] = useState<DoctorColumn>("A");
|
||
|
||
// ── Section 3 state: Hygienist slots ─────────────────────────────
|
||
const [hygienistSlots, setHygienistSlots] = useState<HygienistSlot[]>([]);
|
||
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 <Card><CardContent className="py-10 text-center text-gray-400">Loading...</CardContent></Card>;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
|
||
{/* ── Section 1: Procedure Duration ── */}
|
||
<Card>
|
||
<CardContent className="py-6 space-y-4">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-gray-800">Procedure Duration</h2>
|
||
<p className="text-sm text-gray-500 mt-0.5">Set the standard time needed for common dental procedures by CDT code.</p>
|
||
</div>
|
||
|
||
{/* Existing procedure rows */}
|
||
{procedures.length > 0 && (
|
||
<div className="border rounded overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b">
|
||
<tr>
|
||
<th className="text-left px-3 py-2 font-medium text-gray-600 w-32">CDT Code</th>
|
||
<th className="text-left px-3 py-2 font-medium text-gray-600">Description</th>
|
||
<th className="text-left px-3 py-2 font-medium text-gray-600 w-36">Duration</th>
|
||
<th className="w-10" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y">
|
||
{procedures.map((proc) => (
|
||
<tr key={proc.id} className="hover:bg-gray-50">
|
||
<td className="px-3 py-2">
|
||
<input
|
||
type="text"
|
||
value={proc.code}
|
||
onChange={(e) => updateProcedure(proc.id, "code", e.target.value.toUpperCase())}
|
||
className="w-full border rounded px-2 py-1 text-sm font-mono uppercase"
|
||
placeholder="D1110"
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<input
|
||
type="text"
|
||
value={proc.description}
|
||
onChange={(e) => updateProcedure(proc.id, "description", e.target.value)}
|
||
className="w-full border rounded px-2 py-1 text-sm"
|
||
placeholder="Adult Prophy"
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<select
|
||
value={proc.durationMin}
|
||
onChange={(e) => updateProcedure(proc.id, "durationMin", Number(e.target.value))}
|
||
className="border rounded px-2 py-1 text-sm w-full"
|
||
>
|
||
{DURATION_OPTIONS.map((d) => (
|
||
<option key={d} value={d}>{d} min</option>
|
||
))}
|
||
</select>
|
||
</td>
|
||
<td className="px-2 py-2 text-center">
|
||
<button onClick={() => deleteProcedure(proc.id)} className="text-red-400 hover:text-red-600">
|
||
<Trash2 size={15} />
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Add new procedure row */}
|
||
<div className="flex gap-2 items-end">
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs font-medium text-gray-600">CDT Code</label>
|
||
<input
|
||
type="text"
|
||
value={newCode}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1 flex-1">
|
||
<label className="text-xs font-medium text-gray-600">Description</label>
|
||
<input
|
||
type="text"
|
||
value={newDesc}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs font-medium text-gray-600">Duration</label>
|
||
<select
|
||
value={newDuration}
|
||
onChange={(e) => setNewDuration(Number(e.target.value))}
|
||
className="border rounded px-2 py-1.5 text-sm"
|
||
>
|
||
{DURATION_OPTIONS.map((d) => (
|
||
<option key={d} value={d}>{d} min</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<button
|
||
onClick={addProcedure}
|
||
className="flex items-center gap-1 bg-teal-600 text-white px-3 py-1.5 rounded hover:bg-teal-700 text-sm"
|
||
>
|
||
<Plus size={14} /> Add
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex justify-end pt-1">
|
||
<button
|
||
onClick={handleSaveProcedures}
|
||
disabled={saveMutation.isPending}
|
||
className="bg-teal-600 text-white px-5 py-2 rounded hover:bg-teal-700 text-sm"
|
||
>
|
||
{saveMutation.isPending ? "Saving…" : "Save Procedures"}
|
||
</button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* ── Section 2: Doctor Time Slot Settings ── */}
|
||
<Card>
|
||
<CardContent className="py-6 space-y-4">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-gray-800">Doctor Time Slot Settings</h2>
|
||
<p className="text-sm text-gray-500 mt-0.5">
|
||
Template reference for scheduling — drag to block time ranges in columns A, B, C. This does not affect the main schedule.
|
||
</p>
|
||
<div className="flex gap-4 mt-2">
|
||
{(["A", "B", "C"] as DoctorColumn[]).map((col) => (
|
||
<span key={col} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border ${COL_LIGHT[col]}`}>
|
||
<span className={`w-2.5 h-2.5 rounded-full ${COL_COLORS[col]}`} /> Column {col}
|
||
</span>
|
||
))}
|
||
<span className="text-xs text-gray-400 self-center">Drag cells to block a time range</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pending slot confirmation dialog */}
|
||
{pendingSlot && (
|
||
<div className="border border-blue-200 bg-blue-50 rounded-lg p-4 flex flex-col gap-3">
|
||
<p className="text-sm font-medium text-blue-800">
|
||
New slot in column <strong>{pendingSlot.col}</strong>:{" "}
|
||
{TIME_SLOTS[pendingSlot.startIdx]?.display} → {TIME_SLOTS[pendingSlot.endIdx]?.display}
|
||
</p>
|
||
<div className="flex gap-2 items-center">
|
||
<input
|
||
type="text"
|
||
value={pendingLabel}
|
||
onChange={(e) => 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
|
||
/>
|
||
<button onClick={confirmPendingSlot} className="bg-teal-600 text-white px-3 py-1.5 rounded text-sm hover:bg-teal-700">
|
||
Confirm
|
||
</button>
|
||
<button onClick={() => setPendingSlot(null)} className="text-gray-500 hover:text-gray-700">
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Edit existing slot dialog */}
|
||
{editingSlot && (
|
||
<div className="border border-amber-200 bg-amber-50 rounded-lg p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm font-semibold text-amber-800 flex items-center gap-1.5">
|
||
<Pencil size={14} /> Edit Slot
|
||
</p>
|
||
<button onClick={() => setEditingSlot(null)} className="text-gray-400 hover:text-gray-600">
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs font-medium text-gray-600">Column</label>
|
||
<select
|
||
value={editCol}
|
||
onChange={(e) => setEditCol(e.target.value as DoctorColumn)}
|
||
className="border rounded px-2 py-1.5 text-sm"
|
||
>
|
||
{(["A", "B", "C"] as DoctorColumn[]).map((c) => (
|
||
<option key={c} value={c}>{c}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs font-medium text-gray-600">Start Time</label>
|
||
<select
|
||
value={editStart}
|
||
onChange={(e) => setEditStart(e.target.value)}
|
||
className="border rounded px-2 py-1.5 text-sm"
|
||
>
|
||
{TIME_SLOTS.slice(0, -1).map(({ time, display }) => (
|
||
<option key={time} value={time}>{display}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs font-medium text-gray-600">End Time</label>
|
||
<select
|
||
value={editEnd}
|
||
onChange={(e) => setEditEnd(e.target.value)}
|
||
className="border rounded px-2 py-1.5 text-sm"
|
||
>
|
||
{TIME_SLOTS.filter(({ time }) => time > editStart).map(({ time, display }) => (
|
||
<option key={time} value={time}>{display}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="flex flex-col gap-1 sm:col-span-1 col-span-2">
|
||
<label className="text-xs font-medium text-gray-600">Label</label>
|
||
<input
|
||
type="text"
|
||
value={editLabel}
|
||
onChange={(e) => 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
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 justify-between pt-1">
|
||
<button
|
||
onClick={confirmDeleteEditSlot}
|
||
className="flex items-center gap-1 text-red-600 border border-red-200 bg-red-50 hover:bg-red-100 px-3 py-1.5 rounded text-sm"
|
||
>
|
||
<Trash2 size={13} /> Delete Slot
|
||
</button>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => setEditingSlot(null)} className="border px-3 py-1.5 rounded text-sm text-gray-600 hover:bg-gray-100">
|
||
Cancel
|
||
</button>
|
||
<button onClick={saveEditSlot} className="bg-teal-600 text-white px-4 py-1.5 rounded text-sm hover:bg-teal-700">
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Time grid */}
|
||
<div className="border rounded overflow-auto max-h-[520px]">
|
||
<table
|
||
className="w-full text-xs border-collapse select-none"
|
||
style={{ tableLayout: "fixed" }}
|
||
onMouseLeave={() => {
|
||
// don't cancel drag on table leave — global mouseup handles it
|
||
}}
|
||
>
|
||
<colgroup>
|
||
<col style={{ width: "72px" }} />
|
||
<col />
|
||
<col />
|
||
<col />
|
||
</colgroup>
|
||
<thead className="sticky top-0 z-10 bg-white border-b">
|
||
<tr>
|
||
<th className="text-left px-2 py-2 font-medium text-gray-500 border-r">Time</th>
|
||
{(["A", "B", "C"] as DoctorColumn[]).map((col) => (
|
||
<th key={col} className={`py-2 font-semibold text-center border-r last:border-r-0 ${COL_LIGHT[col]}`}>
|
||
{col}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{TIME_SLOTS.map(({ time, display }, rowIdx) => {
|
||
const isHour = time.endsWith(":00");
|
||
return (
|
||
<tr key={time} className={isHour ? "border-t border-gray-300" : "border-t border-gray-100"}>
|
||
{/* Time label */}
|
||
<td className="px-2 py-0 border-r text-gray-400 whitespace-nowrap" style={{ height: "22px" }}>
|
||
{isHour ? display : ""}
|
||
</td>
|
||
|
||
{/* 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 (
|
||
<td
|
||
key={col}
|
||
rowSpan={occ.rowSpan}
|
||
className={`border-r last:border-r-0 px-1 py-0.5 align-top cursor-pointer ${
|
||
isBeingEdited
|
||
? "ring-2 ring-inset ring-amber-400 " + COL_LIGHT[col]
|
||
: COL_LIGHT[col]
|
||
}`}
|
||
onClick={(e) => { e.stopPropagation(); openEditSlot(occ.slot); }}
|
||
>
|
||
<div className="flex items-start justify-between gap-1 h-full">
|
||
<div className="min-w-0">
|
||
<div className="font-medium leading-tight break-words text-[11px]">
|
||
{occ.slot.label || "Slot"}
|
||
</div>
|
||
<div className="text-[10px] opacity-70 mt-0.5 whitespace-nowrap">
|
||
{TIME_SLOTS[timeToIdx(occ.slot.startTime)]?.display} – {TIME_SLOTS[timeToIdx(occ.slot.endTime)]?.display}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col gap-0.5 flex-shrink-0">
|
||
<button
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onClick={(e) => { e.stopPropagation(); openEditSlot(occ.slot); }}
|
||
className="text-gray-500 hover:text-amber-600"
|
||
title="Edit"
|
||
>
|
||
<Pencil size={11} />
|
||
</button>
|
||
<button
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onClick={(e) => { e.stopPropagation(); deleteDocSlot(occ.slot.id); if (editingSlot?.id === occ.slot.id) setEditingSlot(null); }}
|
||
className="text-red-400 hover:text-red-600"
|
||
title="Delete"
|
||
>
|
||
<X size={11} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
);
|
||
}
|
||
|
||
// Empty / draggable cell
|
||
const highlighted = isDragHighlighted(col, rowIdx);
|
||
return (
|
||
<td
|
||
key={col}
|
||
className={`border-r last:border-r-0 cursor-crosshair transition-colors ${
|
||
highlighted ? "bg-blue-200" : "hover:bg-gray-50"
|
||
}`}
|
||
onMouseDown={() => handleCellMouseDown(col, rowIdx)}
|
||
onMouseEnter={() => handleCellMouseEnter(col, rowIdx)}
|
||
/>
|
||
);
|
||
})}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="flex justify-end pt-1">
|
||
<button
|
||
onClick={handleSaveDoctorSlots}
|
||
disabled={saveMutation.isPending}
|
||
className="bg-teal-600 text-white px-5 py-2 rounded hover:bg-teal-700 text-sm"
|
||
>
|
||
{saveMutation.isPending ? "Saving…" : "Save Doctor Slots"}
|
||
</button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* ── Section 3: Hygienist Time Slot Settings ── */}
|
||
<Card>
|
||
<CardContent className="py-6 space-y-4">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-gray-800">Hygienist Time Slot Settings</h2>
|
||
<p className="text-sm text-gray-500 mt-0.5">
|
||
Define standard procedure types for hygienists with typical durations.
|
||
</p>
|
||
</div>
|
||
|
||
{hygienistSlots.length > 0 && (
|
||
<div className="border rounded overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b">
|
||
<tr>
|
||
<th className="text-left px-3 py-2 font-medium text-gray-600">Procedure Description</th>
|
||
<th className="text-left px-3 py-2 font-medium text-gray-600 w-36">Duration</th>
|
||
<th className="w-10" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y">
|
||
{hygienistSlots.map((slot) => (
|
||
<tr key={slot.id} className="hover:bg-gray-50">
|
||
<td className="px-3 py-2">
|
||
<input
|
||
type="text"
|
||
value={slot.description}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<select
|
||
value={slot.durationMin}
|
||
onChange={(e) => updateHygSlot(slot.id, "durationMin", Number(e.target.value))}
|
||
className="border rounded px-2 py-1 text-sm w-full"
|
||
>
|
||
{DURATION_OPTIONS.map((d) => (
|
||
<option key={d} value={d}>{d} min</option>
|
||
))}
|
||
</select>
|
||
</td>
|
||
<td className="px-2 py-2 text-center">
|
||
<button onClick={() => deleteHygSlot(slot.id)} className="text-red-400 hover:text-red-600">
|
||
<Trash2 size={15} />
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2 items-end">
|
||
<div className="flex flex-col gap-1 flex-1">
|
||
<label className="text-xs font-medium text-gray-600">Procedure Description</label>
|
||
<input
|
||
type="text"
|
||
value={newHygDesc}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs font-medium text-gray-600">Duration</label>
|
||
<select
|
||
value={newHygDuration}
|
||
onChange={(e) => setNewHygDuration(Number(e.target.value))}
|
||
className="border rounded px-2 py-1.5 text-sm"
|
||
>
|
||
{DURATION_OPTIONS.map((d) => (
|
||
<option key={d} value={d}>{d} min</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<button
|
||
onClick={addHygSlot}
|
||
className="flex items-center gap-1 bg-teal-600 text-white px-3 py-1.5 rounded hover:bg-teal-700 text-sm"
|
||
>
|
||
<Plus size={14} /> Add
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex justify-end pt-1">
|
||
<button
|
||
onClick={handleSaveHygienistSlots}
|
||
disabled={saveMutation.isPending}
|
||
className="bg-teal-600 text-white px-5 py-2 rounded hover:bg-teal-700 text-sm"
|
||
>
|
||
{saveMutation.isPending ? "Saving…" : "Save Hygienist Slots"}
|
||
</button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|