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:
97
apps/Frontend/src/utils/appointmentTypeUtils.ts
Normal file
97
apps/Frontend/src/utils/appointmentTypeUtils.ts
Normal 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: D51xx–D58xx (complete/partial dentures, relines, repairs, adjustments)
|
||||
if (/^D5[1-8]/.test(c)) return "dentures";
|
||||
|
||||
// Extractions: D71xx
|
||||
if (/^D71/.test(c)) return "extraction";
|
||||
|
||||
// Periodontics: D43xx–D49xx
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user