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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user