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>
This commit is contained in:
Gitead
2026-05-05 23:08:34 -04:00
parent fea0dd4d59
commit ceb95f1915
11 changed files with 900 additions and 4 deletions

View File

@@ -28,6 +28,7 @@ import twilioRoutes from "./twilio";
import aiSettingsRoutes from "./ai-settings"; import aiSettingsRoutes from "./ai-settings";
import officeHoursRoutes from "./office-hours"; import officeHoursRoutes from "./office-hours";
import officeContactRoutes from "./office-contact"; import officeContactRoutes from "./office-contact";
import procedureTimeslotRoutes from "./procedure-timeslot";
const router = Router(); const router = Router();
@@ -60,5 +61,6 @@ router.use("/twilio", twilioRoutes);
router.use("/ai", aiSettingsRoutes); router.use("/ai", aiSettingsRoutes);
router.use("/office-hours", officeHoursRoutes); router.use("/office-hours", officeHoursRoutes);
router.use("/office-contact", officeContactRoutes); router.use("/office-contact", officeContactRoutes);
router.use("/procedure-timeslot", procedureTimeslotRoutes);
export default router; export default router;

View File

@@ -0,0 +1,37 @@
import express, { Request, Response } from "express";
import { storage } from "../storage";
const router = express.Router();
// GET /api/procedure-timeslot
router.get("/", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const record = await storage.getProcedureTimeslot(userId);
return res.status(200).json(record ?? null);
} catch (err) {
return res.status(500).json({ error: "Failed to fetch procedure timeslot", details: String(err) });
}
});
// PUT /api/procedure-timeslot
router.put("/", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { data } = req.body;
if (!data || typeof data !== "object") {
return res.status(400).json({ error: "Invalid data payload" });
}
const record = await storage.upsertProcedureTimeslot(userId, data);
return res.status(200).json(record);
} catch (err) {
return res.status(500).json({ error: "Failed to save procedure timeslot", details: String(err) });
}
});
export default router;

View File

@@ -21,6 +21,7 @@ import { twilioStorage } from "./twilio-storage";
import { aiSettingsStorage } from "./ai-settings-storage"; import { aiSettingsStorage } from "./ai-settings-storage";
import { officeHoursStorage } from "./office-hours-storage"; import { officeHoursStorage } from "./office-hours-storage";
import { officeContactStorage } from "./office-contact-storage"; import { officeContactStorage } from "./office-contact-storage";
import { procedureTimeslotStorage } from "./procedure-timeslot-storage";
export const storage = { export const storage = {
@@ -45,6 +46,7 @@ export const storage = {
...aiSettingsStorage, ...aiSettingsStorage,
...officeHoursStorage, ...officeHoursStorage,
...officeContactStorage, ...officeContactStorage,
...procedureTimeslotStorage,
}; };

View File

@@ -0,0 +1,15 @@
import { prisma as db } from "@repo/db/client";
export const procedureTimeslotStorage = {
async getProcedureTimeslot(userId: number) {
return db.procedureTimeslot.findUnique({ where: { userId } });
},
async upsertProcedureTimeslot(userId: number, data: object) {
return db.procedureTimeslot.upsert({
where: { userId },
update: { data },
create: { userId, data },
});
},
};

View File

@@ -55,6 +55,10 @@ export function AppointmentForm({
const { user } = useAuth(); const { user } = useAuth();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [prefillPatient, setPrefillPatient] = useState<Patient | null>(null); const [prefillPatient, setPrefillPatient] = useState<Patient | null>(null);
const [otherTypeDesc, setOtherTypeDesc] = useState<string>(() => {
const t = appointment?.type ?? "";
return t.startsWith("other:") ? t.slice(6) : "";
});
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@@ -105,7 +109,7 @@ export function AppointmentForm({
date: parseLocalDate(appointment.date), date: parseLocalDate(appointment.date),
startTime: appointment.startTime || "09:00", // Default "09:00" startTime: appointment.startTime || "09:00", // Default "09:00"
endTime: appointment.endTime || "09:30", // Default "09:30" endTime: appointment.endTime || "09:30", // Default "09:30"
type: appointment.type, type: appointment.type?.startsWith("other:") ? "other" : appointment.type,
notes: appointment.notes || "", notes: appointment.notes || "",
status: appointment.status || "scheduled", status: appointment.status || "scheduled",
staffId: staffId:
@@ -326,6 +330,11 @@ export function AppointmentForm({
const formattedDate = formatLocalDate(data.date); const formattedDate = formatLocalDate(data.date);
const resolvedType =
data.type === "other" && otherTypeDesc.trim()
? `other:${otherTypeDesc.trim()}`
: data.type;
onSubmit({ onSubmit({
...data, ...data,
userId: Number(user?.id), userId: Number(user?.id),
@@ -335,6 +344,7 @@ export function AppointmentForm({
date: formattedDate, date: formattedDate,
startTime: data.startTime, startTime: data.startTime,
endTime: data.endTime, endTime: data.endTime,
type: resolvedType,
}); });
}; };
@@ -559,7 +569,10 @@ export function AppointmentForm({
<FormLabel>Appointment Type</FormLabel> <FormLabel>Appointment Type</FormLabel>
<Select <Select
disabled={isLoading} disabled={isLoading}
onValueChange={field.onChange} onValueChange={(val) => {
field.onChange(val);
if (val !== "other") setOtherTypeDesc("");
}}
value={field.value} value={field.value}
defaultValue={field.value} defaultValue={field.value}
> >
@@ -581,6 +594,16 @@ export function AppointmentForm({
<SelectItem value="other">Other</SelectItem> <SelectItem value="other">Other</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{field.value === "other" && (
<Input
className="mt-2"
placeholder="Describe the appointment type…"
value={otherTypeDesc}
onChange={(e) => setOtherTypeDesc(e.target.value)}
disabled={isLoading}
autoFocus
/>
)}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@@ -28,6 +28,7 @@ import {
Bot, Bot,
Clock, Clock,
Building2, Building2,
Timer,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useMemo, useState, useEffect } from "react"; import { useMemo, useState, useEffect } from "react";
@@ -233,6 +234,11 @@ export function Sidebar() {
path: "/settings/ai", path: "/settings/ai",
icon: <Bot className="h-4 w-4 text-gray-400" />, icon: <Bot className="h-4 w-4 text-gray-400" />,
}, },
{
name: "Procedure Duration/Time Slot",
path: "/settings/proceduretimeslot",
icon: <Timer className="h-4 w-4 text-gray-400" />,
},
], ],
}, },
], ],

View File

@@ -0,0 +1,781 @@
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>
);
}

View File

@@ -817,7 +817,11 @@ export default function AppointmentsPage() {
<Move className="h-3 w-3" /> <Move className="h-3 w-3" />
{appointment.patientName} {appointment.patientName}
</div> </div>
<div className="truncate">{appointment.type}</div> <div className="truncate">
{appointment.type?.startsWith("other:")
? appointment.type.slice(6)
: appointment.type}
</div>
</div> </div>
); );
} }

