initial commit

This commit is contained in:
2026-04-04 22:13:55 -04:00
commit 5d77e207c9
10181 changed files with 522212 additions and 0 deletions

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,83 @@
import React from "react";
import { Input } from "@/components/ui/input";
export function RemarksField({
value,
onChange,
debounceMs = 250, // tweak (150300) 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>
);
}

View 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>
);
}