initial commit

This commit is contained in:
2026-04-04 22:13:55 -04:00
commit 5d77e207c9
10181 changed files with 522212 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
/**
* Use parseLocalDate when you need a Date object at local midnight
* (for calendars, date pickers, Date math in the browser).
*
*
* Parse a date string in yyyy-MM-dd format (assumed local) into a JS Date object.
* No timezone conversion is applied. Returns a Date at midnight local time.
*
* * Accepts:
* - "YYYY-MM-DD"
* - ISO/timestamp string (will take left-of-'T' date portion)
* - Date object (will return a new Date set to that local calendar day at midnight)
*/
export function parseLocalDate(input: string | Date): Date {
if (input instanceof Date) {
return new Date(input.getFullYear(), input.getMonth(), input.getDate());
}
if (typeof input === "string") {
const dateString = input?.split("T")[0] ?? "";
const parts = dateString.split("-");
const [yearStr, monthStr, dayStr] = parts;
// Validate all parts are defined and valid strings
if (!yearStr || !monthStr || !dayStr) {
throw new Error("Invalid date string format. Expected yyyy-MM-dd.");
}
const year = parseInt(yearStr, 10);
const month = parseInt(monthStr, 10) - 1; // JS Date months are 0-based
const day = parseInt(dayStr, 10);
if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) {
throw new Error("Invalid numeric values in date string.");
}
return new Date(year, month, day);
}
throw new Error(
"Unsupported input to parseLocalDate. Expected string or Date."
);
}
/**
* Use formatLocalDate when you need a date-only string "YYYY-MM-DD" (for displaying stable date values in UI lists,
* sending to APIs, storing in sessionStorage/DB where date-only is required).
*
*
* Format a date value into a "YYYY-MM-DD" string with **no timezone shifts**.
*
* Handles all common input cases:
* - "YYYY-MM-DD" string → returned as-is.
* - ISO/timestamp string → takes the date portion before "T" (safe, no TZ math).
* - Date object:
* - If created via `new Date("2025-07-15T00:00:00Z")` (ISO instant),
* UTC vs local calendar components may differ. In this case, use UTC
* fields so the original calendar day (15th) is preserved across timezones.
* - If created via `parseLocalDate("2025-07-15")` or `new Date(2025, 6, 15)`
* (local-midnight Date), UTC and local calendar components match,
* so local fields are safe to use.
*
* This hybrid logic ensures:
* - DOBs and other date-only values will never appear off by one day
* due to timezone differences.
* - Works with both string and Date inputs without requiring code changes elsewhere.
*/
export function formatLocalDate(input?: string | Date): string {
if (!input) return "";
// Case 1: already "YYYY-MM-DD" string
if (typeof input === "string" && /^\d{4}-\d{2}-\d{2}$/.test(input)) {
return input;
}
// Case 2: ISO/timestamp string -> take the left-of-T portion
if (typeof input === "string") {
const dateString = input.split("T")[0] ?? "";
return dateString;
}
// Case 3: Date object
if (input instanceof Date) {
if (isNaN(input.getTime())) return "";
// HYBRID LOGIC:
// - If this Date was likely created from an ISO instant at UTC midnight
// (e.g. "2025-10-15T00:00:00Z"), then getUTCHours() === 0 but getHours()
// will be non-zero in most non-UTC timezones. In that case use UTC date
// parts to preserve the original calendar day.
// - Otherwise use the local calendar fields (safe for local-midnight Dates).
const utcHours = input.getUTCHours();
const localHours = input.getHours();
const useUTC = utcHours === 0 && localHours !== 0;
const year = useUTC ? input.getUTCFullYear() : input.getFullYear();
const month = useUTC ? input.getUTCMonth() + 1 : input.getMonth() + 1;
const day = useUTC ? input.getUTCDate() : input.getDate();
const m = `${month}`.padStart(2, "0");
const d = `${day}`.padStart(2, "0");
return `${year}-${m}-${d}`;
}
return "";
}
// ---------- helpers ----------
const MONTH_SHORT = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
function isDateOnlyString(s: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(s);
}
// ---------- formatDateToHumanReadable ----------
/**
* Frontend-safe human readable formatter.
*
* Rules:
* - If input is a date-only string "YYYY-MM-DD", format it directly (no TZ math).
* - If input is a Date object, use its local calendar fields (getFullYear/getMonth/getDate).
* - If input is any other string (ISO/timestamp), DO NOT call new Date(isoString) directly
* for display. Instead, use parseLocalDate(dateInput) to extract the local calendar day
* (strip time portion) and render that. This prevents off-by-one day drift.
*
* Output example: "Oct 7, 2025"
*/
export function formatDateToHumanReadable(dateInput?: string | Date): string {
if (!dateInput) return "N/A";
// date-only string -> show as-is using MONTH_SHORT
if (typeof dateInput === "string" && isDateOnlyString(dateInput)) {
const [y, m, d] = dateInput.split("-");
if (!y || !m || !d) return "Invalid Date";
return `${MONTH_SHORT[parseInt(m, 10) - 1]} ${d}, ${y}`;
}
// Date object -> use local calendar fields
if (dateInput instanceof Date) {
if (isNaN(dateInput.getTime())) return "Invalid Date";
const dd = String(dateInput.getDate());
const mm = MONTH_SHORT[dateInput.getMonth()];
const yy = dateInput.getFullYear();
return `${mm} ${dd}, ${yy}`;
}
// Other string (likely ISO/timestamp) -> normalize via parseLocalDate
// This preserves the calendar day the user expects (no timezone drift).
if (typeof dateInput === "string") {
try {
const localDate = parseLocalDate(dateInput);
const dd = String(localDate.getDate());
const mm = MONTH_SHORT[localDate.getMonth()];
const yy = localDate.getFullYear();
return `${mm} ${dd}, ${yy}`;
} catch (err) {
console.error("Invalid date input provided:", dateInput, err);
return "Invalid Date";
}
}
return "Invalid Date";
}
// ---------------- OCR Date helper --------------------------
/**
* Convert any OCR numeric-ish value into a number.
* Handles string | number | null | undefined gracefully.
*/
export function toNum(val: string | number | null | undefined): number {
if (val == null || val === "") return 0;
if (typeof val === "number") return val;
const parsed = Number(val);
return isNaN(parsed) ? 0 : parsed;
}
/**
* Convert any OCR string-like value into a safe string.
*/
export function toStr(val: string | number | null | undefined): string {
if (val == null) return "";
return String(val).trim();
}
/**
* Convert OCR date strings like "070825" (MMDDYY) into a JS Date object.
* Example: "070825" → 2025-08-07.
*/
export function convertOCRDate(
input: string | number | null | undefined
): Date {
const raw = toStr(input);
if (!/^\d{6}$/.test(raw)) {
throw new Error(`Invalid OCR date format: ${raw}`);
}
const month = parseInt(raw.slice(0, 2), 10) - 1;
const day = parseInt(raw.slice(2, 4), 10);
const year2 = parseInt(raw.slice(4, 6), 10);
const year = year2 < 50 ? 2000 + year2 : 1900 + year2;
return new Date(year, month, day);
}
/**
* Format a Date or date string into "HH:mm" (24-hour) string.
*
* Options:
* - By default, hours/minutes are taken in local time.
* - Pass { asUTC: true } to format using UTC hours/minutes.
*
* Examples:
* formatLocalTime(new Date(2025, 6, 15, 9, 5)) → "09:05"
* formatLocalTime("2025-07-15") → "00:00"
* formatLocalTime("2025-07-15T14:30:00Z") → "20:30" (in +06:00)
* formatLocalTime("2025-07-15T14:30:00Z", { asUTC:true }) → "14:30"
*/
export function formatLocalTime(
d: Date | string | undefined,
opts: { asUTC?: boolean } = {}
): string {
if (!d) return "";
const { asUTC = false } = opts;
const pad2 = (n: number) => n.toString().padStart(2, "0");
let dateObj: Date;
if (d instanceof Date) {
if (isNaN(d.getTime())) return "";
dateObj = d;
} else if (typeof d === "string") {
const raw = d.trim();
const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(raw);
if (isDateOnly) {
// Parse yyyy-MM-dd safely as local midnight
try {
dateObj = parseLocalDate(raw);
} catch {
dateObj = new Date(raw); // fallback
}
} else {
// For full ISO/timestamp strings, let Date handle TZ
dateObj = new Date(raw);
}
if (isNaN(dateObj.getTime())) return "";
} else {
return "";
}
const hours = asUTC ? dateObj.getUTCHours() : dateObj.getHours();
const minutes = asUTC ? dateObj.getUTCMinutes() : dateObj.getMinutes();
return `${pad2(hours)}:${pad2(minutes)}`;
}

