initial commit
This commit is contained in:
184
apps/Frontend/src/components/patients/add-patient-modal.tsx
Executable file
184
apps/Frontend/src/components/patients/add-patient-modal.tsx
Executable file
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { PatientForm, PatientFormRef } from "./patient-form";
|
||||
import { X, Calendar } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { InsertPatient, Patient, UpdatePatient } from "@repo/db/types";
|
||||
|
||||
interface AddPatientModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: InsertPatient | (UpdatePatient & { id?: number })) => void;
|
||||
isLoading: boolean;
|
||||
patient?: Patient;
|
||||
extractedInfo?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth: string;
|
||||
insuranceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Define the ref type
|
||||
export type AddPatientModalRef = {
|
||||
shouldSchedule: boolean;
|
||||
shouldClaim: boolean;
|
||||
navigateToSchedule: (patientId: number) => void;
|
||||
navigateToClaim: (patientId: number) => void;
|
||||
};
|
||||
|
||||
export const AddPatientModal = forwardRef<
|
||||
AddPatientModalRef,
|
||||
AddPatientModalProps
|
||||
>(function AddPatientModal(props, ref) {
|
||||
const { open, onOpenChange, onSubmit, isLoading, patient, extractedInfo } =
|
||||
props;
|
||||
const [formData, setFormData] = useState<
|
||||
InsertPatient | UpdatePatient | null
|
||||
>(null);
|
||||
const isEditing = !!patient;
|
||||
const [, navigate] = useLocation();
|
||||
const [saveAndSchedule, setSaveAndSchedule] = useState(false);
|
||||
const [saveAndClaim, setSaveAndClaim] = useState(false);
|
||||
const patientFormRef = useRef<PatientFormRef>(null); // Ref for PatientForm
|
||||
|
||||
// Set up the imperativeHandle to expose functionality to the parent component
|
||||
useEffect(() => {
|
||||
if (isEditing && patient) {
|
||||
const { id, userId, createdAt, ...sanitized } = patient;
|
||||
setFormData(sanitized); // Update the form data with the patient data for editing
|
||||
} else {
|
||||
setFormData(null); // Reset form data when not editing
|
||||
}
|
||||
}, [isEditing, patient]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
shouldSchedule: saveAndSchedule,
|
||||
shouldClaim: saveAndClaim, // ✅ NEW
|
||||
navigateToSchedule: (patientId: number) => {
|
||||
navigate(`/appointments?newPatient=${patientId}`);
|
||||
},
|
||||
navigateToClaim: (patientId: number) => {
|
||||
// ✅ NEW
|
||||
navigate(`/claims?newPatient=${patientId}`);
|
||||
},
|
||||
}));
|
||||
|
||||
const handleFormSubmit = (data: InsertPatient | UpdatePatient) => {
|
||||
if (patient && patient.id) {
|
||||
onSubmit({ ...data, id: patient.id });
|
||||
} else {
|
||||
onSubmit(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAndSchedule = () => {
|
||||
setSaveAndClaim(false); // ensure only one flag at a time
|
||||
setSaveAndSchedule(true);
|
||||
patientFormRef.current?.submit();
|
||||
};
|
||||
|
||||
const handleSaveAndClaim = () => {
|
||||
setSaveAndSchedule(false); // ensure only one flag at a time
|
||||
setSaveAndClaim(true);
|
||||
patientFormRef.current?.submit();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>
|
||||
{isEditing ? "Edit Patient" : "Add New Patient"}
|
||||
</DialogTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? "Update patient information in the form below."
|
||||
: "Fill out the patient information to add them to your records."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<PatientForm
|
||||
ref={patientFormRef}
|
||||
patient={patient}
|
||||
extractedInfo={extractedInfo}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
{!isEditing && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-1"
|
||||
onClick={handleSaveAndClaim}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
Save & Claim/PreAuth
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-1"
|
||||
onClick={() => {
|
||||
handleSaveAndSchedule();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
Save & Schedule
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
form="patient-form"
|
||||
onClick={() => {
|
||||
if (patientFormRef.current) {
|
||||
patientFormRef.current.submit();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading
|
||||
? patient
|
||||
? "Updating..."
|
||||
: "Saving..."
|
||||
: patient
|
||||
? "Update Patient"
|
||||
: "Save Patient"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
366
apps/Frontend/src/components/patients/patient-financial-modal.tsx
Executable file
366
apps/Frontend/src/components/patients/patient-financial-modal.tsx
Executable file
@@ -0,0 +1,366 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import LoadingScreen from "../ui/LoadingScreen";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useLocation } from "wouter";
|
||||
import { FinancialRow } from "@repo/db/types";
|
||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||
|
||||
export function PatientFinancialsModal({
|
||||
patientId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
patientId: number | null;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<FinancialRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [limit, setLimit] = useState<number>(50);
|
||||
const [offset, setOffset] = useState<number>(0);
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [, navigate] = useLocation();
|
||||
const { toast } = useToast();
|
||||
|
||||
// patient summary to show in header
|
||||
const [patientName, setPatientName] = useState<string | null>(null);
|
||||
const [patientPID, setPatientPID] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !patientId) return;
|
||||
fetchPatient();
|
||||
fetchRows();
|
||||
}, [open, patientId, limit, offset]);
|
||||
|
||||
async function fetchPatient() {
|
||||
try {
|
||||
const res = await apiRequest("GET", `/api/patients/${patientId}`);
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
const patient = await res.json();
|
||||
setPatientName(`${patient.firstName} ${patient.lastName}`);
|
||||
setPatientPID(patient.id);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch patient", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRows() {
|
||||
if (!patientId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = `/api/patients/${patientId}/financials?limit=${limit}&offset=${offset}`;
|
||||
const res = await apiRequest("GET", url);
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || "Failed to load");
|
||||
}
|
||||
const data = await res.json();
|
||||
setRows(data.rows || []);
|
||||
setTotalCount(Number(data.totalCount || 0));
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
toast?.({
|
||||
title: "Error",
|
||||
description: err.message || "Failed to load financials",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function gotoRow(r: FinancialRow) {
|
||||
const openInNewTab = (url: string) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
// fallback for non-browser env (shouldn't happen in the client)
|
||||
navigate(url);
|
||||
}
|
||||
};
|
||||
|
||||
const makePaymentUrl = (id: number) => `/payments?paymentId=${id}`;
|
||||
|
||||
if (r.linked_payment_id) {
|
||||
openInNewTab(makePaymentUrl(r.linked_payment_id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (r.type === "PAYMENT") {
|
||||
openInNewTab(makePaymentUrl(r.id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const currentPage = Math.floor(offset / limit) + 1;
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / limit));
|
||||
|
||||
function setPage(page: number) {
|
||||
if (page < 1) page = 1;
|
||||
if (page > totalPages) page = totalPages;
|
||||
setOffset((page - 1) * limit);
|
||||
}
|
||||
|
||||
const startItem = useMemo(
|
||||
() => Math.min(offset + 1, totalCount || 0),
|
||||
[offset, totalCount]
|
||||
);
|
||||
const endItem = useMemo(
|
||||
() => Math.min(offset + limit, totalCount || 0),
|
||||
[offset, limit, totalCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-6xl w-[95%] p-0 overflow-hidden">
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<DialogTitle className="text-lg">Financials</DialogTitle>
|
||||
<DialogDescription className="text-sm text-muted-foreground">
|
||||
{patientName ? (
|
||||
<>
|
||||
<span className="font-medium">{patientName}</span>{" "}
|
||||
{patientPID && (
|
||||
<span className="text-muted-foreground">
|
||||
• PID-{String(patientPID).padStart(4, "0")}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"Claims, payments and balances for this patient."
|
||||
)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className="max-h-[56vh] overflow-auto">
|
||||
<Table className="min-w-full">
|
||||
<TableHeader className="sticky top-0 bg-white z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-24">Type</TableHead>
|
||||
<TableHead className="w-36">Date</TableHead>
|
||||
<TableHead>Procedures Codes</TableHead>
|
||||
<TableHead className="w-28">Tooth Number</TableHead>
|
||||
<TableHead className="text-right w-28">Billed</TableHead>
|
||||
<TableHead className="text-right w-28">Paid</TableHead>
|
||||
<TableHead className="text-right w-28">Adjusted</TableHead>
|
||||
<TableHead className="text-right w-28">Total Due</TableHead>
|
||||
<TableHead className="w-28">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-12">
|
||||
<LoadingScreen />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={9}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
No records found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((r) => {
|
||||
const billed = Number(r.total_billed ?? 0);
|
||||
const paid = Number(r.total_paid ?? 0);
|
||||
const adjusted = Number(r.total_adjusted ?? 0);
|
||||
const totalDue = Number(r.total_due ?? 0);
|
||||
|
||||
const serviceLines = r.service_lines || [];
|
||||
|
||||
const procedureCodes =
|
||||
serviceLines.length > 0
|
||||
? serviceLines
|
||||
.map((sl: any) => sl.procedureCode)
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
: r.linked_payment_id
|
||||
? "No Codes Given"
|
||||
: "-";
|
||||
|
||||
const toothNumbers =
|
||||
serviceLines.length > 0
|
||||
? serviceLines
|
||||
.map((sl: any) =>
|
||||
sl.toothNumber ? String(sl.toothNumber) : "-"
|
||||
)
|
||||
.join(", ")
|
||||
: "-";
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={`${r.type}-${r.id}`}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => gotoRow(r)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
{r.type}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{r.date
|
||||
? new Date(r.date).toLocaleDateString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{procedureCodes}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{toothNumbers}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{billed.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{paid.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{adjusted.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right ${totalDue > 0 ? "text-red-600" : "text-green-600"}`}
|
||||
>
|
||||
{totalDue.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell>{r.status ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t px-6 py-3 bg-white">
|
||||
<div className="flex flex-col md:flex-row items-center md:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground">Rows:</label>
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => {
|
||||
setLimit(Number(e.target.value));
|
||||
setOffset(0);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium">{startItem}</span>–
|
||||
<span className="font-medium">{endItem}</span> of{" "}
|
||||
<span className="font-medium">{totalCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) setPage(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();
|
||||
setPage(Number(page));
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages) setPage(currentPage + 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
420
apps/Frontend/src/components/patients/patient-form.tsx
Executable file
420
apps/Frontend/src/components/patients/patient-form.tsx
Executable file
@@ -0,0 +1,420 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { forwardRef, useImperativeHandle } from "react";
|
||||
import { formatLocalDate } from "@/utils/dateUtils";
|
||||
import {
|
||||
InsertPatient,
|
||||
insertPatientSchema,
|
||||
Patient,
|
||||
PatientStatus,
|
||||
patientStatusOptions,
|
||||
UpdatePatient,
|
||||
updatePatientSchema,
|
||||
} from "@repo/db/types";
|
||||
import { z } from "zod";
|
||||
import { DateInputField } from "@/components/ui/dateInputField";
|
||||
|
||||
interface PatientFormProps {
|
||||
patient?: Patient;
|
||||
extractedInfo?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth: string;
|
||||
insuranceId: string;
|
||||
};
|
||||
onSubmit: (data: InsertPatient | UpdatePatient) => void;
|
||||
}
|
||||
|
||||
export type PatientFormRef = {
|
||||
submit: () => void;
|
||||
};
|
||||
|
||||
export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
|
||||
({ patient, extractedInfo, onSubmit }, ref) => {
|
||||
const { user } = useAuth();
|
||||
const isEditing = !!patient;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
isEditing
|
||||
? updatePatientSchema
|
||||
: insertPatientSchema.extend({ userId: z.number().optional() }),
|
||||
[isEditing],
|
||||
);
|
||||
|
||||
const computedDefaultValues = useMemo(() => {
|
||||
if (isEditing && patient) {
|
||||
const { id, userId, createdAt, ...sanitizedPatient } = patient;
|
||||
return {
|
||||
...sanitizedPatient,
|
||||
dateOfBirth: patient.dateOfBirth
|
||||
? formatLocalDate(new Date(patient.dateOfBirth))
|
||||
: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
firstName: extractedInfo?.firstName || "",
|
||||
lastName: extractedInfo?.lastName || "",
|
||||
dateOfBirth: extractedInfo?.dateOfBirth || "",
|
||||
gender: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
address: "",
|
||||
city: "",
|
||||
zipCode: "",
|
||||
insuranceProvider: "",
|
||||
insuranceId: extractedInfo?.insuranceId || "",
|
||||
groupNumber: "",
|
||||
policyHolder: "",
|
||||
allergies: "",
|
||||
medicalConditions: "",
|
||||
status: "UNKNOWN",
|
||||
userId: user?.id,
|
||||
};
|
||||
}, [isEditing, patient, extractedInfo, user?.id]);
|
||||
|
||||
const form = useForm<InsertPatient | UpdatePatient>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: computedDefaultValues,
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
submit() {
|
||||
(
|
||||
document.getElementById("patient-form") as HTMLFormElement | null
|
||||
)?.requestSubmit();
|
||||
},
|
||||
}));
|
||||
|
||||
// Debug form errors
|
||||
useEffect(() => {
|
||||
const errors = form.formState.errors;
|
||||
if (Object.keys(errors).length > 0) {
|
||||
console.log("❌ Form validation errors:", errors);
|
||||
}
|
||||
}, [form.formState.errors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (patient) {
|
||||
const { id, userId, createdAt, ...sanitizedPatient } = patient;
|
||||
const resetValues: Partial<Patient> = {
|
||||
...sanitizedPatient,
|
||||
dateOfBirth: patient.dateOfBirth
|
||||
? formatLocalDate(new Date(patient.dateOfBirth))
|
||||
: "",
|
||||
};
|
||||
form.reset(resetValues);
|
||||
}
|
||||
}, [patient, computedDefaultValues, form]);
|
||||
|
||||
const handleSubmit2 = (data: InsertPatient | UpdatePatient) => {
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="patient-form"
|
||||
key={patient?.id || "new"}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
handleSubmit2(data);
|
||||
})}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Personal Information */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-700 mb-3">
|
||||
Personal Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DateInputField
|
||||
control={form.control}
|
||||
name="dateOfBirth"
|
||||
label="Date of Birth *"
|
||||
disableFuture
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gender"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gender *</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value as string}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select gender" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">Male</SelectItem>
|
||||
<SelectItem value="female">Female</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-700 mb-3">
|
||||
Contact Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Phone Number *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="address"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="city"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>City</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="zipCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ZIP Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insurance Information */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-700 mb-3">
|
||||
Insurance Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => {
|
||||
const options = Object.values(
|
||||
patientStatusOptions,
|
||||
) as PatientStatus[]; // ['ACTIVE','INACTIVE','UNKNOWN']
|
||||
const toLabel = (v: PatientStatus) =>
|
||||
v[0] + v.slice(1).toLowerCase(); // ACTIVE -> Active
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Status *</FormLabel>
|
||||
<Select
|
||||
value={(field.value as PatientStatus) ?? "UNKNOWN"}
|
||||
onValueChange={(v) =>
|
||||
field.onChange(v as PatientStatus)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{options.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{toLabel(v)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="insuranceProvider"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Insurance Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={(field.value as string) || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="placeholder">
|
||||
Select provider
|
||||
</SelectItem>
|
||||
<SelectItem value="Mass Health">Mass Health</SelectItem>
|
||||
<SelectItem value="Delta MA">Delta MA</SelectItem>
|
||||
<SelectItem value="Metlife">MetLife</SelectItem>
|
||||
<SelectItem value="Cigna">Cigna</SelectItem>
|
||||
<SelectItem value="Aetna">Aetna</SelectItem>
|
||||
<SelectItem value="Other">Other</SelectItem>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="insuranceId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Insurance ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={String(field.value) || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="groupNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Group Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="policyHolder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Policy Holder (if not self)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden submit button for form validation */}
|
||||
<button type="submit" className="hidden" aria-hidden="true"></button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
300
apps/Frontend/src/components/patients/patient-search.tsx
Executable file
300
apps/Frontend/src/components/patients/patient-search.tsx
Executable file
@@ -0,0 +1,300 @@
|
||||
import { useState } from "react";
|
||||
import { CalendarIcon, Search, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { format } from "date-fns";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
|
||||
export type SearchCriteria = {
|
||||
searchTerm: string;
|
||||
searchBy: "name" | "insuranceId" | "phone" | "gender" | "dob" | "all";
|
||||
};
|
||||
|
||||
interface PatientSearchProps {
|
||||
onSearch: (criteria: SearchCriteria) => void;
|
||||
onClearSearch: () => void;
|
||||
isSearchActive: boolean;
|
||||
}
|
||||
|
||||
export function PatientSearch({
|
||||
onSearch,
|
||||
onClearSearch,
|
||||
isSearchActive,
|
||||
}: PatientSearchProps) {
|
||||
const [dobOpen, setDobOpen] = useState(false);
|
||||
const [advanceDobOpen, setAdvanceDobOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchBy, setSearchBy] = useState<SearchCriteria["searchBy"]>("name");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [advancedCriteria, setAdvancedCriteria] = useState<SearchCriteria>({
|
||||
searchTerm: "",
|
||||
searchBy: "name",
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
onSearch({
|
||||
searchTerm,
|
||||
searchBy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSearchTerm("");
|
||||
setSearchBy("all");
|
||||
onClearSearch();
|
||||
};
|
||||
|
||||
const handleAdvancedSearch = () => {
|
||||
onSearch(advancedCriteria);
|
||||
setShowAdvanced(false);
|
||||
};
|
||||
|
||||
const updateAdvancedCriteria = (
|
||||
field: keyof SearchCriteria,
|
||||
value: string
|
||||
) => {
|
||||
setAdvancedCriteria({
|
||||
...advancedCriteria,
|
||||
[field]: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full pt-8 pb-4 px-4">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
{searchBy === "dob" ? (
|
||||
<Popover open={dobOpen} onOpenChange={setDobOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
}}
|
||||
className={cn(
|
||||
"w-full pl-3 pr-20 text-left font-normal",
|
||||
!searchTerm && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{searchTerm ? (
|
||||
format(new Date(searchTerm), "PPP")
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-4">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={searchTerm ? new Date(searchTerm) : undefined}
|
||||
onSelect={(date: Date | undefined) => {
|
||||
if (date) {
|
||||
const formattedDate = format(date, "yyyy-MM-dd");
|
||||
setSearchTerm(String(formattedDate));
|
||||
setDobOpen(false);
|
||||
}
|
||||
}}
|
||||
disabled={(date) => date > new Date()}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="Search patients..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pr-10"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{searchTerm && (
|
||||
<button
|
||||
className="absolute right-10 top-3 text-gray-400 hover:text-gray-600"
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
if (isSearchActive) onClearSearch();
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="absolute right-3 top-3 text-gray-400 hover:text-gray-600"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<Search size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={searchBy}
|
||||
onValueChange={(value) => {
|
||||
setSearchBy(value as SearchCriteria["searchBy"]);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Search by..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Fields</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="insuranceId">InsuranceId</SelectItem>
|
||||
<SelectItem value="gender">Gender</SelectItem>
|
||||
<SelectItem value="dob">DOB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Dialog open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Advanced</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[550px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Advanced Search</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search for patients using multiple criteria
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<label className="text-right text-sm font-medium">
|
||||
Search by
|
||||
</label>
|
||||
<Select
|
||||
value={advancedCriteria.searchBy}
|
||||
onValueChange={(value) => {
|
||||
setAdvancedCriteria((prev) => ({
|
||||
...prev,
|
||||
searchBy: value as SearchCriteria["searchBy"],
|
||||
searchTerm: "",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Name" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Fields</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="insuranceId">InsuranceId</SelectItem>
|
||||
<SelectItem value="gender">Gender</SelectItem>
|
||||
<SelectItem value="dob">DOB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<label className="text-right text-sm font-medium">
|
||||
Search term
|
||||
</label>
|
||||
{advancedCriteria.searchBy === "dob" ? (
|
||||
<Popover
|
||||
open={advanceDobOpen}
|
||||
onOpenChange={setAdvanceDobOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
}}
|
||||
className={cn(
|
||||
"col-span-3 text-left font-normal",
|
||||
!advancedCriteria.searchTerm &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{advancedCriteria.searchTerm ? (
|
||||
format(new Date(advancedCriteria.searchTerm), "PPP")
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-4">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={
|
||||
advancedCriteria.searchTerm
|
||||
? new Date(advancedCriteria.searchTerm)
|
||||
: undefined
|
||||
}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
const formattedDate = format(date, "yyyy-MM-dd");
|
||||
updateAdvancedCriteria(
|
||||
"searchTerm",
|
||||
String(formattedDate)
|
||||
);
|
||||
setAdvanceDobOpen(false);
|
||||
}
|
||||
}}
|
||||
disabled={(date) => date > new Date()}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Input
|
||||
className="col-span-3"
|
||||
value={advancedCriteria.searchTerm}
|
||||
onChange={(e) =>
|
||||
updateAdvancedCriteria("searchTerm", e.target.value)
|
||||
}
|
||||
placeholder="Enter search term..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={handleAdvancedSearch}>Search</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isSearchActive && (
|
||||
<Button variant="outline" onClick={handleClear}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1549
apps/Frontend/src/components/patients/patient-table.tsx
Executable file
1549
apps/Frontend/src/components/patients/patient-table.tsx
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user