npiProvider - v1

This commit is contained in:
2026-01-23 09:23:51 +05:30
parent aa609da33d
commit f1ea2d603a
11 changed files with 644 additions and 107 deletions

View File

@@ -4,6 +4,7 @@ import appointmentsRoutes from "./appointments";
import appointmentProceduresRoutes from "./appointments-procedures"; import appointmentProceduresRoutes from "./appointments-procedures";
import usersRoutes from "./users"; import usersRoutes from "./users";
import staffsRoutes from "./staffs"; import staffsRoutes from "./staffs";
import npiProvidersRoutes from "./npiProviders";
import claimsRoutes from "./claims"; import claimsRoutes from "./claims";
import patientDataExtractionRoutes from "./patientDataExtraction"; import patientDataExtractionRoutes from "./patientDataExtraction";
import insuranceCredsRoutes from "./insuranceCreds"; import insuranceCredsRoutes from "./insuranceCreds";
@@ -25,6 +26,7 @@ router.use("/appointments", appointmentsRoutes);
router.use("/appointment-procedures", appointmentProceduresRoutes); router.use("/appointment-procedures", appointmentProceduresRoutes);
router.use("/users", usersRoutes); router.use("/users", usersRoutes);
router.use("/staffs", staffsRoutes); router.use("/staffs", staffsRoutes);
router.use("/npiProviders", npiProvidersRouter);
router.use("/patientDataExtraction", patientDataExtractionRoutes); router.use("/patientDataExtraction", patientDataExtractionRoutes);
router.use("/claims", claimsRoutes); router.use("/claims", claimsRoutes);
router.use("/insuranceCreds", insuranceCredsRoutes); router.use("/insuranceCreds", insuranceCredsRoutes);

View File

@@ -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;

View File

@@ -5,6 +5,7 @@ import { patientsStorage } from './patients-storage';
import { appointmentsStorage } from './appointments-storage'; import { appointmentsStorage } from './appointments-storage';
import { appointmentProceduresStorage } from './appointment-procedures-storage'; import { appointmentProceduresStorage } from './appointment-procedures-storage';
import { staffStorage } from './staff-storage'; import { staffStorage } from './staff-storage';
import { npiProviderStorage } from './npi-providers-storage';
import { claimsStorage } from './claims-storage'; import { claimsStorage } from './claims-storage';
import { insuranceCredsStorage } from './insurance-creds-storage'; import { insuranceCredsStorage } from './insurance-creds-storage';
import { generalPdfStorage } from './general-pdf-storage'; import { generalPdfStorage } from './general-pdf-storage';
@@ -22,6 +23,7 @@ export const storage = {
...appointmentsStorage, ...appointmentsStorage,
...appointmentProceduresStorage, ...appointmentProceduresStorage,
...staffStorage, ...staffStorage,
...npiProviderStorage,
...claimsStorage, ...claimsStorage,
...insuranceCredsStorage, ...insuranceCredsStorage,
...generalPdfStorage, ...generalPdfStorage,

View File

@@ -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<NpiProvider | null>;
getNpiProvidersByUser(userId: number): Promise<NpiProvider[]>;
createNpiProvider(data: InsertNpiProvider): Promise<NpiProvider>;
updateNpiProvider(
id: number,
updates: Partial<NpiProvider>,
): Promise<NpiProvider | null>;
deleteNpiProvider(userId: number, id: number): Promise<boolean>;
}
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<NpiProvider>) {
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;
}
},
};

View File

@@ -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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-lg">
<h2 className="text-lg font-bold mb-4">
{defaultValues?.id
? "Edit NPI Provider"
: "Create NPI Provider"}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium">
NPI Number
</label>
<input
type="text"
value={npiNumber}
onChange={(e) => setNpiNumber(e.target.value)}
className="mt-1 p-2 border rounded w-full"
placeholder="e.g., 1457649092"
/>
</div>
<div>
<label className="block text-sm font-medium">
Provider Name
</label>
<input
type="text"
value={providerName}
onChange={(e) => setProviderName(e.target.value)}
className="mt-1 p-2 border rounded w-full"
placeholder="e.g., Kai Gao"
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="text-gray-600 hover:underline"
>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{mutation.isPending
? defaultValues?.id
? "Updating..."
: "Creating..."
: defaultValues?.id
? "Update"
: "Create"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -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<NpiProvider | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [providerToDelete, setProviderToDelete] =
useState<NpiProvider | null>(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<NpiProvider[]>;
},
});
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 (
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="flex justify-between items-center p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
NPI Providers
</h2>
<Button
onClick={() => {
setEditingProvider(null);
setModalOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" /> Add NPI Provider
</Button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
NPI Number
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Provider Name
</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading ? (
<tr>
<td colSpan={3} className="text-center py-4">
Loading NPI providers...
</td>
</tr>
) : error ? (
<tr>
<td colSpan={3} className="text-center py-4 text-red-600">
Error loading NPI providers
</td>
</tr>
) : currentProviders.length === 0 ? (
<tr>
<td colSpan={3} className="text-center py-4">
No NPI providers found.
</td>
</tr>
) : (
currentProviders.map((provider) => (
<tr key={provider.id}>
<td className="px-4 py-2">
{provider.npiNumber}
</td>
<td className="px-4 py-2">
{provider.providerName}
</td>
<td className="px-4 py-2 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingProvider(provider);
setModalOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(provider)}
>
<Delete className="h-4 w-4 text-red-600" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{providers.length > providersPerPage && (
<div className="px-4 py-3 border-t flex justify-between">
<Button
variant="ghost"
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => p - 1)}
>
Previous
</Button>
<Button
variant="ghost"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage((p) => p + 1)}
>
Next
</Button>
</div>
)}
{modalOpen && (
<NpiProviderForm
defaultValues={editingProvider || undefined}
onClose={() => setModalOpen(false)}
/>
)}
<DeleteConfirmationDialog
isOpen={isDeleteDialogOpen}
onConfirm={handleConfirmDelete}
onCancel={() => setIsDeleteDialogOpen(false)}
entityName={providerToDelete?.providerName}
/>
</div>
);
}

