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:
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user