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