View File

@@ -9,7 +9,7 @@ import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
import { CredentialTable } from "@/components/settings/insuranceCredTable"; import { CredentialTable } from "@/components/settings/insuranceCredTable";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { Staff } from "@repo/db/types"; import { Staff } from "@repo/db/types";
import { NpiProviderTable } from "@/components/settings/npiProviderTable";
export default function SettingsPage() { export default function SettingsPage() {
const { toast } = useToast(); const { toast } = useToast();
@@ -190,7 +190,7 @@ export default function SettingsPage() {
const [isDeleteStaffOpen, setIsDeleteStaffOpen] = useState(false); const [isDeleteStaffOpen, setIsDeleteStaffOpen] = useState(false);
const [currentStaff, setCurrentStaff] = useState<Staff | undefined>( const [currentStaff, setCurrentStaff] = useState<Staff | undefined>(
undefined undefined,
); );
const handleDeleteStaff = (staff: Staff) => { const handleDeleteStaff = (staff: Staff) => {
@@ -212,7 +212,7 @@ export default function SettingsPage() {
const handleViewStaff = (staff: Staff) => const handleViewStaff = (staff: Staff) =>
alert( alert(
`Viewing staff member:\n${staff.name} (${staff.email || "No email"})` `Viewing staff member:\n${staff.name} (${staff.email || "No email"})`,
); );
// MANAGE USER // MANAGE USER
@@ -229,7 +229,7 @@ export default function SettingsPage() {
//update user mutation //update user mutation
const updateUserMutate = useMutation({ const updateUserMutate = useMutation({
mutationFn: async ( mutationFn: async (
updates: Partial<{ username: string; password: string }> updates: Partial<{ username: string; password: string }>,
) => { ) => {
if (!user?.id) throw new Error("User not loaded"); if (!user?.id) throw new Error("User not loaded");
const res = await apiRequest("PUT", `/api/users/${user.id}`, updates); const res = await apiRequest("PUT", `/api/users/${user.id}`, updates);
@@ -333,9 +333,7 @@ export default function SettingsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium"> <label className="block text-sm font-medium">New Password</label>
New Password
</label>
<input <input
type="password" type="password"
name="password" name="password"
@@ -362,6 +360,11 @@ export default function SettingsPage() {
<div className="mt-6"> <div className="mt-6">
<CredentialTable /> <CredentialTable />
</div> </div>
{/* NpiProvider Section */}
<div className="mt-6">
<NpiProviderTable />
</div>
</div> </div>
); );
} }

View File

@@ -25,6 +25,7 @@ model User {
patients Patient[] patients Patient[]
appointments Appointment[] appointments Appointment[]
staff Staff[] staff Staff[]
npiProviders NpiProvider[]
claims Claim[] claims Claim[]
insuranceCredentials InsuranceCredential[] insuranceCredentials InsuranceCredential[]
updatedPayments Payment[] @relation("PaymentUpdatedBy") updatedPayments Payment[] @relation("PaymentUpdatedBy")
@@ -114,6 +115,20 @@ model Staff {
claims Claim[] @relation("ClaimStaff") 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 { enum ProcedureSource {
COMBO COMBO
MANUAL MANUAL

View File

@@ -11,3 +11,4 @@ export * from "./notifications-types";
export * from "./cloudStorage-types"; export * from "./cloudStorage-types";
export * from "./payments-reports-types"; export * from "./payments-reports-types";
export * from "./patientConnection-types"; export * from "./patientConnection-types";
export * from "./npiProviders-types";

View File

@@ -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<any>
).omit({ id: true });
export type InsertNpiProvider = z.infer<
typeof insertNpiProviderSchema
>;

View File

@@ -5,6 +5,7 @@ export * from '../shared/schemas/objects/PatientUncheckedCreateInput.schema';
export * from '../shared/schemas/enums/PatientStatus.schema'; export * from '../shared/schemas/enums/PatientStatus.schema';
export * from '../shared/schemas/objects/UserUncheckedCreateInput.schema'; export * from '../shared/schemas/objects/UserUncheckedCreateInput.schema';
export * from '../shared/schemas/objects/StaffUncheckedCreateInput.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/ClaimUncheckedCreateInput.schema'
export * from '../shared/schemas/objects/InsuranceCredentialUncheckedCreateInput.schema' export * from '../shared/schemas/objects/InsuranceCredentialUncheckedCreateInput.schema'
export * from '../shared/schemas/objects/PdfFileUncheckedCreateInput.schema' export * from '../shared/schemas/objects/PdfFileUncheckedCreateInput.schema'