diff --git a/apps/Backend/src/routes/appointments-procedures.ts b/apps/Backend/src/routes/appointments-procedures.ts index 444cabd..7e980ef 100644 --- a/apps/Backend/src/routes/appointments-procedures.ts +++ b/apps/Backend/src/routes/appointments-procedures.ts @@ -28,6 +28,32 @@ router.get("/:appointmentId", async (req: Request, res: Response) => { } }); +router.get( + "/prefill-from-appointment/:appointmentId", + async (req: Request, res: Response) => { + try { + const appointmentId = Number(req.params.appointmentId); + + if (!appointmentId || isNaN(appointmentId)) { + return res.status(400).json({ error: "Invalid appointmentId" }); + } + + const data = await storage.getPrefillDataByAppointmentId(appointmentId); + + if (!data) { + return res.status(404).json({ error: "Appointment not found" }); + } + + return res.json(data); + } catch (err: any) { + console.error("prefill-from-appointment error", err); + return res + .status(500) + .json({ error: err.message ?? "Failed to prefill claim data" }); + } + } +); + /** * POST /api/appointment-procedures * Add single manual procedure diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index 8e07f70..4ea3db4 100644 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -336,6 +336,15 @@ router.post("/", async (req: Request, res: Response): Promise => { } // --- TRANSFORM serviceLines + if ( + !Array.isArray(req.body.serviceLines) || + req.body.serviceLines.length === 0 + ) { + return res.status(400).json({ + message: "At least one service line is required to create a claim", + }); + } + if (Array.isArray(req.body.serviceLines)) { req.body.serviceLines = req.body.serviceLines.map( (line: InputServiceLine) => ({ diff --git a/apps/Backend/src/storage/appointment-procedures-storage.ts b/apps/Backend/src/storage/appointment-procedures-storage.ts index 39d7376..b243743 100644 --- a/apps/Backend/src/storage/appointment-procedures-storage.ts +++ b/apps/Backend/src/storage/appointment-procedures-storage.ts @@ -1,12 +1,20 @@ import { + Appointment, AppointmentProcedure, InsertAppointmentProcedure, + Patient, UpdateAppointmentProcedure, } from "@repo/db/types"; import { prisma as db } from "@repo/db/client"; export interface IAppointmentProceduresStorage { getByAppointmentId(appointmentId: number): Promise; + getPrefillDataByAppointmentId(appointmentId: number): Promise<{ + appointment: Appointment; + patient: Patient; + procedures: AppointmentProcedure[]; + } | null>; + createProcedure( data: InsertAppointmentProcedure ): Promise; @@ -29,6 +37,28 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = { }); }, + async getPrefillDataByAppointmentId(appointmentId: number) { + const appointment = await db.appointment.findUnique({ + where: { id: appointmentId }, + include: { + patient: true, + procedures: { + orderBy: { createdAt: "asc" }, + }, + }, + }); + + if (!appointment) { + return null; + } + + return { + appointment, + patient: appointment.patient, + procedures: appointment.procedures, + }; + }, + async createProcedure( data: InsertAppointmentProcedure ): Promise { diff --git a/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx b/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx index ea68f84..9c65e5e 100644 --- a/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx +++ b/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx @@ -17,22 +17,9 @@ 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; -} +import { Patient, AppointmentProcedure } from "@repo/db/types"; +import { useLocation } from "wouter"; +import { DeleteConfirmationDialog } from "../ui/deleteDialog"; interface Props { open: boolean; @@ -65,6 +52,10 @@ export function AppointmentProceduresDialog({ // ----------------------------- const [editingId, setEditingId] = useState(null); const [editRow, setEditRow] = useState>({}); + const [clearAllOpen, setClearAllOpen] = useState(false); + + // for redirection to claim submission + const [, setLocation] = useLocation(); // ----------------------------- // fetch procedures @@ -98,7 +89,6 @@ export function AppointmentProceduresDialog({ toothNumber: manualTooth || null, toothSurface: manualSurface || null, source: "MANUAL", - isDirect: false, }; const res = await apiRequest( @@ -163,6 +153,30 @@ export function AppointmentProceduresDialog({ }, }); + const clearAllMutation = useMutation({ + mutationFn: async () => { + const res = await apiRequest( + "DELETE", + `/api/appointment-procedures/clear/${appointmentId}` + ); + if (!res.ok) throw new Error("Failed to clear procedures"); + }, + onSuccess: () => { + toast({ title: "All procedures cleared" }); + queryClient.invalidateQueries({ + queryKey: ["appointment-procedures", appointmentId], + }); + setClearAllOpen(false); + }, + onError: (err: any) => { + toast({ + title: "Error", + description: err.message ?? "Failed to clear procedures", + variant: "destructive", + }); + }, + }); + const updateMutation = useMutation({ mutationFn: async () => { if (!editingId) return; @@ -184,30 +198,6 @@ export function AppointmentProceduresDialog({ }, }); - 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 // ----------------------------- @@ -242,7 +232,6 @@ export function AppointmentProceduresDialog({ source: "COMBO", comboKey: comboKey, toothNumber: combo.toothNumbers?.[idx] ?? null, - isDirect: false, }; }); @@ -250,6 +239,8 @@ export function AppointmentProceduresDialog({ }; const startEdit = (row: AppointmentProcedure) => { + if (!row.id) return; + setEditingId(row.id); setEditRow({ procedureCode: row.procedureCode, @@ -265,12 +256,34 @@ export function AppointmentProceduresDialog({ setEditRow({}); }; + const handleDirectClaim = () => { + setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`); + onOpenChange(false); + }; + + const handleManualClaim = () => { + setLocation(`/claims?appointmentId=${appointmentId}&mode=manual`); + onOpenChange(false); + }; + // ----------------------------- // UI // ----------------------------- return ( - + { + if (clearAllOpen) { + e.preventDefault(); // block only when delete dialog is open + } + }} + onInteractOutside={(e) => { + if (clearAllOpen) { + e.preventDefault(); // block only when delete dialog is open + } + }} + > Appointment Procedures @@ -278,7 +291,7 @@ export function AppointmentProceduresDialog({ {/* ================= COMBOS ================= */} -
+
Quick Add Combos
@@ -374,7 +387,18 @@ export function AppointmentProceduresDialog({ {/* ================= LIST ================= */}
-
Selected Procedures
+
+
Selected Procedures
+ + +
{isLoading && ( @@ -418,11 +442,16 @@ export function AppointmentProceduresDialog({ /> setEditRow({ ...editRow, fee: Number(e.target.value) }) } /> + {p.procedureLabel}
-
{p.fee}
+
+ {p.fee !== null && p.fee !== undefined + ? String(p.fee) + : ""} +
+
{p.toothNumber}
{p.toothSurface}
- - {p.isDirect ? "Direct" : "Manual"} - - @@ -505,8 +529,8 @@ export function AppointmentProceduresDialog({
@@ -514,8 +538,8 @@ export function AppointmentProceduresDialog({ @@ -526,6 +550,16 @@ export function AppointmentProceduresDialog({
+ + setClearAllOpen(false)} + onConfirm={() => { + setClearAllOpen(false); + clearAllMutation.mutate(); + }} + />
); } diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index 9c05de3..fd9ce57 100644 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback, memo } from "react"; +import { useState, useEffect, useRef, useCallback, memo, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -58,6 +58,7 @@ import { RemarksField } from "./claims-ui"; interface ClaimFormProps { patientId: number; appointmentId?: number; + autoSubmit?: boolean; onSubmit: (data: ClaimFormData) => Promise; onHandleAppointmentSubmit: ( appointmentData: InsertAppointment | UpdateAppointment @@ -68,23 +69,10 @@ interface ClaimFormProps { onClose: () => void; } -const PERMANENT_TOOTH_NAMES = Array.from( - { length: 32 }, - (_, i) => `T_${i + 1}` -); -const PRIMARY_TOOTH_NAMES = Array.from("ABCDEFGHIJKLMNOPQRST").map( - (ch) => `T_${ch}` -); - -function isValidToothKey(key: string) { - return ( - PERMANENT_TOOTH_NAMES.includes(key) || PRIMARY_TOOTH_NAMES.includes(key) - ); -} - export function ClaimForm({ patientId, appointmentId, + autoSubmit, onHandleAppointmentSubmit, onHandleUpdatePatient, onHandleForMHSeleniumClaim, @@ -95,6 +83,9 @@ export function ClaimForm({ const { toast } = useToast(); const { user } = useAuth(); + const [prefillDone, setPrefillDone] = useState(false); + const autoSubmittedRef = useRef(false); + const [patient, setPatient] = useState(null); // Query patient based on given patient id @@ -225,6 +216,53 @@ export function ClaimForm({ }; }, [appointmentId]); + // + + // 2. effect - prefill proceduresCodes (if exists for appointment) into serviceLines + useEffect(() => { + if (!appointmentId) return; + + let cancelled = false; + + (async () => { + try { + const res = await apiRequest( + "GET", + `/api/appointment-procedures/prefill-from-appointment/${appointmentId}` + ); + if (!res.ok) return; + + const data = await res.json(); + + if (cancelled) return; + + const mappedLines = (data.procedures || []).map((p: any) => ({ + procedureCode: p.procedureCode, + procedureDate: serviceDate, + oralCavityArea: p.oralCavityArea || "", + toothNumber: p.toothNumber || "", + toothSurface: p.toothSurface || "", + totalBilled: new Decimal(p.fee || 0), + totalAdjusted: new Decimal(0), + totalPaid: new Decimal(0), + })); + + setForm((prev) => ({ + ...prev, + serviceLines: mappedLines, + })); + + setPrefillDone(true); + } catch (err) { + console.error("Failed to prefill procedures:", err); + } + })(); + + return () => { + cancelled = true; + }; + }, [appointmentId, serviceDate]); + // Update service date when calendar date changes const onServiceDateChange = (date: Date | undefined) => { if (date) { @@ -439,21 +477,6 @@ export function ClaimForm({ }); }; - const updateMissingTooth = useCallback( - (name: string, value: "" | "X" | "O") => { - if (!isValidToothKey(name)) return; - setForm((prev) => { - const current = prev.missingTeeth[name] ?? ""; - if (current === value) return prev; - const nextMap = { ...prev.missingTeeth }; - if (!value) delete nextMap[name]; - else nextMap[name] = value; - return { ...prev, missingTeeth: nextMap }; - }); - }, - [] - ); - const clearAllToothSelections = () => setForm((prev) => ({ ...prev, missingTeeth: {} as MissingMapStrict })); @@ -772,6 +795,37 @@ export function ClaimForm({ await handleMHSubmit(nextForm); }; + const isFormReady = useMemo(() => { + return ( + !!patient && + !!form.memberId?.trim() && + !!form.dateOfBirth?.trim() && + !!form.patientName?.trim() && + Array.isArray(form.serviceLines) && + form.serviceLines.some( + (l) => l.procedureCode && l.procedureCode.trim() !== "" + ) + ); + }, [ + patient, + form.memberId, + form.dateOfBirth, + form.patientName, + form.serviceLines, + ]); + + // when autoSubmit mode is given, it will then submit the claims. + useEffect(() => { + if (!autoSubmit) return; + if (!prefillDone) return; + if (!isFormReady) return; + + if (autoSubmittedRef.current) return; + autoSubmittedRef.current = true; + + handleMHSubmit(); + }, [autoSubmit, prefillDone, isFormReady]); + // overlay click handler (close when clicking backdrop) const onOverlayMouseDown = (e: React.MouseEvent) => { // only close if clicked the backdrop itself (not inner modal) @@ -780,6 +834,14 @@ export function ClaimForm({ } }; + useEffect(() => { + return () => { + // reset when ClaimForm unmounts (modal closes) + autoSubmittedRef.current = false; + setPrefillDone(false); + }; + }, []); + return (
-
+
+
e.stopPropagation()} + >

Confirm Deletion

+

Are you sure you want to delete {entityName}?

+
+ diff --git a/apps/Frontend/src/pages/claims-page.tsx b/apps/Frontend/src/pages/claims-page.tsx index 021614a..826cce3 100644 --- a/apps/Frontend/src/pages/claims-page.tsx +++ b/apps/Frontend/src/pages/claims-page.tsx @@ -146,9 +146,13 @@ export default function ClaimsPage() { // case1: - this params are set by pdf extraction/patient page or either by patient-add-form. then used in claim page here. const [location] = useLocation(); - const { newPatient } = useMemo(() => { + + const { newPatient, mode } = useMemo(() => { const params = new URLSearchParams(window.location.search); - return { newPatient: params.get("newPatient") }; + return { + newPatient: params.get("newPatient"), + mode: params.get("mode"), // direct | manual | null}; + }; }, [location]); const handleNewClaim = (patientId: number, appointmentId?: number) => { @@ -532,6 +536,7 @@ export default function ClaimsPage() {