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:
@@ -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" });
|
||||||
|
|||||||
@@ -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));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user