View File

@@ -0,0 +1,21 @@
export function getPageNumbers(current: number, total: number, maxButtons = 7) {
const pages: (number | "...")[] = [];
if (total <= maxButtons) {
for (let i = 1; i <= total; i++) pages.push(i);
return pages;
}
const delta = 2;
const start = Math.max(2, current - delta);
const end = Math.min(total - 1, current + delta);
pages.push(1);
if (start > 2) pages.push("...");
for (let i = start; i <= end; i++) pages.push(i);
if (end < total - 1) pages.push("...");
if (total > 1) pages.push(total);
return pages;
}

View File

@@ -0,0 +1,323 @@
export const PROCEDURE_COMBOS: Record<
string,
{
id: string;
label: string;
codes: string[];
toothNumbers?: (string | null)[];
}
> = {
childRecall: {
id: "childRecall",
label: "Child Recall",
codes: ["D0120", "D1120", "D0272", "D1208"],
},
childRecallDirect: {
id: "childRecallDirect",
label: "Child Recall Direct(no x-ray)",
codes: ["D0120", "D1120", "D1208"],
},
childRecallDirect2BW: {
id: "childRecallDirect2BW",
label: "Child Recall Direct 2BW",
codes: ["D0120", "D1120", "D1208", "D0272"],
},
childRecallDirect4BW: {
id: "childRecallDirect4BW",
label: "Child Recall Direct 4BW",
codes: ["D0120", "D1120", "D1208", "D0274"],
},
childRecallDirect2PA2BW: {
id: "childRecallDirect2PA2BW",
label: "Child Recall Direct 2PA 2BW",
codes: ["D0120", "D1120", "D1208", "D0220", "D0230", "D0272"],
toothNumbers: [null, null, null, "9", "24", null], // only these two need values
},
childRecallDirect2PA4BW: {
id: "childRecallDirect2PA4BW",
label: "Child Recall Direct 2PA 4BW",
codes: ["D0120", "D1120", "D1208", "D0220", "D0230", "D0274"],
toothNumbers: [null, null, null, "9", "24", null], // only these two need values
},
childRecallDirect3PA2BW: {
id: "childRecallDirect3PA2BW",
label: "Child Recall Direct 3PA 2BW",
codes: [
"D0120", // exam
"D1120", // prophy
"D1208", // fluoride
"D0220",
"D0230",
"D0230", // extra PA
"D0272", // 2BW
],
},
childRecallDirect4PA: {
id: "childRecallDirect4PA",
label: "Child Recall Direct 4PA",
codes: ["D0120", "D1120", "D1208", "D0220", "D0230", "D0230", "D0230"],
},
childRecallDirect3PA: {
id: "childRecallDirect3PA",
label: "Child Recall Direct 3PA",
codes: ["D0120", "D1120", "D1208", "D0220", "D0230", "D0230"],
},
childRecallDirectPANO: {
id: "childRecallDirectPANO",
label: "Child Recall Direct PANO",
codes: ["D0120", "D1120", "D1208", "D0330"],
},
adultRecall: {
id: "adultRecall",
label: "Adult Recall",
codes: ["D0120", "D0220", "D0230", "D0274", "D1110"],
toothNumbers: [null, "9", "24", null, null], // only these two need values
},
adultRecallDirect: {
id: "adultRecallDirect",
label: "Adult Recall Direct(no x-ray)",
codes: ["D0120", "D1110"],
},
adultRecallDirect2BW: {
id: "adultRecallDirect2BW",
label: "Adult Recall Direct - 2bw (no x-ray)",
codes: ["D0120", "D1110", "D0272"],
},
adultRecallDirect4BW: {
id: "adultRecallDirect4BW",
label: "Adult Recall Direct - 4bw (no x-ray)",
codes: ["D0120", "D1110", "D0274"],
},
adultRecallDirect2PA2BW: {
id: "adultRecallDirect2PA2BW",
label: "Adult Recall Direct - 2PA 2BW",
codes: ["D0120", "D0220", "D0230", "D0272", "D1110"],
toothNumbers: [null, "9", "24", null, null], // only these two need values
},
adultRecallDirect2PA4BW: {
id: "adultRecallDirect2PA4BW",
label: "Adult Recall Direct - 2PA 4BW",
codes: ["D0120", "D0220", "D0230", "D0274", "D1110"],
toothNumbers: [null, "9", "24", null, null], // only these two need values
},
adultRecallDirect4PA: {
id: "adultRecallDirect4PA",
label: "Adult Recall Direct 4PA",
codes: ["D0120", "D1110", "D0220", "D0230", "D0230", "D0230"],
},
adultRecallDirectPano: {
id: "adultRecallDirectPano",
label: "Adult Recall Direct - PANO",
codes: ["D0120", "D1110", "D0330"],
},
newChildPatient: {
id: "newChildPatient",
label: "New Child Patient",
codes: ["D0150", "D1120", "D1208"],
},
newAdultPatientPano: {
id: "newAdultPatientPano",
label: "New Adult Patient - PANO",
codes: ["D0150", "D0330", "D1110"],
},
newAdultPatientFMX: {
id: "newAdultPatientFMX",
label: "New Adult Patient (FMX)",
codes: ["D0150", "D0210", "D1110"],
},
//Compostie
oneSurfCompFront: {
id: "oneSurfCompFront",
label: "One Surface Composite (Front)",
codes: ["D2330"],
},
oneSurfCompBack: {
id: "oneSurfCompBack",
label: "One Surface Composite (Back)",
codes: ["D2391"],
},
twoSurfCompFront: {
id: "twoSurfCompFront",
label: "Two Surface Composite (Front)",
codes: ["D2331"],
},
twoSurfCompBack: {
id: "twoSurfCompBack",
label: "Two Surface Composite (Back)",
codes: ["D2392"],
},
threeSurfCompFront: {
id: "threeSurfCompFront",
label: "Three Surface Composite (Front)",
codes: ["D2332"],
},
threeSurfCompBack: {
id: "threeSurfCompBack",
label: "Three Surface Composite (Back)",
codes: ["D2393"],
},
fourSurfCompFront: {
id: "fourSurfCompFront",
label: "Four Surface Composite (Front)",
codes: ["D2335"],
},
fourSurfCompBack: {
id: "fourSurfCompBack",
label: "Four Surface Composite (Back)",
codes: ["D2394"],
},
// Dentures / Partials
fu: {
id: "fu",
label: "FU",
codes: ["D5110"],
},
fl: {
id: "fl",
label: "FL",
codes: ["D5120"],
},
puResin: {
id: "puResin",
label: "PU (Resin)",
codes: ["D5211"],
},
puCast: {
id: "puCast",
label: "PU (Cast)",
codes: ["D5213"],
},
plResin: {
id: "plResin",
label: "PL (Resin)",
codes: ["D5212"],
},
plCast: {
id: "plCast",
label: "PL (Cast)",
codes: ["D5214"],
},
// Endodontics
rctAnterior: {
id: "rctAnterior",
label: "RCT Anterior",
codes: ["D3310"],
},
rctPremolar: {
id: "rctPremolar",
label: "RCT PreM",
codes: ["D3320"],
},
rctMolar: {
id: "rctMolar",
label: "RCT Molar",
codes: ["D3330"],
},
postCore: {
id: "postCore",
label: "Post/Core",
codes: ["D2954"],
},
// Prostho / Perio / Oral Surgery
crown: {
id: "crown",
label: "Crown",
codes: ["D2740"],
},
deepCleaning: {
id: "deepCleaning",
label: "Deep Cleaning",
codes: ["D4341"],
},
simpleExtraction: {
id: "simpleExtraction",
label: "Simple EXT",
codes: ["D7140"],
},
surgicalExtraction: {
id: "surgicalExtraction",
label: "Surg EXT",
codes: ["D7210"],
},
babyTeethExtraction: {
id: "babyTeethExtraction",
label: "Baby Teeth EXT",
codes: ["D7111"],
},
// Orthodontics
orthPreExamDirect: {
id: "orthPreExamDirect",
label: "Direct Pre-Orth Exam",
codes: ["D9310"],
},
orthRecordDirect: {
id: "orthRecordDirect",
label: "Direct Orth Record",
codes: ["D8660"],
},
orthPerioVisitDirect: {
id: "orthPerioVisitDirect",
label: "Direct Perio Orth Visit ",
codes: ["D8670"],
},
orthRetentionDirect: {
id: "orthRetentionDirect",
label: "Direct Orth Retention",
codes: ["D8680"],
},
orthPA: {
id: "orthPA",
label: "Orth PA",
codes: ["D8080", "D8670", "D8660"],
},
// add more…
};
// Which combos appear under which heading
export const COMBO_CATEGORIES: Record<
string,
(keyof typeof PROCEDURE_COMBOS)[]
> = {
"Recalls & New Patients": [
"childRecall",
"adultRecall",
"newChildPatient",
"newAdultPatientPano",
"newAdultPatientFMX",
],
"Composite Fillings (Front)": [
"oneSurfCompFront",
"twoSurfCompFront",
"threeSurfCompFront",
"fourSurfCompFront",
],
"Composite Fillings (Back)": [
"oneSurfCompBack",
"twoSurfCompBack",
"threeSurfCompBack",
"fourSurfCompBack",
],
"Dentures / Partials (>21 price)": [
"fu",
"fl",
"puResin",
"puCast",
"plResin",
"plCast",
],
Endodontics: ["rctAnterior", "rctPremolar", "rctMolar", "postCore"],
Prosthodontics: ["crown"],
Periodontics: ["deepCleaning"],
Extractions: [
"simpleExtraction",
"surgicalExtraction",
"babyTeethExtraction",
],
Orthodontics: ["orthPA"],
};