View File

@@ -17,6 +17,7 @@ import { TwilioSettingsCard } from "@/components/settings/twilio-settings-card";
import { AiSettingsCard } from "@/components/settings/ai-settings-card"; import { AiSettingsCard } from "@/components/settings/ai-settings-card";
import { OfficeHoursCard } from "@/components/settings/office-hours-card"; import { OfficeHoursCard } from "@/components/settings/office-hours-card";
import { OfficeContactCard } from "@/components/settings/office-contact-card"; import { OfficeContactCard } from "@/components/settings/office-contact-card";
import { ProcedureTimeslotCard } from "@/components/settings/procedure-timeslot-card";
type SectionId = type SectionId =
| "staff" | "staff"
@@ -28,7 +29,8 @@ type SectionId =
| "twilio" | "twilio"
| "ai" | "ai"
| "officehours" | "officehours"
| "officecontact"; | "officecontact"
| "proceduretimeslot";
export default function SettingsPage() { export default function SettingsPage() {
const { toast } = useToast(); const { toast } = useToast();
@@ -261,6 +263,9 @@ export default function SettingsPage() {
case "officecontact": case "officecontact":
return <OfficeContactCard />; return <OfficeContactCard />;
case "proceduretimeslot":
return <ProcedureTimeslotCard />;
default: default:
return null; return null;
} }

View File

@@ -0,0 +1,10 @@
CREATE TABLE "procedure_timeslot" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"data" JSONB NOT NULL,
CONSTRAINT "procedure_timeslot_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "procedure_timeslot" ADD CONSTRAINT "procedure_timeslot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE UNIQUE INDEX "procedure_timeslot_userId_key" ON "procedure_timeslot"("userId");

View File

@@ -41,6 +41,7 @@ model User {
aiSettings AiSettings? aiSettings AiSettings?
officeHours OfficeHours? officeHours OfficeHours?
officeContact OfficeContact? officeContact OfficeContact?
procedureTimeslot ProcedureTimeslot?
} }
model Patient { model Patient {
@@ -596,3 +597,13 @@ model OfficeContact {
@@map("office_contact") @@map("office_contact")
} }
model ProcedureTimeslot {
id Int @id @default(autoincrement())
userId Int @unique
data Json
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("procedure_timeslot")
}