From f33dfb06c3611d3618c58978ec80a6e2a36ae264 Mon Sep 17 00:00:00 2001 From: Potenz Date: Sat, 20 Sep 2025 20:37:07 +0530 Subject: [PATCH] fix(UI, PdfGroups) - Schema updated, ui changed, backend Route changed --- apps/Backend/src/routes/claims.ts | 9 +- apps/Backend/src/routes/documents.ts | 76 ++++- apps/Backend/src/routes/insuranceStatus.ts | 24 +- apps/Backend/src/storage/index.ts | 96 +++++-- apps/Frontend/src/pages/documents-page.tsx | 316 +++++++++++++++------ packages/db/prisma/schema.prisma | 8 +- packages/db/types/pdf-types.ts | 3 +- packages/db/usedSchemas/index.ts | 1 - 8 files changed, 385 insertions(+), 148 deletions(-) diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index c8a8a56..37c342f 100644 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -127,21 +127,18 @@ router.post( const groupTitle = "Insurance Claim"; const groupTitleKey = "INSURANCE_CLAIM"; - const groupCategory = "CLAIM"; // ✅ Find or create PDF group for this claim - let group = await storage.findPdfGroupByPatientTitleKeyAndCategory( + let group = await storage.findPdfGroupByPatientTitleKey( parsedPatientId, - groupTitleKey, - groupCategory + groupTitleKey ); if (!group) { group = await storage.createPdfGroup( parsedPatientId, groupTitle, - groupTitleKey, - groupCategory + groupTitleKey ); } diff --git a/apps/Backend/src/routes/documents.ts b/apps/Backend/src/routes/documents.ts index 04ecb85..2793114 100644 --- a/apps/Backend/src/routes/documents.ts +++ b/apps/Backend/src/routes/documents.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { Request, Response } from "express"; import { storage } from "../storage"; import multer from "multer"; +import { PdfFile } from "../../../../packages/db/types/pdf-types"; const upload = multer({ storage: multer.memoryStorage() }); @@ -12,18 +13,17 @@ router.post( "/pdf-groups", async (req: Request, res: Response): Promise => { try { - const { patientId, groupTitle, groupTitleKey, groupCategory } = req.body; - if (!patientId || !groupTitle || groupTitleKey || !groupCategory) { + const { patientId, groupTitle, groupTitleKey } = req.body; + if (!patientId || !groupTitle || groupTitleKey) { return res .status(400) - .json({ error: "Missing title, titleKey, category, or patientId" }); + .json({ error: "Missing title, titleKey, or patientId" }); } const group = await storage.createPdfGroup( parseInt(patientId), groupTitle, - groupTitleKey, - groupCategory + groupTitleKey ); res.json(group); @@ -90,11 +90,10 @@ router.put( return res.status(400).json({ error: "Missing ID" }); } const id = parseInt(idParam); - const { title, category, titleKey } = req.body; + const { title, titleKey } = req.body; const updates: any = {}; updates.title = title; - updates.category = category; updates.titleKey = titleKey; const updated = await storage.updatePdfGroup(id, updates); @@ -168,6 +167,69 @@ router.get( } ); +/** + * GET /pdf-files/group/:groupId + * Query params: + * - limit (optional, defaults to 5): number of items per page (max 1000) + * - offset (optional, defaults to 0): offset for pagination + * + * Response: { total: number, data: PdfFile[] } + */ +router.get( + "/recent-pdf-files/group/:groupId", + async (req: Request, res: Response): Promise => { + try { + const rawGroupId = req.params.groupId; + if (!rawGroupId) { + return res.status(400).json({ error: "Missing groupId param" }); + } + + const groupId = Number(rawGroupId); + if (Number.isNaN(groupId) || groupId <= 0) { + return res.status(400).json({ error: "Invalid groupId" }); + } + + // Parse & sanitize query params + const limitQuery = req.query.limit; + const offsetQuery = req.query.offset; + + const limit = + limitQuery !== undefined + ? Math.min(Math.max(Number(limitQuery), 1), 1000) // 1..1000 + : undefined; // if undefined -> treat as "no pagination" (return all) + const offset = + offsetQuery !== undefined ? Math.max(Number(offsetQuery), 0) : 0; + + // Decide whether client asked for paginated response + const wantsPagination = typeof limit === "number"; + + if (wantsPagination) { + // storage.getPdfFilesByGroupId with pagination should return { total, data } + const result = await storage.getPdfFilesByGroupId(groupId, { + limit, + offset, + withGroup: false, // do not include group relation in listing + }); + + // result should be { total, data }, but handle unexpected shapes defensively + if (Array.isArray(result)) { + // fallback: storage returned full array; compute total + return res.json({ total: result.length, data: result }); + } + + return res.json(result); + } else { + // no limit requested -> return all files for the group + const all = (await storage.getPdfFilesByGroupId(groupId)) as PdfFile[]; + return res.json({ total: all.length, data: all }); + } + } catch (err) { + console.error("GET /pdf-files/group/:groupId error:", err); + return res.status(500).json({ error: "Internal server error" }); + } + } +); + router.get( "/pdf-files/:id", async (req: Request, res: Response): Promise => { diff --git a/apps/Backend/src/routes/insuranceStatus.ts b/apps/Backend/src/routes/insuranceStatus.ts index cd837a3..b5dcd44 100644 --- a/apps/Backend/src/routes/insuranceStatus.ts +++ b/apps/Backend/src/routes/insuranceStatus.ts @@ -60,14 +60,12 @@ router.post( if (result.pdf_path && result.pdf_path.endsWith(".pdf")) { const pdfBuffer = await fs.readFile(result.pdf_path); - const groupTitle = "Insurance Status PDFs"; - const groupTitleKey = "INSURANCE_STATUS_PDFs"; - const groupCategory = "ELIGIBILITY_STATUS"; + const groupTitle = "Eligibility Status PDFs"; + const groupTitleKey = "ELIGIBILITY_STATUS"; - let group = await storage.findPdfGroupByPatientTitleKeyAndCategory( + let group = await storage.findPdfGroupByPatientTitleKey( patient.id, - groupTitleKey, - groupCategory + groupTitleKey ); // Step 2b: Create group if it doesn’t exist @@ -75,8 +73,7 @@ router.post( group = await storage.createPdfGroup( patient.id, groupTitle, - groupTitleKey, - groupCategory + groupTitleKey ); } @@ -220,13 +217,11 @@ router.post( if (pdfBuffer && generatedPdfPath) { const groupTitle = "Insurance Status PDFs"; - const groupTitleKey = "INSURANCE_STATUS_PDFs"; - const groupCategory = "CLAIM_STATUS"; + const groupTitleKey = "CLAIM_STATUS"; - let group = await storage.findPdfGroupByPatientTitleKeyAndCategory( + let group = await storage.findPdfGroupByPatientTitleKey( patient.id, - groupTitleKey, - groupCategory + groupTitleKey ); // Create group if missing @@ -234,8 +229,7 @@ router.post( group = await storage.createPdfGroup( patient.id, groupTitle, - groupTitleKey, - groupCategory + groupTitleKey ); } diff --git a/apps/Backend/src/storage/index.ts b/apps/Backend/src/storage/index.ts index 7ddfaed..2c66068 100644 --- a/apps/Backend/src/storage/index.ts +++ b/apps/Backend/src/storage/index.ts @@ -1,5 +1,5 @@ import { prisma as db } from "@repo/db/client"; -import { PdfCategory, PdfTitle } from "@repo/db/generated/prisma"; +import { PdfTitleKey } from "@repo/db/generated/prisma"; import { Appointment, Claim, @@ -150,7 +150,10 @@ export interface IStorage { pdfData: Buffer ): Promise; getPdfFileById(id: number): Promise; - getPdfFilesByGroupId(groupId: number): Promise; + getPdfFilesByGroupId( + groupId: number, + opts?: { limit?: number; offset?: number; withGroup?: boolean } + ): Promise; getRecentPdfFiles(limit: number, offset: number): Promise; deletePdfFile(id: number): Promise; updatePdfFile( @@ -162,20 +165,18 @@ export interface IStorage { createPdfGroup( patientId: number, title: string, - titleKey: PdfTitle, - category: PdfCategory + titleKey: PdfTitleKey ): Promise; - findPdfGroupByPatientTitleKeyAndCategory( + findPdfGroupByPatientTitleKey( patientId: number, - titleKey: PdfTitle, - category: PdfCategory + titleKey: PdfTitleKey ): Promise; getAllPdfGroups(): Promise; getPdfGroupById(id: number): Promise; getPdfGroupsByPatientId(patientId: number): Promise; updatePdfGroup( id: number, - updates: Partial> + updates: Partial> ): Promise; deletePdfGroup(id: number): Promise; @@ -699,11 +700,70 @@ export const storage: IStorage = { return (await db.pdfFile.findUnique({ where: { id } })) ?? undefined; }, - async getPdfFilesByGroupId(groupId) { - return db.pdfFile.findMany({ - where: { groupId }, - orderBy: { uploadedAt: "desc" }, - }); + /** + * getPdfFilesByGroupId: supports + * - getPdfFilesByGroupId(groupId) => Promise + * - getPdfFilesByGroupId(groupId, { limit, offset }) => Promise<{ total, data }> + * - getPdfFilesByGroupId(groupId, { limit, offset, withGroup: true }) => Promise<{ total, data: PdfFileWithGroup[] }> + */ + async getPdfFilesByGroupId(groupId, opts) { + // if pagination is requested (limit provided) return total + page + const wantsPagination = + !!opts && + (typeof opts.limit === "number" || typeof opts.offset === "number"); + + if (wantsPagination) { + const limit = Math.min(Number(opts?.limit ?? 5), 1000); + const offset = Number(opts?.offset ?? 0); + + if (opts?.withGroup) { + // return total + data with group included + const [total, data] = await Promise.all([ + db.pdfFile.count({ where: { groupId } }), + db.pdfFile.findMany({ + where: { groupId }, + orderBy: { uploadedAt: "desc" }, + take: limit, + skip: offset, + include: { group: true }, // only include + }), + ]); + + return { total, data }; + } else { + // return total + data with limited fields via select + const [total, data] = await Promise.all([ + db.pdfFile.count({ where: { groupId } }), + db.pdfFile.findMany({ + where: { groupId }, + orderBy: { uploadedAt: "desc" }, + take: limit, + skip: offset, + select: { id: true, filename: true, uploadedAt: true }, // only select + }), + ]); + + // Note: selected shape won't have all PdfFile fields; cast if needed + return { total, data: data as unknown as PdfFile[] }; + } + } + + // non-paginated: return all files (keep descending order) + if (opts?.withGroup) { + const all = await db.pdfFile.findMany({ + where: { groupId }, + orderBy: { uploadedAt: "desc" }, + include: { group: true }, + }); + return all as PdfFile[]; + } else { + const all = await db.pdfFile.findMany({ + where: { groupId }, + orderBy: { uploadedAt: "desc" }, + // no select or include -> returns full PdfFile + }); + return all as PdfFile[]; + } }, async getRecentPdfFiles(limit: number, offset: number): Promise { @@ -739,28 +799,22 @@ export const storage: IStorage = { // PdfGroup CRUD // ---------------------- - async createPdfGroup(patientId, title, titleKey, category) { + async createPdfGroup(patientId, title, titleKey) { return db.pdfGroup.create({ data: { patientId, title, titleKey, - category, }, }); }, - async findPdfGroupByPatientTitleKeyAndCategory( - patientId, - titleKey, - category - ) { + async findPdfGroupByPatientTitleKey(patientId, titleKey) { return ( (await db.pdfGroup.findFirst({ where: { patientId, titleKey, - category, }, })) ?? undefined ); diff --git a/apps/Frontend/src/pages/documents-page.tsx b/apps/Frontend/src/pages/documents-page.tsx index 40d82a0..0198789 100644 --- a/apps/Frontend/src/pages/documents-page.tsx +++ b/apps/Frontend/src/pages/documents-page.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { Card, @@ -16,23 +16,34 @@ import { PatientTable } from "@/components/patients/patient-table"; import { Patient, PdfFile } from "@repo/db/types"; export default function DocumentsPage() { - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [selectedPatient, setSelectedPatient] = useState(null); - const [selectedGroupId, setSelectedGroupId] = useState(null); - const [selectedPdfId, setSelectedPdfId] = useState(null); + const [expandedGroupId, setExpandedGroupId] = useState(null); + + // pagination state for the expanded group + const [limit, setLimit] = useState(5); + const [offset, setOffset] = useState(0); + const [totalForExpandedGroup, setTotalForExpandedGroup] = useState< + number | null + >(null); + + // PDF view state - viewing / downloading const [fileBlobUrl, setFileBlobUrl] = useState(null); - const [isDeletePdfOpen, setIsDeletePdfOpen] = useState(false); const [currentPdf, setCurrentPdf] = useState(null); - const toggleMobileMenu = () => setIsMobileMenuOpen((prev) => !prev); + // Delete dialog + const [isDeletePdfOpen, setIsDeletePdfOpen] = useState(false); + // reset UI when patient changes useEffect(() => { - setSelectedGroupId(null); + setExpandedGroupId(null); + setLimit(5); + setOffset(0); + setTotalForExpandedGroup(null); setFileBlobUrl(null); - setSelectedPdfId(null); }, [selectedPatient]); - const { data: groups = [] } = useQuery({ + // FETCH GROUPS for patient (includes `category` on each group) + const { data: groups = [], isLoading: isLoadingGroups } = useQuery({ queryKey: ["groups", selectedPatient?.id], enabled: !!selectedPatient, queryFn: async () => { @@ -44,18 +55,59 @@ export default function DocumentsPage() { }, }); - const { data: groupPdfs = [] } = useQuery({ - queryKey: ["groupPdfs", selectedGroupId], - enabled: !!selectedGroupId, + // Group groups by titleKey (use titleKey only for grouping/ordering; don't display it) + const groupsByTitleKey = useMemo(() => { + const map = new Map(); + for (const g of groups as any[]) { + const key = String(g.titleKey ?? "OTHER"); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(g); + } + + // Decide on a stable order for titleKey buckets: prefer enum order then any extras + const preferredOrder = [ + "INSURANCE_CLAIM", + "ELIGIBILITY_STATUS", + "CLAIM_STATUS", + "OTHER", + ]; + const orderedKeys: string[] = []; + + for (const k of preferredOrder) { + if (map.has(k)) orderedKeys.push(k); + } + // append any keys that weren't in preferredOrder + for (const k of map.keys()) { + if (!orderedKeys.includes(k)) orderedKeys.push(k); + } + + return { map, orderedKeys }; + }, [groups]); + + // FETCH PDFs for selected group with pagination (limit & offset) + const { + data: groupPdfsResponse, + refetch: refetchGroupPdfs, + isFetching: isFetchingPdfs, + } = useQuery({ + queryKey: ["groupPdfs", expandedGroupId, limit, offset], + enabled: !!expandedGroupId, queryFn: async () => { + // API should accept ?limit & ?offset and also return total count const res = await apiRequest( "GET", - `/api/documents/pdf-files/group/${selectedGroupId}` + `/api/documents/recent-pdf-files/group/${expandedGroupId}?limit=${limit}&offset=${offset}` ); - return res.json(); + // expected shape: { data: PdfFile[], total: number } + const json = await res.json(); + setTotalForExpandedGroup(json.total ?? null); + return json.data ?? []; }, }); + const groupPdfs: PdfFile[] = groupPdfsResponse ?? []; + + // DELETE mutation const deletePdfMutation = useMutation({ mutationFn: async (id: number) => { await apiRequest("DELETE", `/api/documents/pdf-files/${id}`); @@ -63,9 +115,9 @@ export default function DocumentsPage() { onSuccess: () => { setIsDeletePdfOpen(false); setCurrentPdf(null); - if (selectedGroupId != null) { + if (expandedGroupId != null) { queryClient.invalidateQueries({ - queryKey: ["groupPdfs", selectedGroupId], + queryKey: ["groupPdfs", expandedGroupId], }); } toast({ title: "Success", description: "PDF deleted successfully!" }); @@ -97,7 +149,6 @@ export default function DocumentsPage() { const blob = new Blob([arrayBuffer], { type: "application/pdf" }); const url = URL.createObjectURL(blob); setFileBlobUrl(url); - setSelectedPdfId(pdfId); }; const handleDownloadPdf = async (pdfId: number, filename: string) => { @@ -114,6 +165,25 @@ export default function DocumentsPage() { URL.revokeObjectURL(url); }; + // Expand / collapse a group — when expanding reset pagination + const toggleExpandGroup = (groupId: number) => { + if (expandedGroupId === groupId) { + setExpandedGroupId(null); + setOffset(0); + setLimit(5); + setTotalForExpandedGroup(null); + } else { + setExpandedGroupId(groupId); + setOffset(0); + setLimit(5); + setTotalForExpandedGroup(null); + } + }; + + const handleLoadMore = () => { + setOffset((prev) => prev + limit); + }; + return (
@@ -130,85 +200,148 @@ export default function DocumentsPage() { - Document Groups for {selectedPatient.firstName}{" "} + Document groups of Patient: {selectedPatient.firstName}{" "} {selectedPatient.lastName} - Select a group to view PDFs - - {groups.length === 0 ? ( -

- No groups found for this patient. -

- ) : ( -
- {groups.map((group: any) => ( - - ))} -
- )} -
-
- )} - {selectedGroupId && ( - - - PDFs in Group - - - {groupPdfs.length === 0 ? ( -

- No PDFs found in this group. -

+ + {isLoadingGroups ? ( +
Loading groups…
+ ) : (groups as any[]).length === 0 ? ( +
+ No groups found. +
) : ( - groupPdfs.map((pdf: any) => ( -
- {pdf.filename} -
- - - -
-
- )) +
+ {groupsByTitleKey.orderedKeys.map((key, idx) => { + const bucket = groupsByTitleKey.map.get(key) ?? []; + return ( +
+ {/* subtle divider between buckets (no enum text shown) */} + {idx !== 0 && ( +
+ )} + +
+ {bucket.map((group: any) => ( +
+
+
+ +
+ {/* Only show the group's title string */} +
+ {group.title} +
+
+
+ +
+ +
+
+ + {/* expanded content: show paginated PDFs for this group */} + {expandedGroupId === group.id && ( +
+ {isFetchingPdfs ? ( +
Loading PDFs…
+ ) : groupPdfs.length === 0 ? ( +
+ No PDFs in this group. +
+ ) : ( +
+ {groupPdfs.map((pdf) => ( +
+
+ {pdf.filename} +
+
+ + + +
+
+ ))} + + {/* pagination controls */} +
+ {totalForExpandedGroup !== null && + totalForExpandedGroup > + offset + limit && ( + + )} + +
+ Showing{" "} + {Math.min( + offset + limit, + totalForExpandedGroup ?? 0 + )}{" "} + of {totalForExpandedGroup ?? "?"} +
+
+
+ )} +
+ )} +
+ ))} +
+
+ ); + })} +
)}
@@ -223,7 +356,6 @@ export default function DocumentsPage() { className="ml-auto text-red-600 border-red-500 hover:bg-red-100 hover:border-red-600" onClick={() => { setFileBlobUrl(null); - setSelectedPdfId(null); }} > ✕ Close diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 3357e3c..f1e8fd9 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -186,12 +186,12 @@ model InsuranceCredential { } model PdfGroup { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) title String - titleKey PdfTitleKey? - createdAt DateTime @default(now()) + titleKey PdfTitleKey @default(OTHER) + createdAt DateTime @default(now()) patientId Int - patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) pdfs PdfFile[] @@index([patientId]) diff --git a/packages/db/types/pdf-types.ts b/packages/db/types/pdf-types.ts index 02f4536..38f5d17 100644 --- a/packages/db/types/pdf-types.ts +++ b/packages/db/types/pdf-types.ts @@ -1,9 +1,8 @@ -import { PdfCategorySchema, PdfFileUncheckedCreateInputObjectSchema, PdfGroupUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas"; +import { PdfFileUncheckedCreateInputObjectSchema, PdfGroupUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas"; import {z} from "zod"; export type PdfGroup = z.infer; export type PdfFile = z.infer; -export type PdfCategory = z.infer; export interface ClaimPdfMetadata { id: number; diff --git a/packages/db/usedSchemas/index.ts b/packages/db/usedSchemas/index.ts index 4de9d82..90421b6 100644 --- a/packages/db/usedSchemas/index.ts +++ b/packages/db/usedSchemas/index.ts @@ -7,7 +7,6 @@ export * from '../shared/schemas/objects/ClaimUncheckedCreateInput.schema' export * from '../shared/schemas/objects/InsuranceCredentialUncheckedCreateInput.schema' export * from '../shared/schemas/objects/PdfFileUncheckedCreateInput.schema' export * from '../shared/schemas/objects/PdfGroupUncheckedCreateInput.schema' -export * from '../shared/schemas/enums/PdfCategory.schema' export * from '../shared/schemas/enums/ClaimStatus.schema' export * from '../shared/schemas/objects/PaymentUncheckedCreateInput.schema' export * from '../shared/schemas/objects/ServiceLineTransactionCreateInput.schema'