feat: appointment type inference, procedure codes on cards, claim attachment fixes

- Add appointment type categories matching insurance claim form (recall, filling, pedo, dentures, implant, endo, crown, perio, extraction, ortho, consultation, emergency, other)
- Auto-infer appointment type from CDT codes with priority rules (endo > implant > crown > ...)
- typeLocked flag prevents auto-overwrite when user manually sets type
- Show appointment type label and procedure codes on schedule cards
- Background sync on /day route retroactively fixes stale appointment types
- Fix PUT /api/claims/:id to save claimFiles (previously silently dropped)
- Auto-link AppointmentFile records to ClaimFile when claim is created or updated
- Fix D5750 (denture reline) CDT range to map correctly to dentures category
- Fix typeLocked Zod rejection in appointment update route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-05-29 14:18:10 -04:00
parent b20dc8e976
commit 9d0cfe5dba
260 changed files with 2443 additions and 1968 deletions

View File

@@ -56,8 +56,8 @@
{
"Procedure Code": "D0230",
"Description": "Intraoral - periapical, each additional radiographic image",
"PriceLTEQ21": "13",
"PriceGT21": "13"
"PriceLTEQ21": 60,
"PriceGT21": 60
},
{
"Procedure Code": "D0240",

View File

@@ -854,8 +854,8 @@
{
"Procedure Code": "D7210",
"Description": "Extraction, erupted tooth requiring removal of bone and/or sectioning of tooth, and including elevation of mucoperiosteal flap if indicated",
"PriceLTEQ21": "149",
"PriceGT21": "149"
"PriceLTEQ21": 200,
"PriceGT21": 200
},
{
"Procedure Code": "D7220",

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import {
Dialog,
@@ -6,6 +6,16 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -23,6 +33,8 @@ import { PROCEDURE_COMBOS } from "@/utils/procedureCombos";
import {
CODE_MAP,
getPriceForCodeWithAgeFromMap,
findPriceMismatches,
type PriceMismatch,
} from "@/utils/procedureCombosMapping";
import { Patient, AppointmentProcedure, NpiProvider } from "@repo/db/types";
import { useLocation } from "wouter";
@@ -67,6 +79,51 @@ export function AppointmentProceduresDialog({
const [editRow, setEditRow] = useState<Partial<AppointmentProcedure>>({});
const [clearAllOpen, setClearAllOpen] = useState(false);
// price mismatch dialog
const [priceMismatches, setPriceMismatches] = useState<PriceMismatch[]>([]);
const pendingAction = useRef<(() => void) | null>(null);
const deriveInsuranceSiteKey = (provider: string | null | undefined): string => {
const p = (provider || "").toLowerCase().trim();
if (!p) return "";
if (p.includes("masshealth") || p === "mh" || p === "mass health") return "MH";
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
if ((p.includes("united") && p.includes("sco")) || p === "unitedsco") return "UnitedSCO";
return "";
};
const runWithPriceCheck = (procedureCode: string, fee: number, action: () => void) => {
const siteKey = deriveInsuranceSiteKey((patient as any)?.insuranceProvider);
if (!siteKey || !procedureCode.trim() || !fee) { action(); return; }
const mismatches = findPriceMismatches(
[{ procedureCode, totalBilled: fee as any, procedureDate: "" }],
siteKey,
(patient?.dateOfBirth as string) || "",
serviceDate ?? new Date().toISOString().slice(0, 10),
);
if (mismatches.length === 0) {
action();
} else {
pendingAction.current = action;
setPriceMismatches(mismatches);
}
};
const savePricesToSchedule = async (mismatches: PriceMismatch[]) => {
const siteKey = deriveInsuranceSiteKey((patient as any)?.insuranceProvider);
await Promise.all(
mismatches.map(m =>
apiRequest("POST", "/api/fee-schedule/update-price", {
siteKey,
procedureCode: m.procedureCode,
price: m.enteredPrice,
})
)
);
};
// ── NPI Providers ──────────────────────────────────────────────
const { data: npiProviders = [] } = useQuery<NpiProvider[]>({
queryKey: ["/api/npiProviders/"],
@@ -343,7 +400,7 @@ export function AppointmentProceduresDialog({
</div>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={() => addManualMutation.mutate()} disabled={!manualCode || addManualMutation.isPending}>
<Button size="sm" onClick={() => runWithPriceCheck(manualCode, Number(manualFee), () => addManualMutation.mutate())} disabled={!manualCode || addManualMutation.isPending}>
<Plus className="h-4 w-4 mr-1" />
Add Procedure
</Button>
@@ -380,7 +437,7 @@ export function AppointmentProceduresDialog({
<Input className="w-[80px]" value={editRow.toothNumber ?? ""} onChange={(e) => setEditRow({ ...editRow, toothNumber: e.target.value })} />
<Input className="w-[80px]" value={editRow.toothSurface ?? ""} onChange={(e) => setEditRow({ ...editRow, toothSurface: e.target.value })} />
<div className="flex justify-center">
<Button size="icon" variant="ghost" onClick={() => updateMutation.mutate()}><Save className="h-4 w-4" /></Button>
<Button size="icon" variant="ghost" onClick={() => runWithPriceCheck(editRow.procedureCode || "", Number(editRow.fee), () => updateMutation.mutate())}><Save className="h-4 w-4" /></Button>
</div>
<div className="flex justify-center">
<Button size="icon" variant="ghost" onClick={cancelEdit}><X className="h-4 w-4" /></Button>
@@ -437,6 +494,47 @@ export function AppointmentProceduresDialog({
onCancel={() => setClearAllOpen(false)}
onConfirm={() => { setClearAllOpen(false); clearAllMutation.mutate(); }}
/>
{/* Price mismatch dialog */}
<AlertDialog open={priceMismatches.length > 0} onOpenChange={open => { if (!open) setPriceMismatches([]); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Save new price to the app?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>The following procedure prices differ from the fee schedule:</p>
<ul className="text-sm space-y-1">
{priceMismatches.map(m => (
<li key={m.procedureCode} className="flex justify-between gap-4">
<span className="font-medium">{m.procedureCode}</span>
<span className="text-muted-foreground">Schedule: ${m.schedulePrice.toFixed(2)}</span>
<span className="text-foreground font-semibold">Entered: ${m.enteredPrice.toFixed(2)}</span>
</li>
))}
</ul>
<p className="text-sm">Do you want to save the new price(s) to the fee schedule for future use?</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setPriceMismatches([]);
pendingAction.current?.();
pendingAction.current = null;
}}>
No
</AlertDialogCancel>
<AlertDialogAction onClick={async () => {
await savePricesToSchedule(priceMismatches);
setPriceMismatches([]);
pendingAction.current?.();
pendingAction.current = null;
}}>
Yes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}

View File

@@ -3,6 +3,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { apiRequest } from "@/lib/queryClient";
import { APPOINTMENT_TYPES } from "@/utils/appointmentTypeUtils";
import { Button } from "@/components/ui/button";
import {
Form,
@@ -70,6 +71,10 @@ export function AppointmentForm({
const t = appointment?.type ?? "";
return t.startsWith("other:") ? t.slice(6) : "";
});
// Track whether the user explicitly changed the type during this edit session.
// Used to set typeLocked so the auto-sync won't overwrite a deliberate choice.
const originalType = useRef<string>(appointment?.type ?? "");
const [typeChangedByUser, setTypeChangedByUser] = useState(false);
useEffect(() => {
const timeout = setTimeout(() => {
@@ -295,6 +300,8 @@ export function AppointmentForm({
startTime: data.startTime,
endTime: data.endTime,
type: resolvedType,
// Lock the type when the user has explicitly changed it on an existing appointment
...(appointment && typeChangedByUser ? { typeLocked: true } : {}),
});
};
@@ -522,6 +529,7 @@ export function AppointmentForm({
onValueChange={(val) => {
field.onChange(val);
if (val !== "other") setOtherTypeDesc("");
if (val !== originalType.current) setTypeChangedByUser(true);
}}
value={field.value}
defaultValue={field.value}
@@ -532,16 +540,9 @@ export function AppointmentForm({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="checkup">Checkup</SelectItem>
<SelectItem value="cleaning">Cleaning</SelectItem>
<SelectItem value="filling">Filling</SelectItem>
<SelectItem value="extraction">Extraction</SelectItem>
<SelectItem value="root-canal">Root Canal</SelectItem>
<SelectItem value="crown">Crown</SelectItem>
<SelectItem value="dentures">Dentures</SelectItem>
<SelectItem value="consultation">Consultation</SelectItem>
<SelectItem value="emergency">Emergency</SelectItem>
<SelectItem value="other">Other</SelectItem>
{APPOINTMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
{field.value === "other" && (

View File

@@ -20,7 +20,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { apiRequest, queryClient } from "@/lib/queryClient";
import {
MultipleFileUploadZone,
MultipleFileUploadZoneHandle,
@@ -62,6 +62,7 @@ import {
DirectComboButtons,
RegularComboButtons,
} from "@/components/procedure/procedure-combo-buttons";
import { inferTypeFromProcedureCodes, getAppointmentTypeLabel } from "@/utils/appointmentTypeUtils";
import { Switch } from "@/components/ui/switch";
import {
AlertDialog,
@@ -1487,8 +1488,23 @@ export function ClaimForm({
});
const data = await res.json();
if (!data.success) throw new Error("Failed to save procedures");
// Auto-infer appointment type from saved procedure codes
const codes = filteredServiceLines.map((l) => l.procedureCode ?? "").filter(Boolean);
const inferredType = inferTypeFromProcedureCodes(codes);
if (inferredType && appointmentId) {
try {
await apiRequest("PATCH", `/api/appointments/${appointmentId}/type`, { type: inferredType });
// Refresh the schedule view so the new type shows on the card immediately
queryClient.invalidateQueries({ queryKey: ["appointments", "day"] });
} catch {
// Non-fatal: type update is best-effort
}
}
const attachMsg = attachments.length ? ` and ${attachments.length} attachment(s)` : "";
toast({ title: "Procedures saved", description: `${data.count} procedure(s)${attachMsg} saved.` });
const typeMsg = inferredType ? ` · Type → ${getAppointmentTypeLabel(inferredType)}` : "";
toast({ title: "Procedures saved", description: `${data.count} procedure(s)${attachMsg} saved${typeMsg}.` });
onClose();
} catch (err: any) {
toast({ title: "Save failed", description: err?.message ?? "Failed to save procedures.", variant: "destructive" });

View File

@@ -67,6 +67,7 @@ import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
import { PatientStatusBadge } from "@/components/appointments/patient-status-badge";
import type { OfficeHoursData } from "@/components/settings/office-hours-card";
import { MessageThread } from "@/components/patient-connection/message-thread";
import { getAppointmentTypeLabel } from "@/utils/appointmentTypeUtils";
// Define types for scheduling
interface TimeSlot {
@@ -97,6 +98,7 @@ interface ScheduledAppointment {
endTime: string | Date;
status: string | null;
type: string;
procedureCodes?: string[];
}
function appointmentCardColor(apt: ScheduledAppointment): string {
@@ -569,6 +571,7 @@ export default function AppointmentsPage() {
patientInsuranceProvider,
hasProcedures: !!(apt as any).hasProcedures,
hasClaimWithNumber: !!(apt as any).hasClaimWithNumber,
procedureCodes: (apt as any).procedureCodes ?? [],
movedByAi: !!(apt as any).movedByAi,
staffId,
status: apt.status ?? null,
@@ -797,11 +800,14 @@ export default function AppointmentsPage() {
</span>
)}
</div>
<div className="truncate">
{appointment.type?.startsWith("other:")
? appointment.type.slice(6)
: appointment.type}
<div className="truncate text-[11px]">
{getAppointmentTypeLabel(appointment.type)}
</div>
{appointment.procedureCodes && appointment.procedureCodes.length > 0 && (
<div className="truncate text-[10px] opacity-80">
{appointment.procedureCodes.join(", ")}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,97 @@
export const APPOINTMENT_TYPES = [
{ value: "recall", label: "Recalls & New Patients" },
{ value: "filling", label: "Filling (Composite)" },
{ value: "pedo", label: "Pedo" },
{ value: "dentures", label: "Dentures / Partials" },
{ value: "implant", label: "Implant" },
{ value: "endo", label: "Endo (Root Canal)" },
{ value: "crown", label: "Crown / Prosthodontics" },
{ value: "perio", label: "Periodontics" },
{ value: "extraction", label: "Extraction" },
{ value: "ortho", label: "Orthodontics" },
{ value: "consultation", label: "Consultation" },
{ value: "emergency", label: "Emergency" },
{ value: "other", label: "Other" },
] as const;
const LEGACY_LABELS: Record<string, string> = {
checkup: "Checkup",
cleaning: "Cleaning",
"root-canal": "Root Canal",
};
export function getAppointmentTypeLabel(type: string | null | undefined): string {
if (!type) return "";
if (type.startsWith("other:")) return type.slice(6);
const found = APPOINTMENT_TYPES.find((t) => t.value === type);
if (found) return found.label;
return LEGACY_LABELS[type] ?? type;
}
function codeToType(code: string): string | null {
const c = code.replace(/\s/g, "").toUpperCase();
// Special cases (pedo-specific codes that share ranges with other categories)
if (c === "D1351" || c === "D2930" || c === "D3220") return "pedo";
// Emergency / consultation
if (c === "D9110") return "emergency";
if (c === "D9310") return "consultation";
// Endo: D3xxx (root canals, pulp therapy)
if (/^D3/.test(c)) return "endo";
// Implants: D6xxx
if (/^D6/.test(c)) return "implant";
// Crown / Prosthodontics: D27xx, D28xx (fixed partials, crowns)
if (/^D2[78]/.test(c)) return "crown";
// Dentures / Partials: D51xxD58xx (complete/partial dentures, relines, repairs, adjustments)
if (/^D5[1-8]/.test(c)) return "dentures";
// Extractions: D71xx
if (/^D71/.test(c)) return "extraction";
// Periodontics: D43xxD49xx
if (/^D4[3-9]/.test(c)) return "perio";
// Fillings / Restorations: remaining D2xxx
if (/^D2/.test(c)) return "filling";
// Orthodontics: D8xxx
if (/^D8/.test(c)) return "ortho";
// Recalls & New Patients: D0xxx (exams, x-rays), D1xxx (preventive)
if (/^D[01]/.test(c)) return "recall";
return null;
}
export function inferTypeFromProcedureCodes(codes: string[]): string | null {
if (!codes.length) return null;
const scores: Record<string, number> = {};
for (const code of codes) {
const t = codeToType(code);
if (t) scores[t] = (scores[t] ?? 0) + 1;
}
if (!Object.keys(scores).length) return null;
// Priority order: most specialized/dominant type wins on ties
const priority = [
"endo", "implant", "crown", "pedo", "dentures",
"extraction", "perio", "filling", "ortho",
"recall", "consultation", "emergency",
];
let best: string | null = null;
let bestCount = 0;
for (const type of priority) {
const count = scores[type] ?? 0;
if (count > bestCount) {
best = type;
bestCount = count;
}
}
return best;
}

View File

@@ -93,7 +93,7 @@ const TUFTSSCO_CODE_MAP: Map<string, CodeRow> = (() => {
function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
if (insuranceSiteKey === "CCA") return CCA_CODE_MAP;
if (insuranceSiteKey === "DDMA") return DDMA_CODE_MAP;
if (insuranceSiteKey === "UNITED_SCO") return UNITEDDH_CODE_MAP;
if (insuranceSiteKey === "UNITED_SCO" || insuranceSiteKey === "UnitedSCO" || insuranceSiteKey === "UNITEDDH") return UNITEDDH_CODE_MAP;
if (insuranceSiteKey === "TuftsSCO") return TUFTSSCO_CODE_MAP;
return CODE_MAP; // default: MassHealth
}
@@ -386,7 +386,7 @@ export function findPriceMismatches(
patientDOB: string,
serviceDate: string,
): PriceMismatch[] {
const supported = ["MH", "MASSHEALTH", "CCA", "DDMA", "UNITEDDH", "TUFTSSCO"];
const supported = ["MH", "MASSHEALTH", "CCA", "DDMA", "UNITEDDH", "UNITEDSCO", "TUFTSSCO"];
if (!insuranceSiteKey || !supported.includes(insuranceSiteKey.toUpperCase())) return [];
const map = getCodeMap(insuranceSiteKey);