From f1ea2d603aecf2d852ff162fc8429980ab7e0e72 Mon Sep 17 00:00:00 2001 From: Potenz Date: Fri, 23 Jan 2026 09:23:51 +0530 Subject: [PATCH] npiProvider - v1 --- apps/Backend/src/routes/index.ts | 2 + apps/Backend/src/routes/npiProviders.ts | 101 ++++++++ apps/Backend/src/storage/index.ts | 2 + .../src/storage/npi-providers-storage.ts | 50 ++++ .../components/settings/npiProviderForm.tsx | 149 ++++++++++++ .../components/settings/npiProviderTable.tsx | 199 ++++++++++++++++ apps/Frontend/src/pages/settings-page.tsx | 215 +++++++++--------- packages/db/prisma/schema.prisma | 15 ++ packages/db/types/index.ts | 3 +- packages/db/types/npiProviders-types.ts | 14 ++ packages/db/usedSchemas/index.ts | 1 + 11 files changed, 644 insertions(+), 107 deletions(-) create mode 100644 apps/Backend/src/routes/npiProviders.ts create mode 100644 apps/Backend/src/storage/npi-providers-storage.ts create mode 100644 apps/Frontend/src/components/settings/npiProviderForm.tsx create mode 100644 apps/Frontend/src/components/settings/npiProviderTable.tsx create mode 100644 packages/db/types/npiProviders-types.ts diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 25a5a3a..5be1bb5 100644 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -4,6 +4,7 @@ import appointmentsRoutes from "./appointments"; import appointmentProceduresRoutes from "./appointments-procedures"; import usersRoutes from "./users"; import staffsRoutes from "./staffs"; +import npiProvidersRoutes from "./npiProviders"; import claimsRoutes from "./claims"; import patientDataExtractionRoutes from "./patientDataExtraction"; import insuranceCredsRoutes from "./insuranceCreds"; @@ -25,6 +26,7 @@ router.use("/appointments", appointmentsRoutes); router.use("/appointment-procedures", appointmentProceduresRoutes); router.use("/users", usersRoutes); router.use("/staffs", staffsRoutes); +router.use("/npiProviders", npiProvidersRouter); router.use("/patientDataExtraction", patientDataExtractionRoutes); router.use("/claims", claimsRoutes); router.use("/insuranceCreds", insuranceCredsRoutes); diff --git a/apps/Backend/src/routes/npiProviders.ts b/apps/Backend/src/routes/npiProviders.ts new file mode 100644 index 0000000..90f804a --- /dev/null +++ b/apps/Backend/src/routes/npiProviders.ts @@ -0,0 +1,101 @@ +import express, { Request, Response } from "express"; +import { z } from "zod"; +import { npiProviderStorage } from "../storage/npiProviders"; +import { insertNpiProviderSchema } from "@repo/db/types"; + +const router = express.Router(); + +router.get("/", async (req: Request, res: Response) => { + try { + if (!req.user?.id) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const providers = await npiProviderStorage.getNpiProvidersByUser( + req.user.id, + ); + res.status(200).json(providers); + } catch (err) { + res.status(500).json({ + error: "Failed to fetch NPI providers", + details: String(err), + }); + } +}); + +router.post("/", async (req: Request, res: Response) => { + try { + if (!req.user?.id) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const parsed = insertNpiProviderSchema.safeParse({ + ...req.body, + userId: req.user.id, + }); + + if (!parsed.success) { + const flat = parsed.error.flatten(); + const firstError = + Object.values(flat.fieldErrors)[0]?.[0] || "Invalid input"; + + return res.status(400).json({ + message: firstError, + details: flat.fieldErrors, + }); + } + + const provider = await npiProviderStorage.createNpiProvider(parsed.data); + res.status(201).json(provider); + } catch (err: any) { + if (err.code === "P2002") { + return res.status(400).json({ + message: "This NPI already exists for the user", + }); + } + res.status(500).json({ + error: "Failed to create NPI provider", + details: String(err), + }); + } +}); + +router.put("/:id", async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + if (isNaN(id)) return res.status(400).send("Invalid ID"); + + const provider = await npiProviderStorage.updateNpiProvider(id, req.body); + res.status(200).json(provider); + } catch (err) { + res.status(500).json({ + error: "Failed to update NPI provider", + details: String(err), + }); + } +}); + +router.delete("/:id", async (req: Request, res: Response) => { + try { + if (!req.user?.id) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const id = Number(req.params.id); + if (isNaN(id)) return res.status(400).send("Invalid ID"); + + const ok = await npiProviderStorage.deleteNpiProvider(req.user.id, id); + if (!ok) { + return res.status(404).json({ message: "NPI provider not found" }); + } + + res.status(204).send(); + } catch (err) { + res.status(500).json({ + error: "Failed to delete NPI provider", + details: String(err), + }); + } +}); + +export default router; diff --git a/apps/Backend/src/storage/index.ts b/apps/Backend/src/storage/index.ts index 3778cc8..c46357a 100644 --- a/apps/Backend/src/storage/index.ts +++ b/apps/Backend/src/storage/index.ts @@ -5,6 +5,7 @@ import { patientsStorage } from './patients-storage'; import { appointmentsStorage } from './appointments-storage'; import { appointmentProceduresStorage } from './appointment-procedures-storage'; import { staffStorage } from './staff-storage'; +import { npiProviderStorage } from './npi-providers-storage'; import { claimsStorage } from './claims-storage'; import { insuranceCredsStorage } from './insurance-creds-storage'; import { generalPdfStorage } from './general-pdf-storage'; @@ -22,6 +23,7 @@ export const storage = { ...appointmentsStorage, ...appointmentProceduresStorage, ...staffStorage, + ...npiProviderStorage, ...claimsStorage, ...insuranceCredsStorage, ...generalPdfStorage, diff --git a/apps/Backend/src/storage/npi-providers-storage.ts b/apps/Backend/src/storage/npi-providers-storage.ts new file mode 100644 index 0000000..f84610e --- /dev/null +++ b/apps/Backend/src/storage/npi-providers-storage.ts @@ -0,0 +1,50 @@ +import { prisma as db } from "@repo/db/client"; +import { InsertNpiProvider, NpiProvider } from "@repo/db/types"; + +export interface INpiProviderStorage { + getNpiProvider(id: number): Promise; + getNpiProvidersByUser(userId: number): Promise; + createNpiProvider(data: InsertNpiProvider): Promise; + updateNpiProvider( + id: number, + updates: Partial, + ): Promise; + deleteNpiProvider(userId: number, id: number): Promise; +} + +export const npiProviderStorage: INpiProviderStorage = { + async getNpiProvider(id: number) { + return db.npiProvider.findUnique({ where: { id } }); + }, + + async getNpiProvidersByUser(userId: number) { + return db.npiProvider.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + }); + }, + + async createNpiProvider(data: InsertNpiProvider) { + return db.npiProvider.create({ + data: data as NpiProvider, + }); + }, + + async updateNpiProvider(id: number, updates: Partial) { + return db.npiProvider.update({ + where: { id }, + data: updates, + }); + }, + + async deleteNpiProvider(userId: number, id: number) { + try { + await db.npiProvider.delete({ + where: { id, userId }, + }); + return true; + } catch { + return false; + } + }, +}; diff --git a/apps/Frontend/src/components/settings/npiProviderForm.tsx b/apps/Frontend/src/components/settings/npiProviderForm.tsx new file mode 100644 index 0000000..9ec8852 --- /dev/null +++ b/apps/Frontend/src/components/settings/npiProviderForm.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiRequest } from "@/lib/queryClient"; +import { toast } from "@/hooks/use-toast"; + +type Props = { + onClose: () => void; + defaultValues?: { + id?: number; + npiNumber: string; + providerName: string; + }; +}; + +export function NpiProviderForm({ onClose, defaultValues }: Props) { + const [npiNumber, setNpiNumber] = useState( + defaultValues?.npiNumber || "" + ); + const [providerName, setProviderName] = useState( + defaultValues?.providerName || "" + ); + + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: async () => { + const payload = { + npiNumber: npiNumber.trim(), + providerName: providerName.trim(), + }; + + const url = defaultValues?.id + ? `/api/npiProviders/${defaultValues.id}` + : "/api/npiProviders/"; + + const method = defaultValues?.id ? "PUT" : "POST"; + + const res = await apiRequest(method, url, payload); + + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.message || "Failed to save NPI provider"); + } + + return res.json(); + }, + onSuccess: () => { + toast({ + title: `NPI provider ${ + defaultValues?.id ? "updated" : "created" + }.`, + }); + queryClient.invalidateQueries({ + queryKey: ["/api/npiProviders/"], + }); + onClose(); + }, + onError: (error: any) => { + toast({ + title: "Error", + description: error.message, + variant: "destructive", + }); + }, + }); + + useEffect(() => { + setNpiNumber(defaultValues?.npiNumber || ""); + setProviderName(defaultValues?.providerName || ""); + }, [defaultValues]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!npiNumber || !providerName) { + toast({ + title: "Error", + description: "All fields are required.", + variant: "destructive", + }); + return; + } + + mutation.mutate(); + }; + + return ( +
+
+

+ {defaultValues?.id + ? "Edit NPI Provider" + : "Create NPI Provider"} +

+ +
+
+ + setNpiNumber(e.target.value)} + className="mt-1 p-2 border rounded w-full" + placeholder="e.g., 1457649092" + /> +
+ +
+ + setProviderName(e.target.value)} + className="mt-1 p-2 border rounded w-full" + placeholder="e.g., Kai Gao" + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/Frontend/src/components/settings/npiProviderTable.tsx b/apps/Frontend/src/components/settings/npiProviderTable.tsx new file mode 100644 index 0000000..376b3f9 --- /dev/null +++ b/apps/Frontend/src/components/settings/npiProviderTable.tsx @@ -0,0 +1,199 @@ +import React, { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiRequest } from "@/lib/queryClient"; +import { Button } from "../ui/button"; +import { Edit, Delete, Plus } from "lucide-react"; +import { DeleteConfirmationDialog } from "../ui/deleteDialog"; +import { NpiProviderForm } from "./npiProviderForm"; + +type NpiProvider = { + id: number; + npiNumber: string; + providerName: string; +}; + +export function NpiProviderTable() { + const queryClient = useQueryClient(); + + const [currentPage, setCurrentPage] = useState(1); + const [modalOpen, setModalOpen] = useState(false); + const [editingProvider, setEditingProvider] = + useState(null); + + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [providerToDelete, setProviderToDelete] = + useState(null); + + const providersPerPage = 5; + + const { + data: providers = [], + isLoading, + error, + } = useQuery({ + queryKey: ["/api/npiProviders/"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/npiProviders/"); + if (!res.ok) throw new Error("Failed to fetch NPI providers"); + return res.json() as Promise; + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (provider: NpiProvider) => { + const res = await apiRequest( + "DELETE", + `/api/npiProviders/${provider.id}` + ); + if (!res.ok) throw new Error("Failed to delete NPI provider"); + return true; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["/api/npiProviders/"], + }); + }, + }); + + const handleDeleteClick = (provider: NpiProvider) => { + setProviderToDelete(provider); + setIsDeleteDialogOpen(true); + }; + + const handleConfirmDelete = () => { + if (!providerToDelete) return; + + deleteMutation.mutate(providerToDelete, { + onSuccess: () => { + setIsDeleteDialogOpen(false); + setProviderToDelete(null); + }, + }); + }; + + const indexOfLast = currentPage * providersPerPage; + const indexOfFirst = indexOfLast - providersPerPage; + const currentProviders = providers.slice( + indexOfFirst, + indexOfLast + ); + const totalPages = Math.ceil(providers.length / providersPerPage); + + return ( +
+
+

+ NPI Providers +

+ +
+ +
+ + + + + + + + + {isLoading ? ( + + + + ) : error ? ( + + + + ) : currentProviders.length === 0 ? ( + + + + ) : ( + currentProviders.map((provider) => ( + + + + + + )) + )} + +
+ NPI Number + + Provider Name + +
+ Loading NPI providers... +
+ Error loading NPI providers +
+ No NPI providers found. +
+ {provider.npiNumber} + + {provider.providerName} + + + +
+
+ + {providers.length > providersPerPage && ( +
+ + +
+ )} + + {modalOpen && ( + setModalOpen(false)} + /> + )} + + setIsDeleteDialogOpen(false)} + entityName={providerToDelete?.providerName} + /> +
+ ); +} diff --git a/apps/Frontend/src/pages/settings-page.tsx b/apps/Frontend/src/pages/settings-page.tsx index 066fac7..fa4fbf3 100644 --- a/apps/Frontend/src/pages/settings-page.tsx +++ b/apps/Frontend/src/pages/settings-page.tsx @@ -9,7 +9,7 @@ import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog"; import { CredentialTable } from "@/components/settings/insuranceCredTable"; import { useAuth } from "@/hooks/use-auth"; import { Staff } from "@repo/db/types"; - +import { NpiProviderTable } from "@/components/settings/npiProviderTable"; export default function SettingsPage() { const { toast } = useToast(); @@ -190,7 +190,7 @@ export default function SettingsPage() { const [isDeleteStaffOpen, setIsDeleteStaffOpen] = useState(false); const [currentStaff, setCurrentStaff] = useState( - undefined + undefined, ); const handleDeleteStaff = (staff: Staff) => { @@ -212,7 +212,7 @@ export default function SettingsPage() { const handleViewStaff = (staff: Staff) => alert( - `Viewing staff member:\n${staff.name} (${staff.email || "No email"})` + `Viewing staff member:\n${staff.name} (${staff.email || "No email"})`, ); // MANAGE USER @@ -229,7 +229,7 @@ export default function SettingsPage() { //update user mutation const updateUserMutate = useMutation({ mutationFn: async ( - updates: Partial<{ username: string; password: string }> + updates: Partial<{ username: string; password: string }>, ) => { if (!user?.id) throw new Error("User not loaded"); const res = await apiRequest("PUT", `/api/users/${user.id}`, updates); @@ -258,110 +258,113 @@ export default function SettingsPage() { return (
- - -
- - {isError && ( -

- {(error as Error)?.message || "Failed to load staff data."} -

- )} + + +
+ + {isError && ( +

+ {(error as Error)?.message || "Failed to load staff data."} +

+ )} - setIsDeleteStaffOpen(false)} - entityName={currentStaff?.name} - /> -
-
-
- - {/* Modal Overlay */} - {modalOpen && ( -
-
-

- {editingStaff ? "Edit Staff" : "Add Staff"} -

- -
-
- )} - - {/* User Setting section */} - - -

