From 8fcf7f9784250cadcf85167ad4b11a4ecd91d47d Mon Sep 17 00:00:00 2001 From: Anubhuti Sharma Date: Tue, 4 Aug 2026 23:13:01 +0530 Subject: [PATCH 001/412] feat(ui): add equipment lifecycle and replacement risk predictor (#2322) --- .../EquipmentLifecyclePredictor.test.jsx | 50 ++ src/pages/hospital/Dashboard.jsx | 3 + .../hospital/EquipmentLifecyclePredictor.jsx | 496 ++++++++++++++++++ src/routes/routeRegistry.js | 2 + 4 files changed, 551 insertions(+) create mode 100644 src/__tests__/pages/hospital/EquipmentLifecyclePredictor.test.jsx create mode 100644 src/pages/hospital/EquipmentLifecyclePredictor.jsx diff --git a/src/__tests__/pages/hospital/EquipmentLifecyclePredictor.test.jsx b/src/__tests__/pages/hospital/EquipmentLifecyclePredictor.test.jsx new file mode 100644 index 00000000..343622ba --- /dev/null +++ b/src/__tests__/pages/hospital/EquipmentLifecyclePredictor.test.jsx @@ -0,0 +1,50 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, beforeEach } from "vitest"; +import { renderWithProviders } from "../../utils/renderWithProviders"; +import EquipmentLifecyclePredictor from "../../../pages/hospital/EquipmentLifecyclePredictor"; + +describe("EquipmentLifecyclePredictor Component", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it("renders header and main KPI cards", () => { + renderWithProviders( {}} />); + + expect(screen.getByText("Equipment Lifecycle & Replacement Risk Predictor")).toBeInTheDocument(); + expect(screen.getByText("Total Fleet Capital Value")).toBeInTheDocument(); + expect(screen.getByText("Critical EOL Risk Count")).toBeInTheDocument(); + expect(screen.getByText("3-Year Capital Replacement")).toBeInTheDocument(); + expect(screen.getByText("Avg Fleet Risk Score")).toBeInTheDocument(); + }); + + it("filters fleet items by search input", () => { + renderWithProviders( {}} />); + + const searchInput = screen.getByPlaceholderText(/Search by equipment name/i); + fireEvent.change(searchInput, { target: { value: "Siemens Somatom" } }); + + expect(screen.getByText("Siemens Somatom CT Scanner 64")).toBeInTheDocument(); + expect(screen.queryByText("GE Signa Pioneer MRI 3.0T")).not.toBeInTheDocument(); + }); + + it("filters fleet items by risk level status buttons", () => { + renderWithProviders( {}} />); + + const criticalBtn = screen.getByText("Critical EOL Risk"); + fireEvent.click(criticalBtn); + + expect(screen.getByText("Siemens Somatom CT Scanner 64")).toBeInTheDocument(); + expect(screen.queryByText("Philips Azurion Cardiac Cath Lab")).not.toBeInTheDocument(); + }); + + it("opens asset modal when Inspect EOL button is clicked", () => { + renderWithProviders( {}} />); + + const inspectButtons = screen.getAllByText(/Inspect EOL/i); + fireEvent.click(inspectButtons[0]); + + expect(screen.getByText("Siemens Somatom CT Scanner 64")).toBeInTheDocument(); + expect(screen.getByText("Queue Capital Request")).toBeInTheDocument(); + }); +}); diff --git a/src/pages/hospital/Dashboard.jsx b/src/pages/hospital/Dashboard.jsx index f435d6fe..92cf8509 100644 --- a/src/pages/hospital/Dashboard.jsx +++ b/src/pages/hospital/Dashboard.jsx @@ -100,6 +100,9 @@ export default function Dashboard({ onNavigate }) { + diff --git a/src/pages/hospital/EquipmentLifecyclePredictor.jsx b/src/pages/hospital/EquipmentLifecyclePredictor.jsx new file mode 100644 index 00000000..de11322f --- /dev/null +++ b/src/pages/hospital/EquipmentLifecyclePredictor.jsx @@ -0,0 +1,496 @@ +import React, { useState, useMemo } from 'react'; +import { + TrendingDown, AlertTriangle, ShieldAlert, DollarSign, Calendar, + Activity, Search, Filter, RefreshCw, ArrowUpRight, BarChart2, + Sliders, ShieldCheck, Clock, CheckCircle2, ChevronRight, Download, Cpu +} from 'lucide-react'; +import { useAuth } from '../../context/AuthContext'; + +const INITIAL_FLEET_DATA = [ + { + id: "EQ-1001", + name: "Siemens Somatom CT Scanner 64", + department: "Radiology", + purchaseDate: "2016-04-12", + purchaseCost: 750000, + expectedLifespanYears: 10, + currentAgeYears: 10.3, + accumulatedMaintenanceCost: 285000, + failureIncidentsCount: 14, + riskLevel: "Critical EOL Risk", + riskScore: 92, + recommendedAction: "Immediate Capital Replacement", + tcoRatio: 1.38, + }, + { + id: "EQ-1004", + name: "GE Signa Pioneer MRI 3.0T", + department: "Radiology", + purchaseDate: "2018-09-20", + purchaseCost: 1200000, + expectedLifespanYears: 12, + currentAgeYears: 7.9, + accumulatedMaintenanceCost: 310000, + failureIncidentsCount: 6, + riskLevel: "Moderate Risk", + riskScore: 58, + recommendedAction: "Schedule Component Refurbishment", + tcoRatio: 1.25, + }, + { + id: "EQ-1009", + name: "Puritan Bennett 980 Ventilator Fleet (x5)", + department: "ICU / Critical Care", + purchaseDate: "2017-02-15", + purchaseCost: 180000, + expectedLifespanYears: 8, + currentAgeYears: 9.5, + accumulatedMaintenanceCost: 115000, + failureIncidentsCount: 21, + riskLevel: "Critical EOL Risk", + riskScore: 88, + recommendedAction: "Planned Replacement Q1 2027", + tcoRatio: 1.64, + }, + { + id: "EQ-1012", + name: "Philips Azurion Cardiac Cath Lab", + department: "Cardiology", + purchaseDate: "2021-11-05", + purchaseCost: 950000, + expectedLifespanYears: 10, + currentAgeYears: 4.7, + accumulatedMaintenanceCost: 82000, + failureIncidentsCount: 2, + riskLevel: "Healthy Fleet", + riskScore: 18, + recommendedAction: "Routine Preventive Maintenance", + tcoRatio: 1.08, + }, + { + id: "EQ-1018", + name: "Stryker System 8 Surgical Power Tools", + department: "Surgical Suite", + purchaseDate: "2019-06-30", + purchaseCost: 140000, + expectedLifespanYears: 7, + currentAgeYears: 7.1, + accumulatedMaintenanceCost: 78000, + failureIncidentsCount: 9, + riskLevel: "Critical EOL Risk", + riskScore: 84, + recommendedAction: "Initiate Trade-in Procurement", + tcoRatio: 1.55, + }, + { + id: "EQ-1025", + name: "Mindray BeneVision N22 Patient Monitors", + department: "Emergency", + purchaseDate: "2022-03-14", + purchaseCost: 210000, + expectedLifespanYears: 8, + currentAgeYears: 4.4, + accumulatedMaintenanceCost: 24000, + failureIncidentsCount: 1, + riskLevel: "Healthy Fleet", + riskScore: 22, + recommendedAction: "Routine Calibration", + tcoRatio: 1.11, + } +]; + +export default function EquipmentLifecyclePredictor({ onNavigate }) { + const { user } = useAuth(); + const [fleet, setFleet] = useState(INITIAL_FLEET_DATA); + const [searchQuery, setSearchQuery] = useState(""); + const [riskFilter, setRiskFilter] = useState("ALL"); + const [deptFilter, setDeptFilter] = useState("ALL"); + const [selectedAsset, setSelectedAsset] = useState(null); + const [toastMsg, setToastMsg] = useState(null); + + // EOL Simulation Parameters + const [inflationRate, setInflationRate] = useState(3.5); + const [usageMultiplier, setUsageMultiplier] = useState(1.1); + + const showToast = (text) => { + setToastMsg(text); + setTimeout(() => setToastMsg(null), 4000); + }; + + // Derived KPI Metrics + const metrics = useMemo(() => { + const totalAssets = fleet.length; + const totalCapValue = fleet.reduce((acc, f) => acc + f.purchaseCost, 0); + const criticalEolCount = fleet.filter(f => f.riskLevel === "Critical EOL Risk").length; + const avgRiskScore = Math.round(fleet.reduce((acc, f) => acc + f.riskScore, 0) / (totalAssets || 1)); + + // Simulated 3-Year Capital Replacement Budget requirement + const simulatedReplacementCost = fleet + .filter(f => f.riskLevel === "Critical EOL Risk" || f.riskLevel === "Moderate Risk") + .reduce((acc, f) => acc + (f.purchaseCost * (1 + (inflationRate / 100) * 3) * usageMultiplier), 0); + + return { + totalAssets, + totalCapValue, + criticalEolCount, + avgRiskScore, + simulatedReplacementCost: Math.round(simulatedReplacementCost) + }; + }, [fleet, inflationRate, usageMultiplier]); + + // Filtering Logic + const filteredFleet = useMemo(() => { + return fleet.filter(item => { + const matchesSearch = + item.name.toLowerCase().includes(searchQuery.toLowerCase()) || + item.id.toLowerCase().includes(searchQuery.toLowerCase()) || + item.department.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesRisk = riskFilter === "ALL" || item.riskLevel === riskFilter; + const matchesDept = deptFilter === "ALL" || item.department === deptFilter; + + return matchesSearch && matchesRisk && matchesDept; + }); + }, [fleet, searchQuery, riskFilter, deptFilter]); + + const formatCurrency = (val) => { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(val); + }; + + const getRiskBadge = (level, score) => { + switch (level) { + case "Critical EOL Risk": + return ( + + Critical ({score}/100) + + ); + case "Moderate Risk": + return ( + + Moderate ({score}/100) + + ); + case "Healthy Fleet": + return ( + + Healthy ({score}/100) + + ); + default: + return null; + } + }; + + return ( +
+ {/* Toast Alert */} + {toastMsg && ( +
+ + {toastMsg} +
+ )} + + {/* Header Banner */} +
+
+
+
+ +
+
+

+ Equipment Lifecycle & Replacement Risk Predictor +

+

+ Total Cost of Ownership (TCO) Analytics, End-of-Life (EOL) Forecasting & Capital Replacement Simulator +

+
+
+
+ +
+ +
+
+ + {/* Overview KPI Grid */} +
+
+
+

Total Fleet Capital Value

+

{formatCurrency(metrics.totalCapValue)}

+

{metrics.totalAssets} Active Capital Assets

+
+
+ +
+
+ +
+
+

Critical EOL Risk Count

+

{metrics.criticalEolCount} Assets

+

+ Immediate Action Urged +

+
+
+ +
+
+ +
+
+

3-Year Capital Replacement

+

{formatCurrency(metrics.simulatedReplacementCost)}

+

Inflation & Usage Adjusted

+
+
+ +
+
+ +
+
+

Avg Fleet Risk Score

+

{metrics.avgRiskScore} / 100

+

Fleet Degradation Index

+
+
+ +
+
+
+ + {/* Simulator Parameters Panel */} +
+
+ +

Capital Replacement Financial Simulator

+
+ +
+
+
+ Annual Capital Inflation Rate (%) + {inflationRate}% +
+ setInflationRate(parseFloat(e.target.value))} + className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600" + /> +

Simulates medical equipment procurement cost inflation over time.

+
+ +
+
+ Hospital Duty Cycle / Utilization Multiplier + {usageMultiplier}x +
+ setUsageMultiplier(parseFloat(e.target.value))} + className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600" + /> +

Adjusts wear-and-tear degradation based on ICU/Radiology usage intensity.

