feat: add new frontend components, MH batch worker, and gitignore rules

- Add all new Frontend source files (pages, components, hooks, utils)
- Add selenium_MHBatchPaymentCheckWorker.py and MHSinglePaymentCheckWorker.py
- Add install-steps-5-13.sh setup script
- Update .gitignore to exclude runtime/sensitive data (backups, uploads,
  chat-history, keys, downloads, generated .d.ts files) while keeping folders
- Add .gitkeep to preserve empty runtime folders in git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:23:43 -04:00
co-authored by Claude Sonnet 4.6
parent b7e06adf2f
commit 1edf73fdc8
173 changed files with 33469 additions and 0 deletions
@@ -0,0 +1,104 @@
import React, { useCallback, useRef, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw, FilePlus } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { MultipleFileUploadZone, } from "../file-upload/multiple-file-upload-zone";
export default function ClaimDocumentsUploadMultiple() {
const { toast } = useToast();
// Internal configuration
const MAX_FILES = 10;
const ACCEPTED_FILE_TYPES = "application/pdf,image/jpeg,image/jpg,image/png,image/webp";
const TITLE = "Upload Claim Document(s)";
const DESCRIPTION = "You can upload up to 10 files. Allowed types: PDF, JPG, PNG, WEBP.";
// Zone ref + minimal UI state (parent does not own files)
const uploadZoneRef = useRef(null);
const [filesForUI, setFilesForUI] = useState([]);
const [isUploading, setIsUploading] = useState(false); // forwarded to upload zone
const [isExtracting, setIsExtracting] = useState(false);
// Called by MultipleFileUploadZone when its internal list changes (UI-only)
const handleZoneFilesChange = useCallback((files) => {
setFilesForUI(files);
}, []);
// Dummy save (simulate async). Replace with real API call when needed.
const handleSave = useCallback(async (files) => {
// simulate network / processing time
await new Promise((res) => setTimeout(res, 800));
console.log("handleSave called for files:", files.map((f) => f.name));
}, []);
// Extract handler — reads files from the zone via ref and calls handleSave
const handleExtract = useCallback(async () => {
const files = uploadZoneRef.current?.getFiles() ?? [];
if (files.length === 0) {
toast({
title: "No files",
description: "Please upload at least one file before extracting.",
variant: "destructive",
});
return;
}
if (isExtracting)
return;
setIsExtracting(true);
try {
await handleSave(files);
toast({
title: "Extraction started",
description: `Processing ${files.length} file(s).`,
variant: "default",
});
// we intentionally leave files intact in the zone after extraction
}
catch (err) {
toast({
title: "Extraction failed",
description: "There was an error starting extraction. Please try again.",
variant: "destructive",
});
// eslint-disable-next-line no-console
console.error("extract error", err);
}
finally {
setIsExtracting(false);
}
}, [handleSave, isExtracting, toast]);
return (<div className="space-y-8 py-8">
<Card>
<CardHeader>
<CardTitle>{TITLE}</CardTitle>
<CardDescription>{DESCRIPTION}</CardDescription>
</CardHeader>
<CardContent>
{/* File Upload Section */}
<div className="bg-gray-100 p-4 rounded-md space-y-4">
<MultipleFileUploadZone ref={uploadZoneRef} onFilesChange={handleZoneFilesChange} isUploading={isUploading} acceptedFileTypes={ACCEPTED_FILE_TYPES} maxFiles={MAX_FILES}/>
{/* Show list of files received from the upload zone */}
{filesForUI.length > 0 && (<div>
<p className="text-sm text-gray-600 mb-2">
Uploaded ({filesForUI.length}/{MAX_FILES})
</p>
<ul className="text-sm text-gray-700 list-disc ml-6 max-h-40 overflow-auto">
{filesForUI.map((file, index) => (<li key={index} className="truncate" title={file.name}>
{file.name}
</li>))}
</ul>
</div>)}
</div>
{/* Action Button */}
<div className="mt-4">
<Button className="w-full h-12 gap-2" disabled={filesForUI.length === 0 || isExtracting} onClick={handleExtract}>
{isExtracting ? (<>
<RefreshCw className="h-4 w-4 animate-spin"/>
Processing...
</>) : (<>
<FilePlus className="h-4 w-4"/>
Extract Claim Data
</>)}
</Button>
</div>
</CardContent>
</Card>
</div>);
}
@@ -0,0 +1,271 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import React, { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { safeParseMissingTeeth, splitTeeth, ToothChip, toStatusLabel, } from "./tooth-ui";
export default function ClaimEditModal({ isOpen, onOpenChange, onClose, claim, onSave, }) {
const [status, setStatus] = useState(claim?.status ?? "PENDING");
const [claimNumber, setClaimNumber] = useState(claim?.claimNumber ?? "");
const [selectedNpiProviderId, setSelectedNpiProviderId] = useState(claim?.npiProviderId ?? null);
const { data: npiProviders = [] } = useQuery({
queryKey: ["/api/npiProviders/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/npiProviders/");
return res.json();
},
});
// Default to Mary Scannell (or first provider) only when no provider is set
useEffect(() => {
if (!npiProviders.length)
return;
if (selectedNpiProviderId !== null)
return;
const mary = npiProviders.find((p) => p.providerName.toLowerCase().includes("mary scannell"));
const fallback = mary ?? npiProviders[0];
if (fallback)
setSelectedNpiProviderId(fallback.id);
}, [npiProviders]);
if (!claim)
return null;
const handleSave = () => {
const updatedClaim = {
...claim,
status,
claimNumber: claimNumber.trim() || null,
npiProviderId: selectedNpiProviderId,
npiProvider: npiProviders.find((p) => p.id === selectedNpiProviderId) ?? null,
};
onSave(updatedClaim);
onOpenChange(false);
};
return (<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Claim Status</DialogTitle>
<DialogDescription>Update the status of the claim.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Patient Details */}
<div className="flex items-center space-x-4">
<div className="h-16 w-16 rounded-full bg-blue-600 text-white flex items-center justify-center text-xl font-medium">
{claim.patientName.charAt(0)}
</div>
<div>
<h3 className="text-xl font-semibold">{claim.patientName}</h3>
<p className="text-gray-500">
Claim ID: {claim.id?.toString().padStart(4, "0")}
</p>
<p className="text-gray-500">
Claim No: {claimNumber || "—"}
</p>
</div>
</div>
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
<div>
<h4 className="font-medium text-gray-900">Basic Information</h4>
<div className="mt-2 space-y-2">
<p>
<span className="text-gray-500">Date of Birth:</span>{" "}
{formatDateToHumanReadable(claim.dateOfBirth)}
</p>
<p>
<span className="text-gray-500">Service Date:</span>{" "}
{formatDateToHumanReadable(claim.serviceDate)}
</p>
<div>
<span className="text-gray-500">Status:</span>
<Select value={status} onValueChange={(value) => setStatus(value)}>
<SelectTrigger className="mt-1 w-full">
<SelectValue placeholder="Select status"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="PENDING">Pending</SelectItem>
<SelectItem value="REVIEW">Review</SelectItem>
<SelectItem value="APPROVED">Approved</SelectItem>
<SelectItem value="CANCELLED">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<span className="text-gray-500">Rendering Provider:</span>
<Select value={selectedNpiProviderId?.toString() ?? ""} onValueChange={(val) => setSelectedNpiProviderId(Number(val))}>
<SelectTrigger className="mt-1 w-full">
<SelectValue placeholder="Select Provider"/>
</SelectTrigger>
<SelectContent>
{npiProviders.map((p) => (<SelectItem key={p.id} value={p.id.toString()}>
{p.npiNumber} — {p.providerName}
</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900">Insurance Details</h4>
<div className="mt-2 space-y-2">
<div>
<span className="text-gray-500">Claim Number:</span>
<Input className="mt-1" value={claimNumber} onChange={(e) => setClaimNumber(e.target.value)} placeholder="Enter claim number"/>
</div>
<p>
<span className="text-gray-500">Insurance Provider:</span>{" "}
{claim.insuranceProvider || "N/A"}
</p>
<p>
<span className="text-gray-500">Member ID:</span>{" "}
{claim.memberId}
</p>
<p>
<span className="text-gray-500">Remarks:</span>{" "}
{claim.remarks || "N/A"}
</p>
</div>
</div>
</div>
{/* Timestamps */}
<div className="space-y-2">
<h4 className="font-medium text-gray-900">Timestamps</h4>
<p>
<span className="text-gray-500">Created At:</span>{" "}
{formatDateToHumanReadable(claim.createdAt)}
</p>
<p>
<span className="text-gray-500">Updated At:</span>{" "}
{formatDateToHumanReadable(claim.updatedAt)}
</p>
</div>
{/* Staff Info */}
{claim.staff && (<div className="space-y-2 pt-4">
<h4 className="font-medium text-gray-900">Assigned Staff</h4>
<p>
<span className="text-gray-500">Name:</span> {claim.staff.name}
</p>
<p>
<span className="text-gray-500">Role:</span> {claim.staff.role}
</p>
{claim.staff.email && (<p>
<span className="text-gray-500">Email:</span>{" "}
{claim.staff.email}
</p>)}
{claim.staff.phone && (<p>
<span className="text-gray-500">Phone:</span>{" "}
{claim.staff.phone}
</p>)}
</div>)}
{/* Service Lines */}
<div>
<h4 className="font-medium text-gray-900 pt-4">Service Lines</h4>
<div className="mt-2 space-y-3">
{claim.serviceLines.length > 0 ? (<>
{claim.serviceLines.map((line) => (<div key={line.id} className="border p-3 rounded-md bg-gray-50">
<p>
<span className="text-gray-500">Procedure Code:</span>{" "}
{line.procedureCode}
</p>
<p>
<span className="text-gray-500">Procedure Date:</span>{" "}
{formatDateToHumanReadable(line.procedureDate)}
</p>
{line.quad && (<p>
<span className="text-gray-500">Quad:</span>{" "}
{line.quad}
</p>)}
{line.arch && (<p>
<span className="text-gray-500">Arch:</span>{" "}
{line.arch}
</p>)}
{line.toothNumber && (<p>
<span className="text-gray-500">Tooth Number:</span>{" "}
{line.toothNumber}
</p>)}
{line.toothSurface && (<p>
<span className="text-gray-500">Tooth Surface:</span>{" "}
{line.toothSurface}
</p>)}
<p>
<span className="text-gray-500">Billed Amount:</span> $
{Number(line.totalBilled).toFixed(2)}
</p>
</div>))}
<div className="text-right font-semibold text-gray-900 pt-2 border-t mt-4">
Total Billed Amount: $
{claim.serviceLines
.reduce((total, line) => {
const billed = line.totalBilled
? parseFloat(line.totalBilled)
: 0;
return total + billed;
}, 0)
.toFixed(2)}
</div>
</>) : (<p className="text-gray-500">No service lines available.</p>)}
</div>
</div>
{/* Missing Teeth */}
<div className="space-y-2 pt-4">
<h4 className="font-medium text-gray-900">Missing Teeth</h4>
<p>
<span className="text-gray-500">Status:</span>{" "}
{toStatusLabel(claim.missingTeethStatus)}
</p>
{/* Only show details when the user chose "Specify Missing" */}
{claim.missingTeethStatus === "Yes_missing" &&
(() => {
const map = safeParseMissingTeeth(claim.missingTeeth);
const { permanent, primary } = splitTeeth(map);
const hasAny = permanent.length > 0 || primary.length > 0;
if (!hasAny) {
return (<p className="text-gray-500">
No specific teeth marked as missing.
</p>);
}
return (<div className="mt-2 space-y-3">
{permanent.length > 0 && (<div>
<div className="text-xs font-medium text-gray-600 mb-2">
Permanent
</div>
<div className="flex flex-wrap gap-2">
{permanent.map((t) => (<ToothChip key={t.name} name={t.name} v={t.v}/>))}
</div>
</div>)}
{primary.length > 0 && (<div>
<div className="text-xs font-medium text-gray-600 mb-2">
Primary
</div>
<div className="flex flex-wrap gap-2">
{primary.map((t) => (<ToothChip key={t.name} name={t.name} v={t.v}/>))}
</div>
</div>)}
</div>);
})()}
{claim.missingTeethStatus === "endentulous" && (<p className="text-sm text-gray-700">Patient is edentulous.</p>)}
</div>
{/* Actions */}
<div className="flex justify-end space-x-2 pt-4">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>Save Changes</Button>
</div>
</div>
</DialogContent>
</Dialog>);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,272 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import React from "react";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import { FileText, Paperclip } from "lucide-react";
import { safeParseMissingTeeth, splitTeeth, ToothChip, toStatusLabel, } from "./tooth-ui";
export default function ClaimViewModal({ isOpen, onOpenChange, onClose, claim, onEditClaim, }) {
// Normalizer: supports both ClaimFile[] and nested-create shape { create: ClaimFile[] }
const getClaimFilesArray = (c) => {
if (!c)
return [];
// If it's already a plain array (runtime from Prisma include), return it
const maybeFiles = c.claimFiles;
if (!maybeFiles)
return [];
if (Array.isArray(maybeFiles)) {
// ensure each item has filename field (best-effort)
return maybeFiles.map((f) => ({
id: f?.id,
filename: String(f?.filename ?? ""),
mimeType: f?.mimeType ?? f?.mime ?? null,
}));
}
// Nested-create shape: { create: [...] }
if (maybeFiles && Array.isArray(maybeFiles.create)) {
return maybeFiles.create.map((f) => ({
id: f?.id,
filename: String(f?.filename ?? ""),
mimeType: f?.mimeType ?? f?.mime ?? null,
}));
}
// No recognized shape -> empty
return [];
};
const claimFiles = getClaimFilesArray(claim);
return (<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Claim Details</DialogTitle>
<DialogDescription>
Detailed view of the selected claim.
</DialogDescription>
</DialogHeader>
{claim && (<div className="space-y-4">
<div className="flex items-center space-x-4">
<div className="h-16 w-16 rounded-full bg-blue-600 text-white flex items-center justify-center text-xl font-medium">
{claim.patientName.charAt(0)}
</div>
<div>
<h3 className="text-xl font-semibold">{claim.patientName}</h3>
<p className="text-gray-500">
Claim ID: {claim.id?.toString().padStart(4, "0")}
</p>
<p className="text-gray-500">
Claim No: {claim.claimNumber || "—"}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
<div>
<h4 className="font-medium text-gray-900">Basic Information</h4>
<div className="mt-2 space-y-2">
<p>
<span className="text-gray-500">Date of Birth:</span>{" "}
{formatDateToHumanReadable(claim.dateOfBirth)}
</p>
<p>
<span className="text-gray-500">Service Date:</span>{" "}
{formatDateToHumanReadable(claim.serviceDate)}
</p>
<p>
<span className="text-gray-500">Status:</span>{" "}
<span className={`font-medium ${claim.status === "APPROVED"
? "text-green-600"
: claim.status === "CANCELLED"
? "text-red-600"
: claim.status === "REVIEW"
? "text-yellow-600"
: "text-gray-600"}`}>
{claim?.status
? claim.status.charAt(0).toUpperCase() +
claim.status.slice(1).toLowerCase()
: "Unknown"}
</span>
</p>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900">Insurance Details</h4>
<div className="mt-2 space-y-2">
<p>
<span className="text-gray-500">Claim Number:</span>{" "}
{claim.claimNumber || "—"}
</p>
<p>
<span className="text-gray-500">Insurance Provider:</span>{" "}
{claim.insuranceProvider || "N/A"}
</p>
<p>
<span className="text-gray-500">Member ID:</span>{" "}
{claim.memberId}
</p>
<p>
<span className="text-gray-500">Remarks:</span>{" "}
{claim.remarks || "N/A"}
</p>
</div>
</div>
</div>
{/* Metadata */}
<div className="space-y-2">
<h4 className="font-medium text-gray-900">Timestamps</h4>
<p>
<span className="text-gray-500">Created At:</span>{" "}
{formatDateToHumanReadable(claim.createdAt)}
</p>
<p>
<span className="text-gray-500">Updated At:</span>{" "}
{formatDateToHumanReadable(claim.updatedAt)}
</p>
</div>
{claim.staff && (<div className="space-y-2 pt-4">
<h4 className="font-medium text-gray-900">Assigned Staff</h4>
<p>
<span className="text-gray-500">Name:</span>{" "}
{claim.staff.name}
</p>
<p>
<span className="text-gray-500">Role:</span>{" "}
{claim.staff.role}
</p>
{claim.staff.email && (<p>
<span className="text-gray-500">Email:</span>{" "}
{claim.staff.email}
</p>)}
{claim.staff.phone && (<p>
<span className="text-gray-500">Phone:</span>{" "}
{claim.staff.phone}
</p>)}
</div>)}
<div>
<h4 className="font-medium text-gray-900 pt-4">Service Lines</h4>
<div className="mt-2 space-y-3">
{claim.serviceLines.length > 0 ? (<>
{claim.serviceLines.map((line, index) => (<div key={line.id} className="border p-3 rounded-md bg-gray-50">
<p>
<span className="text-gray-500">Procedure Code:</span>{" "}
{line.procedureCode}
</p>
<p>
<span className="text-gray-500">Procedure Date:</span>{" "}
{formatDateToHumanReadable(line.procedureDate)}
</p>
{line.quad && (<p>
<span className="text-gray-500">Quad:</span>{" "}
{line.quad}
</p>)}
{line.arch && (<p>
<span className="text-gray-500">Arch:</span>{" "}
{line.arch}
</p>)}
{line.toothNumber && (<p>
<span className="text-gray-500">Tooth Number:</span>{" "}
{line.toothNumber}
</p>)}
{line.toothSurface && (<p>
<span className="text-gray-500">
Tooth Surface:
</span>{" "}
{line.toothSurface}
</p>)}
<p>
<span className="text-gray-500">Billed Amount:</span>{" "}
${Number(line.totalBilled).toFixed(2)}
</p>
</div>))}
<div className="text-right font-semibold text-gray-900 pt-2 border-t mt-4">
Total Billed Amount: $
{claim.serviceLines
.reduce((total, line) => total + Number(line.totalBilled || 0), 0)
.toFixed(2)}
</div>
</>) : (<p className="text-gray-500">No service lines available.</p>)}
</div>
</div>
{/* Missing Teeth */}
<div className="space-y-2 pt-4">
<h4 className="font-medium text-gray-900">Missing Teeth</h4>
<p>
<span className="text-gray-500">Status:</span>{" "}
{toStatusLabel(claim.missingTeethStatus)}
</p>
{/* Only show details when the user chose "Specify Missing" */}
{claim.missingTeethStatus === "Yes_missing" &&
(() => {
const map = safeParseMissingTeeth(claim.missingTeeth);
const { permanent, primary } = splitTeeth(map);
const hasAny = permanent.length > 0 || primary.length > 0;
if (!hasAny) {
return (<p className="text-gray-500">
No specific teeth marked as missing.
</p>);
}
return (<div className="mt-2 space-y-3">
{permanent.length > 0 && (<div>
<div className="text-xs font-medium text-gray-600 mb-2">
Permanent
</div>
<div className="flex flex-wrap gap-2">
{permanent.map((t) => (<ToothChip key={t.name} name={t.name} v={t.v}/>))}
</div>
</div>)}
{primary.length > 0 && (<div>
<div className="text-xs font-medium text-gray-600 mb-2">
Primary
</div>
<div className="flex flex-wrap gap-2">
{primary.map((t) => (<ToothChip key={t.name} name={t.name} v={t.v}/>))}
</div>
</div>)}
</div>);
})()}
{claim.missingTeethStatus === "endentulous" && (<p className="text-sm text-gray-700">Patient is edentulous.</p>)}
</div>
{/* Claim Files (metadata) */}
<div className="pt-4">
<h4 className="font-medium text-gray-900 flex items-center space-x-2">
<Paperclip className="w-4 h-4 inline-block"/>
<span>Attached Files</span>
</h4>
{claimFiles.length > 0 ? (<ul className="mt-3 space-y-2">
{claimFiles.map((f) => (<li key={f.id ?? f.filename} className="flex items-center justify-between border rounded-md p-3 bg-white">
<div className="flex items-start space-x-3">
<FileText className="w-5 h-5 text-gray-500 mt-1"/>
<div>
<div className="font-medium">{f.filename}</div>
<div className="text-xs text-gray-500">
{f.mimeType || "unknown"}
</div>
</div>
</div>
</li>))}
</ul>) : (<p className="mt-2 text-gray-500">No files attached.</p>)}
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={() => {
onOpenChange(false);
onEditClaim(claim);
}}>
Edit Claim
</Button>
</div>
</div>)}
</DialogContent>
</Dialog>);
}
@@ -0,0 +1,52 @@
import { useState } from "react";
import ClaimsRecentTable from "./claims-recent-table";
import { PatientTable } from "../patients/patient-table";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../ui/card";
export default function ClaimsOfPatientModal({ onNewClaim, }) {
const [selectedPatient, setSelectedPatient] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [claimsPage, setClaimsPage] = useState(1);
const handleSelectPatient = (patient) => {
if (patient) {
setSelectedPatient(patient);
setClaimsPage(1);
setIsModalOpen(true);
}
else {
setSelectedPatient(null);
setIsModalOpen(false);
}
};
return (<div className="space-y-8 py-8">
{/* Claims Section */}
{selectedPatient && (<Card>
<CardHeader>
<CardTitle>
Claims for {selectedPatient.firstName} {selectedPatient.lastName}
</CardTitle>
<CardDescription>
Displaying recent claims for the selected patient.
</CardDescription>
</CardHeader>
<CardContent>
<ClaimsRecentTable patientId={selectedPatient.id} allowView allowEdit allowDelete onPageChange={setClaimsPage}/>
</CardContent>
</Card>)}
{/* Patients Section */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
Select any patient and View all their recent claims.
</CardDescription>
<CardDescription>
Also create new claim for any patients.
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable allowView allowCheckbox allowNewClaim onNewClaim={onNewClaim} onSelectPatient={handleSelectPatient}/>
</CardContent>
</Card>
</div>);
}
@@ -0,0 +1,481 @@
import { useEffect, useState, useMemo } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { AlertCircle, CheckCircle, Clock, Delete, Edit, Eye, Paperclip, } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { useAppDispatch } from "@/redux/hooks";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import LoadingScreen from "@/components/ui/LoadingScreen";
import { Checkbox } from "@/components/ui/checkbox";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import ClaimViewModal from "./claim-view-modal";
import ClaimEditModal from "./claim-edit-modal";
import { getPageNumbers } from "@/utils/pageNumberGenerator";
// 🔑 exported base key
export const QK_CLAIMS_BASE = ["claims-recent"];
// helper for specific pages/patient scope
export const qkClaimsRecent = (opts) => opts.patientId
? [...QK_CLAIMS_BASE, "patient", opts.patientId, opts.page]
: [...QK_CLAIMS_BASE, "global", opts.page];
export default function ClaimsRecentTable({ allowEdit, allowView, allowDelete, allowCheckbox, onSelectClaim, onPageChange, patientId, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const [isViewClaimOpen, setIsViewClaimOpen] = useState(false);
const [isEditClaimOpen, setIsEditClaimOpen] = useState(false);
const [isDeleteClaimOpen, setIsDeleteClaimOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const claimsPerPage = 5;
const offset = (currentPage - 1) * claimsPerPage;
const [currentClaim, setCurrentClaim] = useState(undefined);
const [selectedClaimId, setSelectedClaimId] = useState(null);
const handleSelectClaim = (claim) => {
const isSelected = selectedClaimId === claim.id;
const newSelectedId = isSelected ? null : claim.id;
setSelectedClaimId(Number(newSelectedId));
if (onSelectClaim) {
onSelectClaim(isSelected ? null : claim);
}
};
useEffect(() => {
setCurrentPage(1);
}, [patientId]);
const queryKey = qkClaimsRecent({
patientId: patientId ?? undefined,
page: currentPage,
});
const { data: claimsData, isLoading, isError, } = useQuery({
queryKey,
queryFn: async () => {
const endpoint = patientId
? `/api/claims/patient/${patientId}?limit=${claimsPerPage}&offset=${offset}`
: `/api/claims/recent?limit=${claimsPerPage}&offset=${offset}`;
const res = await apiRequest("GET", endpoint);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.message || "Search failed");
}
return res.json();
},
placeholderData: { claims: [], totalCount: 0 },
});
const updateClaimMutation = useMutation({
mutationFn: async (claim) => {
const response = await apiRequest("PUT", `/api/claims/${claim.id}`, {
status: claim.status,
...(claim.claimNumber != null
? { claimNumber: claim.claimNumber }
: {}),
...(claim.npiProviderId != null
? { npiProviderId: claim.npiProviderId }
: {}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to update claim");
}
return response.json();
},
onSuccess: () => {
setIsEditClaimOpen(false);
toast({
title: "Success",
description: "Claim updated successfully!",
variant: "default",
});
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
},
onError: (error) => {
toast({
title: "Error",
description: `Update failed: ${error.message}`,
variant: "destructive",
});
},
});
const deleteClaimMutation = useMutation({
mutationFn: async (id) => {
await apiRequest("DELETE", `/api/claims/${id}`);
return;
},
onSuccess: () => {
setIsDeleteClaimOpen(false);
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
toast({
title: "Success",
description: "Claim deleted successfully!",
variant: "default",
});
},
onError: (error) => {
console.log(error);
toast({
title: "Error",
description: `Failed to delete claim: ${error.message}`,
variant: "destructive",
});
},
});
const handleEditClaim = (claim) => {
setCurrentClaim(claim);
setIsEditClaimOpen(true);
};
const handleViewClaim = (claim) => {
setCurrentClaim(claim);
setIsViewClaimOpen(true);
};
const handleDeleteClaim = (claim) => {
setCurrentClaim(claim);
setIsDeleteClaimOpen(true);
};
const handleConfirmDeleteClaim = async () => {
if (currentClaim) {
if (typeof currentClaim.id === "number") {
deleteClaimMutation.mutate(currentClaim.id);
}
else {
toast({
title: "Error",
description: "Selected claim is missing an ID for deletion.",
variant: "destructive",
});
}
}
else {
toast({
title: "Error",
description: "No patient selected for deletion.",
variant: "destructive",
});
}
};
useEffect(() => {
if (onPageChange)
onPageChange(currentPage);
}, [currentPage, onPageChange]);
const totalPages = useMemo(() => Math.ceil((claimsData?.totalCount || 0) / claimsPerPage), [claimsData?.totalCount, claimsPerPage]);
const startItem = offset + 1;
const endItem = Math.min(offset + claimsPerPage, claimsData?.totalCount || 0);
const getInitialsFromName = (fullName) => {
const parts = fullName.trim().split(/\s+/);
const filteredParts = parts.filter((part) => part.length > 0);
if (filteredParts.length === 0) {
return "";
}
const firstInitial = filteredParts[0].charAt(0).toUpperCase();
if (filteredParts.length === 1) {
return firstInitial;
}
else {
const lastInitial = filteredParts[filteredParts.length - 1].charAt(0).toUpperCase();
return firstInitial + lastInitial;
}
};
const getAvatarColor = (id) => {
const colorClasses = [
"bg-blue-500",
"bg-teal-500",
"bg-amber-500",
"bg-rose-500",
"bg-indigo-500",
"bg-green-500",
"bg-purple-500",
];
return colorClasses[id % colorClasses.length];
};
const getStatusInfo = (status) => {
switch (status) {
case "PENDING":
return {
label: "Pending",
color: "bg-yellow-100 text-yellow-800",
icon: <Clock className="h-3 w-3 mr-1"/>,
};
case "APPROVED":
return {
label: "Approved",
color: "bg-green-100 text-green-800",
icon: <CheckCircle className="h-3 w-3 mr-1"/>,
};
case "CANCELLED":
return {
label: "Cancelled",
color: "bg-red-100 text-red-800",
icon: <AlertCircle className="h-3 w-3 mr-1"/>,
};
default:
return {
label: status
? status.charAt(0).toUpperCase() + status.slice(1)
: "Unknown",
color: "bg-gray-100 text-gray-800",
icon: <AlertCircle className="h-3 w-3 mr-1"/>,
};
}
};
const getTotalBilled = (claim) => {
return claim.serviceLines.reduce((sum, line) => sum + Number(line.totalBilled || 0), 0);
};
return (<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{allowCheckbox && <TableHead>Select</TableHead>}
<TableHead>Claim ID</TableHead>
<TableHead>Claim No</TableHead>
<TableHead>PreAuth No</TableHead>
<TableHead>Patient Name</TableHead>
<TableHead>Service Date</TableHead>
<TableHead>Submission Date</TableHead>
<TableHead>Insurance Provider</TableHead>
<TableHead>Member ID</TableHead>
<TableHead>Total Billed</TableHead>
<TableHead>Status</TableHead>
<TableHead>Attachments</TableHead>
<TableHead>Provider</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
<LoadingScreen />
</TableCell>
</TableRow>) : isError ? (<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-red-500">
Error loading claims.
</TableCell>
</TableRow>) : (claimsData?.claims ?? []).length === 0 ? (<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
No claims found.
</TableCell>
</TableRow>) : (claimsData?.claims.map((claim) => (<TableRow key={claim.id} className="hover:bg-gray-50">
{allowCheckbox && (<TableCell>
<Checkbox checked={selectedClaimId === claim.id} onCheckedChange={() => handleSelectClaim(claim)}/>
</TableCell>)}
<TableCell>
<div className="text-sm font-medium text-gray-900">
CLM-{claim.id.toString().padStart(4, "0")}
</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium text-gray-900">
{claim.claimNumber ?? "—"}
</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium text-blue-700">
{claim.preAuthNumber ?? "—"}
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
<Avatar className={`h-10 w-10 ${getAvatarColor(claim.patientId)}`}>
<AvatarFallback className="text-white">
{getInitialsFromName(claim.patientName)}
</AvatarFallback>
</Avatar>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{claim.patientName}
</div>
<div className="text-sm text-gray-500">
DOB: {formatDateToHumanReadable(claim.dateOfBirth)}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.serviceDate ? formatDateToHumanReadable(claim.serviceDate) : "—"}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{formatDateToHumanReadable(claim.createdAt)}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.insuranceProvider ?? "Not specified"}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.memberId ?? "Not specified"}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
${getTotalBilled(claim).toFixed(2)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{(() => {
const { label, color, icon } = getStatusInfo(claim.status);
return (<span className={`px-2 py-1 text-xs font-medium rounded-full ${color}`}>
<span className="flex items-center">
{icon}
{label}
</span>
</span>);
})()}
</div>
</TableCell>
<TableCell>
{claim.claimFiles && claim.claimFiles.length > 0 ? (<ul className="space-y-1">
{claim.claimFiles.map((f) => (<li key={f.id ?? f.filename} className="flex items-center gap-1 text-xs text-gray-700">
<Paperclip className="h-3 w-3 text-gray-400 shrink-0"/>
<span className="truncate max-w-[140px]" title={f.filename}>
{f.filename}
</span>
</li>))}
</ul>) : (<span className="text-xs text-gray-400">—</span>)}
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.npiProvider?.providerName ?? "—"}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
{allowDelete && (<Button onClick={() => {
handleDeleteClaim(claim);
}} className="text-red-600 hover:text-red-900" aria-label="Delete Staff" variant="ghost" size="icon">
<Delete />
</Button>)}
{allowEdit && (<Button variant="ghost" size="icon" onClick={() => {
handleEditClaim(claim);
}} className="text-blue-600 hover:text-blue-800 hover:bg-blue-50">
<Edit className="h-4 w-4"/>
</Button>)}
{allowView && (<Button variant="ghost" size="icon" onClick={() => {
handleViewClaim(claim);
}} className="text-gray-600 hover:text-gray-800 hover:bg-gray-50">
<Eye className="h-4 w-4"/>
</Button>)}
{/* {allowView && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
dispatch(
setTaskStatus({
key: "claimSubmit",
status: "pending",
message: "Sending Data to Selenium...",
})
);
const response = await apiRequest(
"POST",
"/api/claims/mh-provider-login",
{
memberId: claim.memberId,
dateOfBirth: claim.dateOfBirth,
submissionDate: claim.createdAt,
firstName: claim.patientName?.split(' ')[0] || '',
lastName: claim.patientName?.split(' ').slice(1).join(' ') || '',
procedureCode: claim.serviceLines?.[0]?.procedureCode || '',
toothNumber: claim.serviceLines?.[0]?.toothNumber || '',
toothSurface: claim.serviceLines?.[0]?.toothSurface || '',
insuranceSiteKey: "MH",
}
);
const data = await response.json();
if (data?.error) throw new Error(data.error);
if (data?.status === "success") {
dispatch(
setTaskStatus({
key: "claimSubmit",
status: "success",
message: "Claims automation completed. Browser remains open.",
})
);
} else {
handleViewClaim(claim);
}
} catch {
dispatch(
setTaskStatus({
key: "claimSubmit",
status: "error",
message: "Selenium submission failed",
})
);
handleViewClaim(claim);
}
}}
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
>
Claims
</Button>
)} */}
</div>
</TableCell>
</TableRow>)))}
</TableBody>
</Table>
</div>
<DeleteConfirmationDialog isOpen={isDeleteClaimOpen} onConfirm={handleConfirmDeleteClaim} onCancel={() => setIsDeleteClaimOpen(false)} entityName={currentClaim?.patientName}/>
{isViewClaimOpen && currentClaim && (<ClaimViewModal isOpen={isViewClaimOpen} onClose={() => setIsViewClaimOpen(false)} onOpenChange={(open) => setIsViewClaimOpen(open)} onEditClaim={(claim) => handleEditClaim(claim)} claim={currentClaim}/>)}
{isEditClaimOpen && currentClaim && (<ClaimEditModal isOpen={isEditClaimOpen} onClose={() => setIsEditClaimOpen(false)} onOpenChange={(open) => setIsEditClaimOpen(open)} claim={currentClaim} onSave={(updatedClaim) => {
updateClaimMutation.mutate(updatedClaim);
}}/>)}
{/* Pagination */}
{totalPages > 1 && (<div className="bg-white px-4 py-3 border-t border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground mb-2 sm:mb-0 whitespace-nowrap">
Showing {startItem}–{endItem} of {claimsData?.totalCount || 0}{" "}
results
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (currentPage > 1)
setCurrentPage(currentPage - 1);
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map((page, idx) => (<PaginationItem key={idx}>
{page === "..." ? (<span className="px-2 text-gray-500">...</span>) : (<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage(page);
}} isActive={currentPage === page}>
{page}
</PaginationLink>)}
</PaginationItem>))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages)
setCurrentPage(currentPage + 1);
}} className={currentPage === totalPages
? "pointer-events-none opacity-50"
: ""}/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>)}
</div>);
}
@@ -0,0 +1,58 @@
import React from "react";
import { Input } from "@/components/ui/input";
export function RemarksField({ value, onChange, debounceMs = 250, // tweak (150–300) if you like
}) {
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(null);
const schedulePush = React.useCallback((next) => {
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>);
}
@@ -0,0 +1,161 @@
import React from "react";
import { Input } from "../ui/input";
export function toStatusLabel(s) {
if (!s)
return "Unknown";
if (s === "No_missing")
return "No Missing";
if (s === "endentulous")
return "Edentulous";
if (s === "Yes_missing")
return "Specify Missing";
// best-effort prettify
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
}
export function safeParseMissingTeeth(raw) {
if (!raw)
return {};
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object")
return parsed;
}
catch { }
return {};
}
if (typeof raw === "object")
return raw;
return {};
}
const PERM = new Set(Array.from({ length: 32 }, (_, i) => `T_${i + 1}`));
const PRIM = new Set(Array.from("ABCDEFGHIJKLMNOPQRST").map((ch) => `T_${ch}`));
export function splitTeeth(map) {
const permanent = [];
const primary = [];
for (const [k, v] of Object.entries(map)) {
if (!v)
continue;
if (PERM.has(k))
permanent.push({ name: k, v });
else if (PRIM.has(k))
primary.push({ name: k, v });
}
// stable, human-ish order
permanent.sort((a, b) => Number(a.name.slice(2)) - Number(b.name.slice(2)));
primary.sort((a, b) => a.name.localeCompare(b.name));
return { permanent, primary };
}
export function ToothChip({ name, v }) {
return (<span className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs bg-white">
<span className="font-medium">{name.replace("T_", "")}</span>
<span className="inline-flex h-5 w-5 items-center justify-center rounded border">
{v}
</span>
</span>);
}
/* ---------- 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) {
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, val) {
if (!list)
return [];
const seen = new Set();
return list
.split(/[,\s]+/g) // commas OR spaces
.map(normalizeToothToken) // uppercase + validate
.filter((t) => !!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, pullList) {
const map = {};
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) {
const missing = [];
const toPull = [];
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, b) => {
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, }) {
// 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, pStr) => {
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>);
}