User Settings

-
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const password = - formData.get("password")?.toString().trim() || undefined; - - updateUserMutate.mutate({ - username: usernameUser?.trim() || undefined, - password: password || undefined, - }); - }} - > -
- - setUsernameUser(e.target.value)} - className="mt-1 p-2 border rounded w-full" - /> -
- -
- - -

- Leave blank to keep current password. -

-
- - -
-
-
- - {/* Credential Section */} -
- + setIsDeleteStaffOpen(false)} + entityName={currentStaff?.name} + />
+ + + + {/* Modal Overlay */} + {modalOpen && ( +
+
+

+ {editingStaff ? "Edit Staff" : "Add Staff"} +

+ +
+
+ )} + + {/* User Setting section */} + + +

User Settings

+
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const password = + formData.get("password")?.toString().trim() || undefined; + + updateUserMutate.mutate({ + username: usernameUser?.trim() || undefined, + password: password || undefined, + }); + }} + > +
+ + setUsernameUser(e.target.value)} + className="mt-1 p-2 border rounded w-full" + /> +
+ +
+ + +

+ Leave blank to keep current password. +

+
+ + +
+
+
+ + {/* Credential Section */} +
+ +
+ + {/* NpiProvider Section */} +
+ +
); } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 0974c7f..f332bb4 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { patients Patient[] appointments Appointment[] staff Staff[] + npiProviders NpiProvider[] claims Claim[] insuranceCredentials InsuranceCredential[] updatedPayments Payment[] @relation("PaymentUpdatedBy") @@ -114,6 +115,20 @@ model Staff { claims Claim[] @relation("ClaimStaff") } +model NpiProvider { + id Int @id @default(autoincrement()) + userId Int + npiNumber String + providerName String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, npiNumber]) + @@index([userId]) +} + + enum ProcedureSource { COMBO MANUAL diff --git a/packages/db/types/index.ts b/packages/db/types/index.ts index e9da2e9..9b6891d 100644 --- a/packages/db/types/index.ts +++ b/packages/db/types/index.ts @@ -10,4 +10,5 @@ export * from "./databaseBackup-types"; export * from "./notifications-types"; export * from "./cloudStorage-types"; export * from "./payments-reports-types"; -export * from "./patientConnection-types"; \ No newline at end of file +export * from "./patientConnection-types"; +export * from "./npiProviders-types"; \ No newline at end of file diff --git a/packages/db/types/npiProviders-types.ts b/packages/db/types/npiProviders-types.ts new file mode 100644 index 0000000..da0fb48 --- /dev/null +++ b/packages/db/types/npiProviders-types.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { NpiProviderUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas"; + +export type NpiProvider = z.infer< + typeof NpiProviderUncheckedCreateInputObjectSchema +>; + +export const insertNpiProviderSchema = ( + NpiProviderUncheckedCreateInputObjectSchema as unknown as z.ZodObject +).omit({ id: true }); + +export type InsertNpiProvider = z.infer< + typeof insertNpiProviderSchema +>; diff --git a/packages/db/usedSchemas/index.ts b/packages/db/usedSchemas/index.ts index 0d2e8ab..1a0e919 100644 --- a/packages/db/usedSchemas/index.ts +++ b/packages/db/usedSchemas/index.ts @@ -5,6 +5,7 @@ export * from '../shared/schemas/objects/PatientUncheckedCreateInput.schema'; export * from '../shared/schemas/enums/PatientStatus.schema'; export * from '../shared/schemas/objects/UserUncheckedCreateInput.schema'; export * from '../shared/schemas/objects/StaffUncheckedCreateInput.schema' +export * from '../shared/schemas/objects/NpiProviderUncheckedCreateInput.schema' export * from '../shared/schemas/objects/ClaimUncheckedCreateInput.schema' export * from '../shared/schemas/objects/InsuranceCredentialUncheckedCreateInput.schema' export * from '../shared/schemas/objects/PdfFileUncheckedCreateInput.schema'