diff --git a/apps/Backend/src/routes/appointments-procedures.ts b/apps/Backend/src/routes/appointments-procedures.ts new file mode 100644 index 0000000..444cabd --- /dev/null +++ b/apps/Backend/src/routes/appointments-procedures.ts @@ -0,0 +1,139 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { prisma } from "@repo/db/client"; +import { + insertAppointmentProcedureSchema, + updateAppointmentProcedureSchema, +} from "@repo/db/types"; + +const router = Router(); + +/** + * GET /api/appointment-procedures/:appointmentId + * Get all procedures for an appointment + */ +router.get("/:appointmentId", async (req: Request, res: Response) => { + try { + const appointmentId = Number(req.params.appointmentId); + if (isNaN(appointmentId)) { + return res.status(400).json({ message: "Invalid appointmentId" }); + } + + const rows = await storage.getByAppointmentId(appointmentId); + + return res.json(rows); + } catch (err: any) { + console.error("GET appointment procedures error", err); + return res.status(500).json({ message: err.message ?? "Server error" }); + } +}); + +/** + * POST /api/appointment-procedures + * Add single manual procedure + */ +router.post("/", async (req: Request, res: Response) => { + try { + const parsed = insertAppointmentProcedureSchema.parse(req.body); + + const created = await storage.createProcedure(parsed); + + return res.json(created); + } catch (err: any) { + console.error("POST appointment procedure error", err); + if (err.name === "ZodError") { + return res.status(400).json({ message: err.errors }); + } + return res.status(500).json({ message: err.message ?? "Server error" }); + } +}); + +/** + * POST /api/appointment-procedures/bulk + * Add multiple procedures (combos) + */ +router.post("/bulk", async (req: Request, res: Response) => { + try { + const rows = req.body; + + if (!Array.isArray(rows) || rows.length === 0) { + return res.status(400).json({ message: "Invalid payload" }); + } + + const count = await storage.createProceduresBulk(rows); + + return res.json({ success: true, count }); + } catch (err: any) { + console.error("POST bulk appointment procedures error", err); + return res.status(500).json({ message: err.message ?? "Server error" }); + } +}); + +/** + * PUT /api/appointment-procedures/:id + * Update a procedure + */ +router.put("/:id", async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ message: "Invalid id" }); + } + + const parsed = updateAppointmentProcedureSchema.parse(req.body); + + const updated = await storage.updateProcedure(id, parsed); + + return res.json(updated); + } catch (err: any) { + console.error("PUT appointment procedure error", err); + + if (err.name === "ZodError") { + return res.status(400).json({ message: err.errors }); + } + + return res.status(500).json({ message: err.message ?? "Server error" }); + } +}); + +/** + * DELETE /api/appointment-procedures/:id + * Delete single procedure + */ +router.delete("/:id", async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ message: "Invalid id" }); + } + + await storage.deleteProcedure(id); + + return res.json({ success: true }); + } catch (err: any) { + console.error("DELETE appointment procedure error", err); + return res.status(500).json({ message: err.message ?? "Server error" }); + } +}); + +/** + * DELETE /api/appointment-procedures/clear/:appointmentId + * Clear all procedures for appointment + */ +router.delete("/clear/:appointmentId", async (req: Request, res: Response) => { + try { + const appointmentId = Number(req.params.appointmentId); + if (isNaN(appointmentId)) { + return res.status(400).json({ message: "Invalid appointmentId" }); + } + + await storage.clearByAppointmentId(appointmentId); + + return res.json({ success: true }); + } catch (err: any) { + console.error("CLEAR appointment procedures error", err); + return res.status(500).json({ message: err.message ?? "Server error" }); + } +}); + +export default router; diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 96a45e9..25a5a3a 100644 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import patientsRoutes from "./patients"; import appointmentsRoutes from "./appointments"; +import appointmentProceduresRoutes from "./appointments-procedures"; import usersRoutes from "./users"; import staffsRoutes from "./staffs"; import claimsRoutes from "./claims"; @@ -21,6 +22,7 @@ const router = Router(); router.use("/patients", patientsRoutes); router.use("/appointments", appointmentsRoutes); +router.use("/appointment-procedures", appointmentProceduresRoutes); router.use("/users", usersRoutes); router.use("/staffs", staffsRoutes); router.use("/patientDataExtraction", patientDataExtractionRoutes); diff --git a/apps/Backend/src/storage/appointment-procedures-storage.ts b/apps/Backend/src/storage/appointment-procedures-storage.ts new file mode 100644 index 0000000..39d7376 --- /dev/null +++ b/apps/Backend/src/storage/appointment-procedures-storage.ts @@ -0,0 +1,70 @@ +import { + AppointmentProcedure, + InsertAppointmentProcedure, + UpdateAppointmentProcedure, +} from "@repo/db/types"; +import { prisma as db } from "@repo/db/client"; + +export interface IAppointmentProceduresStorage { + getByAppointmentId(appointmentId: number): Promise; + createProcedure( + data: InsertAppointmentProcedure + ): Promise; + createProceduresBulk(data: InsertAppointmentProcedure[]): Promise; + updateProcedure( + id: number, + data: UpdateAppointmentProcedure + ): Promise; + deleteProcedure(id: number): Promise; + clearByAppointmentId(appointmentId: number): Promise; +} + +export const appointmentProceduresStorage: IAppointmentProceduresStorage = { + async getByAppointmentId( + appointmentId: number + ): Promise { + return db.appointmentProcedure.findMany({ + where: { appointmentId }, + orderBy: { createdAt: "asc" }, + }); + }, + + async createProcedure( + data: InsertAppointmentProcedure + ): Promise { + return db.appointmentProcedure.create({ + data: data as AppointmentProcedure, + }); + }, + + async createProceduresBulk( + data: InsertAppointmentProcedure[] + ): Promise { + const result = await db.appointmentProcedure.createMany({ + data: data as any[], + }); + return result.count; + }, + + async updateProcedure( + id: number, + data: UpdateAppointmentProcedure + ): Promise { + return db.appointmentProcedure.update({ + where: { id }, + data: data as any, + }); + }, + + async deleteProcedure(id: number): Promise { + await db.appointmentProcedure.delete({ + where: { id }, + }); + }, + + async clearByAppointmentId(appointmentId: number): Promise { + await db.appointmentProcedure.deleteMany({ + where: { appointmentId }, + }); + }, +}; diff --git a/apps/Backend/src/storage/appointements-storage.ts b/apps/Backend/src/storage/appointments-storage.ts similarity index 100% rename from apps/Backend/src/storage/appointements-storage.ts rename to apps/Backend/src/storage/appointments-storage.ts diff --git a/apps/Backend/src/storage/index.ts b/apps/Backend/src/storage/index.ts index 5b5674a..3778cc8 100644 --- a/apps/Backend/src/storage/index.ts +++ b/apps/Backend/src/storage/index.ts @@ -2,7 +2,8 @@ import { usersStorage } from './users-storage'; import { patientsStorage } from './patients-storage'; -import { appointmentsStorage } from './appointements-storage'; +import { appointmentsStorage } from './appointments-storage'; +import { appointmentProceduresStorage } from './appointment-procedures-storage'; import { staffStorage } from './staff-storage'; import { claimsStorage } from './claims-storage'; import { insuranceCredsStorage } from './insurance-creds-storage'; @@ -19,6 +20,7 @@ export const storage = { ...usersStorage, ...patientsStorage, ...appointmentsStorage, + ...appointmentProceduresStorage, ...staffStorage, ...claimsStorage, ...insuranceCredsStorage, diff --git a/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx b/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx new file mode 100644 index 0000000..ea68f84 --- /dev/null +++ b/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx @@ -0,0 +1,531 @@ +import { useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Trash2, Plus, Save, X } from "lucide-react"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; +import { PROCEDURE_COMBOS, COMBO_CATEGORIES } from "@/utils/procedureCombos"; +import { + CODE_MAP, + getPriceForCodeWithAgeFromMap, +} from "@/utils/procedureCombosMapping"; +import { Patient } from "@repo/db/types"; + +interface AppointmentProcedure { + id: number; + appointmentId: number; + patientId: number; + procedureCode: string; + procedureLabel?: string | null; + fee?: number | null; + isDirect: boolean; + toothNumber?: string | null; + toothSurface?: string | null; + oralCavityArea?: string | null; + source: "COMBO" | "MANUAL"; + comboKey?: string | null; +} + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + appointmentId: number; + patientId: number; + patient: Patient; +} + +export function AppointmentProceduresDialog({ + open, + onOpenChange, + appointmentId, + patientId, + patient, +}: Props) { + const { toast } = useToast(); + + // ----------------------------- + // state for manual add + // ----------------------------- + const [manualCode, setManualCode] = useState(""); + const [manualLabel, setManualLabel] = useState(""); + const [manualFee, setManualFee] = useState(""); + const [manualTooth, setManualTooth] = useState(""); + const [manualSurface, setManualSurface] = useState(""); + + // ----------------------------- + // state for inline edit + // ----------------------------- + const [editingId, setEditingId] = useState(null); + const [editRow, setEditRow] = useState>({}); + + // ----------------------------- + // fetch procedures + // ----------------------------- + const { data: procedures = [], isLoading } = useQuery( + { + queryKey: ["appointment-procedures", appointmentId], + queryFn: async () => { + const res = await apiRequest( + "GET", + `/api/appointment-procedures/${appointmentId}` + ); + if (!res.ok) throw new Error("Failed to load procedures"); + return res.json(); + }, + enabled: open && !!appointmentId, + } + ); + + // ----------------------------- + // mutations + // ----------------------------- + const addManualMutation = useMutation({ + mutationFn: async () => { + const payload = { + appointmentId, + patientId, + procedureCode: manualCode, + procedureLabel: manualLabel || null, + fee: manualFee ? Number(manualFee) : null, + toothNumber: manualTooth || null, + toothSurface: manualSurface || null, + source: "MANUAL", + isDirect: false, + }; + + const res = await apiRequest( + "POST", + "/api/appointment-procedures", + payload + ); + if (!res.ok) throw new Error("Failed to add procedure"); + return res.json(); + }, + onSuccess: () => { + toast({ title: "Procedure added" }); + setManualCode(""); + setManualLabel(""); + setManualFee(""); + setManualTooth(""); + setManualSurface(""); + queryClient.invalidateQueries({ + queryKey: ["appointment-procedures", appointmentId], + }); + }, + onError: (err: any) => { + toast({ + title: "Error", + description: err.message ?? "Failed to add procedure", + variant: "destructive", + }); + }, + }); + + const bulkAddMutation = useMutation({ + mutationFn: async (rows: any[]) => { + const res = await apiRequest( + "POST", + "/api/appointment-procedures/bulk", + rows + ); + if (!res.ok) throw new Error("Failed to add combo procedures"); + return res.json(); + }, + onSuccess: () => { + toast({ title: "Combo added" }); + queryClient.invalidateQueries({ + queryKey: ["appointment-procedures", appointmentId], + }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: number) => { + const res = await apiRequest( + "DELETE", + `/api/appointment-procedures/${id}` + ); + if (!res.ok) throw new Error("Failed to delete"); + }, + onSuccess: () => { + toast({ title: "Deleted" }); + queryClient.invalidateQueries({ + queryKey: ["appointment-procedures", appointmentId], + }); + }, + }); + + const updateMutation = useMutation({ + mutationFn: async () => { + if (!editingId) return; + const res = await apiRequest( + "PUT", + `/api/appointment-procedures/${editingId}`, + editRow + ); + if (!res.ok) throw new Error("Failed to update"); + return res.json(); + }, + onSuccess: () => { + toast({ title: "Updated" }); + setEditingId(null); + setEditRow({}); + queryClient.invalidateQueries({ + queryKey: ["appointment-procedures", appointmentId], + }); + }, + }); + + const markClaimModeMutation = useMutation({ + mutationFn: async (mode: "DIRECT" | "MANUAL") => { + const payload = { + mode, + appointmentId, + }; + const res = await apiRequest( + "POST", + "/api/appointment-procedures/mark-claim-mode", + payload + ); + if (!res.ok) throw new Error("Failed to mark claim mode"); + }, + onSuccess: (_, mode) => { + toast({ + title: + mode === "DIRECT" ? "Direct claim selected" : "Manual claim selected", + }); + queryClient.invalidateQueries({ + queryKey: ["appointment-procedures", appointmentId], + }); + }, + }); + + // ----------------------------- + // handlers + // ----------------------------- + const handleAddCombo = (comboKey: string) => { + const combo = PROCEDURE_COMBOS[comboKey]; + if (!combo || !patient?.dateOfBirth) return; + + const serviceDate = new Date(); + const dob = patient.dateOfBirth; + + const age = (() => { + const birth = new Date(dob); + const ref = new Date(serviceDate); + let a = ref.getFullYear() - birth.getFullYear(); + const hadBirthday = + ref.getMonth() > birth.getMonth() || + (ref.getMonth() === birth.getMonth() && + ref.getDate() >= birth.getDate()); + if (!hadBirthday) a -= 1; + return a; + })(); + + const rows = combo.codes.map((code: string, idx: number) => { + const priceDecimal = getPriceForCodeWithAgeFromMap(CODE_MAP, code, age); + + return { + appointmentId, + patientId, + procedureCode: code, + procedureLabel: combo.label, + fee: priceDecimal.toNumber(), + source: "COMBO", + comboKey: comboKey, + toothNumber: combo.toothNumbers?.[idx] ?? null, + isDirect: false, + }; + }); + + bulkAddMutation.mutate(rows); + }; + + const startEdit = (row: AppointmentProcedure) => { + setEditingId(row.id); + setEditRow({ + procedureCode: row.procedureCode, + procedureLabel: row.procedureLabel, + fee: row.fee, + toothNumber: row.toothNumber, + toothSurface: row.toothSurface, + }); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditRow({}); + }; + + // ----------------------------- + // UI + // ----------------------------- + return ( + + + + + Appointment Procedures + + + + {/* ================= COMBOS ================= */} +
+
+ Quick Add Combos +
+ + {Object.entries(COMBO_CATEGORIES).map(([categoryName, comboKeys]) => ( +
+
{categoryName}
+ +
+ {comboKeys.map((comboKey) => { + const combo = PROCEDURE_COMBOS[comboKey]; + if (!combo) return null; + + return ( + + ); + })} +
+
+ ))} +
+ + {/* ================= MANUAL ADD ================= */} +
+
Add Manual Procedure
+ +
+
+ + setManualCode(e.target.value)} + placeholder="D0120" + /> +
+ +
+ + setManualLabel(e.target.value)} + placeholder="Exam" + /> +
+ +
+ + setManualFee(e.target.value)} + placeholder="100" + type="number" + /> +
+ +
+ + setManualTooth(e.target.value)} + placeholder="14" + /> +
+ +
+ + setManualSurface(e.target.value)} + placeholder="MO" + /> +
+
+ +
+ +
+
+ + {/* ================= LIST ================= */} +
+
Selected Procedures
+ +
+ {isLoading && ( +
+ Loading... +
+ )} + + {!isLoading && procedures.length === 0 && ( +
+ No procedures added +
+ )} + + {procedures.map((p) => ( +
+ {editingId === p.id ? ( + <> + + setEditRow({ + ...editRow, + procedureCode: e.target.value, + }) + } + /> + + setEditRow({ + ...editRow, + procedureLabel: e.target.value, + }) + } + /> + + setEditRow({ ...editRow, fee: Number(e.target.value) }) + } + /> + + setEditRow({ + ...editRow, + toothNumber: e.target.value, + }) + } + /> + + setEditRow({ + ...editRow, + toothSurface: e.target.value, + }) + } + /> + + + + + + ) : ( + <> +
+ {p.procedureCode} +
+
+ {p.procedureLabel} +
+
{p.fee}
+
{p.toothNumber}
+
{p.toothSurface}
+ + + {p.isDirect ? "Direct" : "Manual"} + + + + + + + )} +
+ ))} +
+
+ + {/* ================= FOOTER ================= */} +
+
+ + + +
+ + +
+
+
+ ); +} diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index dae0dc3..94f57b3 100644 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -53,6 +53,7 @@ import { } from "@/redux/slices/seleniumEligibilityBatchCheckTaskSlice"; import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner"; import { PatientStatusBadge } from "@/components/appointments/patient-status-badge"; +import { AppointmentProceduresDialog } from "@/components/appointment-procedures/appointment-procedures-dialog"; // Define types for scheduling interface TimeSlot { @@ -93,6 +94,17 @@ export default function AppointmentsPage() { const { toast } = useToast(); const { user } = useAuth(); const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [proceduresDialogOpen, setProceduresDialogOpen] = useState(false); + const [proceduresAppointmentId, setProceduresAppointmentId] = useState< + number | null + >(null); + const [proceduresPatientId, setProceduresPatientId] = useState( + null + ); + const [proceduresPatient, setProceduresPatient] = useState( + null + ); + const [calendarOpen, setCalendarOpen] = useState(false); const [editingAppointment, setEditingAppointment] = useState< Appointment | undefined @@ -866,6 +878,34 @@ export default function AppointmentsPage() { // intentionally do not clear task status here so banner persists until user dismisses it } }; + + const handleOpenProcedures = (appointmentId: number) => { + const apt = appointments.find((a) => a.id === appointmentId); + if (!apt) { + toast({ + title: "Error", + description: "Appointment not found", + variant: "destructive", + }); + return; + } + + const patient = patientsFromDay.find((p) => p.id === apt.patientId); + if (!patient) { + toast({ + title: "Error", + description: "Patient not found for this appointment", + variant: "destructive", + }); + return; + } + + setProceduresAppointmentId(Number(apt.id)); + setProceduresPatientId(apt.patientId); + setProceduresPatient(patient); + setProceduresDialogOpen(true); + }; + return (
+ {/* Procedures */} + handleOpenProcedures(props.appointmentId)} + > + + + Procedures + + + {/* Clinic Notes */} handleClinicNotes(props.appointmentId)}> @@ -1126,6 +1176,24 @@ export default function AppointmentsPage() { onDelete={handleDeleteAppointment} /> + {/* Appointment Procedure Dialog */} + {proceduresAppointmentId && proceduresPatientId && proceduresPatient && ( + { + setProceduresDialogOpen(open); + if (!open) { + setProceduresAppointmentId(null); + setProceduresPatientId(null); + setProceduresPatient(null); + } + }} + appointmentId={proceduresAppointmentId} + patientId={proceduresPatientId} + patient={proceduresPatient} + /> + )} + ( return next; } + + +export { CODE_MAP, getPriceForCodeWithAgeFromMap }; \ No newline at end of file diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 46e1ab3..ef69428 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -58,6 +58,7 @@ model Patient { createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) appointments Appointment[] + procedures AppointmentProcedure[] claims Claim[] groups PdfGroup[] payment Payment[] @@ -92,6 +93,7 @@ model Appointment { patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id]) staff Staff? @relation(fields: [staffId], references: [id]) + procedures AppointmentProcedure[] claims Claim[] @@index([patientId]) @@ -111,6 +113,40 @@ model Staff { claims Claim[] @relation("ClaimStaff") } +enum ProcedureSource { + COMBO + MANUAL +} + +model AppointmentProcedure { + id Int @id @default(autoincrement()) + appointmentId Int + patientId Int + + procedureCode String + procedureLabel String? + fee Decimal? @db.Decimal(10,2) + + category String? + isDirect Boolean @default(false) + + toothNumber String? + toothSurface String? + oralCavityArea String? + + source ProcedureSource @default(MANUAL) + comboKey String? + + createdAt DateTime @default(now()) + + appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade) + patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + + @@index([appointmentId]) + @@index([patientId]) +} + + model Claim { id Int @id @default(autoincrement()) patientId Int diff --git a/packages/db/types/appointment-types.ts b/packages/db/types/appointment-types.ts index b0ff4a8..5dfd5ba 100644 --- a/packages/db/types/appointment-types.ts +++ b/packages/db/types/appointment-types.ts @@ -1,4 +1,4 @@ -import { AppointmentUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas"; +import { AppointmentUncheckedCreateInputObjectSchema, AppointmentProcedureUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas"; import {z} from "zod"; export type Appointment = z.infer; @@ -19,4 +19,35 @@ export const updateAppointmentSchema = ( createdAt: true, }) .partial(); -export type UpdateAppointment = z.infer; \ No newline at end of file +export type UpdateAppointment = z.infer; + + +// Appointment Procedure Types. + +export type AppointmentProcedure = z.infer< + typeof AppointmentProcedureUncheckedCreateInputObjectSchema +>; + +export const insertAppointmentProcedureSchema = ( + AppointmentProcedureUncheckedCreateInputObjectSchema as unknown as z.ZodObject +).omit({ + id: true, + createdAt: true, +}); + +export type InsertAppointmentProcedure = z.infer< + typeof insertAppointmentProcedureSchema +>; + +export const updateAppointmentProcedureSchema = ( + AppointmentProcedureUncheckedCreateInputObjectSchema as unknown as z.ZodObject +) + .omit({ + id: true, + createdAt: true, + }) + .partial(); + +export type UpdateAppointmentProcedure = z.infer< + typeof updateAppointmentProcedureSchema +>; diff --git a/packages/db/usedSchemas/index.ts b/packages/db/usedSchemas/index.ts index 573567c..0d2e8ab 100644 --- a/packages/db/usedSchemas/index.ts +++ b/packages/db/usedSchemas/index.ts @@ -1,5 +1,6 @@ // using this, as the browser load only the required files , not whole db/shared/schemas/ files. export * from '../shared/schemas/objects/AppointmentUncheckedCreateInput.schema'; +export * from '../shared/schemas/objects/AppointmentProcedureUncheckedCreateInput.schema'; export * from '../shared/schemas/objects/PatientUncheckedCreateInput.schema'; export * from '../shared/schemas/enums/PatientStatus.schema'; export * from '../shared/schemas/objects/UserUncheckedCreateInput.schema';