fix( remarks, tooth missing) - ui fixed
This commit is contained in:
@@ -52,7 +52,8 @@ 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";
|
import { MissingTeethSimple, type MissingMapStrict } from "./tooth-ui";
|
||||||
|
import { RemarksField } from "./claims-ui";
|
||||||
|
|
||||||
interface ClaimFormProps {
|
interface ClaimFormProps {
|
||||||
patientId: number;
|
patientId: number;
|
||||||
@@ -349,7 +350,7 @@ export function ClaimForm({
|
|||||||
dateOfBirth: normalizeToIsoDateString(patient?.dateOfBirth),
|
dateOfBirth: normalizeToIsoDateString(patient?.dateOfBirth),
|
||||||
remarks: "",
|
remarks: "",
|
||||||
missingTeethStatus: "No_missing",
|
missingTeethStatus: "No_missing",
|
||||||
missingTeeth: {},
|
missingTeeth: {} as MissingMapStrict,
|
||||||
serviceDate: serviceDate,
|
serviceDate: serviceDate,
|
||||||
insuranceProvider: "",
|
insuranceProvider: "",
|
||||||
insuranceSiteKey: "",
|
insuranceSiteKey: "",
|
||||||
@@ -453,13 +454,8 @@ export function ClaimForm({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onToothChange = useCallback(
|
|
||||||
(name: string, v: "" | "X" | "O") => updateMissingTooth(name, v),
|
|
||||||
[updateMissingTooth]
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearAllToothSelections = () =>
|
const clearAllToothSelections = () =>
|
||||||
setForm((prev) => ({ ...prev, missingTeeth: {} }));
|
setForm((prev) => ({ ...prev, missingTeeth: {} as MissingMapStrict }));
|
||||||
|
|
||||||
// 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)[]>([]);
|
||||||
@@ -862,20 +858,6 @@ export function ClaimForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clinical Notes Entry */}
|
|
||||||
<div className="mb-4 flex items-center gap-2">
|
|
||||||
<Label htmlFor="remarks" className="whitespace-nowrap">
|
|
||||||
Remarks:
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="remarks"
|
|
||||||
className="flex-grow"
|
|
||||||
placeholder="Paste clinical notes here"
|
|
||||||
value={form.remarks}
|
|
||||||
onChange={(e) => setForm({ ...form, remarks: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service Lines */}
|
{/* Service Lines */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold mb-2 text-center">
|
<h3 className="text-xl font-semibold mb-2 text-center">
|
||||||
@@ -1401,23 +1383,26 @@ export function ClaimForm({
|
|||||||
|
|
||||||
{/* When specifying per-tooth values, show Permanent + Primary grids */}
|
{/* When specifying per-tooth values, show Permanent + Primary grids */}
|
||||||
{form.missingTeethStatus === "Yes_missing" && (
|
{form.missingTeethStatus === "Yes_missing" && (
|
||||||
<div className="grid md:grid-cols-2 gap-6 overflow-visible">
|
<MissingTeethSimple
|
||||||
<TeethGrid
|
value={form.missingTeeth}
|
||||||
title="PERMANENT"
|
onChange={(next) =>
|
||||||
toothNames={PERMANENT_TOOTH_NAMES}
|
setForm((prev) => ({ ...prev, missingTeeth: next }))
|
||||||
values={form.missingTeeth}
|
}
|
||||||
onChange={onToothChange}
|
|
||||||
/>
|
/>
|
||||||
<TeethGrid
|
|
||||||
title="PRIMARY"
|
|
||||||
toothNames={PRIMARY_TOOTH_NAMES}
|
|
||||||
values={form.missingTeeth}
|
|
||||||
onChange={onToothChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Clinical Notes Entry */}
|
||||||
|
<div className="mt-8 pt-8 space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold text-center">Remarks</h3>
|
||||||
|
<RemarksField
|
||||||
|
value={form.remarks}
|
||||||
|
onChange={(next) =>
|
||||||
|
setForm((prev) => ({ ...prev, remarks: next }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Insurance Carriers */}
|
{/* Insurance Carriers */}
|
||||||
<div className="pt-6">
|
<div className="pt-6">
|
||||||
<h3 className="text-xl font-semibold mb-4 text-center">
|
<h3 className="text-xl font-semibold mb-4 text-center">
|
||||||
|
|||||||
83
apps/Frontend/src/components/claims/claims-ui.tsx
Normal file
83
apps/Frontend/src/components/claims/claims-ui.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export function RemarksField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
debounceMs = 250, // tweak (150–300) if you like
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
debounceMs?: number;
|
||||||
|
}) {
|
||||||
|
const [local, setLocal] = React.useState(() => value);
|
||||||
|
|
||||||
|
// Track last prop we saw to detect true external changes
|
||||||
|
const lastPropRef = React.useRef(value);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (value !== lastPropRef.current && value !== local) {
|
||||||
|
// Only sync when parent changed from elsewhere
|
||||||
|
setLocal(value);
|
||||||
|
}
|
||||||
|
lastPropRef.current = value;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [value]); // (intentionally ignoring `local` in deps)
|
||||||
|
|
||||||
|
// Debounce: call parent onChange after user pauses typing
|
||||||
|
const timerRef = React.useRef<number | null>(null);
|
||||||
|
const schedulePush = React.useCallback(
|
||||||
|
(next: string) => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
timerRef.current = null;
|
||||||
|
onChange(next);
|
||||||
|
// update lastPropRef so the next parent echo won't resync over local
|
||||||
|
lastPropRef.current = next;
|
||||||
|
}, debounceMs);
|
||||||
|
},
|
||||||
|
[onChange, debounceMs]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flush on unmount to avoid losing the last input
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
onChange(local);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
id="remarks"
|
||||||
|
placeholder="Paste clinical notes here"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={local}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
setLocal(next); // instant local update (no lag)
|
||||||
|
schedulePush(next); // debounced parent update
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// ensure latest text is pushed when the field loses focus
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
if (local !== lastPropRef.current) {
|
||||||
|
onChange(local);
|
||||||
|
lastPropRef.current = local;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,105 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import { Label } from "recharts";
|
||||||
Select,
|
import { Input } from "../ui/input";
|
||||||
SelectContent,
|
import { Button } from "../ui/button";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ——— Missing Teeth helpers for claim-view and edit modal———
|
// ——— Missing Teeth helpers for claim-view and edit modal———
|
||||||
type MissingMap = Record<string, ToothVal | undefined>;
|
type MissingMap = Record<string, ToothVal | undefined>;
|
||||||
@@ -153,3 +55,137 @@ export function ToothChip({ name, v }: { name: string; v: ToothVal }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ToothVal = "X" | "O";
|
||||||
|
export type MissingMapStrict = Record<string, ToothVal>;
|
||||||
|
|
||||||
|
/* ---------- parsing helpers ---------- */
|
||||||
|
const PERM_NUMBERS = new Set(
|
||||||
|
Array.from({ length: 32 }, (_, i) => String(i + 1))
|
||||||
|
);
|
||||||
|
const PRIM_LETTERS = new Set(Array.from("ABCDEFGHIJKLMNOPQRST"));
|
||||||
|
|
||||||
|
function normalizeToothToken(token: string): string | null {
|
||||||
|
const t = token.trim().toUpperCase();
|
||||||
|
if (!t) return null;
|
||||||
|
if (PERM_NUMBERS.has(t)) return t; // 1..32
|
||||||
|
if (t.length === 1 && PRIM_LETTERS.has(t)) return t; // A..T
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listToEntries(list: string, val: ToothVal): Array<[string, ToothVal]> {
|
||||||
|
if (!list) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return list
|
||||||
|
.split(/[,\s]+/g) // commas OR spaces
|
||||||
|
.map(normalizeToothToken) // uppercase + validate
|
||||||
|
.filter((t): t is string => !!t)
|
||||||
|
.filter((t) => {
|
||||||
|
// de-duplicate within field
|
||||||
|
if (seen.has(t)) return false;
|
||||||
|
seen.add(t);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((t) => [`T_${t}`, val]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build map; 'O' overrides 'X' when duplicated across fields. */
|
||||||
|
export function mapFromLists(
|
||||||
|
missingList: string,
|
||||||
|
pullList: string
|
||||||
|
): MissingMapStrict {
|
||||||
|
const map: MissingMapStrict = {};
|
||||||
|
for (const [k, v] of listToEntries(missingList, "X")) map[k] = v;
|
||||||
|
for (const [k, v] of listToEntries(pullList, "O")) map[k] = v;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For initializing the inputs from an existing map (used only on mount or clear). */
|
||||||
|
export function listsFromMap(map: Record<string, ToothVal | undefined>): {
|
||||||
|
missing: string;
|
||||||
|
toPull: string;
|
||||||
|
} {
|
||||||
|
const missing: string[] = [];
|
||||||
|
const toPull: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(map || {})) {
|
||||||
|
if (v === "X") missing.push(k.replace(/^T_/, ""));
|
||||||
|
else if (v === "O") toPull.push(k.replace(/^T_/, ""));
|
||||||
|
}
|
||||||
|
const sort = (a: string, b: string) => {
|
||||||
|
const na = Number(a),
|
||||||
|
nb = Number(b);
|
||||||
|
const an = !Number.isNaN(na),
|
||||||
|
bn = !Number.isNaN(nb);
|
||||||
|
if (an && bn) return na - nb;
|
||||||
|
if (an) return -1;
|
||||||
|
if (bn) return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
};
|
||||||
|
missing.sort(sort);
|
||||||
|
toPull.sort(sort);
|
||||||
|
return { missing: missing.join(", "), toPull: toPull.join(", ") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- UI ---------- */
|
||||||
|
export function MissingTeethSimple({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
/** Must match ClaimFormData.missingTeeth exactly */
|
||||||
|
value: MissingMapStrict;
|
||||||
|
onChange: (next: MissingMapStrict) => void;
|
||||||
|
}) {
|
||||||
|
// initialize text inputs from incoming map
|
||||||
|
const init = React.useMemo(() => listsFromMap(value), []); // only on mount
|
||||||
|
const [missingField, setMissingField] = React.useState(init.missing);
|
||||||
|
const [pullField, setPullField] = React.useState(init.toPull);
|
||||||
|
|
||||||
|
// only resync when parent CLEARS everything (so your Clear All works)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!value || Object.keys(value).length === 0) {
|
||||||
|
setMissingField("");
|
||||||
|
setPullField("");
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const recompute = (mStr: string, pStr: string) => {
|
||||||
|
onChange(mapFromLists(mStr, pStr));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{/* simple text label (no recharts Label) */}
|
||||||
|
<div className="text-sm font-medium">Tooth Number - Missing - X</div>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 1,2,A,B"
|
||||||
|
value={missingField}
|
||||||
|
onChange={(e) => {
|
||||||
|
const m = e.target.value.toUpperCase(); // keep uppercase in the field
|
||||||
|
setMissingField(m);
|
||||||
|
recompute(m, pullField);
|
||||||
|
}}
|
||||||
|
aria-label="Tooth Numbers — Missing"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
Tooth Number - To be pulled - O
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 4,5,D"
|
||||||
|
value={pullField}
|
||||||
|
onChange={(e) => {
|
||||||
|
const p = e.target.value.toUpperCase(); // keep uppercase in the field
|
||||||
|
setPullField(p);
|
||||||
|
recompute(missingField, p);
|
||||||
|
}}
|
||||||
|
aria-label="Tooth Numbers — To be pulled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user