View File

@@ -0,0 +1,315 @@
import { InputServiceLine } from "@repo/db/types";
import Decimal from "decimal.js";
import rawCodeTable from "@/assets/data/procedureCodes.json";
import { PROCEDURE_COMBOS } from "./procedureCombos";
/* ----------------------------- Types ----------------------------- */
export type CodeRow = {
"Procedure Code": string;
Description?: string;
Price?: string | number | null;
PriceLTEQ21?: string | number | null;
PriceGT21?: string | number | null;
[k: string]: unknown;
};
const CODE_TABLE = rawCodeTable as CodeRow[];
export type ClaimFormLike = {
serviceDate: string; // form-level service date
serviceLines: InputServiceLine[];
};
export type ApplyOptions = {
append?: boolean;
startIndex?: number;
lineDate?: string;
clearTrailing?: boolean;
replaceAll?: boolean;
};
/* ----------------------------- Helpers ----------------------------- */
export const COMBO_BUTTONS = Object.values(PROCEDURE_COMBOS).map((c) => ({
id: c.id,
label: c.label,
}));
// Build a fast lookup map keyed by normalized code
const normalizeCode = (code: string) => code.replace(/\s+/g, "").toUpperCase();
const CODE_MAP: Map<string, CodeRow> = (() => {
const m = new Map<string, CodeRow>();
for (const r of CODE_TABLE) {
const k = normalizeCode(String(r["Procedure Code"] || ""));
if (k && !m.has(k)) m.set(k, r);
}
return m;
})();
// this function is solely for abbrevations feature in claim-form
export function getDescriptionForCode(
code: string | undefined
): string | undefined {
if (!code) return undefined;
const row = CODE_MAP.get(normalizeCode(code));
return row?.Description;
}
const isBlankPrice = (v: unknown) => {
if (v == null) return true;
const s = String(v).trim().toUpperCase();
return s === "" || s === "IC" || s === "NC";
};
const toDecimalOrZero = (v: unknown): Decimal => {
if (isBlankPrice(v)) return new Decimal(0);
const n = typeof v === "string" ? parseFloat(v) : (v as number);
return new Decimal(Number.isFinite(n) ? n : 0);
};
// Accepts string or Date, supports MM/DD/YYYY and YYYY-MM-DD
type DateInput = string | Date;
const parseDate = (d: DateInput): Date => {
if (d instanceof Date)
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
const s = String(d).trim();
// MM/DD/YYYY
const mdy = /^(\d{2})\/(\d{2})\/(\d{4})$/;
const m1 = mdy.exec(s);
if (m1) {
const mm = Number(m1[1]);
const dd = Number(m1[2]);
const yyyy = Number(m1[3]);
return new Date(yyyy, mm - 1, dd);
}
// YYYY-MM-DD
const ymd = /^(\d{4})-(\d{2})-(\d{2})$/;
const m2 = ymd.exec(s);
if (m2) {
const yyyy = Number(m2[1]);
const mm = Number(m2[2]);
const dd = Number(m2[3]);
return new Date(yyyy, mm - 1, dd);
}
// Fallback
return new Date(s);
};
const ageOnDate = (dob: DateInput, on: DateInput): number => {
const birth = parseDate(dob);
const ref = parseDate(on);
let age = ref.getFullYear() - birth.getFullYear();
const hadBirthday =
ref.getMonth() > birth.getMonth() ||
(ref.getMonth() === birth.getMonth() && ref.getDate() >= birth.getDate());
if (!hadBirthday) age -= 1;
return age;
};
/**
* we can implement per-code age buckets without changing the JSON.
*
* Behavior:
* - Default: same as before: age <= 21 -> PriceLTEQ21, else PriceGT21
* - Fallback to Price if tiered field is blank/IC/NC
* - Special-cases D1110 and D1120 according to MH rules
*/
export function pickPriceForRowByAge(
row: CodeRow,
age: number,
normalizedCode?: string
): Decimal {
// Special-case rules (add more codes here if needed)
if (normalizedCode) {
// D1110: only valid for age >=14 (14..21 => PriceLTEQ21, >21 => PriceGT21)
if (normalizedCode === "D1110") {
if (age < 14) {
// D1110 not applicable to children <14 (those belong to D1120)
return new Decimal(0);
}
if (age >= 14 && age <= 21) {
// use PriceLTEQ21 only if present
if (!isBlankPrice(row.PriceLTEQ21))
return toDecimalOrZero(row.PriceLTEQ21);
return new Decimal(0);
}
// age > 21
if (!isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
return new Decimal(0);
}
// D1120: child 0-13 => PriceLTEQ21, otherwise no price (NC)
if (normalizedCode === "D1120") {
if (age < 14) {
if (!isBlankPrice(row.PriceLTEQ21))
return toDecimalOrZero(row.PriceLTEQ21);
return new Decimal(0);
}
// age >= 14 => NC / no price
return new Decimal(0);
}
}
// Generic/default behavior (unchanged)
if (age <= 21) {
if (!isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
} else {
if (!isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
}
// Fallback to Price if tiered not available/blank
if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price);
return new Decimal(0);
}
/**
* Gets price for a code using age & code table.
*/
function getPriceForCodeWithAgeFromMap(
map: Map<string, CodeRow>,
code: string,
age: number
): Decimal {
const norm = normalizeCode(code);
const row = map.get(norm);
return row ? pickPriceForRowByAge(row, age, norm) : new Decimal(0);
}
// helper keeping lines empty,
export const makeEmptyLine = (lineDate: string): InputServiceLine => ({
procedureCode: "",
procedureDate: lineDate,
quad: "",
arch: "",
toothNumber: "",
toothSurface: "",
totalBilled: new Decimal(0),
totalAdjusted: new Decimal(0),
totalPaid: new Decimal(0),
});
// Ensure the array has at least `min` lines; append blank ones if needed.
const ensureCapacity = (
lines: (InputServiceLine | undefined)[],
min: number,
lineDate: string
) => {
while (lines.length < min) {
lines.push(makeEmptyLine(lineDate));
}
};
/* ------------------------- Main entry points ------------------------- */
/**
* Map prices for ALL existing lines in a form (your "Map Price" button),
* using patient's DOB and the form's serviceDate (or per-line procedureDate).
* Returns a NEW form object (immutable).
*/
export function mapPricesForForm<T extends ClaimFormLike>(params: {
form: T;
patientDOB: DateInput;
}): T {
const { form, patientDOB } = params;
return {
...form,
serviceLines: form.serviceLines.map((ln) => {
const age = ageOnDate(patientDOB, form.serviceDate);
const code = normalizeCode(ln.procedureCode || "");
if (!code) return { ...ln };
const price = getPriceForCodeWithAgeFromMap(CODE_MAP, code, age);
return { ...ln, procedureCode: code, totalBilled: price };
}),
};
}
/**
* Apply a preset combo (fills codes & prices) using patientDOB and serviceDate.
* Returns a NEW form object (immutable).
*/
export function applyComboToForm<T extends ClaimFormLike>(
form: T,
comboId: keyof typeof PROCEDURE_COMBOS,
patientDOB: DateInput,
options: ApplyOptions = {}
): T {
const preset = PROCEDURE_COMBOS[String(comboId)];
if (!preset) return form;
const {
append = true,
startIndex,
lineDate = form.serviceDate,
clearTrailing = false,
replaceAll = false, // NEW
} = options;
const next: T = { ...form, serviceLines: [...form.serviceLines] };
// Replace-all: blank all existing and start from 0
if (replaceAll) {
for (let i = 0; i < next.serviceLines.length; i++) {
next.serviceLines[i] = makeEmptyLine(lineDate);
}
}
// determine insertion index
let insertAt = 0;
if (!replaceAll) {
if (append) {
let last = -1;
next.serviceLines.forEach((ln, i) => {
if (ln.procedureCode?.trim()) last = i;
});
insertAt = Math.max(0, last + 1);
} else if (typeof startIndex === "number") {
insertAt = Math.max(
0,
Math.min(startIndex, next.serviceLines.length - 1)
);
}
} // if replaceAll, insertAt stays 0
// Make sure we have enough rows for the whole combo
ensureCapacity(next.serviceLines, insertAt + preset.codes.length, lineDate);
// Age on the specific line date we will set
const age = ageOnDate(patientDOB, lineDate);
for (let j = 0; j < preset.codes.length; j++) {
const i = insertAt + j;
if (i >= next.serviceLines.length) break;
const codeRaw = preset.codes[j];
if (!codeRaw) continue;
const code = normalizeCode(codeRaw);
const price = getPriceForCodeWithAgeFromMap(CODE_MAP, code, age);
const original = next.serviceLines[i];
next.serviceLines[i] = {
...original,
procedureCode: code,
procedureDate: lineDate,
quad: (original as any)?.quad ?? "",
arch: (original as any)?.arch ?? "",
toothNumber: preset.toothNumbers?.[j] ?? (original as any)?.toothNumber ?? "",
toothSurface: (original as any)?.toothSurface ?? "",
totalBilled: price,
totalAdjusted: new Decimal(0),
totalPaid: new Decimal(0),
} as InputServiceLine;
}
if (replaceAll || clearTrailing) {
const after = insertAt + preset.codes.length;
for (let i = after; i < next.serviceLines.length; i++) {
next.serviceLines[i] = makeEmptyLine(lineDate);
}
}
return next;
}
export { CODE_MAP, getPriceForCodeWithAgeFromMap };