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:
@@ -28,6 +28,7 @@ import twilioRoutes from "./twilio";
|
||||
import aiSettingsRoutes from "./ai-settings";
|
||||
import officeHoursRoutes from "./office-hours";
|
||||
import officeContactRoutes from "./office-contact";
|
||||
import procedureTimeslotRoutes from "./procedure-timeslot";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -60,5 +61,6 @@ router.use("/twilio", twilioRoutes);
|
||||
router.use("/ai", aiSettingsRoutes);
|
||||
router.use("/office-hours", officeHoursRoutes);
|
||||
router.use("/office-contact", officeContactRoutes);
|
||||
router.use("/procedure-timeslot", procedureTimeslotRoutes);
|
||||
|
||||
export default router;
|
||||
|
||||
37
apps/Backend/src/routes/procedure-timeslot.ts
Normal file
37
apps/Backend/src/routes/procedure-timeslot.ts
Normal 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;
|
||||
@@ -21,6 +21,7 @@ import { twilioStorage } from "./twilio-storage";
|
||||
import { aiSettingsStorage } from "./ai-settings-storage";
|
||||
import { officeHoursStorage } from "./office-hours-storage";
|
||||
import { officeContactStorage } from "./office-contact-storage";
|
||||
import { procedureTimeslotStorage } from "./procedure-timeslot-storage";
|
||||
|
||||
|
||||
export const storage = {
|
||||
@@ -45,6 +46,7 @@ export const storage = {
|
||||
...aiSettingsStorage,
|
||||
...officeHoursStorage,
|
||||
...officeContactStorage,
|
||||
...procedureTimeslotStorage,
|
||||
|
||||
};
|
||||
|
||||
|
||||
15
apps/Backend/src/storage/procedure-timeslot-storage.ts
Normal file
15
apps/Backend/src/storage/procedure-timeslot-storage.ts
Normal 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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -55,6 +55,10 @@ export function AppointmentForm({
|
||||
const { user } = useAuth();
|
||||
const inputRef = useRef<HTMLInputElement>(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(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -105,7 +109,7 @@ export function AppointmentForm({
|
||||
date: parseLocalDate(appointment.date),
|
||||
startTime: appointment.startTime || "09:00", // Default "09:00"
|
||||
endTime: appointment.endTime || "09:30", // Default "09:30"
|
||||
type: appointment.type,
|
||||
type: appointment.type?.startsWith("other:") ? "other" : appointment.type,
|
||||
notes: appointment.notes || "",
|
||||
status: appointment.status || "scheduled",
|
||||
staffId:
|
||||
@@ -326,6 +330,11 @@ export function AppointmentForm({
|
||||
|
||||
const formattedDate = formatLocalDate(data.date);
|
||||
|
||||
const resolvedType =
|
||||
data.type === "other" && otherTypeDesc.trim()
|
||||
? `other:${otherTypeDesc.trim()}`
|
||||
: data.type;
|
||||
|
||||
onSubmit({
|
||||
...data,
|
||||
userId: Number(user?.id),
|
||||
@@ -335,6 +344,7 @@ export function AppointmentForm({
|
||||
date: formattedDate,
|
||||
startTime: data.startTime,
|
||||
endTime: data.endTime,
|
||||
type: resolvedType,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -559,7 +569,10 @@ export function AppointmentForm({
|
||||
<FormLabel>Appointment Type</FormLabel>
|
||||
<Select
|
||||
disabled={isLoading}
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
if (val !== "other") setOtherTypeDesc("");
|
||||
}}
|
||||
value={field.value}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
@@ -581,6 +594,16 @@ export function AppointmentForm({
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</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 />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Bot,
|
||||
Clock,
|
||||
Building2,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
@@ -233,6 +234,11 @@ export function Sidebar() {
|
||||
path: "/settings/ai",
|
||||
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" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -817,7 +817,11 @@ export default function AppointmentsPage() {
|
||||
<Move className="h-3 w-3" />
|
||||
{appointment.patientName}
|
||||
</div>
|
||||
<div className="truncate">{appointment.type}</div>
|
||||
<div className="truncate">
|
||||
{appointment.type?.startsWith("other:")
|
||||
? appointment.type.slice(6)
|
||||
: appointment.type}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TwilioSettingsCard } from "@/components/settings/twilio-settings-card";
|
||||
import { AiSettingsCard } from "@/components/settings/ai-settings-card";
|
||||
import { OfficeHoursCard } from "@/components/settings/office-hours-card";
|
||||
import { OfficeContactCard } from "@/components/settings/office-contact-card";
|
||||
import { ProcedureTimeslotCard } from "@/components/settings/procedure-timeslot-card";
|
||||
|
||||
type SectionId =
|
||||
| "staff"
|
||||
@@ -28,7 +29,8 @@ type SectionId =
|
||||
| "twilio"
|
||||
| "ai"
|
||||
| "officehours"
|
||||
| "officecontact";
|
||||
| "officecontact"
|
||||
| "proceduretimeslot";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { toast } = useToast();
|
||||
@@ -261,6 +263,9 @@ export default function SettingsPage() {
|
||||
case "officecontact":
|
||||
return <OfficeContactCard />;
|
||||
|
||||
case "proceduretimeslot":
|
||||
return <ProcedureTimeslotCard />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -41,6 +41,7 @@ model User {
|
||||
aiSettings AiSettings?
|
||||
officeHours OfficeHours?
|
||||
officeContact OfficeContact?
|
||||
procedureTimeslot ProcedureTimeslot?
|
||||
}
|
||||
|
||||
model Patient {
|
||||
@@ -596,3 +597,13 @@ model OfficeContact {
|
||||
|
||||
@@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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user