feat: appointment card colors by status, MassHealth badge, date nav controls

- Default card color: light (bg-slate-100 / dark text)
- Blue card when procedures selected, gray when claim has a number
- Status badge: green/red from appointment or patient-level MassHealth status
- Solid dot badge (removed ring), overflow-visible to prevent corner clipping
- Date nav: << < [today circle] > >> for week/day jumps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-04-28 10:09:08 -04:00
parent dfa04981c5
commit efe73410e7
5 changed files with 100 additions and 8 deletions

View File

@@ -56,7 +56,20 @@ router.get("/day", async (req: Request, res: Response): Promise<any> => {
? await storage.getPatientsByIds(patientIds) ? await storage.getPatientsByIds(patientIds)
: []; : [];
return res.json({ appointments, patients }); // Enrich each appointment with procedure / claim status flags
const appointmentIds = appointments.map((a) => a.id).filter((id): id is number => id != null);
const [idsWithProcedures, idsWithClaimNumbers] = await Promise.all([
storage.getAppointmentIdsWithProcedures(appointmentIds),
storage.getAppointmentIdsWithClaimNumbers(appointmentIds),
]);
const enrichedAppointments = appointments.map((a) => ({
...a,
hasProcedures: a.id != null && idsWithProcedures.has(a.id),
hasClaimWithNumber: a.id != null && idsWithClaimNumbers.has(a.id),
}));
return res.json({ appointments: enrichedAppointments, patients });
} catch (err) { } catch (err) {
console.error("Error in /api/appointments/day:", err); console.error("Error in /api/appointments/day:", err);
res.status(500).json({ message: "Failed to load appointments for date" }); res.status(500).json({ message: "Failed to load appointments for date" });

View File

@@ -37,6 +37,7 @@ export interface IAppointmentProceduresStorage {
): Promise<AppointmentProcedure>; ): Promise<AppointmentProcedure>;
deleteProcedure(id: number): Promise<void>; deleteProcedure(id: number): Promise<void>;
clearByAppointmentId(appointmentId: number): Promise<void>; clearByAppointmentId(appointmentId: number): Promise<void>;
getAppointmentIdsWithProcedures(ids: number[]): Promise<Set<number>>;
} }
export const appointmentProceduresStorage: IAppointmentProceduresStorage = { export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
@@ -130,4 +131,14 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
where: { appointmentId }, where: { appointmentId },
}); });
}, },
async getAppointmentIdsWithProcedures(ids: number[]): Promise<Set<number>> {
if (!ids.length) return new Set();
const rows = await db.appointmentProcedure.findMany({
where: { appointmentId: { in: ids } },
select: { appointmentId: true },
distinct: ["appointmentId"],
});
return new Set(rows.map((r) => r.appointmentId));
},
}; };

View File

@@ -23,6 +23,7 @@ export interface IStorage {
updateClaim(id: number, updates: UpdateClaim): Promise<Claim>; updateClaim(id: number, updates: UpdateClaim): Promise<Claim>;
updateClaimStatus(id: number, status: ClaimStatus): Promise<Claim>; updateClaimStatus(id: number, status: ClaimStatus): Promise<Claim>;
deleteClaim(id: number): Promise<void>; deleteClaim(id: number): Promise<void>;
getAppointmentIdsWithClaimNumbers(ids: number[]): Promise<Set<number>>;
} }
export const claimsStorage: IStorage = { export const claimsStorage: IStorage = {
@@ -120,4 +121,14 @@ export const claimsStorage: IStorage = {
throw new Error(`Claim with ID ${id} not found`); throw new Error(`Claim with ID ${id} not found`);
} }
}, },
async getAppointmentIdsWithClaimNumbers(ids: number[]): Promise<Set<number>> {
if (!ids.length) return new Set();
const rows = await db.claim.findMany({
where: { appointmentId: { in: ids }, claimNumber: { not: null } },
select: { appointmentId: true },
distinct: ["appointmentId"],
});
return new Set(rows.map((r) => r.appointmentId));
},
}; };

View File

