diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index b0c24913..81316bca 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -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; diff --git a/apps/Backend/src/routes/procedure-timeslot.ts b/apps/Backend/src/routes/procedure-timeslot.ts new file mode 100644 index 00000000..24368dd8 --- /dev/null +++ b/apps/Backend/src/routes/procedure-timeslot.ts @@ -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 => { + 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 => { + 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; diff --git a/apps/Backend/src/storage/index.ts b/apps/Backend/src/storage/index.ts index 3c71e1d1..d9667ad5 100755 --- a/apps/Backend/src/storage/index.ts +++ b/apps/Backend/src/storage/index.ts @@ -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, }; diff --git a/apps/Backend/src/storage/procedure-timeslot-storage.ts b/apps/Backend/src/storage/procedure-timeslot-storage.ts new file mode 100644 index 00000000..d41b3ec6 --- /dev/null +++ b/apps/Backend/src/storage/procedure-timeslot-storage.ts @@ -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 }, + }); + }, +}; diff --git a/apps/Frontend/src/components/appointments/appointment-form.tsx b/apps/Frontend/src/components/appointments/appointment-form.tsx index 2bc56e88..8fc1b8f7 100755 --- a/apps/Frontend/src/components/appointments/appointment-form.tsx +++ b/apps/Frontend/src/components/appointments/appointment-form.tsx @@ -55,6 +55,10 @@ export function AppointmentForm({ const { user } = useAuth(); const inputRef = useRef(null); const [prefillPatient, setPrefillPatient] = useState(null); + const [otherTypeDesc, setOtherTypeDesc] = useState(() => { + 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({ Appointment Type + {field.value === "other" && ( + setOtherTypeDesc(e.target.value)} + disabled={isLoading} + autoFocus + /> + )} )} diff --git a/apps/Frontend/src/components/layout/sidebar.tsx b/apps/Frontend/src/components/layout/sidebar.tsx index e315b3a3..bbc43396 100755 --- a/apps/Frontend/src/components/layout/sidebar.tsx +++ b/apps/Frontend/src/components/layout/sidebar.tsx @@ -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: , }, + { + name: "Procedure Duration/Time Slot", + path: "/settings/proceduretimeslot", + icon: , + }, ], }, ], diff --git a/apps/Frontend/src/components/settings/procedure-timeslot-card.tsx b/apps/Frontend/src/components/settings/procedure-timeslot-card.tsx new file mode 100644 index 00000000..cc77b948 --- /dev/null +++ b/apps/Frontend/src/components/settings/procedure-timeslot-card.tsx @@ -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 = { + A: "bg-sky-500", + B: "bg-teal-500", + C: "bg-indigo-500", +}; +const COL_LIGHT: Record = { + A: "bg-sky-100 border-sky-300 text-sky-800", + B: "bg-teal-100 border-teal-300 text-teal-800", + C: "bg-indigo-100 border-indigo-300 text-indigo-800", +}; + +// ── Main component ──────────────────────────────────────────────────────────── + +export function ProcedureTimeslotCard() { + const { toast } = useToast(); + + // ── Remote data ───────────────────────────────────────────────── + const { data: remote, isLoading } = useQuery<{ data: TimeslotData } | null>({ + queryKey: ["/api/procedure-timeslot"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/procedure-timeslot"); + if (!res.ok) throw new Error("Failed to load"); + return res.json(); + }, + }); + + const saveMutation = useMutation({ + mutationFn: async (payload: TimeslotData) => { + const res = await apiRequest("PUT", "/api/procedure-timeslot", { data: payload }); + if (!res.ok) throw new Error("Failed to save"); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/procedure-timeslot"] }); + toast({ title: "Saved", description: "Settings saved successfully." }); + }, + onError: () => toast({ title: "Error", description: "Failed to save.", variant: "destructive" }), + }); + + // ── Section 1 state: Procedure durations ───────────────────────── + const [procedures, setProcedures] = useState([]); + const [newCode, setNewCode] = useState(""); + const [newDesc, setNewDesc] = useState(""); + const [newDuration, setNewDuration] = useState(30); + + // ── Section 2 state: Doctor time slots ─────────────────────────── + const [doctorSlots, setDoctorSlots] = useState([]); + + // Drag selection state + const [dragCol, setDragCol] = useState(null); + const [dragStartIdx, setDragStartIdx] = useState(null); + const [dragEndIdx, setDragEndIdx] = useState(null); + const mouseDownRef = useRef(false); + + // Pending slot confirmation + const [pendingSlot, setPendingSlot] = useState<{ col: DoctorColumn; startIdx: number; endIdx: number } | null>(null); + const [pendingLabel, setPendingLabel] = useState(""); + + // Edit existing slot + const [editingSlot, setEditingSlot] = useState(null); + const [editLabel, setEditLabel] = useState(""); + const [editStart, setEditStart] = useState(""); + const [editEnd, setEditEnd] = useState(""); + const [editCol, setEditCol] = useState("A"); + + // ── Section 3 state: Hygienist slots ───────────────────────────── + const [hygienistSlots, setHygienistSlots] = useState([]); + const [newHygDesc, setNewHygDesc] = useState(""); + const [newHygDuration, setNewHygDuration] = useState(30); + + // Populate from remote when loaded + useEffect(() => { + if (!remote?.data) return; + const d = remote.data; + setProcedures(d.procedures ?? []); + setDoctorSlots(d.doctorSlots ?? []); + setHygienistSlots(d.hygienistSlots ?? []); + }, [remote]); + + // Global mouseup handler to end drag regardless of cursor position + useEffect(() => { + const onMouseUp = () => { + if (!mouseDownRef.current) return; + mouseDownRef.current = false; + if (dragCol !== null && dragStartIdx !== null && dragEndIdx !== null) { + const s = Math.min(dragStartIdx, dragEndIdx); + const e = Math.max(dragStartIdx, dragEndIdx); + if (e > s) { + setPendingSlot({ col: dragCol, startIdx: s, endIdx: e }); + setPendingLabel(""); + } + } + setDragCol(null); + setDragStartIdx(null); + setDragEndIdx(null); + }; + window.addEventListener("mouseup", onMouseUp); + return () => window.removeEventListener("mouseup", onMouseUp); + }, [dragCol, dragStartIdx, dragEndIdx]); + + // ── Drag handlers ──────────────────────────────────────────────── + + const handleCellMouseDown = useCallback((col: DoctorColumn, idx: number) => { + mouseDownRef.current = true; + setDragCol(col); + setDragStartIdx(idx); + setDragEndIdx(idx); + }, []); + + const handleCellMouseEnter = useCallback((col: DoctorColumn, idx: number) => { + if (!mouseDownRef.current || dragCol !== col) return; + setDragEndIdx(idx); + }, [dragCol]); + + const isDragHighlighted = (col: DoctorColumn, idx: number) => { + if (dragCol !== col || dragStartIdx === null || dragEndIdx === null) return false; + const s = Math.min(dragStartIdx, dragEndIdx); + const e = Math.max(dragStartIdx, dragEndIdx); + return idx >= s && idx <= e; + }; + + // ── Doctor slot helpers ────────────────────────────────────────── + + const getSlotOccupancy = (col: DoctorColumn, rowIdx: number) => { + for (const slot of doctorSlots) { + if (slot.column !== col) continue; + const startIdx = timeToIdx(slot.startTime); + const endIdx = timeToIdx(slot.endTime); + if (rowIdx === startIdx) return { slot, isStart: true, rowSpan: endIdx - startIdx }; + if (rowIdx > startIdx && rowIdx < endIdx) return { slot, isStart: false, rowSpan: 0 }; + } + return null; + }; + + const confirmPendingSlot = () => { + if (!pendingSlot) return; + // Check for overlap with existing slots in same column + const newStart = idxToTime(pendingSlot.startIdx); + const newEnd = idxToTime(pendingSlot.endIdx); + const overlap = doctorSlots.some((s) => { + if (s.column !== pendingSlot.col) return false; + return !(newEnd <= s.startTime || newStart >= s.endTime); + }); + if (overlap) { + toast({ title: "Overlap", description: "This time range overlaps an existing slot.", variant: "destructive" }); + setPendingSlot(null); + return; + } + setDoctorSlots((prev) => [ + ...prev, + { id: uid(), column: pendingSlot.col, startTime: newStart, endTime: newEnd, label: pendingLabel.trim() }, + ]); + setPendingSlot(null); + setPendingLabel(""); + }; + + const deleteDocSlot = (id: string) => setDoctorSlots((prev) => prev.filter((s) => s.id !== id)); + + const openEditSlot = (slot: DoctorSlot) => { + setEditingSlot(slot); + setEditLabel(slot.label); + setEditStart(slot.startTime); + setEditEnd(slot.endTime); + setEditCol(slot.column); + setPendingSlot(null); + }; + + const saveEditSlot = () => { + if (!editingSlot) return; + if (editStart >= editEnd) { + toast({ title: "Invalid time range", description: "End time must be after start time.", variant: "destructive" }); + return; + } + const overlap = doctorSlots.some((s) => { + if (s.id === editingSlot.id || s.column !== editCol) return false; + return !(editEnd <= s.startTime || editStart >= s.endTime); + }); + if (overlap) { + toast({ title: "Overlap", description: "This time range overlaps an existing slot.", variant: "destructive" }); + return; + } + setDoctorSlots((prev) => + prev.map((s) => + s.id === editingSlot.id + ? { ...s, column: editCol, startTime: editStart, endTime: editEnd, label: editLabel.trim() } + : s + ) + ); + setEditingSlot(null); + }; + + const confirmDeleteEditSlot = () => { + if (!editingSlot) return; + deleteDocSlot(editingSlot.id); + setEditingSlot(null); + }; + + // ── Save helpers ───────────────────────────────────────────────── + + const buildPayload = (): TimeslotData => ({ procedures, doctorSlots, hygienistSlots }); + + const handleSaveProcedures = () => saveMutation.mutate({ ...buildPayload(), procedures }); + const handleSaveDoctorSlots = () => saveMutation.mutate({ ...buildPayload(), doctorSlots }); + const handleSaveHygienistSlots = () => saveMutation.mutate({ ...buildPayload(), hygienistSlots }); + + // ── Section 1 actions ──────────────────────────────────────────── + + const addProcedure = () => { + if (!newCode.trim()) return; + setProcedures((prev) => [...prev, { id: uid(), code: newCode.trim().toUpperCase(), description: newDesc.trim(), durationMin: newDuration }]); + setNewCode(""); + setNewDesc(""); + setNewDuration(30); + }; + + const deleteProcedure = (id: string) => setProcedures((prev) => prev.filter((p) => p.id !== id)); + + const updateProcedure = (id: string, field: keyof ProcedureEntry, value: string | number) => { + setProcedures((prev) => prev.map((p) => (p.id === id ? { ...p, [field]: value } : p))); + }; + + // ── Section 3 actions ──────────────────────────────────────────── + + const addHygSlot = () => { + if (!newHygDesc.trim()) return; + setHygienistSlots((prev) => [...prev, { id: uid(), description: newHygDesc.trim(), durationMin: newHygDuration }]); + setNewHygDesc(""); + setNewHygDuration(30); + }; + + const deleteHygSlot = (id: string) => setHygienistSlots((prev) => prev.filter((s) => s.id !== id)); + + const updateHygSlot = (id: string, field: keyof HygienistSlot, value: string | number) => { + setHygienistSlots((prev) => prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))); + }; + + // ── Render ─────────────────────────────────────────────────────── + + if (isLoading) return Loading...; + + return ( +
+ + {/* ── Section 1: Procedure Duration ── */} + + +
+

