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 { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
ClaimPreAuthData,
|
||||
InputServiceLine,
|
||||
InsertAppointment,
|
||||
MissingTeethStatus,
|
||||
Patient,
|
||||
Staff,
|
||||
UpdateAppointment,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
} from "@/utils/procedureCombosMapping";
|
||||
import { COMBO_CATEGORIES, PROCEDURE_COMBOS } from "@/utils/procedureCombos";
|
||||
import { DateInput } from "../ui/dateInput";
|
||||
import { TeethGrid, ToothSelectRadix } from "./tooth-ui";
|
||||
|
||||
interface ClaimFormProps {
|
||||
patientId: number;
|
||||
@@ -65,6 +67,20 @@ interface ClaimFormProps {
|
||||
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({
|
||||
patientId,
|
||||
appointmentId,
|
||||
@@ -332,6 +348,8 @@ export function ClaimForm({
|
||||
memberId: patient?.insuranceId ?? "",
|
||||
dateOfBirth: normalizeToIsoDateString(patient?.dateOfBirth),
|
||||
remarks: "",
|
||||
missingTeethStatus: "No_missing",
|
||||
missingTeeth: {},
|
||||
serviceDate: serviceDate,
|
||||
insuranceProvider: "",
|
||||
insuranceSiteKey: "",
|
||||
@@ -408,6 +426,41 @@ export function ClaimForm({
|
||||
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.
|
||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
@@ -1260,6 +1313,9 @@ export function ClaimForm({
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<p className="text-sm text-gray-500">
|
||||
You can upload up to 10 files. Allowed types: PDF, JPG, PNG,
|
||||
@@ -1283,8 +1339,87 @@ export function ClaimForm({
|
||||
)}
|
||||
</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 */}
|
||||
<div>
|
||||
<div className="pt-6">
|
||||
<h3 className="text-xl font-semibold mb-4 text-center">
|
||||
Insurance Carriers
|
||||
</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
|
||||
dateOfBirth DateTime @db.Date
|
||||
remarks String
|
||||
missingTeethStatus MissingTeethStatus @default(No_missing)
|
||||
missingTeeth Json? // { "T_14": "X", "T_G": "O", ... }
|
||||
serviceDate DateTime
|
||||
insuranceProvider String // e.g., "Delta MA"
|
||||
createdAt DateTime @default(now())
|
||||
@@ -143,6 +145,12 @@ enum ClaimStatus {
|
||||
VOID
|
||||
}
|
||||
|
||||
enum MissingTeethStatus {
|
||||
No_missing
|
||||
endentulous
|
||||
Yes_missing
|
||||
}
|
||||
|
||||
model ServiceLine {
|
||||
id Int @id @default(autoincrement())
|
||||
claimId Int?
|
||||
|
||||
@@ -83,6 +83,8 @@ export type ClaimWithServiceLines = Claim & {
|
||||
claimFiles?: ClaimFileMeta[] | null;
|
||||
};
|
||||
|
||||
export type MissingTeethStatus = "No_missing" | "endentulous" | "Yes_missing";
|
||||
|
||||
export interface ClaimFormData {
|
||||
patientId: number;
|
||||
appointmentId: number;
|
||||
@@ -92,6 +94,8 @@ export interface ClaimFormData {
|
||||
memberId: string;
|
||||
dateOfBirth: string;
|
||||
remarks: string;
|
||||
missingTeethStatus: MissingTeethStatus;
|
||||
missingTeeth: Record<string, "X" | "O">; // keys: T_1..T_32, T_A..T_T
|
||||
serviceDate: string; // YYYY-MM-DD
|
||||
insuranceProvider: string;
|
||||
insuranceSiteKey?: string;
|
||||
@@ -109,6 +113,8 @@ export interface ClaimPreAuthData {
|
||||
memberId: string;
|
||||
dateOfBirth: string;
|
||||
remarks: string;
|
||||
missingTeethStatus: MissingTeethStatus;
|
||||
missingTeeth: Record<string, "X" | "O">; // keys: T_1..T_32, T_A..T_T
|
||||
serviceDate: string; // YYYY-MM-DD
|
||||
insuranceProvider: string;
|
||||
insuranceSiteKey?: string;
|
||||
|
||||
Reference in New Issue
Block a user