initial commit
This commit is contained in:
148
apps/Frontend/src/components/claims/claim-document-upload-modal.tsx
Executable file
148
apps/Frontend/src/components/claims/claim-document-upload-modal.tsx
Executable file
@@ -0,0 +1,148 @@
|
||||
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,
|
||||
MultipleFileUploadZoneHandle,
|
||||
} 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<MultipleFileUploadZoneHandle | null>(null);
|
||||
const [filesForUI, setFilesForUI] = useState<File[]>([]);
|
||||
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: File[]) => {
|
||||
setFilesForUI(files);
|
||||
}, []);
|
||||
|
||||
// Dummy save (simulate async). Replace with real API call when needed.
|
||||
const handleSave = useCallback(async (files: File[]) => {
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
313
apps/Frontend/src/components/claims/claim-edit-modal.tsx
Executable file
313
apps/Frontend/src/components/claims/claim-edit-modal.tsx
Executable file
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||
import React, { useState } from "react";
|
||||
import { ClaimStatus, ClaimWithServiceLines } from "@repo/db/types";
|
||||
import {
|
||||
safeParseMissingTeeth,
|
||||
splitTeeth,
|
||||
ToothChip,
|
||||
toStatusLabel,
|
||||
} from "./tooth-ui";
|
||||
|
||||
type ClaimEditModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onClose: () => void;
|
||||
claim: ClaimWithServiceLines | null;
|
||||
onSave: (updatedClaim: ClaimWithServiceLines) => void;
|
||||
};
|
||||
|
||||
export default function ClaimEditModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onClose,
|
||||
claim,
|
||||
onSave,
|
||||
}: ClaimEditModalProps) {
|
||||
const [status, setStatus] = useState<ClaimStatus>(
|
||||
claim?.status ?? ("PENDING" as ClaimStatus)
|
||||
);
|
||||
|
||||
if (!claim) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
const updatedClaim: ClaimWithServiceLines = {
|
||||
...claim,
|
||||
status,
|
||||
};
|
||||
|
||||
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: {claim.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 as ClaimStatus)}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* 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 as any)
|
||||
: 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 as any).missingTeethStatus)}
|
||||
</p>
|
||||
|
||||
{/* Only show details when the user chose "Specify Missing" */}
|
||||
{(claim as any).missingTeethStatus === "Yes_missing" &&
|
||||
(() => {
|
||||
const map = safeParseMissingTeeth((claim as any).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 as any).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>
|
||||
);
|
||||
}
|
||||
1504
apps/Frontend/src/components/claims/claim-form.tsx
Executable file
1504
apps/Frontend/src/components/claims/claim-form.tsx
Executable file
File diff suppressed because it is too large
Load Diff
369
apps/Frontend/src/components/claims/claim-view-modal.tsx
Executable file
369
apps/Frontend/src/components/claims/claim-view-modal.tsx
Executable file
@@ -0,0 +1,369 @@
|
||||
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 { ClaimFileMeta, ClaimWithServiceLines } from "@repo/db/types";
|
||||
import { FileText, Paperclip } from "lucide-react";
|
||||
import {
|
||||
safeParseMissingTeeth,
|
||||
splitTeeth,
|
||||
ToothChip,
|
||||
toStatusLabel,
|
||||
} from "./tooth-ui";
|
||||
|
||||
type ClaimViewModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onClose: () => void;
|
||||
claim: ClaimWithServiceLines | null;
|
||||
onEditClaim: (claim: ClaimWithServiceLines) => void;
|
||||
};
|
||||
|
||||
export default function ClaimViewModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onClose,
|
||||
claim,
|
||||
onEditClaim,
|
||||
}: ClaimViewModalProps) {
|
||||
// Normalizer: supports both ClaimFile[] and nested-create shape { create: ClaimFile[] }
|
||||
const getClaimFilesArray = (
|
||||
c: ClaimWithServiceLines | null
|
||||
): ClaimFileMeta[] => {
|
||||
if (!c) return [];
|
||||
|
||||
// If it's already a plain array (runtime from Prisma include), return it
|
||||
const maybeFiles = (c as any).claimFiles;
|
||||
if (!maybeFiles) return [];
|
||||
|
||||
if (Array.isArray(maybeFiles)) {
|
||||
// ensure each item has filename field (best-effort)
|
||||
return maybeFiles.map((f: any) => ({
|
||||
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: any) => ({
|
||||
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 as any).missingTeethStatus)}
|
||||
</p>
|
||||
|
||||
{/* Only show details when the user chose "Specify Missing" */}
|
||||
{(claim as any).missingTeethStatus === "Yes_missing" &&
|
||||
(() => {
|
||||
const map = safeParseMissingTeeth(
|
||||
(claim as any).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 as any).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>
|
||||
);
|
||||
}
|
||||
83
apps/Frontend/src/components/claims/claims-of-patient-table.tsx
Executable file
83
apps/Frontend/src/components/claims/claims-of-patient-table.tsx
Executable file
@@ -0,0 +1,83 @@
|
||||
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";
|
||||
import { Patient } from "@repo/db/types";
|
||||
|
||||
interface ClaimsOfPatientModalProps {
|
||||
onNewClaim?: (patientId: number) => void;
|
||||
}
|
||||
|
||||
export default function ClaimsOfPatientModal({
|
||||
onNewClaim,
|
||||
}: ClaimsOfPatientModalProps) {
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [claimsPage, setClaimsPage] = useState(1);
|
||||
|
||||
const handleSelectPatient = (patient: Patient | null) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
626
apps/Frontend/src/components/claims/claims-recent-table.tsx
Executable file
626
apps/Frontend/src/components/claims/claims-recent-table.tsx
Executable file
@@ -0,0 +1,626 @@
|
||||
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,
|
||||
} from "lucide-react";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAppDispatch } from "@/redux/hooks";
|
||||
import { setTaskStatus } from "@/redux/slices/seleniumClaimSubmitTaskSlice";
|
||||
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 { Claim, ClaimStatus, ClaimWithServiceLines } from "@repo/db/types";
|
||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||
|
||||
interface ClaimApiResponse {
|
||||
claims: ClaimWithServiceLines[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
interface ClaimsRecentTableProps {
|
||||
allowEdit?: boolean;
|
||||
allowView?: boolean;
|
||||
allowDelete?: boolean;
|
||||
allowCheckbox?: boolean;
|
||||
onSelectClaim?: (claim: Claim | null) => void;
|
||||
onPageChange?: (page: number) => void;
|
||||
patientId?: number;
|
||||
}
|
||||
|
||||
// 🔑 exported base key
|
||||
export const QK_CLAIMS_BASE = ["claims-recent"] as const;
|
||||
// helper for specific pages/patient scope
|
||||
export const qkClaimsRecent = (opts: {
|
||||
patientId?: number | null;
|
||||
page: number;
|
||||
}) =>
|
||||
opts.patientId
|
||||
? ([...QK_CLAIMS_BASE, "patient", opts.patientId, opts.page] as const)
|
||||
: ([...QK_CLAIMS_BASE, "global", opts.page] as const);
|
||||
|
||||
export default function ClaimsRecentTable({
|
||||
allowEdit,
|
||||
allowView,
|
||||
allowDelete,
|
||||
allowCheckbox,
|
||||
onSelectClaim,
|
||||
onPageChange,
|
||||
patientId,
|
||||
}: ClaimsRecentTableProps) {
|
||||
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<
|
||||
ClaimWithServiceLines | undefined
|
||||
>(undefined);
|
||||
const [selectedClaimId, setSelectedClaimId] = useState<number | null>(null);
|
||||
|
||||
const handleSelectClaim = (claim: 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<ClaimApiResponse, Error>({
|
||||
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: ClaimWithServiceLines) => {
|
||||
const response = await apiRequest("PUT", `/api/claims/${claim.id}`, {
|
||||
status: claim.status,
|
||||
});
|
||||
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: number) => {
|
||||
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: ClaimWithServiceLines) => {
|
||||
setCurrentClaim(claim);
|
||||
setIsEditClaimOpen(true);
|
||||
};
|
||||
|
||||
const handleViewClaim = (claim: ClaimWithServiceLines) => {
|
||||
setCurrentClaim(claim);
|
||||
setIsViewClaimOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClaim = (claim: ClaimWithServiceLines) => {
|
||||
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: string) => {
|
||||
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: number) => {
|
||||
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?: ClaimStatus) => {
|
||||
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: ClaimWithServiceLines) => {
|
||||
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>Patient Name</TableHead>
|
||||
<TableHead>Submission Date</TableHead>
|
||||
<TableHead>Insurance Provider</TableHead>
|
||||
<TableHead>Member ID</TableHead>
|
||||
<TableHead>Total Billed</TableHead>
|
||||
<TableHead>Status</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="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">
|
||||
{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 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({
|
||||
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({
|
||||
status: "success",
|
||||
message: "Claims automation completed. Browser remains open.",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
handleViewClaim(claim);
|
||||
}
|
||||
} catch {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
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 as number);
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
83
apps/Frontend/src/components/claims/claims-ui.tsx
Executable file
83
apps/Frontend/src/components/claims/claims-ui.tsx
Executable file
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function RemarksField({
|
||||
value,
|
||||
onChange,
|
||||
debounceMs = 250, // tweak (150–300) if you like
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
debounceMs?: number;
|
||||
}) {
|
||||
const [local, setLocal] = React.useState(() => value);
|
||||
|
||||
// Track last prop we saw to detect true external changes
|
||||
const lastPropRef = React.useRef(value);
|
||||
React.useEffect(() => {
|
||||
if (value !== lastPropRef.current && value !== local) {
|
||||
// Only sync when parent changed from elsewhere
|
||||
setLocal(value);
|
||||
}
|
||||
lastPropRef.current = value;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]); // (intentionally ignoring `local` in deps)
|
||||
|
||||
// Debounce: call parent onChange after user pauses typing
|
||||
const timerRef = React.useRef<number | null>(null);
|
||||
const schedulePush = React.useCallback(
|
||||
(next: string) => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
onChange(next);
|
||||
// update lastPropRef so the next parent echo won't resync over local
|
||||
lastPropRef.current = next;
|
||||
}, debounceMs);
|
||||
},
|
||||
[onChange, debounceMs]
|
||||
);
|
||||
|
||||
// Flush on unmount to avoid losing the last input
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
onChange(local);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
id="remarks"
|
||||
placeholder="Paste clinical notes here"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={local}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setLocal(next); // instant local update (no lag)
|
||||
schedulePush(next); // debounced parent update
|
||||
}}
|
||||
onBlur={() => {
|
||||
// ensure latest text is pushed when the field loses focus
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (local !== lastPropRef.current) {
|
||||
onChange(local);
|
||||
lastPropRef.current = local;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
apps/Frontend/src/components/claims/tooth-ui.tsx
Executable file
191
apps/Frontend/src/components/claims/tooth-ui.tsx
Executable file
@@ -0,0 +1,191 @@
|
||||
import React from "react";
|
||||
import { Label } from "recharts";
|
||||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
// ——— Missing Teeth helpers for claim-view and edit modal———
|
||||
type MissingMap = Record<string, ToothVal | undefined>;
|
||||
|
||||
export function toStatusLabel(s?: string) {
|
||||
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: unknown): MissingMap {
|
||||
if (!raw) return {};
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed as MissingMap;
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
if (typeof raw === "object") return raw as MissingMap;
|
||||
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: MissingMap) {
|
||||
const permanent: Array<{ name: string; v: ToothVal }> = [];
|
||||
const primary: Array<{ name: string; v: ToothVal }> = [];
|
||||
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 }: { name: string; v: ToothVal }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export type ToothVal = "X" | "O";
|
||||
export type MissingMapStrict = Record<string, ToothVal>;
|
||||
|
||||
/* ---------- parsing helpers ---------- */
|
||||
const PERM_NUMBERS = new Set(
|
||||
Array.from({ length: 32 }, (_, i) => String(i + 1))
|
||||
);
|
||||
const PRIM_LETTERS = new Set(Array.from("ABCDEFGHIJKLMNOPQRST"));
|
||||
|
||||
function normalizeToothToken(token: string): string | null {
|
||||
const t = token.trim().toUpperCase();
|
||||
if (!t) return null;
|
||||
if (PERM_NUMBERS.has(t)) return t; // 1..32
|
||||
if (t.length === 1 && PRIM_LETTERS.has(t)) return t; // A..T
|
||||
return null;
|
||||
}
|
||||
|
||||
function listToEntries(list: string, val: ToothVal): Array<[string, ToothVal]> {
|
||||
if (!list) return [];
|
||||
const seen = new Set<string>();
|
||||
return list
|
||||
.split(/[,\s]+/g) // commas OR spaces
|
||||
.map(normalizeToothToken) // uppercase + validate
|
||||
.filter((t): t is string => !!t)
|
||||
.filter((t) => {
|
||||
// de-duplicate within field
|
||||
if (seen.has(t)) return false;
|
||||
seen.add(t);
|
||||
return true;
|
||||
})
|
||||
.map((t) => [`T_${t}`, val]);
|
||||
}
|
||||
|
||||
/** Build map; 'O' overrides 'X' when duplicated across fields. */
|
||||
export function mapFromLists(
|
||||
missingList: string,
|
||||
pullList: string
|
||||
): MissingMapStrict {
|
||||
const map: MissingMapStrict = {};
|
||||
for (const [k, v] of listToEntries(missingList, "X")) map[k] = v;
|
||||
for (const [k, v] of listToEntries(pullList, "O")) map[k] = v;
|
||||
return map;
|
||||
}
|
||||
|
||||
/** For initializing the inputs from an existing map (used only on mount or clear). */
|
||||
export function listsFromMap(map: Record<string, ToothVal | undefined>): {
|
||||
missing: string;
|
||||
toPull: string;
|
||||
} {
|
||||
const missing: string[] = [];
|
||||
const toPull: string[] = [];
|
||||
for (const [k, v] of Object.entries(map || {})) {
|
||||
if (v === "X") missing.push(k.replace(/^T_/, ""));
|
||||
else if (v === "O") toPull.push(k.replace(/^T_/, ""));
|
||||
}
|
||||
const sort = (a: string, b: string) => {
|
||||
const na = Number(a),
|
||||
nb = Number(b);
|
||||
const an = !Number.isNaN(na),
|
||||
bn = !Number.isNaN(nb);
|
||||
if (an && bn) return na - nb;
|
||||
if (an) return -1;
|
||||
if (bn) return 1;
|
||||
return a.localeCompare(b);
|
||||
};
|
||||
missing.sort(sort);
|
||||
toPull.sort(sort);
|
||||
return { missing: missing.join(", "), toPull: toPull.join(", ") };
|
||||
}
|
||||
|
||||
/* ---------- UI ---------- */
|
||||
export function MissingTeethSimple({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
/** Must match ClaimFormData.missingTeeth exactly */
|
||||
value: MissingMapStrict;
|
||||
onChange: (next: MissingMapStrict) => void;
|
||||
}) {
|
||||
// initialize text inputs from incoming map
|
||||
const init = React.useMemo(() => listsFromMap(value), []); // only on mount
|
||||
const [missingField, setMissingField] = React.useState(init.missing);
|
||||
const [pullField, setPullField] = React.useState(init.toPull);
|
||||
|
||||
// only resync when parent CLEARS everything (so your Clear All works)
|
||||
React.useEffect(() => {
|
||||
if (!value || Object.keys(value).length === 0) {
|
||||
setMissingField("");
|
||||
setPullField("");
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const recompute = (mStr: string, pStr: string) => {
|
||||
onChange(mapFromLists(mStr, pStr));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
{/* simple text label (no recharts Label) */}
|
||||
<div className="text-sm font-medium">Tooth Number - Missing - X</div>
|
||||
<Input
|
||||
placeholder="e.g. 1,2,A,B"
|
||||
value={missingField}
|
||||
onChange={(e) => {
|
||||
const m = e.target.value.toUpperCase(); // keep uppercase in the field
|
||||
setMissingField(m);
|
||||
recompute(m, pullField);
|
||||
}}
|
||||
aria-label="Tooth Numbers — Missing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
Tooth Number - To be pulled - O
|
||||
</div>
|
||||
<Input
|
||||
placeholder="e.g. 4,5,D"
|
||||
value={pullField}
|
||||
onChange={(e) => {
|
||||
const p = e.target.value.toUpperCase(); // keep uppercase in the field
|
||||
setPullField(p);
|
||||
recompute(missingField, p);
|
||||
}}
|
||||
aria-label="Tooth Numbers — To be pulled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user