patient table fixed
This commit is contained in:
@@ -6,6 +6,8 @@ import {
|
|||||||
PatientUncheckedCreateInputObjectSchema,
|
PatientUncheckedCreateInputObjectSchema,
|
||||||
} from "@repo/db/usedSchemas";
|
} from "@repo/db/usedSchemas";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { extractDobParts } from "../utils/DobParts";
|
||||||
|
import { parseDobParts } from "../utils/DobPartsParsing";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -86,7 +88,7 @@ router.get("/recent", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/search", async (req: Request, res: Response) => {
|
router.get("/search", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
@@ -94,14 +96,24 @@ router.get("/search", async (req: Request, res: Response) => {
|
|||||||
insuranceId,
|
insuranceId,
|
||||||
gender,
|
gender,
|
||||||
dob,
|
dob,
|
||||||
|
term,
|
||||||
limit = "10",
|
limit = "10",
|
||||||
offset = "0",
|
offset = "0",
|
||||||
} = req.query as Record<string, string>;
|
} = req.query as Record<string, string>;
|
||||||
|
|
||||||
const filters: any = {
|
const filters: any = {
|
||||||
userId: req.user!.id, // Assumes auth middleware sets this
|
userId: req.user!.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (term) {
|
||||||
|
filters.OR = [
|
||||||
|
{ firstName: { contains: term, mode: "insensitive" } },
|
||||||
|
{ lastName: { contains: term, mode: "insensitive" } },
|
||||||
|
{ phone: { contains: term, mode: "insensitive" } },
|
||||||
|
{ insuranceId: { contains: term, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
filters.OR = [
|
filters.OR = [
|
||||||
{ firstName: { contains: name, mode: "insensitive" } },
|
{ firstName: { contains: name, mode: "insensitive" } },
|
||||||
@@ -122,9 +134,70 @@ router.get("/search", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dob) {
|
if (dob) {
|
||||||
const parsedDate = new Date(dob);
|
const range = parseDobParts(dob);
|
||||||
if (!isNaN(parsedDate.getTime())) {
|
|
||||||
filters.dateOfBirth = parsedDate;
|
if (range) {
|
||||||
|
if (!filters.AND) filters.AND = [];
|
||||||
|
|
||||||
|
// Check what kind of match this was
|
||||||
|
const fromYear = range.from.getUTCFullYear();
|
||||||
|
const toYear = range.to.getUTCFullYear();
|
||||||
|
const fromMonth = range.from.getUTCMonth() + 1; // Prisma months: 1-12
|
||||||
|
const toMonth = range.to.getUTCMonth() + 1;
|
||||||
|
const fromDay = range.from.getUTCDate();
|
||||||
|
const toDay = range.to.getUTCDate();
|
||||||
|
|
||||||
|
const isFullDate =
|
||||||
|
fromYear === toYear && fromMonth === toMonth && fromDay === toDay;
|
||||||
|
|
||||||
|
const isDayMonthOnly =
|
||||||
|
fromYear === 1900 &&
|
||||||
|
toYear === 2100 &&
|
||||||
|
fromMonth === toMonth &&
|
||||||
|
fromDay === toDay;
|
||||||
|
|
||||||
|
const isMonthOnly =
|
||||||
|
fromYear === 1900 &&
|
||||||
|
toYear === 2100 &&
|
||||||
|
fromDay === 1 &&
|
||||||
|
toDay >= 28 &&
|
||||||
|
fromMonth === toMonth &&
|
||||||
|
toMonth === toMonth;
|
||||||
|
|
||||||
|
const isYearOnly = fromMonth === 1 && toMonth === 12;
|
||||||
|
|
||||||
|
if (isFullDate) {
|
||||||
|
filters.AND.push({
|
||||||
|
dob_day: fromDay,
|
||||||
|
dob_month: fromMonth,
|
||||||
|
dob_year: fromYear,
|
||||||
|
});
|
||||||
|
} else if (isDayMonthOnly) {
|
||||||
|
filters.AND.push({
|
||||||
|
dob_day: fromDay,
|
||||||
|
dob_month: fromMonth,
|
||||||
|
});
|
||||||
|
} else if (isMonthOnly) {
|
||||||
|
filters.AND.push({
|
||||||
|
dob_month: fromMonth,
|
||||||
|
});
|
||||||
|
} else if (isYearOnly) {
|
||||||
|
filters.AND.push({
|
||||||
|
dob_year: fromYear,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: search via dateOfBirth range
|
||||||
|
filters.AND.push({
|
||||||
|
dateOfBirth: {
|
||||||
|
gte: range.from,
|
||||||
|
lte: range.to,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: `Invalid date format for DOB. Try formats like "12 March", "March", "1980", "12/03/1980", etc.`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +210,10 @@ router.get("/search", async (req: Request, res: Response) => {
|
|||||||
storage.countPatients(filters),
|
storage.countPatients(filters),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.json({ patients, totalCount });
|
return res.json({ patients, totalCount });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Search error:", error);
|
console.error("Search error:", error);
|
||||||
res.status(500).json({ message: "Failed to search patients" });
|
return res.status(500).json({ message: "Failed to search patients" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,7 +250,6 @@ router.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// Create a new patient
|
// Create a new patient
|
||||||
router.post("/", async (req: Request, res: Response): Promise<any> => {
|
router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
@@ -187,8 +259,14 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
|||||||
userId: req.user!.id,
|
userId: req.user!.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create patient
|
// Extract dob_* from dateOfBirth
|
||||||
const patient = await storage.createPatient(patientData);
|
const dobParts = extractDobParts(new Date(patientData.dateOfBirth));
|
||||||
|
|
||||||
|
const patient = await storage.createPatient({
|
||||||
|
...patientData,
|
||||||
|
...dobParts, // adds dob_day/month/year
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json(patient);
|
res.status(201).json(patient);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
@@ -229,11 +307,15 @@ router.put(
|
|||||||
// Validate request body
|
// Validate request body
|
||||||
const patientData = updatePatientSchema.parse(req.body);
|
const patientData = updatePatientSchema.parse(req.body);
|
||||||
|
|
||||||
|
let dobParts = {};
|
||||||
|
if (patientData.dateOfBirth) {
|
||||||
|
dobParts = extractDobParts(new Date(patientData.dateOfBirth));
|
||||||
|
}
|
||||||
// Update patient
|
// Update patient
|
||||||
const updatedPatient = await storage.updatePatient(
|
const updatedPatient = await storage.updatePatient(patientId, {
|
||||||
patientId,
|
...patientData,
|
||||||
patientData
|
...dobParts,
|
||||||
);
|
});
|
||||||
res.json(updatedPatient);
|
res.json(updatedPatient);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
@@ -280,7 +362,7 @@ router.delete(
|
|||||||
// Delete patient
|
// Delete patient
|
||||||
await storage.deletePatient(patientId);
|
await storage.deletePatient(patientId);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error:any) {
|
} catch (error: any) {
|
||||||
console.error("Delete patient error:", error);
|
console.error("Delete patient error:", error);
|
||||||
res.status(500).json({ message: "Failed to delete patient" });
|
res.status(500).json({ message: "Failed to delete patient" });
|
||||||
}
|
}
|
||||||
|
|||||||
7
apps/Backend/src/utils/DobParts.ts
Normal file
7
apps/Backend/src/utils/DobParts.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function extractDobParts(date: Date) {
|
||||||
|
return {
|
||||||
|
dob_day: date.getUTCDate(),
|
||||||
|
dob_month: date.getUTCMonth() + 1,
|
||||||
|
dob_year: date.getUTCFullYear(),
|
||||||
|
};
|
||||||
|
}
|
||||||
81
apps/Backend/src/utils/DobPartsParsing.ts
Normal file
81
apps/Backend/src/utils/DobPartsParsing.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
export function parseDobParts(input: string): { from: Date; to: Date } | null {
|
||||||
|
if (!input || typeof input !== "string") return null;
|
||||||
|
|
||||||
|
const parts = input.trim().split(/[\s/-]+/).filter(Boolean);
|
||||||
|
|
||||||
|
if (parts.length === 1) {
|
||||||
|
const part = parts[0]?.toLowerCase();
|
||||||
|
|
||||||
|
// Year
|
||||||
|
if (part && /^\d{4}$/.test(part)) {
|
||||||
|
const year = parseInt(part);
|
||||||
|
return {
|
||||||
|
from: new Date(Date.UTC(year, 0, 1)),
|
||||||
|
to: new Date(Date.UTC(year, 11, 31, 23, 59, 59)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Month
|
||||||
|
const month = part ? parseMonth(part) : null;
|
||||||
|
if (month !== null) {
|
||||||
|
return {
|
||||||
|
from: new Date(Date.UTC(1900, month, 1)),
|
||||||
|
to: new Date(Date.UTC(2100, month + 1, 0, 23, 59, 59)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const [part1, part2] = parts;
|
||||||
|
const day = tryParseInt(part1);
|
||||||
|
const month = part2 ? parseMonth(part2) : null;
|
||||||
|
|
||||||
|
if (day !== null && month !== null) {
|
||||||
|
return {
|
||||||
|
from: new Date(Date.UTC(1900, month, day)),
|
||||||
|
to: new Date(Date.UTC(2100, month, day, 23, 59, 59)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const [part1, part2, part3] = parts;
|
||||||
|
const day = tryParseInt(part1);
|
||||||
|
const month = part2 ? parseMonth(part2) : null;
|
||||||
|
const year = tryParseInt(part3);
|
||||||
|
|
||||||
|
if (day !== null && month !== null && year !== null) {
|
||||||
|
return {
|
||||||
|
from: new Date(Date.UTC(year, month, day)),
|
||||||
|
to: new Date(Date.UTC(year, month, day, 23, 59, 59)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMonth(input: string): number | null {
|
||||||
|
const normalized = input.toLowerCase();
|
||||||
|
const months = [
|
||||||
|
"january", "february", "march", "april", "may", "june",
|
||||||
|
"july", "august", "september", "october", "november", "december",
|
||||||
|
];
|
||||||
|
|
||||||
|
const index = months.findIndex(
|
||||||
|
(m) => m === normalized || m.startsWith(normalized)
|
||||||
|
);
|
||||||
|
return index !== -1 ? index : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseInt(value?: string): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = parseInt(value);
|
||||||
|
return isNaN(parsed) ? null : parsed;
|
||||||
|
}
|
||||||
@@ -36,11 +36,11 @@ export function PatientSearch({
|
|||||||
isSearchActive,
|
isSearchActive,
|
||||||
}: PatientSearchProps) {
|
}: PatientSearchProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [searchBy, setSearchBy] = useState<SearchCriteria["searchBy"]>("all");
|
const [searchBy, setSearchBy] = useState<SearchCriteria["searchBy"]>("name");
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [advancedCriteria, setAdvancedCriteria] = useState<SearchCriteria>({
|
const [advancedCriteria, setAdvancedCriteria] = useState<SearchCriteria>({
|
||||||
searchTerm: "",
|
searchTerm: "",
|
||||||
searchBy: "all",
|
searchBy: "name",
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
@@ -72,7 +72,7 @@ export function PatientSearch({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full pt-8 pb-4 px-4">
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex gap-2 mb-4">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
@@ -151,7 +151,7 @@ export function PatientSearch({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="col-span-3">
|
<SelectTrigger className="col-span-3">
|
||||||
<SelectValue placeholder="All Fields" />
|
<SelectValue placeholder="Name" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Fields</SelectItem>
|
<SelectItem value="all">All Fields</SelectItem>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
|||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { PatientSearch, SearchCriteria } from "./patient-search";
|
import { PatientSearch, SearchCriteria } from "./patient-search";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const PatientSchema = (
|
const PatientSchema = (
|
||||||
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
PatientUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
@@ -97,29 +98,59 @@ export function PatientTable({
|
|||||||
data: patientsData,
|
data: patientsData,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = useQuery<PatientApiResponse>({
|
} = useQuery<PatientApiResponse, Error>({
|
||||||
queryKey: ["patients", currentPage, debouncedSearchCriteria?.searchTerm || "recent"],
|
queryKey: ["patients", { page: currentPage, search: debouncedSearchCriteria?.searchTerm || "recent" }],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const trimmedTerm = debouncedSearchCriteria?.searchTerm?.trim();
|
const trimmedTerm = debouncedSearchCriteria?.searchTerm?.trim();
|
||||||
const isSearch = trimmedTerm && trimmedTerm.length > 0;
|
const isSearch = trimmedTerm && trimmedTerm.length > 0;
|
||||||
|
|
||||||
const baseUrl = isSearch
|
const rawSearchBy = debouncedSearchCriteria?.searchBy || "name";
|
||||||
? `/api/patients/search?term=${encodeURIComponent(trimmedTerm)}&by=${debouncedSearchCriteria!.searchBy}`
|
const validSearchKeys = [
|
||||||
: `/api/patients/recent`;
|
"name",
|
||||||
|
"phone",
|
||||||
|
"insuranceId",
|
||||||
|
"gender",
|
||||||
|
"dob",
|
||||||
|
"all",
|
||||||
|
];
|
||||||
|
const searchKey = validSearchKeys.includes(rawSearchBy)
|
||||||
|
? rawSearchBy
|
||||||
|
: "name";
|
||||||
|
|
||||||
const hasQueryParams = baseUrl.includes("?");
|
let url: string;
|
||||||
const url = `${baseUrl}${hasQueryParams ? "&" : "?"}limit=${patientsPerPage}&offset=${offset}`;
|
|
||||||
|
if (isSearch) {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
limit: String(patientsPerPage),
|
||||||
|
offset: String(offset),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (searchKey === "all") {
|
||||||
|
searchParams.set("term", trimmedTerm!);
|
||||||
|
} else {
|
||||||
|
searchParams.set(searchKey, trimmedTerm!);
|
||||||
|
}
|
||||||
|
|
||||||
|
url = `/api/patients/search?${searchParams.toString()}`;
|
||||||
|
} else {
|
||||||
|
url = `/api/patients/recent?limit=${patientsPerPage}&offset=${offset}`;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await apiRequest("GET", url);
|
const res = await apiRequest("GET", url);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json();
|
||||||
|
throw new Error(errorData.message || "Search failed");
|
||||||
|
}
|
||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
placeholderData: {
|
placeholderData: {
|
||||||
patients: [],
|
patients: [],
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
// Update patient mutation
|
// Update patient mutation
|
||||||
const updatePatientMutation = useMutation({
|
const updatePatientMutation = useMutation({
|
||||||
@@ -356,14 +387,18 @@ export function PatientTable({
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<div className="col-span-1">
|
||||||
variant={
|
<span
|
||||||
patient.status === "active" ? "success" : "warning"
|
className={cn(
|
||||||
}
|
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
|
||||||
className="capitalize"
|
patient.status === "active"
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-gray-100 text-gray-800"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{patient.status}
|
{patient.status === "active" ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function Dashboard() {
|
|||||||
},
|
},
|
||||||
onSuccess: (newPatient) => {
|
onSuccess: (newPatient) => {
|
||||||
setIsAddPatientOpen(false);
|
setIsAddPatientOpen(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/patients/"] });
|
queryClient.invalidateQueries({ queryKey: ["patients"] });
|
||||||
toast({
|
toast({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
description: "Patient added successfully!",
|
description: "Patient added successfully!",
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function PatientsPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: (newPatient) => {
|
onSuccess: (newPatient) => {
|
||||||
setIsAddPatientOpen(false);
|
setIsAddPatientOpen(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/patients/"] });
|
queryClient.invalidateQueries({ queryKey: ["patients"] });
|
||||||
toast({
|
toast({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
description: "Patient added successfully!",
|
description: "Patient added successfully!",
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ model Patient {
|
|||||||
firstName String
|
firstName String
|
||||||
lastName String
|
lastName String
|
||||||
dateOfBirth DateTime @db.Date
|
dateOfBirth DateTime @db.Date
|
||||||
|
dob_day Int?
|
||||||
|
dob_month Int?
|
||||||
|
dob_year Int?
|
||||||
gender String
|
gender String
|
||||||
phone String
|
phone String
|
||||||
email String?
|
email String?
|
||||||
|
|||||||
Reference in New Issue
Block a user