feat(missing teeth) - added v1
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback, memo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
ClaimPreAuthData,
|
ClaimPreAuthData,
|
||||||
InputServiceLine,
|
InputServiceLine,
|
||||||
InsertAppointment,
|
InsertAppointment,
|
||||||
|
MissingTeethStatus,
|
||||||
Patient,
|
Patient,
|
||||||
Staff,
|
Staff,
|
||||||
UpdateAppointment,
|
UpdateAppointment,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
} from "@/utils/procedureCombosMapping";
|
} from "@/utils/procedureCombosMapping";
|
||||||
import { COMBO_CATEGORIES, PROCEDURE_COMBOS } from "@/utils/procedureCombos";
|
import { COMBO_CATEGORIES, PROCEDURE_COMBOS } from "@/utils/procedureCombos";
|
||||||
import { DateInput } from "../ui/dateInput";
|
import { DateInput } from "../ui/dateInput";
|
||||||
|
import { TeethGrid, ToothSelectRadix } from "./tooth-ui";
|
||||||
|
|
||||||
interface ClaimFormProps {
|
interface ClaimFormProps {
|
||||||
patientId: number;
|
patientId: number;
|
||||||
@@ -65,6 +67,20 @@ interface ClaimFormProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PERMANENT_TOOTH_NAMES = Array.from(
|
||||||
|
{ length: 32 },
|
||||||
|
(_, i) => `T_${i + 1}`
|
||||||
|
);
|
||||||
|
const PRIMARY_TOOTH_NAMES = Array.from("ABCDEFGHIJKLMNOPQRST").map(
|
||||||
|
(ch) => `T_${ch}`
|
||||||
|
);
|
||||||
|
|
||||||
|
function isValidToothKey(key: string) {
|
||||||
|
return (
|
||||||
|
PERMANENT_TOOTH_NAMES.includes(key) || PRIMARY_TOOTH_NAMES.includes(key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ClaimForm({
|
export function ClaimForm({
|
||||||
patientId,
|
patientId,
|
||||||
appointmentId,
|
appointmentId,
|
||||||
@@ -332,6 +348,8 @@ export function ClaimForm({
|
|||||||
memberId: patient?.insuranceId ?? "",
|
memberId: patient?.insuranceId ?? "",
|
||||||
dateOfBirth: normalizeToIsoDateString(patient?.dateOfBirth),
|
dateOfBirth: normalizeToIsoDateString(patient?.dateOfBirth),
|
||||||
remarks: "",
|
remarks: "",
|
||||||
|
missingTeethStatus: "No_missing",
|
||||||
|
missingTeeth: {},
|
||||||
serviceDate: serviceDate,
|
serviceDate: serviceDate,
|
||||||
insuranceProvider: "",
|
insuranceProvider: "",
|
||||||
insuranceSiteKey: "",
|
insuranceSiteKey: "",
|
||||||
@@ -408,6 +426,41 @@ export function ClaimForm({
|
|||||||
return raw.toUpperCase().replace(/[^A-Z,\s]/g, "");
|
return raw.toUpperCase().replace(/[^A-Z,\s]/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Missing teeth section
|
||||||
|
const setMissingTeethStatus = (status: MissingTeethStatus) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
if (prev.missingTeethStatus === status) return prev; // no-op
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
missingTeethStatus: status,
|
||||||
|
missingTeeth: status === "Yes_missing" ? prev.missingTeeth : {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMissingTooth = useCallback(
|
||||||
|
(name: string, value: "" | "X" | "O") => {
|
||||||
|
if (!isValidToothKey(name)) return;
|
||||||
|
setForm((prev) => {
|
||||||
|
const current = prev.missingTeeth[name] ?? "";
|
||||||
|
if (current === value) return prev;
|
||||||
|
const nextMap = { ...prev.missingTeeth };
|
||||||
|
if (!value) delete nextMap[name];
|
||||||
|
else nextMap[name] = value;
|
||||||
|
return { ...prev, missingTeeth: nextMap };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onToothChange = useCallback(
|
||||||
|
(name: string, v: "" | "X" | "O") => updateMissingTooth(name, v),
|
||||||
|
[updateMissingTooth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearAllToothSelections = () =>
|
||||||
|
setForm((prev) => ({ ...prev, missingTeeth: {} }));
|
||||||
|
|
||||||
// for serviceLine rows, to auto scroll when it got updated by combo buttons and all.
|
// for serviceLine rows, to auto scroll when it got updated by combo buttons and all.
|
||||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
@@ -1260,6 +1313,9 @@ export function ClaimForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Upload Section */}
|
{/* File Upload Section */}
|
||||||
|
<h3 className="text-xl pt-8 font-semibold text-center">
|
||||||
|
File Upload
|
||||||
|
</h3>
|
||||||
<div className="mt-4 bg-gray-100 p-4 rounded-md space-y-4">
|
<div className="mt-4 bg-gray-100 p-4 rounded-md space-y-4">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
You can upload up to 10 files. Allowed types: PDF, JPG, PNG,
|
You can upload up to 10 files. Allowed types: PDF, JPG, PNG,
|
||||||
@@ -1283,8 +1339,87 @@ export function ClaimForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Missing Teeth (MassHealth Step 3) */}
|
||||||
|
<div className="mt-8 pt-8 space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold text-center">
|
||||||
|
Missing Teeth
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Status selector – must match Selenium's exact case */}
|
||||||
|
<div className="flex flex-wrap gap-2 items-center justify-center">
|
||||||
|
<Label className="mr-2">Status</Label>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={
|
||||||
|
form.missingTeethStatus === "No_missing"
|
||||||
|
? "default"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
onClick={() => setMissingTeethStatus("No_missing")}
|
||||||
|
aria-pressed={form.missingTeethStatus === "No_missing"}
|
||||||
|
>
|
||||||
|
No Missing
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={
|
||||||
|
form.missingTeethStatus === "endentulous"
|
||||||
|
? "default"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
onClick={() => setMissingTeethStatus("endentulous")}
|
||||||
|
aria-pressed={form.missingTeethStatus === "endentulous"}
|
||||||
|
>
|
||||||
|
Edentulous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={
|
||||||
|
form.missingTeethStatus === "Yes_missing"
|
||||||
|
? "default"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
onClick={() => setMissingTeethStatus("Yes_missing")}
|
||||||
|
aria-pressed={form.missingTeethStatus === "Yes_missing"}
|
||||||
|
>
|
||||||
|
Specify Missing
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{form.missingTeethStatus === "Yes_missing" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={clearAllToothSelections}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* When specifying per-tooth values, show Permanent + Primary grids */}
|
||||||
|
{form.missingTeethStatus === "Yes_missing" && (
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 overflow-visible">
|
||||||
|
<TeethGrid
|
||||||
|
title="PERMANENT"
|
||||||
|
toothNames={PERMANENT_TOOTH_NAMES}
|
||||||
|
values={form.missingTeeth}
|
||||||
|
onChange={onToothChange}
|
||||||
|
/>
|
||||||
|
<TeethGrid
|
||||||
|
title="PRIMARY"
|
||||||
|
toothNames={PRIMARY_TOOTH_NAMES}
|
||||||
|
values={form.missingTeeth}
|
||||||
|
onChange={onToothChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Insurance Carriers */}
|
{/* Insurance Carriers */}
|
||||||
<div>
|
<div className="pt-6">
|
||||||
<h3 className="text-xl font-semibold mb-4 text-center">
|
<h3 className="text-xl font-semibold mb-4 text-center">
|
||||||
Insurance Carriers
|
Insurance Carriers
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
101
apps/Frontend/src/components/claims/tooth-ui.tsx
Normal file
101
apps/Frontend/src/components/claims/tooth-ui.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
const NONE = "__NONE__" as const;
|
||||||
|
export type ToothVal = "" | "X" | "O";
|
||||||
|
|
||||||
|
interface ToothProps {
|
||||||
|
name: string;
|
||||||
|
value: ToothVal; // "" | "X" | "O"
|
||||||
|
onChange: (name: string, v: ToothVal) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean, single select:
|
||||||
|
* - Empty state shows a blank trigger (no "None" text).
|
||||||
|
* - First menu item sets empty value, but its label is visually blank.
|
||||||
|
* - Cell fills column width so the grid can wrap -> no overflow.
|
||||||
|
*/
|
||||||
|
function ToothSelect({ name, value, onChange }: ToothProps) {
|
||||||
|
const label = name.replace("T_", "");
|
||||||
|
const uiValue = (value === "" ? NONE : value) as typeof NONE | "X" | "O";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center w-full h-16 rounded-lg border bg-white p-1">
|
||||||
|
<div className="text-[10px] leading-none opacity-70 mb-1">{label}</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={uiValue}
|
||||||
|
onValueChange={(v) => onChange(name, v === NONE ? "" : (v as ToothVal))}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
aria-label={`${name} selection`}
|
||||||
|
className="
|
||||||
|
h-8 w-full px-2 text-xs justify-center
|
||||||
|
data-[placeholder]:opacity-0 /* hide placeholder text entirely */
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* placeholder is a single space so trigger height stays stable */}
|
||||||
|
<SelectValue placeholder=" " />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper" sideOffset={6} align="center" className="z-50">
|
||||||
|
{/* blank option -> sets empty string; visually blank, still accessible */}
|
||||||
|
<SelectItem value={NONE}>
|
||||||
|
{/* visually blank but keeps item height/click area */}
|
||||||
|
<span className="sr-only">Empty</span>
|
||||||
|
{" "}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="X">X</SelectItem>
|
||||||
|
<SelectItem value="O">O</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToothSelectRadix = React.memo(
|
||||||
|
ToothSelect,
|
||||||
|
(prev, next) => prev.value === next.value && prev.name === next.name
|
||||||
|
);
|
||||||
|
|
||||||
|
export function TeethGrid({
|
||||||
|
title,
|
||||||
|
toothNames,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
toothNames: string[];
|
||||||
|
values: Record<string, ToothVal | undefined>;
|
||||||
|
onChange: (name: string, v: ToothVal) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-3">
|
||||||
|
<div className="text-center font-medium mb-2">{title}</div>
|
||||||
|
|
||||||
|
{/* responsive grid that auto-fits cells; no horizontal overflow */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
grid gap-2
|
||||||
|
[grid-template-columns:repeat(auto-fit,minmax(4.5rem,1fr))]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{toothNames.map((name) => (
|
||||||
|
<ToothSelectRadix
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
value={(values[name] as ToothVal) ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5517
package-lock.json
generated
5517
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -119,6 +119,8 @@ model Claim {
|
|||||||
memberId String
|
memberId String
|
||||||
dateOfBirth DateTime @db.Date
|
dateOfBirth DateTime @db.Date
|
||||||
remarks String
|
remarks String
|
||||||
|
missingTeethStatus MissingTeethStatus @default(No_missing)
|
||||||
|
missingTeeth Json? // { "T_14": "X", "T_G": "O", ... }
|
||||||
serviceDate DateTime
|
serviceDate DateTime
|
||||||
insuranceProvider String // e.g., "Delta MA"
|
insuranceProvider String // e.g., "Delta MA"
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -143,6 +145,12 @@ enum ClaimStatus {
|
|||||||
VOID
|
VOID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum MissingTeethStatus {
|
||||||
|
No_missing
|
||||||
|
endentulous
|
||||||
|
Yes_missing
|
||||||
|
}
|
||||||
|
|
||||||
model ServiceLine {
|
model ServiceLine {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
claimId Int?
|
claimId Int?
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export type ClaimWithServiceLines = Claim & {
|
|||||||
claimFiles?: ClaimFileMeta[] | null;
|
claimFiles?: ClaimFileMeta[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MissingTeethStatus = "No_missing" | "endentulous" | "Yes_missing";
|
||||||
|
|
||||||
export interface ClaimFormData {
|
export interface ClaimFormData {
|
||||||
patientId: number;
|
patientId: number;
|
||||||
appointmentId: number;
|
appointmentId: number;
|
||||||
@@ -92,6 +94,8 @@ export interface ClaimFormData {
|
|||||||
memberId: string;
|
memberId: string;
|
||||||
dateOfBirth: string;
|
dateOfBirth: string;
|
||||||
remarks: string;
|
remarks: string;
|
||||||
|
missingTeethStatus: MissingTeethStatus;
|
||||||
|
missingTeeth: Record<string, "X" | "O">; // keys: T_1..T_32, T_A..T_T
|
||||||
serviceDate: string; // YYYY-MM-DD
|
serviceDate: string; // YYYY-MM-DD
|
||||||
insuranceProvider: string;
|
insuranceProvider: string;
|
||||||
insuranceSiteKey?: string;
|
insuranceSiteKey?: string;
|
||||||
@@ -109,6 +113,8 @@ export interface ClaimPreAuthData {
|
|||||||
memberId: string;
|
memberId: string;
|
||||||
dateOfBirth: string;
|
dateOfBirth: string;
|
||||||
remarks: string;
|
remarks: string;
|
||||||
|
missingTeethStatus: MissingTeethStatus;
|
||||||
|
missingTeeth: Record<string, "X" | "O">; // keys: T_1..T_32, T_A..T_T
|
||||||
serviceDate: string; // YYYY-MM-DD
|
serviceDate: string; // YYYY-MM-DD
|
||||||
insuranceProvider: string;
|
insuranceProvider: string;
|
||||||
insuranceSiteKey?: string;
|
insuranceSiteKey?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user