initial commit
This commit is contained in:
272
apps/Frontend/src/utils/dateUtils.ts
Executable file
272
apps/Frontend/src/utils/dateUtils.ts
Executable 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)}`;
|
||||
}
|
||||
21
apps/Frontend/src/utils/pageNumberGenerator.ts
Executable file
21
apps/Frontend/src/utils/pageNumberGenerator.ts
Executable 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;
|
||||
}
|
||||
323
apps/Frontend/src/utils/procedureCombos.ts
Executable file
323
apps/Frontend/src/utils/procedureCombos.ts
Executable 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"],
|
||||
};
|
||||
315
apps/Frontend/src/utils/procedureCombosMapping.ts
Executable file
315
apps/Frontend/src/utils/procedureCombosMapping.ts
Executable 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 };
|
||||
Reference in New Issue
Block a user