Files
DentalManagementMH05/apps/Frontend/src/components/settings/procedure-timeslot-card.tsx
Gitead ceb95f1915 feat: add Procedure Duration/Time Slot settings and custom appointment type
- 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>
2026-05-05 23:08:34 -04:00

782 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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