+
+
+
+ + {/* Search and Filters */} +
+
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:bg-white transition-all" + /> +
+ +
+ {["ALL", "Critical EOL Risk", "Moderate Risk", "Healthy Fleet"].map((risk) => ( + + ))} +
+
+
+ + {/* Fleet Risk & Lifecycle Ledger Table */} +
+
+
+ +

Capital Equipment Lifecycle & Risk Ledger

+
+ + Showing {filteredFleet.length} items + +
+ +
+ + + + + + + + + + + + + + {filteredFleet.length === 0 ? ( + + + + ) : ( + filteredFleet.map((item) => ( + + + + + + + + + + )) + )} + +
Equipment & IDDepartmentAge / Expected EOLPurchase Price & Maint SpendRisk StatusRecommended Capital ActionDetails
+ No equipment lifecycle records match your current filter. +
+
{item.name}
+
{item.id}
+
+ {item.department} + +
{item.currentAgeYears} yrs / {item.expectedLifespanYears} yrs
+
Purchased: {item.purchaseDate}
+
+
{formatCurrency(item.purchaseCost)}
+
Maint Spend: {formatCurrency(item.accumulatedMaintenanceCost)}
+
+ {getRiskBadge(item.riskLevel, item.riskScore)} + + {item.recommendedAction} + + +
+
+
+ + {/* Asset EOL Modal */} + {selectedAsset && ( +
+
+
+
+ +
+

{selectedAsset.name}

+

{selectedAsset.id} • {selectedAsset.department}

+
+
+ +
+ +
+
+
+ Risk Assessment Score: + {selectedAsset.riskScore} / 100 +
+
+ Failure Incidents Logged: + {selectedAsset.failureIncidentsCount} Breakdown Events +
+
+ TCO Ratio (Spend / Cost): + {selectedAsset.tcoRatio}x +
+
+ +
+
+ Original Purchase Cost: + {formatCurrency(selectedAsset.purchaseCost)} +
+
+ Accumulated Repair Cost: + {formatCurrency(selectedAsset.accumulatedMaintenanceCost)} +
+
+ Estimated Replacement Cost: + + {formatCurrency(Math.round(selectedAsset.purchaseCost * (1 + (inflationRate / 100) * 3) * usageMultiplier))} + +
+
+
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/routes/routeRegistry.js b/src/routes/routeRegistry.js index 029c591f..387c4fee 100644 --- a/src/routes/routeRegistry.js +++ b/src/routes/routeRegistry.js @@ -51,6 +51,7 @@ const RequestEquipmentPage = lazy(() => import("../pages/hospital/RequestEquipme const PreventiveMaintenanceRules = lazy(() => import("../pages/hospital/PreventiveMaintenanceRules")); const MaintenanceSlaDashboard = lazy(() => import("../pages/hospital/MaintenanceSlaDashboard")); const EquipmentCalibrationHub = lazy(() => import("../pages/hospital/EquipmentCalibrationHub")); +const EquipmentLifecyclePredictor = lazy(() => import("../pages/hospital/EquipmentLifecyclePredictor")); const TaskList = lazy(() => import("../pages/technician/TaskList")); const UpdateTask = lazy(() => import("../pages/technician/UpdateTask")); @@ -165,6 +166,7 @@ export const ROUTES = [ { page: "maintenance-rules", slugs: ["maintenance-rules"], component: PreventiveMaintenanceRules, access: HOSPITAL_ONLY }, { page: "sla-dashboard", slugs: ["sla-dashboard"], component: MaintenanceSlaDashboard, access: HOSPITAL_ONLY }, { page: "calibration", slugs: ["calibration", "equipment-calibration"], component: EquipmentCalibrationHub, access: HOSPITAL_ONLY }, + { page: "lifecycle-predictor", slugs: ["lifecycle-predictor", "equipment-lifecycle"], component: EquipmentLifecyclePredictor, access: HOSPITAL_ONLY }, // --- technician ------------------------------------------------------------- { page: "tasks", slugs: ["tasks"], component: TaskList, access: AUTHENTICATED }, From a63a1fd63e30ad04259811b43c156ce04b654f04 Mon Sep 17 00:00:00 2001 From: Anubhuti Sharma Date: Tue, 4 Aug 2026 23:19:25 +0530 Subject: [PATCH 002/412] feat(hospital): add Equipment Lifecycle & Predictive Failure Analytics page and test suite --- .../EquipmentLifecyclePredictor.test.jsx | 35 ++ .../hospital/EquipmentLifecyclePredictor.jsx | 488 ++++++++++++++++++ 2 files changed, 523 insertions(+) create mode 100644 src/components/hospital/EquipmentLifecyclePredictor.test.jsx create mode 100644 src/pages/hospital/EquipmentLifecyclePredictor.jsx diff --git a/src/components/hospital/EquipmentLifecyclePredictor.test.jsx b/src/components/hospital/EquipmentLifecyclePredictor.test.jsx new file mode 100644 index 00000000..e7210692 --- /dev/null +++ b/src/components/hospital/EquipmentLifecyclePredictor.test.jsx @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import EquipmentLifecyclePredictor from '../../pages/hospital/EquipmentLifecyclePredictor'; + +describe('EquipmentLifecyclePredictor Component', () => { + it('renders summary metrics and equipment list', () => { + render(); + + expect(screen.getByText('Equipment Lifecycle & Predictive Failure Analytics')).toBeInTheDocument(); + expect(screen.getByText('Monitored Fleet')).toBeInTheDocument(); + expect(screen.getByText('Replacement Due')).toBeInTheDocument(); + expect(screen.getByText('MRI Scanner 3T Signature')).toBeInTheDocument(); + }); + + it('filters equipment list by risk tier selector', () => { + render(); + + const select = screen.getByDisplayValue('All Risk Tiers'); + fireEvent.change(select, { target: { value: 'CRITICAL' } }); + + expect(screen.getByText('CT Scanner Revolution 128-Slice')).toBeInTheDocument(); + expect(screen.queryByText('Patient Monitor IntelliVue MX800')).not.toBeInTheDocument(); + }); + + it('opens inspect modal on row click', () => { + render(); + + const rowItem = screen.getByText('MRI Scanner 3T Signature'); + fireEvent.click(rowItem); + + expect(screen.getByText('AI Recommendation')).toBeInTheDocument(); + expect(screen.getByText('Plan procurement replacement within 2 quarters. High compressor wear.')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/hospital/EquipmentLifecyclePredictor.jsx b/src/pages/hospital/EquipmentLifecyclePredictor.jsx new file mode 100644 index 00000000..d4292ae2 --- /dev/null +++ b/src/pages/hospital/EquipmentLifecyclePredictor.jsx @@ -0,0 +1,488 @@ +// src/pages/hospital/EquipmentLifecyclePredictor.jsx +import React, { useState, useMemo } from 'react'; +import { useAuth } from '../../context/AuthContext'; +import { + Activity, + AlertTriangle, + TrendingDown, + Clock, + DollarSign, + Search, + Filter, + ShieldAlert, + ArrowRight, + RefreshCw, + Zap, + CheckCircle2, + Calendar, + Layers, + Sparkles, +} from 'lucide-react'; + +/* Demo Equipment Lifecycle Dataset */ +const DEMO_LIFECYCLE_DATA = [ + { + id: 'EQ-1001', + name: 'MRI Scanner 3T Signature', + category: 'IMAGING', + department: 'Radiology', + purchaseDate: '2017-03-15', + expectedLifespanYears: 10, + ageYears: 7.4, + healthIndex: 42, // % + failureProbability: 78, // % + mtbfHours: 420, + maintenanceCount: 14, + estimatedReplacementCost: 450000, + riskTier: 'HIGH_RISK', // 'LOW_RISK' | 'MODERATE' | 'HIGH_RISK' | 'CRITICAL' + rulMonths: 8, + lastCalibrated: '2023-11-10', + recommendation: 'Plan procurement replacement within 2 quarters. High compressor wear.', + }, + { + id: 'EQ-1002', + name: 'Ventilator Servo-U ICU', + category: 'RESPIRATORY', + department: 'Intensive Care Unit (ICU)', + purchaseDate: '2020-06-20', + expectedLifespanYears: 7, + ageYears: 4.1, + healthIndex: 84, + failureProbability: 12, + mtbfHours: 1250, + maintenanceCount: 4, + estimatedReplacementCost: 35000, + riskTier: 'LOW_RISK', + rulMonths: 35, + lastCalibrated: '2023-12-01', + recommendation: 'Optimal operating condition. Perform routine preventive maintenance.', + }, + { + id: 'EQ-1003', + name: 'CT Scanner Revolution 128-Slice', + category: 'IMAGING', + department: 'Radiology', + purchaseDate: '2016-01-10', + expectedLifespanYears: 8, + ageYears: 8.6, + healthIndex: 28, + failureProbability: 92, + mtbfHours: 180, + maintenanceCount: 22, + estimatedReplacementCost: 380000, + riskTier: 'CRITICAL', + rulMonths: 2, + lastCalibrated: '2023-10-05', + recommendation: 'X-ray tube at end of life. Urgent replacement requisition advised.', + }, + { + id: 'EQ-1004', + name: 'Anesthesia Workstation Primus', + category: 'SURGICAL', + department: 'Operating Room 2', + purchaseDate: '2019-09-12', + expectedLifespanYears: 9, + ageYears: 4.9, + healthIndex: 68, + failureProbability: 34, + mtbfHours: 850, + maintenanceCount: 7, + estimatedReplacementCost: 65000, + riskTier: 'MODERATE', + rulMonths: 22, + lastCalibrated: '2023-11-22', + recommendation: 'Vaporizer flow sensor calibration recommended next month.', + }, + { + id: 'EQ-1005', + name: 'Patient Monitor IntelliVue MX800', + category: 'MONITORING', + department: 'Cardiology', + purchaseDate: '2021-04-05', + expectedLifespanYears: 6, + ageYears: 3.3, + healthIndex: 91, + failureProbability: 8, + mtbfHours: 1800, + maintenanceCount: 2, + estimatedReplacementCost: 18000, + riskTier: 'LOW_RISK', + rulMonths: 32, + lastCalibrated: '2023-12-10', + recommendation: 'Excellent status. All telemetry metrics operating nominal.', + }, + { + id: 'EQ-1006', + name: 'Haemodialysis Machine 5008S', + category: 'LABORATORY', + department: 'Nephrology', + purchaseDate: '2018-11-30', + expectedLifespanYears: 7, + ageYears: 5.7, + healthIndex: 51, + failureProbability: 64, + mtbfHours: 520, + maintenanceCount: 11, + estimatedReplacementCost: 42000, + riskTier: 'MODERATE', + rulMonths: 14, + lastCalibrated: '2023-11-18', + recommendation: 'Hydraulic blood pump seal showing initial degradation.', + }, +]; + +export default function EquipmentLifecyclePredictor({ onNavigate }) { + const { user } = useAuth(); + const [data, setData] = useState(DEMO_LIFECYCLE_DATA); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedRisk, setSelectedRisk] = useState('ALL'); + const [selectedDepartment, setSelectedDepartment] = useState('ALL'); + const [selectedItemModal, setSelectedItemModal] = useState(null); + + // Departments List + const departmentsList = useMemo(() => { + const set = new Set(); + data.forEach((d) => set.add(d.department)); + return Array.from(set); + }, [data]); + + // Filtered Equipment Data + const filteredData = useMemo(() => { + return data.filter((item) => { + const matchesSearch = + !searchQuery || + item.name.toLowerCase().includes(searchQuery.toLowerCase()) || + item.id.toLowerCase().includes(searchQuery.toLowerCase()) || + item.category.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesRisk = selectedRisk === 'ALL' || item.riskTier === selectedRisk; + const matchesDept = selectedDepartment === 'ALL' || item.department === selectedDepartment; + + return matchesSearch && matchesRisk && matchesDept; + }); + }, [data, searchQuery, selectedRisk, selectedDepartment]); + + // Metrics + const metrics = useMemo(() => { + const total = data.length; + const criticalCount = data.filter((d) => d.riskTier === 'CRITICAL' || d.riskTier === 'HIGH_RISK').length; + const avgHealth = Math.round(data.reduce((acc, d) => acc + d.healthIndex, 0) / (total || 1)); + const totalReplacementBudget = data + .filter((d) => d.riskTier === 'CRITICAL' || d.riskTier === 'HIGH_RISK') + .reduce((acc, d) => acc + d.estimatedReplacementCost, 0); + + return { total, criticalCount, avgHealth, totalReplacementBudget }; + }, [data]); + + const riskBadgeStyle = (tier) => { + switch (tier) { + case 'CRITICAL': + return 'bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950 dark:text-rose-300'; + case 'HIGH_RISK': + return 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-300'; + case 'MODERATE': + return 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300'; + default: + return 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-300'; + } + }; + + const handleReset = () => { + setSearchQuery(''); + setSelectedRisk('ALL'); + setSelectedDepartment('ALL'); + }; + + return ( +
+ {/* Header */} +
+
+
+
+
+ + + +

Equipment Lifecycle & Predictive Failure Analytics

+
+

+ AI-driven Remaining Useful Life (RUL) estimation & replacement capital budgeting +

+
+ +
+ + +
+
+
+
+ + {/* Main Content */} +
+ {/* KPI Metrics Cards */} +
+
+ + Monitored Fleet + +
+ {metrics.total} + + 100% Active + +
+
+ +
+ + Replacement Due + +
+ {metrics.criticalCount} + + High Priority + +
+
+ +
+ + Fleet Health Index + +
+ {metrics.avgHealth}% + + Nominal + +
+
+ +
+ + Est. CapEx Needed + +
+ + ${(metrics.totalReplacementBudget / 1000).toFixed(0)}k + + + Next 2Q + +
+
+
+ + {/* Filter Bar */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-4 py-2 text-xs bg-surface border border-subtle rounded-lg text-primary focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+ + + + + {(searchQuery || selectedRisk !== 'ALL' || selectedDepartment !== 'ALL') && ( + + )} +
+
+ + {/* Equipment Lifecycle Table */} +
+
+ + + + + + + + + + + + + + + {filteredData.length === 0 ? ( + + + + ) : ( + filteredData.map((item) => ( + setSelectedItemModal(item)} + className="hover:bg-hover transition cursor-pointer" + > + + + + + + + + + + + + + + + + + )) + )} + +
EquipmentDepartmentAge / SpanHealth IndexEst. RULRisk TierEst. CapExAction
+
+ +

No lifecycle analytics match your criteria.

+
+
+
{item.name}
+
{item.id} · {item.category}
+
+ {item.department} + + {item.ageYears} yrs / {item.expectedLifespanYears} yrs + +
+
+
+
+ {item.healthIndex}% +
+
+ {item.rulMonths} months + + + {item.riskTier.replace('_', ' ')} + + + ${item.estimatedReplacementCost.toLocaleString()} + + +
+
+
+
+ + {/* Item Inspection & Failure Prediction Modal */} + {selectedItemModal && ( +
+
+
+
+ {selectedItemModal.id} +

{selectedItemModal.name}

+
+ +
+ +
+ + AI Recommendation + +

{selectedItemModal.recommendation}

+
+ +
+
+ Failure Probability + {selectedItemModal.failureProbability}% +
+
+ MTBF Metric + {selectedItemModal.mtbfHours} hrs +
+
+ +
+ + +
+
+
+ )} +
+ ); +} From 692891a104b2d5483a42c1029ad5a6d12bd7c904 Mon Sep 17 00:00:00 2001 From: Gautam-Bharadwaj <136326437+Gautam-Bharadwaj@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:38:33 +0530 Subject: [PATCH 003/412] fix(events): resolve real hospital/user for operations events instead of hardcoded placeholder OperationsEventController.getHospitalId/getUserId returned a hardcoded 1L for every caller, so every hospital's Activity Center showed hospital #1's events and read state was shared across accounts. Also switch unread tracking to the per-user event_read_receipts table: the shared `read` column on OperationsEvent was never mutated after insert, so unread counts never decreased after marking events read. --- .../controller/OperationsEventController.java | 141 +++++++++----- .../EventReadReceiptRepository.java | 2 + .../repository/OperationsEventRepository.java | 65 +++++-- .../OperationsEventControllerTest.java | 181 ++++++++++++++++++ 4 files changed, 332 insertions(+), 57 deletions(-) create mode 100644 Backend/src/test/java/com/medtrack/controller/OperationsEventControllerTest.java diff --git a/Backend/src/main/java/com/medtrack/controller/OperationsEventController.java b/Backend/src/main/java/com/medtrack/controller/OperationsEventController.java index b9965333..b6e256d9 100644 --- a/Backend/src/main/java/com/medtrack/controller/OperationsEventController.java +++ b/Backend/src/main/java/com/medtrack/controller/OperationsEventController.java @@ -1,13 +1,17 @@ package com.medtrack.controller; +import com.medtrack.auth.model.User; +import com.medtrack.auth.repository.UserRepository; import com.medtrack.dto.EventReadRequest; import com.medtrack.dto.OperationsEventResponse; import com.medtrack.dto.UnreadCountResponse; import com.medtrack.model.EventReadReceipt; +import com.medtrack.model.Hospital; import com.medtrack.model.OperationsEvent; import com.medtrack.repository.EventReadReceiptRepository; +import com.medtrack.repository.HospitalRepository; +import com.medtrack.repository.NotificationPreferenceRepository; import com.medtrack.repository.OperationsEventRepository; -import com.medtrack.service.EventPublisherService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -15,6 +19,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -24,8 +29,11 @@ import org.springframework.web.bind.annotation.RestController; import java.time.LocalDateTime; +import java.util.EnumMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** @@ -39,10 +47,16 @@ public class OperationsEventController { private final OperationsEventRepository eventRepository; private final EventReadReceiptRepository readReceiptRepository; - private final EventPublisherService eventPublisherService; + private final NotificationPreferenceRepository preferenceRepository; + private final UserRepository userRepository; + private final HospitalRepository hospitalRepository; /** * Get paginated event history for the user's hospital. + * + *

Muted categories are excluded from the unfiltered ("All") view, but remain reachable by + * requesting that category explicitly - muting quiets the default feed, it does not delete + * access to the data.

*/ @GetMapping public ResponseEntity> getEvents( @@ -53,47 +67,52 @@ public ResponseEntity> getEvents( Authentication authentication) { Long hospitalId = getHospitalId(authentication); + Long userId = getUserId(authentication); Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Order.desc("createdAt"))); + Set muted = category == null + ? preferenceRepository.mutedCategoriesFor(userId) + : Set.of(); + Page events; if (unreadOnly != null && unreadOnly) { if (category != null) { - events = eventRepository.findByHospitalIdAndCategoryAndReadFalseOrderByCreatedAtDesc(hospitalId, category, pageable); + events = eventRepository.findUnreadForUserByCategory(hospitalId, category, userId, pageable); + } else if (!muted.isEmpty()) { + events = eventRepository.findUnreadForUserExcludingCategories(hospitalId, muted, userId, pageable); } else { - events = eventRepository.findByHospitalIdAndReadFalseOrderByCreatedAtDesc(hospitalId, pageable); + events = eventRepository.findUnreadForUser(hospitalId, userId, pageable); } } else if (category != null) { events = eventRepository.findByHospitalIdAndCategoryOrderByCreatedAtDesc(hospitalId, category, pageable); + } else if (!muted.isEmpty()) { + events = eventRepository.findByHospitalIdExcludingCategories(hospitalId, muted, pageable); } else { events = eventRepository.findByHospitalIdOrderByCreatedAtDesc(hospitalId, pageable); } - return ResponseEntity.ok(events.map(this::toResponse)); + Set readEventIds = readEventIdsFor(userId, events.getContent()); + return ResponseEntity.ok(events.map(event -> toResponse(event, readEventIds))); } /** - * Get unread event counts by category for the user's hospital. + * Get unread event counts by category for the user's hospital. Muted categories are + * reported as zero so the notification bell badge reflects what the user actually wants + * to see. */ @GetMapping("/unread-counts") public ResponseEntity getUnreadCounts(Authentication authentication) { Long hospitalId = getHospitalId(authentication); Long userId = getUserId(authentication); - - // Count unread events per category - Map counts = Map.ofEntries( - Map.entry(OperationsEvent.EventCategory.MAINTENANCE, - eventRepository.countByHospitalIdAndCategoryAndReadFalse(hospitalId, OperationsEvent.EventCategory.MAINTENANCE)), - Map.entry(OperationsEvent.EventCategory.EQUIPMENT, - eventRepository.countByHospitalIdAndCategoryAndReadFalse(hospitalId, OperationsEvent.EventCategory.EQUIPMENT)), - Map.entry(OperationsEvent.EventCategory.PROCUREMENT, - eventRepository.countByHospitalIdAndCategoryAndReadFalse(hospitalId, OperationsEvent.EventCategory.PROCUREMENT)), - Map.entry(OperationsEvent.EventCategory.SHIPMENT, - eventRepository.countByHospitalIdAndCategoryAndReadFalse(hospitalId, OperationsEvent.EventCategory.SHIPMENT)), - Map.entry(OperationsEvent.EventCategory.APPROVAL, - eventRepository.countByHospitalIdAndCategoryAndReadFalse(hospitalId, OperationsEvent.EventCategory.APPROVAL)), - Map.entry(OperationsEvent.EventCategory.SLA, - eventRepository.countByHospitalIdAndCategoryAndReadFalse(hospitalId, OperationsEvent.EventCategory.SLA)) - ); + Set muted = preferenceRepository.mutedCategoriesFor(userId); + + Map counts = new EnumMap<>(OperationsEvent.EventCategory.class); + for (OperationsEvent.EventCategory eventCategory : OperationsEvent.EventCategory.values()) { + long count = muted.contains(eventCategory) + ? 0L + : eventRepository.countUnreadForUserByCategory(hospitalId, eventCategory, userId); + counts.put(eventCategory, count); + } long total = counts.values().stream().mapToLong(Long::longValue).sum(); @@ -109,8 +128,10 @@ public ResponseEntity> getRecentEvents( Authentication authentication) { Long hospitalId = getHospitalId(authentication); + Long userId = getUserId(authentication); List events = eventRepository.findByHospitalIdAndCreatedAtAfterOrderByCreatedAtAsc(hospitalId, since); - return ResponseEntity.ok(events.stream().map(this::toResponse).collect(Collectors.toList())); + Set readEventIds = readEventIdsFor(userId, events); + return ResponseEntity.ok(events.stream().map(event -> toResponse(event, readEventIds)).collect(Collectors.toList())); } /** @@ -129,14 +150,7 @@ public ResponseEntity markAsRead(@Valid @RequestBody EventReadRequest requ } } - // Create read receipts - List receipts = request.getEventIds().stream() - .map(eventId -> EventReadReceipt.builder() - .eventId(eventId) - .userId(userId) - .build()) - .collect(Collectors.toList()); - readReceiptRepository.saveAll(receipts); + saveNewReceipts(userId, request.getEventIds()); return ResponseEntity.ok().build(); } @@ -149,22 +163,47 @@ public ResponseEntity markAllAsRead(@RequestParam(defaultValue = "100") in Long userId = getUserId(authentication); Long hospitalId = getHospitalId(authentication); - // Get unread event IDs for this hospital Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Order.desc("createdAt"))); - List unreadEvents = eventRepository.findByHospitalIdAndReadFalseOrderByCreatedAtDesc(hospitalId, pageable).getContent(); + List unreadEvents = eventRepository.findUnreadForUser(hospitalId, userId, pageable).getContent(); + + saveNewReceipts(userId, unreadEvents.stream().map(OperationsEvent::getId).collect(Collectors.toList())); + + return ResponseEntity.ok().build(); + } - List receipts = unreadEvents.stream() - .map(event -> EventReadReceipt.builder() - .eventId(event.getId()) + /** + * Inserts a read receipt for each event id not already read by this user. The + * {@code (event_id, user_id)} unique constraint means a duplicate insert would otherwise + * fail if the same event were marked read twice (e.g. two browser tabs). + */ + private void saveNewReceipts(Long userId, List eventIds) { + if (eventIds.isEmpty()) { + return; + } + Set existing = readReceiptRepository.findByUserIdAndEventIdIn(userId, eventIds).stream() + .map(EventReadReceipt::getEventId) + .collect(Collectors.toSet()); + List receipts = eventIds.stream() + .filter(eventId -> !existing.contains(eventId)) + .map(eventId -> EventReadReceipt.builder() + .eventId(eventId) .userId(userId) .build()) .collect(Collectors.toList()); readReceiptRepository.saveAll(receipts); + } - return ResponseEntity.ok().build(); + private Set readEventIdsFor(Long userId, List events) { + if (events.isEmpty()) { + return Set.of(); + } + List eventIds = events.stream().map(OperationsEvent::getId).collect(Collectors.toList()); + return readReceiptRepository.findByUserIdAndEventIdIn(userId, eventIds).stream() + .map(EventReadReceipt::getEventId) + .collect(Collectors.toSet()); } - private OperationsEventResponse toResponse(OperationsEvent event) { + private OperationsEventResponse toResponse(OperationsEvent event, Set readEventIds) { return OperationsEventResponse.builder() .id(event.getId()) .category(event.getCategory()) @@ -176,19 +215,31 @@ private OperationsEventResponse toResponse(OperationsEvent event) { .entityType(event.getEntityType()) .actor(event.getActor()) .severity(event.getSeverity()) - .read(event.getRead()) + .read(readEventIds.contains(event.getId())) .createdAt(event.getCreatedAt()) .build(); } + /** + * Resolves the caller's hospital from their authenticated account. Only hospital-role + * accounts use the Activity Center today. + */ private Long getHospitalId(Authentication authentication) { - // In a real implementation, this would come from the user's hospital context - // For now, extracting from principal or using a service - return 1L; // Placeholder - should use HospitalAccessGuard or similar + Hospital hospital = hospitalRepository.findByUserId(getAuthenticatedUser(authentication).getId()) + .orElseThrow(() -> new AccessDeniedException("An active hospital account is required")); + return hospital.getId(); } private Long getUserId(Authentication authentication) { - // Extract user ID from authentication - return 1L; // Placeholder + return getAuthenticatedUser(authentication).getId(); + } + + private User getAuthenticatedUser(Authentication authentication) { + if (authentication == null || authentication.getName() == null || authentication.getName().isBlank()) { + throw new AccessDeniedException("An authenticated account is required"); + } + String normalizedEmail = authentication.getName().trim().toLowerCase(Locale.ROOT); + return userRepository.findByEmail(normalizedEmail) + .orElseThrow(() -> new AccessDeniedException("An authenticated account is required")); } -} \ No newline at end of file +} diff --git a/Backend/src/main/java/com/medtrack/repository/EventReadReceiptRepository.java b/Backend/src/main/java/com/medtrack/repository/EventReadReceiptRepository.java index 81740f7e..b9ca0bb8 100644 --- a/Backend/src/main/java/com/medtrack/repository/EventReadReceiptRepository.java +++ b/Backend/src/main/java/com/medtrack/repository/EventReadReceiptRepository.java @@ -20,6 +20,8 @@ public interface EventReadReceiptRepository extends JpaRepository findByUserId(Long userId); + List findByUserIdAndEventIdIn(Long userId, List eventIds); + List findByEventId(Long eventId); @Modifying diff --git a/Backend/src/main/java/com/medtrack/repository/OperationsEventRepository.java b/Backend/src/main/java/com/medtrack/repository/OperationsEventRepository.java index de992b1a..dc32da2b 100644 --- a/Backend/src/main/java/com/medtrack/repository/OperationsEventRepository.java +++ b/Backend/src/main/java/com/medtrack/repository/OperationsEventRepository.java @@ -10,6 +10,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Set; /** * Repository for operations events. @@ -37,32 +38,72 @@ Page findByHospitalIdAndCategoryAndTypeOrderByCreatedAtDesc( /** * Find unread events for a hospital. + * + *

Unread is determined per-user via {@code event_read_receipts}, not the shared + * {@code read} column on {@code OperationsEvent} (that column is never mutated after + * insert and cannot represent "read by this user" once more than one user shares a + * hospital account).

*/ - Page findByHospitalIdAndReadFalseOrderByCreatedAtDesc(Long hospitalId, Pageable pageable); + @Query("SELECT e FROM OperationsEvent e WHERE e.hospitalId = :hospitalId " + + "AND NOT EXISTS (SELECT 1 FROM EventReadReceipt r WHERE r.eventId = e.id AND r.userId = :userId) " + + "ORDER BY e.createdAt DESC") + Page findUnreadForUser( + @Param("hospitalId") Long hospitalId, @Param("userId") Long userId, Pageable pageable); /** - * Find unread events for a hospital with category filter. + * Find unread events for a hospital with category filter, per-user (see {@link #findUnreadForUser}). */ - Page findByHospitalIdAndCategoryAndReadFalseOrderByCreatedAtDesc( - Long hospitalId, OperationsEvent.EventCategory category, Pageable pageable); + @Query("SELECT e FROM OperationsEvent e WHERE e.hospitalId = :hospitalId AND e.category = :category " + + "AND NOT EXISTS (SELECT 1 FROM EventReadReceipt r WHERE r.eventId = e.id AND r.userId = :userId) " + + "ORDER BY e.createdAt DESC") + Page findUnreadForUserByCategory( + @Param("hospitalId") Long hospitalId, + @Param("category") OperationsEvent.EventCategory category, + @Param("userId") Long userId, + Pageable pageable); /** - * Find events since a given timestamp (for replay/recovery). + * Count unread events for a hospital and category, per-user (see {@link #findUnreadForUser}). */ - @Query("SELECT e FROM OperationsEvent e WHERE e.hospitalId = :hospitalId AND e.createdAt > :since ORDER BY e.createdAt ASC") - List findByHospitalIdAndCreatedAtAfterOrderByCreatedAtAsc( + @Query("SELECT COUNT(e) FROM OperationsEvent e WHERE e.hospitalId = :hospitalId AND e.category = :category " + + "AND NOT EXISTS (SELECT 1 FROM EventReadReceipt r WHERE r.eventId = e.id AND r.userId = :userId)") + long countUnreadForUserByCategory( @Param("hospitalId") Long hospitalId, - @Param("since") LocalDateTime since); + @Param("category") OperationsEvent.EventCategory category, + @Param("userId") Long userId); + + /** + * Find events for a hospital, excluding one or more muted categories. Used for the + * unfiltered "All" view once the caller has muted at least one category. + */ + @Query("SELECT e FROM OperationsEvent e WHERE e.hospitalId = :hospitalId " + + "AND e.category NOT IN :excludedCategories ORDER BY e.createdAt DESC") + Page findByHospitalIdExcludingCategories( + @Param("hospitalId") Long hospitalId, + @Param("excludedCategories") Set excludedCategories, + Pageable pageable); /** - * Count unread events for a hospital. + * Per-user unread events for a hospital, excluding muted categories (see + * {@link #findByHospitalIdExcludingCategories} and {@link #findUnreadForUser}). */ - long countByHospitalIdAndReadFalse(Long hospitalId); + @Query("SELECT e FROM OperationsEvent e WHERE e.hospitalId = :hospitalId " + + "AND e.category NOT IN :excludedCategories " + + "AND NOT EXISTS (SELECT 1 FROM EventReadReceipt r WHERE r.eventId = e.id AND r.userId = :userId) " + + "ORDER BY e.createdAt DESC") + Page findUnreadForUserExcludingCategories( + @Param("hospitalId") Long hospitalId, + @Param("excludedCategories") Set excludedCategories, + @Param("userId") Long userId, + Pageable pageable); /** - * Count unread events for a hospital by category. + * Find events since a given timestamp (for replay/recovery). */ - long countByHospitalIdAndCategoryAndReadFalse(Long hospitalId, OperationsEvent.EventCategory category); + @Query("SELECT e FROM OperationsEvent e WHERE e.hospitalId = :hospitalId AND e.createdAt > :since ORDER BY e.createdAt ASC") + List findByHospitalIdAndCreatedAtAfterOrderByCreatedAtAsc( + @Param("hospitalId") Long hospitalId, + @Param("since") LocalDateTime since); /** * Find events by entity reference. diff --git a/Backend/src/test/java/com/medtrack/controller/OperationsEventControllerTest.java b/Backend/src/test/java/com/medtrack/controller/OperationsEventControllerTest.java new file mode 100644 index 00000000..6975726e --- /dev/null +++ b/Backend/src/test/java/com/medtrack/controller/OperationsEventControllerTest.java @@ -0,0 +1,181 @@ +package com.medtrack.controller; + +import com.medtrack.auth.model.User; +import com.medtrack.auth.repository.UserRepository; +import com.medtrack.dto.EventReadRequest; +import com.medtrack.dto.OperationsEventResponse; +import com.medtrack.dto.UnreadCountResponse; +import com.medtrack.model.EventReadReceipt; +import com.medtrack.model.Hospital; +import com.medtrack.model.OperationsEvent; +import com.medtrack.repository.EventReadReceiptRepository; +import com.medtrack.repository.HospitalRepository; +import com.medtrack.repository.NotificationPreferenceRepository; +import com.medtrack.repository.OperationsEventRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OperationsEventControllerTest { + + private static final String EMAIL = "hospital@medtrack.com"; + + @Mock + private OperationsEventRepository eventRepository; + + @Mock + private EventReadReceiptRepository readReceiptRepository; + + @Mock + private NotificationPreferenceRepository preferenceRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private HospitalRepository hospitalRepository; + + @InjectMocks + private OperationsEventController controller; + + @Mock + private Authentication authentication; + + private User user; + private Hospital hospital; + + @BeforeEach + void setUp() { + user = User.builder().id(7L).email(EMAIL).build(); + hospital = Hospital.builder().id(77L).name("Test Hospital").user(user).build(); + + lenient().when(authentication.getName()).thenReturn(EMAIL); + lenient().when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + lenient().when(hospitalRepository.findByUserId(7L)).thenReturn(Optional.of(hospital)); + } + + @Test + void getUnreadCounts_ResolvesRealHospitalAndUserInsteadOfHardcodedOne() { + when(preferenceRepository.mutedCategoriesFor(7L)).thenReturn(Set.of()); + when(eventRepository.countUnreadForUserByCategory(eq(77L), any(), eq(7L))).thenReturn(1L); + + ResponseEntity response = controller.getUnreadCounts(authentication); + + assertEquals(6L, response.getBody().getTotal()); + verify(eventRepository, never()).countUnreadForUserByCategory(eq(1L), any(), any()); + } + + @Test + void getUnreadCounts_ZeroesMutedCategories() { + when(preferenceRepository.mutedCategoriesFor(7L)) + .thenReturn(Set.of(OperationsEvent.EventCategory.SHIPMENT)); + when(eventRepository.countUnreadForUserByCategory(eq(77L), any(), eq(7L))).thenReturn(3L); + + ResponseEntity response = controller.getUnreadCounts(authentication); + + assertEquals(0L, response.getBody().getByCategory().get(OperationsEvent.EventCategory.SHIPMENT)); + verify(eventRepository, never()) + .countUnreadForUserByCategory(eq(77L), eq(OperationsEvent.EventCategory.SHIPMENT), eq(7L)); + } + + @Test + void getEvents_DefaultViewExcludesMutedCategoriesAtTheQueryLevel() { + Set muted = Set.of(OperationsEvent.EventCategory.PROCUREMENT); + when(preferenceRepository.mutedCategoriesFor(7L)).thenReturn(muted); + when(eventRepository.findByHospitalIdExcludingCategories(eq(77L), eq(muted), any())) + .thenReturn(new PageImpl<>(List.of())); + + controller.getEvents(null, null, 0, 20, authentication); + + verify(eventRepository).findByHospitalIdExcludingCategories(eq(77L), eq(muted), any()); + verify(eventRepository, never()).findByHospitalIdOrderByCreatedAtDesc(any(), any()); + } + + @Test + void getEvents_ExplicitCategoryFilterIgnoresMute() { + when(eventRepository.findByHospitalIdAndCategoryOrderByCreatedAtDesc( + eq(77L), eq(OperationsEvent.EventCategory.PROCUREMENT), any())) + .thenReturn(new PageImpl<>(List.of())); + + controller.getEvents(OperationsEvent.EventCategory.PROCUREMENT, null, 0, 20, authentication); + + verify(preferenceRepository, never()).mutedCategoriesFor(any()); + } + + @Test + void getEvents_ReadFlagReflectsThisUsersReceiptNotTheSharedColumn() { + OperationsEvent readByMe = OperationsEvent.builder().id(1L).hospitalId(77L) + .category(OperationsEvent.EventCategory.EQUIPMENT).read(false).build(); + OperationsEvent unread = OperationsEvent.builder().id(2L).hospitalId(77L) + .category(OperationsEvent.EventCategory.EQUIPMENT).read(false).build(); + when(preferenceRepository.mutedCategoriesFor(7L)).thenReturn(Set.of()); + when(eventRepository.findByHospitalIdOrderByCreatedAtDesc(eq(77L), any())) + .thenReturn(new PageImpl<>(List.of(readByMe, unread))); + when(readReceiptRepository.findByUserIdAndEventIdIn(eq(7L), any())) + .thenReturn(List.of(EventReadReceipt.builder().eventId(1L).userId(7L).build())); + + Page page = controller.getEvents(null, null, 0, 20, authentication).getBody(); + + assertEquals(true, page.getContent().get(0).getRead()); + assertEquals(false, page.getContent().get(1).getRead()); + } + + @Test + void markAsRead_RejectsEventsFromAnotherHospital() { + OperationsEvent foreignEvent = OperationsEvent.builder().id(1L).hospitalId(999L).build(); + when(eventRepository.findAllById(List.of(1L))).thenReturn(List.of(foreignEvent)); + + ResponseEntity response = controller.markAsRead( + EventReadRequest.builder().eventIds(List.of(1L)).build(), authentication); + + assertEquals(400, response.getStatusCode().value()); + verify(readReceiptRepository, never()).saveAll(any()); + } + + @Test + void markAsRead_DoesNotDuplicateAnExistingReceipt() { + OperationsEvent event = OperationsEvent.builder().id(1L).hospitalId(77L).build(); + when(eventRepository.findAllById(List.of(1L))).thenReturn(List.of(event)); + when(readReceiptRepository.findByUserIdAndEventIdIn(eq(7L), eq(List.of(1L)))) + .thenReturn(List.of(EventReadReceipt.builder().eventId(1L).userId(7L).build())); + + controller.markAsRead(EventReadRequest.builder().eventIds(List.of(1L)).build(), authentication); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(readReceiptRepository).saveAll(captor.capture()); + assertEquals(0, captor.getValue().size()); + } + + @Test + void getHospitalId_RejectsCallerWithNoHospitalProfile() { + when(hospitalRepository.findByUserId(7L)).thenReturn(Optional.empty()); + + assertThrows(AccessDeniedException.class, + () -> controller.getUnreadCounts(authentication)); + } +} From 1dc46a686b12fdbac435d001ec124085228d0430 Mon Sep 17 00:00:00 2001 From: Gautam-Bharadwaj <136326437+Gautam-Bharadwaj@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:38:45 +0530 Subject: [PATCH 004/412] feat(events): wire low-stock and SLA operations events that were defined but never published Of the 28 EventType values on OperationsEvent, only EQUIPMENT_WARRANTY_EXPIRING was ever actually published; the Activity Center's other category tabs were permanently empty. Wire the two producers with real underlying data: - EquipmentService.adjustStock publishes EQUIPMENT_LOW_STOCK when a stock movement crosses the reorder threshold (not on every adjustment while already low, to avoid spamming the feed). - A new MaintenanceSlaAlertScheduler runs PreventiveMaintenanceService's SLA recomputation across every hospital hourly (previously it only ran when a hospital user opened the SLA dashboard), publishing SLA_WARNING/BREACHED/ ESCALATED and MAINTENANCE_OVERDUE on state transitions. SHIPMENT_DELAYED is deliberately left unwired: shipments/orders are scoped by a free-text organization string, not a Hospital.id FK, so bridging it into OperationsEvent would need a real FK addition, not a producer wire-up. --- .../medtrack/service/EquipmentService.java | 41 ++++++++ .../service/EventPublisherService.java | 6 ++ .../service/MaintenanceSlaAlertScheduler.java | 52 ++++++++++ .../service/PreventiveMaintenanceService.java | 94 ++++++++++++++++++- .../service/EquipmentServiceTest.java | 53 +++++++++++ .../service/EquipmentStockServiceTest.java | 3 + .../PreventiveMaintenanceServiceTest.java | 70 ++++++++++++++ 7 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 Backend/src/main/java/com/medtrack/service/MaintenanceSlaAlertScheduler.java diff --git a/Backend/src/main/java/com/medtrack/service/EquipmentService.java b/Backend/src/main/java/com/medtrack/service/EquipmentService.java index 5f4f9085..bfabf60f 100644 --- a/Backend/src/main/java/com/medtrack/service/EquipmentService.java +++ b/Backend/src/main/java/com/medtrack/service/EquipmentService.java @@ -15,6 +15,7 @@ import com.medtrack.model.EquipmentImportAuditLog; import com.medtrack.model.EquipmentStatus; import com.medtrack.model.Hospital; +import com.medtrack.model.OperationsEvent; import com.medtrack.model.WarrantyCoverageType; import com.medtrack.repository.EquipmentImportAuditLogRepository; import com.medtrack.repository.EquipmentRepository; @@ -69,6 +70,7 @@ public class EquipmentService { private final HospitalRepository hospitalRepository; private final UserRepository userRepository; private final EquipmentImportAuditLogRepository equipmentImportAuditLogRepository; + private final EventPublisherService eventPublisherService; private static final Logger logger = LoggerFactory.getLogger(EquipmentService.class); @@ -238,8 +240,15 @@ public Equipment adjustStock(Long id, StockAdjustmentRequest request, String use equipment.setMinimumStock(request.getMinimumStock()); } + int minimumStock = equipment.getMinimumStock() != null ? equipment.getMinimumStock() : 0; + boolean crossedIntoLowStock = currentQuantity > minimumStock && (int) adjusted <= minimumStock; + Equipment savedEquipment = equipmentRepository.save(equipment); + if (crossedIntoLowStock) { + publishLowStockEvent(savedEquipment, minimumStock); + } + logger.info( "Equipment stock adjusted | User: {} | Equipment ID: {} | Delta: {} | " + "Quantity: {} -> {} | Reason: {}", @@ -254,6 +263,38 @@ public Equipment adjustStock(Long id, StockAdjustmentRequest request, String use return savedEquipment; } + /** + * Raises an {@code EQUIPMENT_LOW_STOCK} operations event the moment a stock adjustment + * drives quantity down to or below the minimum threshold. Fired only on the crossing + * (see the caller), so repeated adjustments while already low do not spam the feed. + */ + private void publishLowStockEvent(Equipment equipment, int minimumStock) { + if (equipment.getHospital() == null) { + return; + } + String title = equipment.getQuantity() == 0 + ? "Out of stock: " + equipment.getName() + : "Low stock: " + equipment.getName(); + String detail = "{" + + "\"equipmentCode\":\"" + escapeJson(equipment.getEquipmentCode()) + "\"," + + "\"quantity\":" + equipment.getQuantity() + "," + + "\"minimumStock\":" + minimumStock + + "}"; + OperationsEvent.EventSeverity severity = equipment.getQuantity() == 0 + ? OperationsEvent.EventSeverity.CRITICAL + : OperationsEvent.EventSeverity.WARNING; + + eventPublisherService.publishEvent( + equipment.getHospital().getId(), + OperationsEvent.EventCategory.EQUIPMENT, + OperationsEvent.EventType.EQUIPMENT_LOW_STOCK, + title, + detail, + equipment.getId(), + OperationsEvent.EntityType.EQUIPMENT, + "system", + severity); + } public EquipmentUtilizationResponse getEquipmentUtilization(String username) { diff --git a/Backend/src/main/java/com/medtrack/service/EventPublisherService.java b/Backend/src/main/java/com/medtrack/service/EventPublisherService.java index 232ffdeb..bd22f0c8 100644 --- a/Backend/src/main/java/com/medtrack/service/EventPublisherService.java +++ b/Backend/src/main/java/com/medtrack/service/EventPublisherService.java @@ -27,6 +27,12 @@ public class EventPublisherService { public OperationsEvent publishEvent(OperationsEvent event) { OperationsEvent saved = eventRepository.save(event); // Broadcast to WebSocket subscribers + // ponytail: broadcast is hospital-wide and does not consult NotificationPreference, since + // EventWebSocketHandler tracks subscribers by hospitalId only, not userId - a muted user + // still receives the live push. Mute is enforced in OperationsEventController's REST feed + // and unread-counts (what ActivityCenter actually renders and polls every 30s), so the + // gap self-corrects quickly. Upgrade path if a muted push turns out to matter: track + // (session -> userId) in EventWebSocketHandler and filter broadcastToHospital per session. webSocketHandler.broadcastToHospital(event.getHospitalId(), saved); return saved; } diff --git a/Backend/src/main/java/com/medtrack/service/MaintenanceSlaAlertScheduler.java b/Backend/src/main/java/com/medtrack/service/MaintenanceSlaAlertScheduler.java new file mode 100644 index 00000000..9c269d5e --- /dev/null +++ b/Backend/src/main/java/com/medtrack/service/MaintenanceSlaAlertScheduler.java @@ -0,0 +1,52 @@ +package com.medtrack.service; + +import com.medtrack.model.Hospital; +import com.medtrack.repository.HospitalRepository; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Scheduled driver for SLA state recomputation and alerting. + * + *

Before this scheduler existed, {@link PreventiveMaintenanceService#refreshSla} only ran when + * a hospital user opened the SLA dashboard, so a task could sit breached for days with nobody + * notified if no one happened to load that page. This runs the same recomputation across every + * hospital on a timer and publishes {@code SLA_WARNING}/{@code SLA_BREACHED}/{@code SLA_ESCALATED} + * and {@code MAINTENANCE_OVERDUE} operations events on each state transition (see + * {@code publishSlaTransitionEvent}), so the Activity Center reflects overdue work proactively.

+ */ +@Service +@RequiredArgsConstructor +public class MaintenanceSlaAlertScheduler { + + private static final Logger log = LoggerFactory.getLogger(MaintenanceSlaAlertScheduler.class); + + private final HospitalRepository hospitalRepository; + private final PreventiveMaintenanceService preventiveMaintenanceService; + + /** + * Runs hourly by default (configurable via {@code app.maintenance.sla.alert.cron}). + */ + @Scheduled(cron = "${app.maintenance.sla.alert.cron:0 0 * * * *}") + public void runSlaSweep() { + log.debug("Running scheduled SLA sweep..."); + List hospitals = hospitalRepository.findAll(); + int processed = 0; + + for (Hospital hospital : hospitals) { + try { + preventiveMaintenanceService.refreshSlaForHospitalId(hospital.getId()); + processed++; + } catch (RuntimeException exception) { + log.warn("Scheduled SLA sweep failed for hospital {}: {}", hospital.getId(), exception.getMessage()); + } + } + + log.info("SLA sweep processed {} of {} hospitals", processed, hospitals.size()); + } +} diff --git a/Backend/src/main/java/com/medtrack/service/PreventiveMaintenanceService.java b/Backend/src/main/java/com/medtrack/service/PreventiveMaintenanceService.java index 62755c62..76d842cb 100644 --- a/Backend/src/main/java/com/medtrack/service/PreventiveMaintenanceService.java +++ b/Backend/src/main/java/com/medtrack/service/PreventiveMaintenanceService.java @@ -12,11 +12,13 @@ import com.medtrack.model.Equipment; import com.medtrack.model.EquipmentCategory; import com.medtrack.model.EquipmentStatus; +import com.medtrack.model.Hospital; import com.medtrack.model.MaintenanceGenerationRun; import com.medtrack.model.MaintenancePolicyRule; import com.medtrack.model.MaintenanceRuleScope; import com.medtrack.model.MaintenanceStatus; import com.medtrack.model.MaintenanceTask; +import com.medtrack.model.OperationsEvent; import com.medtrack.model.RecurrenceFrequency; import com.medtrack.model.SlaState; import com.medtrack.repository.EquipmentRepository; @@ -67,6 +69,7 @@ public class PreventiveMaintenanceService { private final HospitalRepository hospitalRepository; private final UserRepository userRepository; private final MaintenanceActivityService activityService; + private final EventPublisherService eventPublisherService; // ------------------------------------------------------------------ // Rule CRUD @@ -298,15 +301,36 @@ private MaintenanceGenerationRun generateTasksForRule(Long id, Long hospitalId, */ @Transactional public SlaSummaryResponse refreshSla(Authentication authentication) { - Long hospitalId = getHospitalForUser(authentication).getId(); + Hospital hospital = getHospitalForUser(authentication); + return refreshSlaForHospital(hospital); + } + + /** + * Hospital-agnostic entry point for {@link MaintenanceSlaAlertScheduler}: recomputes SLA + * state and escalation for one hospital without an {@link Authentication}, so the scheduler + * can sweep every hospital on a timer instead of only when a user opens the SLA dashboard. + */ + @Transactional + public SlaSummaryResponse refreshSlaForHospitalId(Long hospitalId) { + Hospital hospital = hospitalRepository.findById(hospitalId) + .orElseThrow(() -> new ResourceNotFoundException("Hospital not found")); + return refreshSlaForHospital(hospital); + } + + private SlaSummaryResponse refreshSlaForHospital(Hospital hospital) { + Long hospitalId = hospital.getId(); List openTasks = taskRepository.findByHospitalId(hospitalId).stream() .filter(task -> task.getStatus() != MaintenanceStatus.COMPLETED) .toList(); LocalDateTime now = LocalDateTime.now(); for (MaintenanceTask task : openTasks) { + SlaState previousState = task.getSlaState(); computeSlaState(task, now); taskRepository.save(task); + if (task.getSlaState() != previousState) { + publishSlaTransitionEvent(task); + } } // Escalate overdue critical tasks to the hospital account. @@ -314,12 +338,13 @@ public SlaSummaryResponse refreshSla(Authentication authentication) { .findByHospitalIdAndSlaStateAndStatusNot(hospitalId, SlaState.BREACHED, MaintenanceStatus.COMPLETED).stream() .filter(task -> "Critical".equalsIgnoreCase(task.getPriority())) .toList(); - User hospitalUser = getHospitalForUser(authentication).getUser(); + User hospitalUser = hospital.getUser(); for (MaintenanceTask task : breachedCritical) { if (task.getSlaState() != SlaState.ESCALATED) { task.setSlaState(SlaState.ESCALATED); task.setEscalatedTo(hospitalUser != null ? hospitalUser.getEmail() : null); taskRepository.save(task); + publishSlaEscalatedEvent(task); } } @@ -330,12 +355,75 @@ public SlaSummaryResponse refreshSla(Authentication authentication) { task.setSlaState(SlaState.ESCALATED); task.setEscalatedTo(hospitalUser != null ? hospitalUser.getEmail() : null); taskRepository.save(task); + publishSlaEscalatedEvent(task); } } return buildSlaSummary(hospitalId); } + /** + * Publishes the SLA-category event for a task's new state, and mirrors a breach into the + * maintenance feed as {@code MAINTENANCE_OVERDUE} - the same underlying fact ("this task + * missed its deadline") matters to both the SLA and maintenance Activity Center tabs. + */ + private void publishSlaTransitionEvent(MaintenanceTask task) { + if (task.getSlaState() == SlaState.WARNING) { + eventPublisherService.publishEvent( + task.getHospitalId(), + OperationsEvent.EventCategory.SLA, + OperationsEvent.EventType.SLA_WARNING, + "SLA warning: " + task.getEquipment(), + slaEventDetail(task), + task.getId(), + OperationsEvent.EntityType.MAINTENANCE_TASK, + "system", + OperationsEvent.EventSeverity.WARNING); + } else if (task.getSlaState() == SlaState.BREACHED) { + eventPublisherService.publishEvent( + task.getHospitalId(), + OperationsEvent.EventCategory.SLA, + OperationsEvent.EventType.SLA_BREACHED, + "SLA breached: " + task.getEquipment(), + slaEventDetail(task), + task.getId(), + OperationsEvent.EntityType.MAINTENANCE_TASK, + "system", + OperationsEvent.EventSeverity.CRITICAL); + eventPublisherService.publishEvent( + task.getHospitalId(), + OperationsEvent.EventCategory.MAINTENANCE, + OperationsEvent.EventType.MAINTENANCE_OVERDUE, + "Overdue: " + task.getEquipment(), + slaEventDetail(task), + task.getId(), + OperationsEvent.EntityType.MAINTENANCE_TASK, + "system", + OperationsEvent.EventSeverity.WARNING); + } + } + + private void publishSlaEscalatedEvent(MaintenanceTask task) { + eventPublisherService.publishEvent( + task.getHospitalId(), + OperationsEvent.EventCategory.SLA, + OperationsEvent.EventType.SLA_ESCALATED, + "SLA escalated: " + task.getEquipment(), + slaEventDetail(task), + task.getId(), + OperationsEvent.EntityType.MAINTENANCE_TASK, + "system", + OperationsEvent.EventSeverity.CRITICAL); + } + + private String slaEventDetail(MaintenanceTask task) { + return "{" + + "\"taskCode\":\"" + (task.getTaskCode() != null ? task.getTaskCode() : "") + "\"," + + "\"priority\":\"" + (task.getPriority() != null ? task.getPriority() : "") + "\"," + + "\"deadline\":\"" + (task.getDeadline() != null ? task.getDeadline() : "") + "\"" + + "}"; + } + private void computeSlaState(MaintenanceTask task, LocalDateTime now) { if (task.getDeadline() == null) { task.setSlaState(SlaState.UPCOMING); @@ -664,7 +752,7 @@ private void validateWindow(LocalDate start, LocalDate end) { } } - private com.medtrack.model.Hospital getHospitalForUser(Authentication authentication) { + private Hospital getHospitalForUser(Authentication authentication) { if (authentication == null || authentication.getName() == null || authentication.getName().isBlank()) { throw new AccessDeniedException("An active hospital account is required"); } diff --git a/Backend/src/test/java/com/medtrack/service/EquipmentServiceTest.java b/Backend/src/test/java/com/medtrack/service/EquipmentServiceTest.java index ecda3b34..26a83e8e 100644 --- a/Backend/src/test/java/com/medtrack/service/EquipmentServiceTest.java +++ b/Backend/src/test/java/com/medtrack/service/EquipmentServiceTest.java @@ -12,6 +12,7 @@ import com.medtrack.model.EquipmentImportAuditLog; import com.medtrack.model.EquipmentStatus; import com.medtrack.model.Hospital; +import com.medtrack.model.OperationsEvent; import com.medtrack.repository.EquipmentImportAuditLogRepository; import com.medtrack.repository.EquipmentRepository; import com.medtrack.repository.HospitalRepository; @@ -50,6 +51,9 @@ public class EquipmentServiceTest { @Mock private EquipmentImportAuditLogRepository equipmentImportAuditLogRepository; + @Mock + private EventPublisherService eventPublisherService; + @InjectMocks private EquipmentService equipmentService; @@ -146,6 +150,55 @@ void generateQrCodeBase64_Success() { assertTrue(base64Qr.matches("^[a-zA-Z0-9+/\\s=]+$")); } + @Test + void adjustStock_CrossingIntoLowStock_PublishesEvent() { + mockEquipment.setQuantity(15); + mockEquipment.setMinimumStock(10); + + when(userRepository.findByUsername(username)).thenReturn(Optional.of(mockUser)); + when(hospitalRepository.findByUserId(mockUser.getId())).thenReturn(Optional.of(mockHospital)); + when(equipmentRepository.findByIdAndHospitalId(100L, mockHospital.getId())).thenReturn(Optional.of(mockEquipment)); + when(equipmentRepository.save(any(Equipment.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + com.medtrack.dto.StockAdjustmentRequest request = com.medtrack.dto.StockAdjustmentRequest.builder() + .delta(-8) + .build(); + + Equipment result = equipmentService.adjustStock(100L, request, username); + + assertEquals(7, result.getQuantity()); + verify(eventPublisherService).publishEvent( + eq(mockHospital.getId()), + eq(OperationsEvent.EventCategory.EQUIPMENT), + eq(OperationsEvent.EventType.EQUIPMENT_LOW_STOCK), + any(String.class), + any(String.class), + eq(mockEquipment.getId()), + eq(OperationsEvent.EntityType.EQUIPMENT), + eq("system"), + eq(OperationsEvent.EventSeverity.WARNING)); + } + + @Test + void adjustStock_AlreadyLowStock_DoesNotRepublishEvent() { + mockEquipment.setQuantity(8); + mockEquipment.setMinimumStock(10); + + when(userRepository.findByUsername(username)).thenReturn(Optional.of(mockUser)); + when(hospitalRepository.findByUserId(mockUser.getId())).thenReturn(Optional.of(mockHospital)); + when(equipmentRepository.findByIdAndHospitalId(100L, mockHospital.getId())).thenReturn(Optional.of(mockEquipment)); + when(equipmentRepository.save(any(Equipment.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + com.medtrack.dto.StockAdjustmentRequest request = com.medtrack.dto.StockAdjustmentRequest.builder() + .delta(2) + .build(); + + equipmentService.adjustStock(100L, request, username); + + verify(eventPublisherService, never()).publishEvent( + any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + @Test void importEquipmentFromCsv_Success() { when(userRepository.findByUsername(username)).thenReturn(Optional.of(mockUser)); diff --git a/Backend/src/test/java/com/medtrack/service/EquipmentStockServiceTest.java b/Backend/src/test/java/com/medtrack/service/EquipmentStockServiceTest.java index 0cc9fd09..1d2ce59e 100644 --- a/Backend/src/test/java/com/medtrack/service/EquipmentStockServiceTest.java +++ b/Backend/src/test/java/com/medtrack/service/EquipmentStockServiceTest.java @@ -51,6 +51,9 @@ class EquipmentStockServiceTest { @Mock private UserRepository userRepository; + @Mock + private EventPublisherService eventPublisherService; + @InjectMocks private EquipmentService equipmentService; diff --git a/Backend/src/test/java/com/medtrack/service/PreventiveMaintenanceServiceTest.java b/Backend/src/test/java/com/medtrack/service/PreventiveMaintenanceServiceTest.java index 56175c78..d944d1e6 100644 --- a/Backend/src/test/java/com/medtrack/service/PreventiveMaintenanceServiceTest.java +++ b/Backend/src/test/java/com/medtrack/service/PreventiveMaintenanceServiceTest.java @@ -36,6 +36,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -67,6 +68,9 @@ class PreventiveMaintenanceServiceTest { @Mock private MaintenanceActivityService activityService; + @Mock + private EventPublisherService eventPublisherService; + @Mock private Authentication authentication; @@ -276,6 +280,72 @@ void exactWindowRerunReturnsTheExistingRunWithoutGeneratingAgain() { verify(runRepository, never()).save(any(MaintenanceGenerationRun.class)); } + @Test + void refreshSlaPublishesBreachedAndOverdueEventsOnTransition() { + MaintenanceTask task = MaintenanceTask.builder() + .id(500L) + .taskCode("MNT-1") + .equipment("MRI Scanner") + .hospitalId(hospital.getId()) + .hospital(hospital.getName()) + .status(MaintenanceStatus.SCHEDULED) + .priority("High") + .deadline(LocalDate.now().minusDays(10)) + .slaState(com.medtrack.model.SlaState.UPCOMING) + .build(); + + when(taskRepository.findByHospitalId(hospital.getId())).thenReturn(List.of(task)); + when(taskRepository.save(any(MaintenanceTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(taskRepository.findByHospitalIdAndSlaStateAndStatusNot( + hospital.getId(), com.medtrack.model.SlaState.BREACHED, MaintenanceStatus.COMPLETED)) + .thenReturn(List.of()); + when(taskRepository.findUnassignedByPriority(any(), any(), any())).thenReturn(List.of()); + + service.refreshSla(authentication); + + verify(eventPublisherService).publishEvent( + eq(hospital.getId()), + eq(com.medtrack.model.OperationsEvent.EventCategory.SLA), + eq(com.medtrack.model.OperationsEvent.EventType.SLA_BREACHED), + any(String.class), any(String.class), eq(500L), + eq(com.medtrack.model.OperationsEvent.EntityType.MAINTENANCE_TASK), + eq("system"), eq(com.medtrack.model.OperationsEvent.EventSeverity.CRITICAL)); + verify(eventPublisherService).publishEvent( + eq(hospital.getId()), + eq(com.medtrack.model.OperationsEvent.EventCategory.MAINTENANCE), + eq(com.medtrack.model.OperationsEvent.EventType.MAINTENANCE_OVERDUE), + any(String.class), any(String.class), eq(500L), + eq(com.medtrack.model.OperationsEvent.EntityType.MAINTENANCE_TASK), + eq("system"), eq(com.medtrack.model.OperationsEvent.EventSeverity.WARNING)); + } + + @Test + void refreshSlaDoesNotRepublishWhenStateIsUnchanged() { + MaintenanceTask task = MaintenanceTask.builder() + .id(501L) + .taskCode("MNT-2") + .equipment("MRI Scanner") + .hospitalId(hospital.getId()) + .hospital(hospital.getName()) + .status(MaintenanceStatus.SCHEDULED) + .priority("Low") + .deadline(LocalDate.now().plusDays(30)) + .slaState(com.medtrack.model.SlaState.UPCOMING) + .build(); + + when(taskRepository.findByHospitalId(hospital.getId())).thenReturn(List.of(task)); + when(taskRepository.save(any(MaintenanceTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(taskRepository.findByHospitalIdAndSlaStateAndStatusNot( + hospital.getId(), com.medtrack.model.SlaState.BREACHED, MaintenanceStatus.COMPLETED)) + .thenReturn(List.of()); + when(taskRepository.findUnassignedByPriority(any(), any(), any())).thenReturn(List.of()); + + service.refreshSla(authentication); + + verify(eventPublisherService, never()).publishEvent( + any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + private MaintenanceTaskRepository.GeneratedOccurrence occurrence( Long equipmentRecordId, LocalDate deadline) { From a8c2a02e388f75a4c0cf45fb1da8e584369e6ab2 Mon Sep 17 00:00:00 2001 From: Gautam-Bharadwaj <136326437+Gautam-Bharadwaj@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:38:55 +0530 Subject: [PATCH 005/412] feat(notifications): add per-user event category mute preferences New NotificationPreference entity/table (one row per user+category that has been muted; absence of a row means not muted). OperationsEventController's event list and unread-counts endpoints now exclude muted categories from the default view and zero their counts, while an explicit category filter still returns the data - muting quiets the default feed, it doesn't delete access. REST API: GET/PUT /api/notifications/preferences. --- .../NotificationPreferenceController.java | 40 ++++++ .../dto/NotificationPreferenceResponse.java | 22 +++ .../NotificationPreferenceUpdateRequest.java | 24 ++++ .../model/NotificationPreference.java | 48 +++++++ .../NotificationPreferenceRepository.java | 29 ++++ .../NotificationPreferenceService.java | 79 +++++++++++ .../h2/V13__add_notification_preferences.sql | 13 ++ .../V13__add_notification_preferences.sql | 13 ++ .../FlywayMigrationConsistencyTest.java | 1 + .../NotificationPreferenceServiceTest.java | 127 ++++++++++++++++++ 10 files changed, 396 insertions(+) create mode 100644 Backend/src/main/java/com/medtrack/controller/NotificationPreferenceController.java create mode 100644 Backend/src/main/java/com/medtrack/dto/NotificationPreferenceResponse.java create mode 100644 Backend/src/main/java/com/medtrack/dto/NotificationPreferenceUpdateRequest.java create mode 100644 Backend/src/main/java/com/medtrack/model/NotificationPreference.java create mode 100644 Backend/src/main/java/com/medtrack/repository/NotificationPreferenceRepository.java create mode 100644 Backend/src/main/java/com/medtrack/service/NotificationPreferenceService.java create mode 100644 Backend/src/main/resources/db/migration/h2/V13__add_notification_preferences.sql create mode 100644 Backend/src/main/resources/db/migration/mysql/V13__add_notification_preferences.sql create mode 100644 Backend/src/test/java/com/medtrack/service/NotificationPreferenceServiceTest.java diff --git a/Backend/src/main/java/com/medtrack/controller/NotificationPreferenceController.java b/Backend/src/main/java/com/medtrack/controller/NotificationPreferenceController.java new file mode 100644 index 00000000..210d5031 --- /dev/null +++ b/Backend/src/main/java/com/medtrack/controller/NotificationPreferenceController.java @@ -0,0 +1,40 @@ +package com.medtrack.controller; + +import com.medtrack.dto.NotificationPreferenceResponse; +import com.medtrack.dto.NotificationPreferenceUpdateRequest; +import com.medtrack.service.NotificationPreferenceService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller for the authenticated user's per-category notification mute preferences. + */ +@RestController +@RequestMapping("/api/notifications/preferences") +@RequiredArgsConstructor +@CrossOrigin(origins = "http://localhost:3000") +public class NotificationPreferenceController { + + private final NotificationPreferenceService preferenceService; + + @GetMapping + public ResponseEntity getPreferences(Authentication authentication) { + return ResponseEntity.ok(preferenceService.getPreferences(authentication)); + } + + @PutMapping + public ResponseEntity updatePreference( + @Valid @RequestBody NotificationPreferenceUpdateRequest request, + Authentication authentication) { + return ResponseEntity.ok(preferenceService.setPreference( + authentication, request.getCategory(), request.getMuted())); + } +} diff --git a/Backend/src/main/java/com/medtrack/dto/NotificationPreferenceResponse.java b/Backend/src/main/java/com/medtrack/dto/NotificationPreferenceResponse.java new file mode 100644 index 00000000..234439d9 --- /dev/null +++ b/Backend/src/main/java/com/medtrack/dto/NotificationPreferenceResponse.java @@ -0,0 +1,22 @@ +package com.medtrack.dto; + +import com.medtrack.model.OperationsEvent; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * Response DTO listing mute state per event category for the authenticated user. + * Categories absent from a stored preference are reported as not muted. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationPreferenceResponse { + + private Map muted; +} diff --git a/Backend/src/main/java/com/medtrack/dto/NotificationPreferenceUpdateRequest.java b/Backend/src/main/java/com/medtrack/dto/NotificationPreferenceUpdateRequest.java new file mode 100644 index 00000000..f25463c6 --- /dev/null +++ b/Backend/src/main/java/com/medtrack/dto/NotificationPreferenceUpdateRequest.java @@ -0,0 +1,24 @@ +package com.medtrack.dto; + +import com.medtrack.model.OperationsEvent; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Request DTO for muting or unmuting one event category for the authenticated user. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationPreferenceUpdateRequest { + + @NotNull + private OperationsEvent.EventCategory category; + + @NotNull + private Boolean muted; +} diff --git a/Backend/src/main/java/com/medtrack/model/NotificationPreference.java b/Backend/src/main/java/com/medtrack/model/NotificationPreference.java new file mode 100644 index 00000000..6391bd1b --- /dev/null +++ b/Backend/src/main/java/com/medtrack/model/NotificationPreference.java @@ -0,0 +1,48 @@ +package com.medtrack.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +/** + * Per-user mute preference for one {@link OperationsEvent.EventCategory}. + * Absence of a row for a (user, category) pair means that category is not muted. + */ +@Entity +@Table(name = "notification_preferences", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "category"})) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationPreference { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private OperationsEvent.EventCategory category; + + @Builder.Default + @Column(nullable = false) + private Boolean muted = Boolean.TRUE; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/Backend/src/main/java/com/medtrack/repository/NotificationPreferenceRepository.java b/Backend/src/main/java/com/medtrack/repository/NotificationPreferenceRepository.java new file mode 100644 index 00000000..08cd2b39 --- /dev/null +++ b/Backend/src/main/java/com/medtrack/repository/NotificationPreferenceRepository.java @@ -0,0 +1,29 @@ +package com.medtrack.repository; + +import com.medtrack.model.NotificationPreference; +import com.medtrack.model.OperationsEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Repository for per-user notification category mute preferences. + */ +@Repository +public interface NotificationPreferenceRepository extends JpaRepository { + + List findByUserId(Long userId); + + Optional findByUserIdAndCategory(Long userId, OperationsEvent.EventCategory category); + + List findByUserIdAndMutedTrue(Long userId); + + default Set mutedCategoriesFor(Long userId) { + return findByUserIdAndMutedTrue(userId).stream() + .map(NotificationPreference::getCategory) + .collect(java.util.stream.Collectors.toSet()); + } +} diff --git a/Backend/src/main/java/com/medtrack/service/NotificationPreferenceService.java b/Backend/src/main/java/com/medtrack/service/NotificationPreferenceService.java new file mode 100644 index 00000000..24cb54f3 --- /dev/null +++ b/Backend/src/main/java/com/medtrack/service/NotificationPreferenceService.java @@ -0,0 +1,79 @@ +package com.medtrack.service; + +import com.medtrack.auth.model.User; +import com.medtrack.auth.repository.UserRepository; +import com.medtrack.dto.NotificationPreferenceResponse; +import com.medtrack.model.NotificationPreference; +import com.medtrack.model.OperationsEvent; +import com.medtrack.repository.NotificationPreferenceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.EnumMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Per-user notification category mute preferences, backing the Activity Center's + * "mute this category" control. A missing row for a category means it is not muted. + */ +@Service +@RequiredArgsConstructor +public class NotificationPreferenceService { + + private final NotificationPreferenceRepository preferenceRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public NotificationPreferenceResponse getPreferences(Authentication authentication) { + Long userId = getAuthenticatedUserId(authentication); + return new NotificationPreferenceResponse(mutedMapFor(userId)); + } + + @Transactional + public NotificationPreferenceResponse setPreference(Authentication authentication, + OperationsEvent.EventCategory category, + boolean muted) { + if (category == null) { + throw new IllegalArgumentException("Category is required"); + } + Long userId = getAuthenticatedUserId(authentication); + + NotificationPreference preference = preferenceRepository + .findByUserIdAndCategory(userId, category) + .orElseGet(() -> NotificationPreference.builder() + .userId(userId) + .category(category) + .build()); + preference.setMuted(muted); + preferenceRepository.save(preference); + + return new NotificationPreferenceResponse(mutedMapFor(userId)); + } + + private Map mutedMapFor(Long userId) { + Map result = new EnumMap<>(OperationsEvent.EventCategory.class); + for (OperationsEvent.EventCategory category : OperationsEvent.EventCategory.values()) { + result.put(category, Boolean.FALSE); + } + List stored = preferenceRepository.findByUserId(userId); + for (NotificationPreference preference : stored) { + result.put(preference.getCategory(), Boolean.TRUE.equals(preference.getMuted())); + } + return result; + } + + private Long getAuthenticatedUserId(Authentication authentication) { + if (authentication == null || authentication.getName() == null || authentication.getName().isBlank()) { + throw new AccessDeniedException("An authenticated account is required"); + } + String normalizedEmail = authentication.getName().trim().toLowerCase(Locale.ROOT); + User user = userRepository.findByEmail(normalizedEmail) + .orElseThrow(() -> new AccessDeniedException("An authenticated account is required")); + return user.getId(); + } +} diff --git a/Backend/src/main/resources/db/migration/h2/V13__add_notification_preferences.sql b/Backend/src/main/resources/db/migration/h2/V13__add_notification_preferences.sql new file mode 100644 index 00000000..09ad4ad6 --- /dev/null +++ b/Backend/src/main/resources/db/migration/h2/V13__add_notification_preferences.sql @@ -0,0 +1,13 @@ +-- Flyway migration V13: Add per-user notification category mute preferences + +CREATE TABLE IF NOT EXISTS notification_preferences ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + category VARCHAR(32) NOT NULL, + muted BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_notification_preferences_user_category UNIQUE (user_id, category) +); + +CREATE INDEX IF NOT EXISTS idx_notification_preferences_user ON notification_preferences(user_id); diff --git a/Backend/src/main/resources/db/migration/mysql/V13__add_notification_preferences.sql b/Backend/src/main/resources/db/migration/mysql/V13__add_notification_preferences.sql new file mode 100644 index 00000000..f466e48b --- /dev/null +++ b/Backend/src/main/resources/db/migration/mysql/V13__add_notification_preferences.sql @@ -0,0 +1,13 @@ +-- Flyway migration V13: Add per-user notification category mute preferences (MySQL) + +CREATE TABLE notification_preferences ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + category VARCHAR(32) NOT NULL, + muted BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_notification_preferences_user_category UNIQUE (user_id, category) +); + +CREATE INDEX idx_notification_preferences_user ON notification_preferences(user_id); diff --git a/Backend/src/test/java/com/medtrack/architecture/FlywayMigrationConsistencyTest.java b/Backend/src/test/java/com/medtrack/architecture/FlywayMigrationConsistencyTest.java index 7ea47dab..e9694e8b 100644 --- a/Backend/src/test/java/com/medtrack/architecture/FlywayMigrationConsistencyTest.java +++ b/Backend/src/test/java/com/medtrack/architecture/FlywayMigrationConsistencyTest.java @@ -66,6 +66,7 @@ class FlywayMigrationConsistencyTest { "equipment_lifecycle_actions", "operations_events", "event_read_receipts", + "notification_preferences", "procurement_requests", "approval_policies", "approval_policy_steps", diff --git a/Backend/src/test/java/com/medtrack/service/NotificationPreferenceServiceTest.java b/Backend/src/test/java/com/medtrack/service/NotificationPreferenceServiceTest.java new file mode 100644 index 00000000..365e0eea --- /dev/null +++ b/Backend/src/test/java/com/medtrack/service/NotificationPreferenceServiceTest.java @@ -0,0 +1,127 @@ +package com.medtrack.service; + +import com.medtrack.auth.model.User; +import com.medtrack.auth.repository.UserRepository; +import com.medtrack.dto.NotificationPreferenceResponse; +import com.medtrack.model.NotificationPreference; +import com.medtrack.model.OperationsEvent; +import com.medtrack.repository.NotificationPreferenceRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NotificationPreferenceServiceTest { + + private static final String EMAIL = "hospital@medtrack.com"; + + @Mock + private NotificationPreferenceRepository preferenceRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private Authentication authentication; + + @InjectMocks + private NotificationPreferenceService service; + + private User user; + + @BeforeEach + void setUp() { + user = User.builder().id(42L).email(EMAIL).build(); + } + + @Test + void getPreferences_DefaultsEveryCategoryToNotMuted() { + when(authentication.getName()).thenReturn(EMAIL); + when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + when(preferenceRepository.findByUserId(42L)).thenReturn(List.of()); + + NotificationPreferenceResponse response = service.getPreferences(authentication); + + assertEquals(OperationsEvent.EventCategory.values().length, response.getMuted().size()); + assertTrue(response.getMuted().values().stream().allMatch(muted -> !muted)); + } + + @Test + void getPreferences_ReflectsStoredMuteState() { + when(authentication.getName()).thenReturn(EMAIL); + when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + when(preferenceRepository.findByUserId(42L)).thenReturn(List.of( + NotificationPreference.builder() + .userId(42L) + .category(OperationsEvent.EventCategory.SHIPMENT) + .muted(true) + .build())); + + NotificationPreferenceResponse response = service.getPreferences(authentication); + + assertTrue(response.getMuted().get(OperationsEvent.EventCategory.SHIPMENT)); + assertFalse(response.getMuted().get(OperationsEvent.EventCategory.EQUIPMENT)); + } + + @Test + void setPreference_CreatesRowWhenNoneExists() { + when(authentication.getName()).thenReturn(EMAIL); + when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + when(preferenceRepository.findByUserIdAndCategory(42L, OperationsEvent.EventCategory.MAINTENANCE)) + .thenReturn(Optional.empty()); + when(preferenceRepository.findByUserId(42L)).thenReturn(List.of()); + + service.setPreference(authentication, OperationsEvent.EventCategory.MAINTENANCE, true); + + ArgumentCaptor captor = ArgumentCaptor.forClass(NotificationPreference.class); + verify(preferenceRepository).save(captor.capture()); + assertEquals(42L, captor.getValue().getUserId()); + assertEquals(OperationsEvent.EventCategory.MAINTENANCE, captor.getValue().getCategory()); + assertTrue(captor.getValue().getMuted()); + } + + @Test + void setPreference_UpdatesExistingRowInsteadOfDuplicating() { + NotificationPreference existing = NotificationPreference.builder() + .id(9L) + .userId(42L) + .category(OperationsEvent.EventCategory.SLA) + .muted(true) + .build(); + when(authentication.getName()).thenReturn(EMAIL); + when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + when(preferenceRepository.findByUserIdAndCategory(42L, OperationsEvent.EventCategory.SLA)) + .thenReturn(Optional.of(existing)); + when(preferenceRepository.findByUserId(42L)).thenReturn(List.of(existing)); + + service.setPreference(authentication, OperationsEvent.EventCategory.SLA, false); + + ArgumentCaptor captor = ArgumentCaptor.forClass(NotificationPreference.class); + verify(preferenceRepository).save(captor.capture()); + assertEquals(9L, captor.getValue().getId()); + assertFalse(captor.getValue().getMuted()); + } + + @Test + void getPreferences_RejectsUnauthenticatedCaller() { + when(authentication.getName()).thenReturn(null); + + assertThrows(AccessDeniedException.class, () -> service.getPreferences(authentication)); + } +} From db49c43de328960097819ac3d958243e51ed8f7d Mon Sep 17 00:00:00 2001 From: Gautam-Bharadwaj <136326437+Gautam-Bharadwaj@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:39:02 +0530 Subject: [PATCH 006/412] feat(notifications): add category mute controls to Activity Center Each category chip in the Activity Center filter row gets a mute/unmute toggle backed by the new preferences API. Muted state is loaded on mount and persisted on toggle; the event list and unread badge refresh to reflect it immediately. --- src/pages/hospital/ActivityCenter.jsx | 69 +++++++++++++++---- src/pages/hospital/ActivityCenter.test.jsx | 59 ++++++++++++++++ src/services/NotificationPreferenceService.js | 23 +++++++ 3 files changed, 137 insertions(+), 14 deletions(-) create mode 100644 src/pages/hospital/ActivityCenter.test.jsx create mode 100644 src/services/NotificationPreferenceService.js diff --git a/src/pages/hospital/ActivityCenter.jsx b/src/pages/hospital/ActivityCenter.jsx index f5defda1..4db1fca5 100644 --- a/src/pages/hospital/ActivityCenter.jsx +++ b/src/pages/hospital/ActivityCenter.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { eventStream, getEvents, getUnreadCounts, markEventsAsRead, markAllEventsAsRead } from '../../services/EventStreamService'; +import { getNotificationPreferences, setNotificationPreference } from '../../services/NotificationPreferenceService'; import { useAuth } from '../../context/AuthContext'; import { getLocalDemoEvents, saveLocalDemoEvents } from '../../components/hospital/ActivityCenterDemoEvents'; import ActivityCenterEventDetailModal from '../../components/hospital/ActivityCenterEventDetailModal'; @@ -29,6 +30,7 @@ const ActivityCenter = ({ onClose, onNavigate }) => { const [filterCategory, setFilterCategory] = useState(null); const [showUnreadOnly, setShowUnreadOnly] = useState(false); const [connected, setConnected] = useState(false); + const [mutedCategories, setMutedCategories] = useState({}); // Selected event for detail modal const [selectedEventModal, setSelectedEventModal] = useState(null); @@ -86,10 +88,35 @@ const ActivityCenter = ({ onClose, onNavigate }) => { } }, []); + const loadMutedCategories = useCallback(async () => { + try { + const data = await getNotificationPreferences(); + setMutedCategories(data.muted || {}); + } catch (err) { + console.warn('Backend API unavailable, notification preferences default to unmuted'); + } + }, []); + useEffect(() => { loadEvents(0, false); loadUnreadCounts(); - }, [loadEvents, loadUnreadCounts]); + loadMutedCategories(); + }, [loadEvents, loadUnreadCounts, loadMutedCategories]); + + const handleToggleMute = async (category, event) => { + event.stopPropagation(); + const nextMuted = !mutedCategories[category]; + setMutedCategories(prev => ({ ...prev, [category]: nextMuted })); + try { + await setNotificationPreference(category, nextMuted); + } catch (err) { + console.warn('Backend API unavailable, mute preference not persisted'); + } + loadUnreadCounts(); + if (!filterCategory) { + loadEvents(0, false); + } + }; useEffect(() => { if (!user?.token) return; @@ -220,25 +247,39 @@ const ActivityCenter = ({ onClose, onNavigate }) => {
{categories.map(cat => ( - + {cat.value && ( + )} - + ))}
); })()} + + {/* Disposal records (issue #744) */} +
+
+

Disposal / Decommission Records

+ +
+ {disposalRecordsLoading ? ( +

Loading disposal records...

+ ) : disposalRecords.length === 0 ? ( +

+ No decommission requests recorded for this asset. +

+ ) : ( + disposalRecords.map((disposal) => ( +
+
+
+

+ {disposal.disposalMethod?.replaceAll("_", " ")} + + {disposal.status?.replaceAll("_", " ")} + + {disposal.certificateNumber && ( + + {disposal.certificateNumber} + + )} +

+

+ {disposal.disposalReason || "No reason recorded"} + {disposal.storesPatientData + ? ` · ${disposal.dataSanitizationConfirmed ? "Data sanitised" : "Sanitisation pending"}` + : " · No stored patient data"} +

+
+
+ {disposal.status === "PENDING_APPROVAL" && ( + <> + + + + )} + {disposal.status === "APPROVED" && ( + <> + {disposal.storesPatientData && !disposal.dataSanitizationConfirmed && ( + + )} + + + )} + {disposal.status === "COMPLETED" && ( + + )} +
+
+
+ )) + )} +
)} @@ -1366,6 +1622,248 @@ export default function EquipmentList({ onNavigate }) { )} + {/* Retirement / Disposal Workflow Modal (issue #744) */} + {disposalOpen && ( +
+
+ + +
+
+ 🏁 +
+
+

+ Retire / Dispose Equipment +

+

+ Decommission{" "} + + {disposalTarget?.name} ({disposalTarget?.id}) + {" "} + with a documented, approvable record and certificate of disposal. +

+
+
+ + {/* Step indicator */} +
+ {["Disposal Details", "Data Sanitisation", "Review & Submit"].map((label, index) => { + const step = index + 1; + return ( +
+ = step ? "bg-slate-700 text-white" : "bg-subtle text-secondary" + }`} + > + {step} + + = step ? "text-primary" : "text-secondary"}> + {label} + + {step < 3 && } +
+ ); + })} +
+ + {disposalSuccess ? ( +
+

+ ✓ Disposal request submitted +

+

{disposalSuccess}

+
+ +
+
+ ) : ( +
+ {disposalStep === 1 && ( +
+
+ + +
+
+ +