- Claims & Payments: save npiProviderId when submitting MH claim; sync between claim and payment on update - Claims table: add Provider column showing rendering provider name - Payments table: add Provider column + purple Commissioned badge on status - Claim edit modal: add Rendering Provider dropdown (defaults to Mary Scannell) - Payment edit modal: add Rendering Provider dropdown + Commissioned metadata display - Reports page: add Provider filter dropdown (dynamic from NPI providers settings) - Reports page: remove Collections by Doctor report type and Select Doctor dropdown - Commission section: new section in reports page with date range + provider filter, shows eligible paid claims/payments per provider, multi-select checkboxes, Pay Commission modal with print + save, marks payments as commissioned so they are excluded from future cycles - DB: add CommissionBatch and CommissionBatchItem tables; backfill Payment.npiProviderId from linked claims - Backend: PATCH /api/payments/:id/provider syncs to linked claim; PUT /api/claims/:id syncs to linked payment; new /api/commissions routes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
359 lines
13 KiB
TypeScript
Executable File
359 lines
13 KiB
TypeScript
Executable File
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, { useEffect, useState } from "react";
|
|
import { ClaimStatus, ClaimWithServiceLines, NpiProvider } from "@repo/db/types";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
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)
|
|
);
|
|
const [selectedNpiProviderId, setSelectedNpiProviderId] = useState<number | null>(
|
|
(claim as any)?.npiProviderId ?? null
|
|
);
|
|
|
|
const { data: npiProviders = [] } = useQuery<NpiProvider[]>({
|
|
queryKey: ["/api/npiProviders/"],
|
|
queryFn: async () => {
|
|
const res = await apiRequest("GET", "/api/npiProviders/");
|
|
return res.json();
|
|
},
|
|
});
|
|
|
|
// Default to Mary Scannell (or first provider) only when no provider is set
|
|
useEffect(() => {
|
|
if (!npiProviders.length) return;
|
|
if (selectedNpiProviderId !== null) return;
|
|
const mary = npiProviders.find((p) =>
|
|
p.providerName.toLowerCase().includes("mary scannell")
|
|
);
|
|
const fallback = mary ?? npiProviders[0];
|
|
if (fallback) setSelectedNpiProviderId(fallback.id);
|
|
}, [npiProviders]);
|
|
|
|
if (!claim) return null;
|
|
|
|
const handleSave = () => {
|
|
const updatedClaim: ClaimWithServiceLines = {
|
|
...claim,
|
|
status,
|
|
npiProviderId: selectedNpiProviderId,
|
|
npiProvider: npiProviders.find((p) => p.id === selectedNpiProviderId) ?? null,
|
|
} as ClaimWithServiceLines;
|
|
|
|
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>
|
|
<span className="text-gray-500">Rendering Provider:</span>
|
|
<Select
|
|
value={selectedNpiProviderId?.toString() ?? ""}
|
|
onValueChange={(val) => setSelectedNpiProviderId(Number(val))}
|
|
>
|
|
<SelectTrigger className="mt-1 w-full">
|
|
<SelectValue placeholder="Select Provider" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{npiProviders.map((p) => (
|
|
<SelectItem key={p.id} value={p.id.toString()}>
|
|
{p.npiNumber} — {p.providerName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="font-medium text-gray-900">Insurance Details</h4>
|
|
<div className="mt-2 space-y-2">
|
|
<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>
|
|
);
|
|
}
|