feat: United/DentalHub + Tufts SCO pre-auth, cloud storage search & file thumbnails

- Add United/DentalHub pre-authorization Selenium worker, helpers, backend route/processor/client, and frontend OTP modal + wired button
- Add Tufts SCO pre-authorization Selenium worker, helpers, backend route/processor/client, and frontend OTP modal + wired button
- Fix btnSubmitAuthorization selector for UnitedDH preauth step2
- Fix Tufts SCO preauth step3 to target <span>pre-authorization</span> button
- Cloud storage search: default to "both" mode so patient folder names match on first search
- Cloud storage folder panel: auto-scroll to panel when opened from search results
- Cloud storage files: show inline image thumbnails and PDF badge in file grid

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-13 00:03:58 -04:00
parent fd4feb3e76
commit 3978b16d7d
20 changed files with 4044 additions and 19 deletions

View File

@@ -93,6 +93,8 @@ interface ClaimFormProps {
onHandleForMHSeleniumClaimPreAuth: (data: ClaimPreAuthData) => void;
onHandleForCCASeleniumClaim: (data: ClaimFormData) => void;
onHandleForCCASeleniumPreAuth: (data: ClaimPreAuthData) => void;
onHandleForUnitedDHSeleniumPreAuth: (data: ClaimPreAuthData) => void;
onHandleForTuftsSCOSeleniumPreAuth: (data: ClaimPreAuthData) => void;
onHandleForDDMASeleniumClaim: (data: ClaimFormData) => void;
onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void;
onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void;
@@ -112,6 +114,8 @@ export function ClaimForm({
onHandleForMHSeleniumClaimPreAuth,
onHandleForCCASeleniumClaim,
onHandleForCCASeleniumPreAuth,
onHandleForUnitedDHSeleniumPreAuth,
onHandleForTuftsSCOSeleniumPreAuth,
onHandleForDDMASeleniumClaim,
onHandleForUnitedDHSeleniumClaim,
onHandleForTuftsSCOSeleniumClaim,
@@ -1433,6 +1437,82 @@ export function ClaimForm({
onClose();
};
const handleUnitedDHPreAuth = async () => {
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!patient?.firstName?.trim()) missingFields.push("First Name");
if (missingFields.length > 0) {
toast({
title: "Missing Required Fields",
description: `Please fill out the following field(s): ${missingFields.join(", ")}`,
variant: "destructive",
});
return;
}
const filteredServiceLines = (form.serviceLines || []).filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
toast({
title: "No procedure codes",
description: "Please add at least one procedure code before submitting the pre-authorization.",
variant: "destructive",
});
return;
}
onHandleForUnitedDHSeleniumPreAuth({
...form,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId,
insuranceProvider: "United/DentalHub",
insuranceSiteKey: "UNITED_SCO",
});
onClose();
};
const handleTuftsSCOPreAuth = async () => {
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!patient?.firstName?.trim()) missingFields.push("First Name");
if (missingFields.length > 0) {
toast({
title: "Missing Required Fields",
description: `Please fill out the following field(s): ${missingFields.join(", ")}`,
variant: "destructive",
});
return;
}
const filteredServiceLines = (form.serviceLines || []).filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
toast({
title: "No procedure codes",
description: "Please add at least one procedure code before submitting the pre-authorization.",
variant: "destructive",
});
return;
}
onHandleForTuftsSCOSeleniumPreAuth({
...form,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId,
insuranceProvider: "Tufts SCO",
insuranceSiteKey: "TUFTS_SCO",
});
onClose();
};
/** Check prices against the fee schedule. If mismatches exist, show dialog and
* store the action to run after the user responds. Otherwise run immediately. */
const runWithPriceCheck = (action: () => void) => {
@@ -2931,16 +3011,16 @@ export function ClaimForm({
CCA PreAuth
</Button>
<Button
className="w-44"
variant="secondary"
className="w-44 bg-blue-600 hover:bg-blue-700 text-white"
onClick={handleUnitedDHPreAuth}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
>
United/DentalHub PreAuth
</Button>
<Button
className="w-32"
variant="secondary"
className="w-32 bg-blue-600 hover:bg-blue-700 text-white"
onClick={handleTuftsSCOPreAuth}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
>

View File

@@ -75,6 +75,51 @@ function fileIcon(mime?: string) {
return <FileIcon className="h-6 w-6" />;
}
function FileThumbnail({ fileId, mime, name }: { fileId: number; mime?: string | null; name?: string | null }) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
useEffect(() => {
if (!mime?.startsWith("image/")) return;
let url: string | null = null;
apiRequest("GET", `/api/cloud-storage/files/${fileId}/content`)
.then((res) => {
if (!res.ok) throw new Error();
return res.blob();
})
.then((blob) => {
url = URL.createObjectURL(blob);
setBlobUrl(url);
})
.catch(() => {});
return () => { if (url) URL.revokeObjectURL(url); };
}, [fileId, mime]);
if (mime?.startsWith("image/")) {
return blobUrl ? (
<img src={blobUrl} alt={name ?? ""} className="w-full h-full object-cover rounded" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<ImageIcon className="h-6 w-6" />
</div>
);
}
if (mime === "application/pdf" || mime?.endsWith("/pdf")) {
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-red-50 rounded gap-1">
<FileText className="h-7 w-7 text-red-500" />
<span className="text-[10px] font-bold text-red-500 tracking-wide">PDF</span>
</div>
);
}
return (
<div className="w-full h-full flex items-center justify-center text-gray-400">
{fileIcon(mime ?? undefined)}
</div>
);
}
export default function FilesSection({
parentId,
pageSize = FILES_LIMIT_DEFAULT,
@@ -388,22 +433,23 @@ export default function FilesSection({
{data.map((file) => (
<div
key={file.id}
className="p-3 rounded border hover:bg-gray-50 cursor-pointer"
className="rounded border hover:border-primary hover:shadow-sm cursor-pointer overflow-hidden transition-all"
onContextMenu={(e) => showMenu(e, file)}
onClick={() => openPreview(file)}
>
<div className="flex flex-col items-center">
<div className="h-10 w-10 text-gray-500 mb-2 flex items-center justify-center">
{fileIcon((file as any).mimeType)}
<div className="h-28 w-full bg-gray-100">
<FileThumbnail
fileId={Number(file.id)}
mime={(file as any).mimeType}
name={file.name}
/>
</div>
<div className="p-2">
<div className="text-sm font-medium truncate" title={file.name ?? ""}>
{file.name}
</div>
<div
className="text-sm truncate text-center"
style={{ maxWidth: 140 }}
>
<div title={file.name}>{file.name}</div>
<div className="text-xs text-gray-400">
{((file as any).fileSize ?? 0).toString()} bytes
</div>
<div className="text-xs text-gray-400">
{((file as any).fileSize ?? 0).toString()} bytes
</div>
</div>
</div>

View File

@@ -53,7 +53,7 @@ export default function CloudSearchBar({
const [q, setQ] = useState("");
const [searchTarget, setSearchTarget] = useState<
"filename" | "foldername" | "both"
>("filename"); // default filename
>("both");
const [typeFilter, setTypeFilter] = useState<
"any" | "images" | "pdf" | "video" | "audio"
>("any");
@@ -265,7 +265,7 @@ export default function CloudSearchBar({
<SelectValue placeholder="Search target" />
</SelectTrigger>
<SelectContent>
<SelectItem value="filename">Filename (default)</SelectItem>
<SelectItem value="filename">Filename only</SelectItem>
<SelectItem value="foldername">
Folder name (top-level)
</SelectItem>