@@ -24,7 +24,7 @@ export function PatientStatusBadge({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span <span
aria-label={`Patient status: ${label}`} aria-label={`Patient status: ${label}`}
className={`inline-block rounded-full ring-2 ring-white shadow ${className}`} className={`inline-block rounded-full shadow ${className}`}
style={{ style={{
width: size, width: size,
height: size, height: size,

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { addDays, startOfToday, addMinutes } from "date-fns"; import { addDays, addWeeks, startOfToday, addMinutes } from "date-fns";
import { import {
parseLocalDate, parseLocalDate,
formatLocalDate, formatLocalDate,
@@ -14,6 +14,8 @@ import {
Plus, Plus,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ChevronsLeft,
ChevronsRight,
Move, Move,
Trash2, Trash2,
CreditCard, CreditCard,
@@ -75,6 +77,10 @@ interface ScheduledAppointment {
patientId: number; patientId: number;
patientName: string; patientName: string;
eligibilityStatus: PatientStatus; eligibilityStatus: PatientStatus;
patientStatus: PatientStatus;
patientInsuranceProvider: string | null;
hasProcedures: boolean;
hasClaimWithNumber: boolean;
staffId: number; staffId: number;
date: string | Date; date: string | Date;
startTime: string | Date; startTime: string | Date;
@@ -83,6 +89,23 @@ interface ScheduledAppointment {
type: string; type: string;
} }
function appointmentCardColor(apt: ScheduledAppointment): string {
if (apt.hasClaimWithNumber) return "bg-gray-500 text-white";
if (apt.hasProcedures) return "bg-blue-500 text-white";
return "bg-slate-100 text-gray-700 border-slate-300";
}
function resolveAppointmentBadgeStatus(apt: ScheduledAppointment): PatientStatus {
if (apt.eligibilityStatus === "ACTIVE") return "ACTIVE";
if (apt.eligibilityStatus === "INACTIVE") return "INACTIVE";
const isMassHealth = apt.patientInsuranceProvider?.toLowerCase().includes("masshealth");
if (apt.eligibilityStatus === "UNKNOWN" && isMassHealth) {
if (apt.patientStatus === "ACTIVE") return "ACTIVE";
if (apt.patientStatus === "INACTIVE") return "INACTIVE";
}
return apt.eligibilityStatus ?? "UNKNOWN";
}
// Define a unique ID for the appointment context menu // Define a unique ID for the appointment context menu
const APPOINTMENT_CONTEXT_MENU_ID = "appointment-context-menu"; const APPOINTMENT_CONTEXT_MENU_ID = "appointment-context-menu";
@@ -454,6 +477,8 @@ export default function AppointmentsPage() {
: "Unknown Patient"; : "Unknown Patient";
const eligibilityStatus = (apt as any).eligibilityStatus as PatientStatus; const eligibilityStatus = (apt as any).eligibilityStatus as PatientStatus;
const patientStatus = (patient as any)?.status as PatientStatus ?? "UNKNOWN";
const patientInsuranceProvider = (patient as any)?.insuranceProvider as string | null ?? null;
const staffId = Number(apt.staffId ?? 1); const staffId = Number(apt.staffId ?? 1);
@@ -470,6 +495,10 @@ export default function AppointmentsPage() {
...apt, ...apt,
patientName, patientName,
eligibilityStatus, eligibilityStatus,
patientStatus,
patientInsuranceProvider,
hasProcedures: !!(apt as any).hasProcedures,
hasClaimWithNumber: !!(apt as any).hasClaimWithNumber,
staffId, staffId,
status: apt.status ?? null, status: apt.status ?? null,
date: formatLocalDate(apt.date), date: formatLocalDate(apt.date),
@@ -649,7 +678,7 @@ export default function AppointmentsPage() {
return ( return (
<div <div
ref={drag as unknown as React.RefObject<HTMLDivElement>} // Type assertion to make TypeScript happy ref={drag as unknown as React.RefObject<HTMLDivElement>} // Type assertion to make TypeScript happy
className={`${staff.color} border border-white shadow-md text-white rounded p-1 text-xs h-full overflow-hidden cursor-move relative ${ className={`${appointmentCardColor(appointment)} border shadow-md rounded p-1 text-xs h-full overflow-visible cursor-move relative ${
isDragging ? "opacity-50" : "opacity-100" isDragging ? "opacity-50" : "opacity-100"
}`} }`}
style={{ fontWeight: 500 }} style={{ fontWeight: 500 }}
@@ -668,7 +697,7 @@ export default function AppointmentsPage() {
onContextMenu={(e) => handleContextMenu(e, appointment.id ?? 0)} onContextMenu={(e) => handleContextMenu(e, appointment.id ?? 0)}
> >
<PatientStatusBadge <PatientStatusBadge
status={appointment.eligibilityStatus ?? "UNKNOWN"} status={resolveAppointmentBadgeStatus(appointment)}
className="pointer-events-auto" // ensure tooltip works className="pointer-events-auto" // ensure tooltip works
size={30} // bump size up from 10 → 14 size={30} // bump size up from 10 → 14
/> />
@@ -1247,24 +1276,52 @@ export default function AppointmentsPage() {
<div className="w-full overflow-x-auto bg-white rounded-md shadow"> <div className="w-full overflow-x-auto bg-white rounded-md shadow">
<div className="p-4 border-b"> <div className="p-4 border-b">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-1">
{/* << prev week */}
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
title="Previous week"
onClick={() => setSelectedDate(addWeeks(selectedDate, -1))}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
{/* < prev day */}
<Button
variant="outline"
size="icon"
title="Previous day"
onClick={() => setSelectedDate(addDays(selectedDate, -1))} onClick={() => setSelectedDate(addDays(selectedDate, -1))}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<h2 className="text-xl font-semibold"> {/* today circle */}
<Button
variant="outline"
title="Go to today"
onClick={() => setSelectedDate(startOfToday())}
className="rounded-full w-auto px-3 font-semibold text-sm"
>
{formattedSelectedDate} {formattedSelectedDate}
</h2> </Button>
{/* > next day */}
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
title="Next day"
onClick={() => setSelectedDate(addDays(selectedDate, 1))} onClick={() => setSelectedDate(addDays(selectedDate, 1))}
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
{/* >> next week */}
<Button
variant="outline"
size="icon"
title="Next week"
onClick={() => setSelectedDate(addWeeks(selectedDate, 1))}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div> </div>
{/* Top button with popover calendar */} {/* Top button with popover calendar */}