Procedure Duration

+

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

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

Doctor Time Slot Settings

+

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

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

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

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

+ Edit Slot +

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

Hygienist Time Slot Settings

+

+ Define standard procedure types for hygienists with typical durations. +

+
+ + {hygienistSlots.length > 0 && ( +
+ + + + + + + + + {hygienistSlots.map((slot) => ( + + + + + + ))} + +
Procedure DescriptionDuration +
+ updateHygSlot(slot.id, "description", e.target.value)} + className="w-full border rounded px-2 py-1 text-sm" + placeholder="e.g. Exam / Recalls / Teeth Cleaning" + /> + + + + +
+
+ )} + +
+
+ + setNewHygDesc(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addHygSlot()} + className="border rounded px-2 py-1.5 text-sm w-full" + placeholder="e.g. Exam, Recalls / Teeth Cleaning" + /> +
+
+ + +
+ +
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index aaa5153a..720efe73 100755 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -817,7 +817,11 @@ export default function AppointmentsPage() { {appointment.patientName} -
{appointment.type}
+
+ {appointment.type?.startsWith("other:") + ? appointment.type.slice(6) + : appointment.type} +
); } diff --git a/apps/Frontend/src/pages/settings-page.tsx b/apps/Frontend/src/pages/settings-page.tsx index da9c042c..d50da9b5 100755 --- a/apps/Frontend/src/pages/settings-page.tsx +++ b/apps/Frontend/src/pages/settings-page.tsx @@ -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 ; + case "proceduretimeslot": + return ; + default: return null; } diff --git a/packages/db/prisma/migrations/20260505000000_add_procedure_timeslot/migration.sql b/packages/db/prisma/migrations/20260505000000_add_procedure_timeslot/migration.sql new file mode 100644 index 00000000..5599c751 --- /dev/null +++ b/packages/db/prisma/migrations/20260505000000_add_procedure_timeslot/migration.sql @@ -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"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b528c5d4..2294af87 100755 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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") +}