feat(patient - financial tabular view) - added
This commit is contained in:
@@ -131,6 +131,38 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/patients/:id/financials?limit=50&offset=0
|
||||
router.get(
|
||||
"/:id/financials",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const patientIdParam = req.params.id;
|
||||
if (!patientIdParam)
|
||||
return res.status(400).json({ message: "Patient ID required" });
|
||||
|
||||
const patientId = parseInt(patientIdParam, 10);
|
||||
if (isNaN(patientId))
|
||||
return res.status(400).json({ message: "Invalid patient ID" });
|
||||
|
||||
const limit = Math.min(1000, Number(req.query.limit ?? 50)); // cap maximums
|
||||
const offset = Math.max(0, Number(req.query.offset ?? 0));
|
||||
|
||||
const { rows, totalCount } = await storage.getPatientFinancialRows(
|
||||
patientId,
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
|
||||
return res.json({ rows, totalCount, limit, offset });
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch financial rows:", err);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to fetch financial rows" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get a single patient by ID
|
||||
router.get(
|
||||
"/:id",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { InsertPatient, Patient, UpdatePatient } from "@repo/db/types";
|
||||
import {
|
||||
FinancialRow,
|
||||
InsertPatient,
|
||||
Patient,
|
||||
UpdatePatient,
|
||||
} from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IStorage {
|
||||
@@ -30,6 +35,11 @@ export interface IStorage {
|
||||
>;
|
||||
getTotalPatientCount(): Promise<number>;
|
||||
countPatients(filters: any): Promise<number>; // optional but useful
|
||||
getPatientFinancialRows(
|
||||
patientId: number,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<{ rows: any[]; totalCount: number }>;
|
||||
}
|
||||
|
||||
export const patientsStorage: IStorage = {
|
||||
@@ -138,4 +148,158 @@ export const patientsStorage: IStorage = {
|
||||
async countPatients(filters: any) {
|
||||
return db.patient.count({ where: filters });
|
||||
},
|
||||
|
||||
async getPatientFinancialRows(patientId: number, limit = 50, offset = 0) {
|
||||
return getPatientFinancialRowsFn(patientId, limit, offset);
|
||||
},
|
||||
};
|
||||
|
||||
export const getPatientFinancialRowsFn = async (
|
||||
patientId: number,
|
||||
limit = 50,
|
||||
offset = 0
|
||||
): Promise<{ rows: FinancialRow[]; totalCount: number }> => {
|
||||
try {
|
||||
// counts
|
||||
const [[{ count_claims }], [{ count_payments_without_claim }]] =
|
||||
(await Promise.all([
|
||||
db.$queryRaw`SELECT COUNT(1) AS count_claims FROM "Claim" c WHERE c."patientId" = ${patientId}`,
|
||||
db.$queryRaw`SELECT COUNT(1) AS count_payments_without_claim FROM "Payment" p WHERE p."patientId" = ${patientId} AND p."claimId" IS NULL`,
|
||||
])) as any;
|
||||
|
||||
const totalCount =
|
||||
Number(count_claims ?? 0) + Number(count_payments_without_claim ?? 0);
|
||||
|
||||
const rawRows = (await db.$queryRaw`
|
||||
WITH claim_rows AS (
|
||||
SELECT
|
||||
'CLAIM'::text AS type,
|
||||
c.id,
|
||||
COALESCE(c."serviceDate", c."createdAt")::timestamptz AS date,
|
||||
c."createdAt"::timestamptz AS created_at,
|
||||
c.status::text AS status,
|
||||
COALESCE(sum(sl."totalBilled")::numeric::text, '0') AS total_billed,
|
||||
COALESCE(sum(sl."totalPaid")::numeric::text, '0') AS total_paid,
|
||||
COALESCE(sum(sl."totalAdjusted")::numeric::text, '0') AS total_adjusted,
|
||||
COALESCE(sum(sl."totalDue")::numeric::text, '0') AS total_due,
|
||||
(
|
||||
SELECT (pat."firstName" || ' ' || pat."lastName") FROM "Patient" pat WHERE pat.id = c."patientId" LIMIT 1
|
||||
) AS patient_name,
|
||||
(
|
||||
SELECT coalesce(json_agg(
|
||||
json_build_object(
|
||||
'id', sl2.id,
|
||||
'procedureCode', sl2."procedureCode",
|
||||
'procedureDate', sl2."procedureDate",
|
||||
'toothNumber', sl2."toothNumber",
|
||||
'toothSurface', sl2."toothSurface",
|
||||
'totalBilled', sl2."totalBilled",
|
||||
'totalPaid', sl2."totalPaid",
|
||||
'totalAdjusted', sl2."totalAdjusted",
|
||||
'totalDue', sl2."totalDue",
|
||||
'status', sl2.status
|
||||
)
|
||||
), '[]'::json)
|
||||
FROM "ServiceLine" sl2 WHERE sl2."claimId" = c.id
|
||||
) AS service_lines,
|
||||
(
|
||||
SELECT coalesce(json_agg(
|
||||
json_build_object(
|
||||
'id', p2.id,
|
||||
'totalBilled', p2."totalBilled",
|
||||
'totalPaid', p2."totalPaid",
|
||||
'totalAdjusted', p2."totalAdjusted",
|
||||
'totalDue', p2."totalDue",
|
||||
'status', p2.status::text,
|
||||
'createdAt', p2."createdAt",
|
||||
'icn', p2.icn,
|
||||
'notes', p2.notes
|
||||
)
|
||||
), '[]'::json)
|
||||
FROM "Payment" p2 WHERE p2."claimId" = c.id
|
||||
) AS payments
|
||||
FROM "Claim" c
|
||||
LEFT JOIN "ServiceLine" sl ON sl."claimId" = c.id
|
||||
WHERE c."patientId" = ${patientId}
|
||||
GROUP BY c.id
|
||||
),
|
||||
payment_rows AS (
|
||||
SELECT
|
||||
'PAYMENT'::text AS type,
|
||||
p.id,
|
||||
p."createdAt"::timestamptz AS date,
|
||||
p."createdAt"::timestamptz AS created_at,
|
||||
p.status::text AS status,
|
||||
p."totalBilled"::numeric::text AS total_billed,
|
||||
p."totalPaid"::numeric::text AS total_paid,
|
||||
p."totalAdjusted"::numeric::text AS total_adjusted,
|
||||
p."totalDue"::numeric::text AS total_due,
|
||||
(
|
||||
SELECT (pat."firstName" || ' ' || pat."lastName") FROM "Patient" pat WHERE pat.id = p."patientId" LIMIT 1
|
||||
) AS patient_name,
|
||||
-- aggregate service lines that belong to this payment (if any)
|
||||
(
|
||||
SELECT coalesce(json_agg(
|
||||
json_build_object(
|
||||
'id', sl3.id,
|
||||
'procedureCode', sl3."procedureCode",
|
||||
'procedureDate', sl3."procedureDate",
|
||||
'toothNumber', sl3."toothNumber",
|
||||
'toothSurface', sl3."toothSurface",
|
||||
'totalBilled', sl3."totalBilled",
|
||||
'totalPaid', sl3."totalPaid",
|
||||
'totalAdjusted', sl3."totalAdjusted",
|
||||
'totalDue', sl3."totalDue",
|
||||
'status', sl3.status
|
||||
)
|
||||
), '[]'::json)
|
||||
FROM "ServiceLine" sl3 WHERE sl3."paymentId" = p.id
|
||||
) AS service_lines,
|
||||
json_build_array(
|
||||
json_build_object(
|
||||
'id', p.id,
|
||||
'totalBilled', p."totalBilled",
|
||||
'totalPaid', p."totalPaid",
|
||||
'totalAdjusted', p."totalAdjusted",
|
||||
'totalDue', p."totalDue",
|
||||
'status', p.status::text,
|
||||
'createdAt', p."createdAt",
|
||||
'icn', p.icn,
|
||||
'notes', p.notes
|
||||
)
|
||||
) AS payments
|
||||
FROM "Payment" p
|
||||
WHERE p."patientId" = ${patientId} AND p."claimId" IS NULL
|
||||
)
|
||||
SELECT type, id, date, created_at, status, total_billed, total_paid, total_adjusted, total_due, patient_name, service_lines, payments
|
||||
FROM (
|
||||
SELECT * FROM claim_rows
|
||||
UNION ALL
|
||||
SELECT * FROM payment_rows
|
||||
) t
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`) as any[];
|
||||
|
||||
// map to expected JS shape; convert totals to numbers
|
||||
const rows: FinancialRow[] = rawRows.map((r: any) => ({
|
||||
type: r.type,
|
||||
id: Number(r.id),
|
||||
date: r.date ? r.date.toString() : null,
|
||||
createdAt: r.created_at ? r.created_at.toString() : null,
|
||||
status: r.status ?? null,
|
||||
total_billed: Number(r.total_billed ?? 0),
|
||||
total_paid: Number(r.total_paid ?? 0),
|
||||
total_adjusted: Number(r.total_adjusted ?? 0),
|
||||
total_due: Number(r.total_due ?? 0),
|
||||
patient_name: r.patient_name ?? null,
|
||||
service_lines: r.service_lines ?? [],
|
||||
payments: r.payments ?? [],
|
||||
}));
|
||||
|
||||
return { rows, totalCount };
|
||||
} catch (err) {
|
||||
console.error("getPatientFinancialRowsFn error:", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user