mirror of
https://github.com/nprasad2077/medicare_enrollment_dashboard.git
synced 2026-11-04 04:44:24 +00:00
design
This commit is contained in:
1 parent
b0cd4cff09
commit
baae2907e8
19 files changed
+2079
-2125
No files matched your search
@@ -1 +1,43 @@
|
||||
# medicare_enrollment_dashboard
|
||||
# Medicare Enrollment Dashboard
|
||||
|
||||
An interactive analytics dashboard for CMS Medicare Monthly Enrollment data. Visualizes enrollment trends, plan type breakdowns, and geographic penetration rates across all US states and counties.
|
||||
|
||||
## Running the App
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Switch views** — Use the toggle in the top-right to switch between Hospital/Medical and Prescription Drug enrollment data.
|
||||
2. **Read the KPI cards** — The four summary cards at the top show total enrollment, plan type counts, and penetration rate for the current view.
|
||||
3. **Explore the map** — Hover over states to see a tooltip with penetration percentages. Click a state to drill down.
|
||||
4. **Drill into a state** — When you click a state, the right sidebar shows that state's enrollment summary and a scrollable list of all its counties with enrollment counts and penetration rates.
|
||||
5. **Analyze trends** — Below the map, toggle between Yearly and 12-Month views to see enrollment count and percent-of-total charts. When a state is selected, trends update to show that state's data.
|
||||
6. **Return to overview** — Click the ✕ button in the sidebar header to deselect the state and return to the top-10 states overview.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dual view modes**: Hospital/Medical (MA vs FFS) and Prescription Drug (MAPD vs PDP)
|
||||
- **Interactive choropleth map**: State-level penetration rates with click-to-drill-down
|
||||
- **Contextual sidebar**: Selecting a state reveals county-level breakdown with enrollment counts and penetration bars — no redundant grid views
|
||||
- **Trend charts**: Yearly and 12-month enrollment count (line) and percent-of-total (stacked bar)
|
||||
- **Summary KPI cards**: Total enrollment, plan type counts, and penetration rate at a glance
|
||||
- **Live CMS data**: Fetches directly from the data.cms.gov API with react-query caching
|
||||
|
||||
## Design
|
||||
|
||||
Modernized UI based on a Figma analytics dashboard template:
|
||||
|
||||
- **Typography**: Inter font with semibold/medium/normal weight hierarchy
|
||||
- **Color system**: Neutral gray palette with accessible chart colors (sky blue, emerald, amber, red, violet)
|
||||
- **Layout**: Centered max-width container, responsive 3-column grid (map + trends | sidebar)
|
||||
- **Components**: Rounded-xl cards with subtle borders, pill-style segmented controls, colored icon badges
|
||||
- **Stack**: React 19, Vite 8, Tailwind CSS 4, Recharts, Leaflet, lucide-react, Radix UI primitives
|
||||
|
||||
## Data Source
|
||||
|
||||
[CMS Medicare Monthly Enrollment](https://data.cms.gov/summary-statistics-on-beneficiary-enrollment/medicare-and-medicaid-reports/medicare-monthly-enrollment) — National, State, and County level enrollment counts.
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<title>Medicare Enrollment Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+1476
-1630
File diff suppressed because it is too large.
Load diff
+12
-14
@@ -1,37 +1,35 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@duckdb/duckdb-wasm": "^1.29.0",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"ag-grid-community": "^33.2.4",
|
||||
"ag-grid-react": "^33.2.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.487.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"tslib": "^2.8.1"
|
||||
"recharts": "^2.15.2",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tw-animate-css": "^1.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/leaflet": "^1.9.17",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: {} }
|
||||
@@ -1,74 +0,0 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
|
||||
|
||||
.loading { display: flex; align-items: center; justify-content: center; height: 100vh; font-size: 1.5rem; color: #1a3a5c; }
|
||||
|
||||
.dashboard { min-height: 100vh; }
|
||||
|
||||
.header { background: #1a3a5c; color: white; padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.header h1 { font-size: 1.8rem; font-weight: 700; }
|
||||
.view-toggle { display: flex; flex-direction: column; gap: 4px; }
|
||||
.view-toggle button { padding: 8px 16px; border: 2px solid white; background: transparent; color: white; cursor: pointer; font-size: 0.85rem; font-weight: 600; border-radius: 4px; text-align: left; }
|
||||
.view-toggle button.active { background: white; color: #1a3a5c; }
|
||||
|
||||
.dashboard-body { display: flex; gap: 16px; padding: 16px; }
|
||||
.left-panel { flex: 1; min-width: 0; }
|
||||
.right-panel { width: 500px; flex-shrink: 0; }
|
||||
|
||||
.summary { display: flex; align-items: center; justify-content: center; gap: 32px; padding: 16px; background: white; border-radius: 8px; margin-bottom: 12px; border: 2px solid #1a3a5c; }
|
||||
.summary-side { text-align: center; font-weight: 700; }
|
||||
.summary-side .value { font-size: 1.3rem; color: #1a3a5c; }
|
||||
.summary-side .pct { font-size: 0.9rem; color: #666; }
|
||||
.summary-center { text-align: center; }
|
||||
.summary-center .total { font-size: 2rem; font-weight: 800; color: #1a3a5c; }
|
||||
.summary-center .label { font-size: 0.85rem; color: #666; }
|
||||
|
||||
.tab-bar { display: flex; gap: 0; margin-bottom: 8px; }
|
||||
.tab-bar button { padding: 8px 20px; border: 2px solid #1a3a5c; background: white; color: #1a3a5c; cursor: pointer; font-weight: 600; font-size: 0.85rem; }
|
||||
.tab-bar button:first-child { border-radius: 4px 0 0 4px; }
|
||||
.tab-bar button:last-child { border-radius: 0 4px 4px 0; }
|
||||
.tab-bar button.active { background: #1a3a5c; color: white; }
|
||||
|
||||
.map-panel, .grid-panel { background: white; border: 2px solid #1a3a5c; border-radius: 8px; padding: 12px; min-height: 500px; }
|
||||
.map-panel h3 { font-size: 0.9rem; color: #333; margin-bottom: 8px; }
|
||||
|
||||
.trend-panel { background: white; border: 2px solid #1a3a5c; border-radius: 8px; padding: 12px; }
|
||||
.trend-panel .tab-bar { justify-content: center; margin-bottom: 12px; }
|
||||
.trend-section { margin-bottom: 16px; }
|
||||
.trend-section h3 { font-size: 0.9rem; color: #333; margin-bottom: 4px; border-bottom: 1px solid #eee; padding-bottom: 4px; }
|
||||
|
||||
.state-detail { position: fixed; bottom: 0; left: 0; right: 0; background: white; border-top: 3px solid #1a3a5c; box-shadow: 0 -4px 20px rgba(0,0,0,0.2); z-index: 1000; max-height: 60vh; overflow-y: auto; }
|
||||
.state-detail-header { background: #1a3a5c; color: white; padding: 10px 20px; display: flex; justify-content: space-between; align-items: center; font-weight: 700; }
|
||||
.state-detail-header button { background: none; border: none; color: white; font-size: 1.2rem; cursor: pointer; }
|
||||
.state-detail-body { display: flex; gap: 16px; padding: 16px; }
|
||||
.state-detail-body .county-table { flex: 1; }
|
||||
.state-detail-body .state-trends { flex: 1; }
|
||||
|
||||
.data-grid { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
|
||||
.data-grid th { background: #f0f0f0; padding: 6px 10px; text-align: left; border-bottom: 2px solid #ddd; position: sticky; top: 0; }
|
||||
.data-grid td { padding: 5px 10px; border-bottom: 1px solid #eee; }
|
||||
.data-grid tr:hover td { background: #f8f8f8; }
|
||||
.data-grid tr.clickable { cursor: pointer; }
|
||||
.data-grid tr.clickable:hover td { background: #e8f0ff; }
|
||||
|
||||
.donut-chart { width: 140px; height: 140px; }
|
||||
|
||||
.legend { display: flex; gap: 12px; justify-content: flex-end; margin-top: 4px; font-size: 0.75rem; }
|
||||
.legend-item { display: flex; align-items: center; gap: 4px; }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
|
||||
.map-container { height: 400px; width: 100%; border-radius: 4px; overflow: hidden; }
|
||||
.map-note { font-size: 0.7rem; color: #666; font-style: italic; margin-top: 4px; }
|
||||
|
||||
.penetration-legend { position: absolute; bottom: 20px; left: 10px; background: white; padding: 8px; border-radius: 4px; box-shadow: 0 2px 6px rgba(0,0,0,0.2); font-size: 0.7rem; z-index: 500; }
|
||||
.penetration-legend h4 { margin-bottom: 4px; font-size: 0.75rem; }
|
||||
.legend-row { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
|
||||
.legend-color { width: 14px; height: 14px; border-radius: 50%; }
|
||||
|
||||
.tooltip-box { background: white; border: 1px solid #ccc; padding: 8px; border-radius: 4px; font-size: 0.8rem; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
.tooltip-box table td { padding: 2px 8px; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard-body { flex-direction: column; }
|
||||
.right-panel { width: 100%; }
|
||||
}
|
||||
+33
-52
@@ -2,12 +2,10 @@ import { useState, useCallback } from 'react'
|
||||
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
|
||||
import * as dataService from './services/dataService'
|
||||
import Header from './components/Header'
|
||||
import Summary from './components/Summary'
|
||||
import SummaryMetrics from './components/SummaryMetrics'
|
||||
import TrendPanel from './components/TrendPanel'
|
||||
import MapPanel from './components/MapPanel'
|
||||
import GridPanel from './components/GridPanel'
|
||||
import StateDetail from './components/StateDetail'
|
||||
import './App.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 5 * 60 * 1000, retry: 1 } }
|
||||
@@ -15,9 +13,6 @@ const queryClient = new QueryClient({
|
||||
|
||||
function Dashboard() {
|
||||
const [view, setView] = useState('medical')
|
||||
const [mainTab, setMainTab] = useState('map')
|
||||
const [trendTab, setTrendTab] = useState('yearly')
|
||||
const [mapTab, setMapTab] = useState('all')
|
||||
const [selectedState, setSelectedState] = useState(null)
|
||||
|
||||
const { data: summary, isLoading } = useQuery({
|
||||
@@ -48,76 +43,62 @@ function Dashboard() {
|
||||
|
||||
const handleStateClick = useCallback((stateAbbr) => {
|
||||
setSelectedState(stateAbbr)
|
||||
setMapTab('counties')
|
||||
}, [])
|
||||
|
||||
const handleBackToAll = useCallback(() => {
|
||||
setSelectedState(null)
|
||||
setMapTab('all')
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="loading">Loading Medicare Enrollment Data...</div>
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-4 border-gray-200 border-t-gray-600 rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Loading Medicare Enrollment Data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const latestData = summary?.[0]
|
||||
const title = view === 'medical'
|
||||
? `📊 CMS Medicare Enrollment for ${latestData?.MONTH || ''} ${latestData?.YEAR || ''}`
|
||||
: `📊 CMS Enrollment Counts for ${latestData?.MONTH || ''} ${latestData?.YEAR || ''}`
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<Header title={title} view={view} setView={setView} />
|
||||
<div className="dashboard-body">
|
||||
<div className="left-panel">
|
||||
<Summary data={latestData} view={view} />
|
||||
<div className="tab-bar main-tabs">
|
||||
<button className={mainTab === 'map' ? 'active' : ''} onClick={() => setMainTab('map')}>Map/Graphs</button>
|
||||
<button className={mainTab === 'grid' ? 'active' : ''} onClick={() => setMainTab('grid')}>Grid</button>
|
||||
</div>
|
||||
{mainTab === 'map' ? (
|
||||
<div className="min-h-screen bg-background font-[var(--font-family)]">
|
||||
<div className="w-full max-w-[1400px] mx-auto p-6 space-y-6">
|
||||
<Header view={view} setView={setView} latestData={latestData} />
|
||||
<SummaryMetrics data={latestData} view={view} />
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: Map + Trends */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<MapPanel
|
||||
view={view}
|
||||
stateData={stateData}
|
||||
mapTab={mapTab}
|
||||
setMapTab={setMapTab}
|
||||
selectedState={selectedState}
|
||||
onStateClick={handleStateClick}
|
||||
/>
|
||||
) : (
|
||||
<GridPanel
|
||||
<TrendPanel
|
||||
view={view}
|
||||
yearlyTrend={yearlyTrend}
|
||||
monthlyTrend={monthlyTrend}
|
||||
selectedState={selectedState}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: State Detail Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<StateDetail
|
||||
view={view}
|
||||
state={selectedState}
|
||||
stateData={stateData}
|
||||
countyData={countyData}
|
||||
selectedState={selectedState}
|
||||
onStateClick={handleStateClick}
|
||||
yearlyTrend={yearlyTrend}
|
||||
onClose={handleBackToAll}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="right-panel">
|
||||
<TrendPanel
|
||||
view={view}
|
||||
trendTab={trendTab}
|
||||
setTrendTab={setTrendTab}
|
||||
yearlyTrend={yearlyTrend}
|
||||
monthlyTrend={monthlyTrend}
|
||||
selectedState={selectedState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedState && (
|
||||
<StateDetail
|
||||
view={view}
|
||||
state={selectedState}
|
||||
stateData={stateData}
|
||||
countyData={countyData}
|
||||
yearlyTrend={yearlyTrend}
|
||||
monthlyTrend={monthlyTrend}
|
||||
trendTab={trendTab}
|
||||
setTrendTab={setTrendTab}
|
||||
onClose={handleBackToAll}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export default function GridPanel({ view, stateData, countyData, selectedState, onStateClick }) {
|
||||
const isMedical = view === 'medical'
|
||||
const cat1 = isMedical ? 'FFS' : 'PDP'
|
||||
const cat2 = isMedical ? 'MA' : 'MAPD'
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const source = selectedState ? countyData : stateData
|
||||
if (!source) return []
|
||||
return source.map(row => {
|
||||
const total = Number(row.TOTAL) || 0
|
||||
const v1 = Number(row[cat1]) || 0
|
||||
const v2 = Number(row[cat2]) || 0
|
||||
return {
|
||||
name: row.county || row.name,
|
||||
state: row.state,
|
||||
total,
|
||||
val1: v1,
|
||||
val2: v2,
|
||||
pct1: total ? Math.round((v1 / total) * 100) : 0,
|
||||
pct2: total ? Math.round((v2 / total) * 100) : 0
|
||||
}
|
||||
})
|
||||
}, [stateData, countyData, selectedState, cat1, cat2])
|
||||
|
||||
return (
|
||||
<div className="grid-panel">
|
||||
<h3 style={{ marginBottom: 8 }}>{selectedState ? `${selectedState}` : 'All Areas'}</h3>
|
||||
<div style={{ maxHeight: 500, overflowY: 'auto' }}>
|
||||
<table className="data-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{selectedState ? 'County' : 'State'}</th>
|
||||
<th>TOTAL</th>
|
||||
<th>{cat1}</th>
|
||||
<th>{cat2}</th>
|
||||
<th>{cat1} %</th>
|
||||
<th>{cat2} %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} className={!selectedState ? 'clickable' : ''} onClick={() => !selectedState && onStateClick(row.state)}>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.total.toLocaleString()}</td>
|
||||
<td>{row.val1.toLocaleString()}</td>
|
||||
<td>{row.val2.toLocaleString()}</td>
|
||||
<td>{row.pct1}%</td>
|
||||
<td>{row.pct2}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,46 @@
|
||||
export default function Header({ title, view, setView }) {
|
||||
import { HeartPulse, Stethoscope, Pill } from 'lucide-react'
|
||||
|
||||
export default function Header({ view, setView, latestData }) {
|
||||
const period = latestData ? `${latestData.MONTH} ${latestData.YEAR}` : ''
|
||||
|
||||
return (
|
||||
<div className="header">
|
||||
<h1>{title}</h1>
|
||||
<div className="view-toggle">
|
||||
<button className={view === 'medical' ? 'active' : ''} onClick={() => setView('medical')}>
|
||||
🏥 Hospital/Medical
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-gray-900 rounded-lg flex items-center justify-center">
|
||||
<HeartPulse className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 tracking-tight">
|
||||
Medicare Enrollment Dashboard
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
CMS Monthly Enrollment Data {period && `· ${period}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setView('medical')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
view === 'medical'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Stethoscope className="w-4 h-4" />
|
||||
Hospital/Medical
|
||||
</button>
|
||||
<button className={view === 'drug' ? 'active' : ''} onClick={() => setView('drug')}>
|
||||
💊 Prescription Drug
|
||||
<button
|
||||
onClick={() => setView('drug')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
view === 'drug'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Pill className="w-4 h-4" />
|
||||
Prescription Drug
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,46 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { MapContainer, TileLayer, GeoJSON } from 'react-leaflet'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/card'
|
||||
import { Map } from 'lucide-react'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
const STATE_GEO_URL = 'https://raw.githubusercontent.com/PublicaMundi/MappingAPI/master/data/geojson/us-states.json'
|
||||
|
||||
function getPenetrationColor(pct, view) {
|
||||
if (view === 'medical') {
|
||||
if (pct >= 67) return '#2d1b4e'
|
||||
if (pct >= 51) return '#6b3fa0'
|
||||
if (pct >= 34) return '#d64b8a'
|
||||
if (pct >= 17) return '#f4a261'
|
||||
return '#f7dc6f'
|
||||
if (pct >= 67) return '#6d28d9'
|
||||
if (pct >= 51) return '#8b5cf6'
|
||||
if (pct >= 34) return '#a78bfa'
|
||||
if (pct >= 17) return '#c4b5fd'
|
||||
return '#ede9fe'
|
||||
}
|
||||
if (pct >= 79) return '#1a3a5c'
|
||||
if (pct >= 60) return '#a8d8ea'
|
||||
if (pct >= 40) return '#2d8f6f'
|
||||
if (pct >= 21) return '#7dcea0'
|
||||
return '#f7dc6f'
|
||||
if (pct >= 79) return '#0369a1'
|
||||
if (pct >= 60) return '#0ea5e9'
|
||||
if (pct >= 40) return '#38bdf8'
|
||||
if (pct >= 21) return '#7dd3fc'
|
||||
return '#e0f2fe'
|
||||
}
|
||||
|
||||
function getLegendItems(view) {
|
||||
if (view === 'medical') {
|
||||
return [
|
||||
{ color: '#f7dc6f', label: '1% - 17%' },
|
||||
{ color: '#f4a261', label: '17% - 34%' },
|
||||
{ color: '#d64b8a', label: '34% - 51%' },
|
||||
{ color: '#6b3fa0', label: '51% - 67%' },
|
||||
{ color: '#2d1b4e', label: '67% - 86%' }
|
||||
{ color: '#ede9fe', label: '< 17%' },
|
||||
{ color: '#c4b5fd', label: '17–34%' },
|
||||
{ color: '#a78bfa', label: '34–51%' },
|
||||
{ color: '#8b5cf6', label: '51–67%' },
|
||||
{ color: '#6d28d9', label: '> 67%' }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ color: '#f7dc6f', label: '2% - 21%' },
|
||||
{ color: '#7dcea0', label: '21% - 40%' },
|
||||
{ color: '#2d8f6f', label: '40% - 60%' },
|
||||
{ color: '#a8d8ea', label: '60% - 79%' },
|
||||
{ color: '#1a3a5c', label: '79% - 98%' }
|
||||
{ color: '#e0f2fe', label: '< 21%' },
|
||||
{ color: '#7dd3fc', label: '21–40%' },
|
||||
{ color: '#38bdf8', label: '40–60%' },
|
||||
{ color: '#0ea5e9', label: '60–79%' },
|
||||
{ color: '#0369a1', label: '> 79%' }
|
||||
]
|
||||
}
|
||||
|
||||
export default function MapPanel({ view, stateData, mapTab, setMapTab, onStateClick, selectedState }) {
|
||||
export default function MapPanel({ view, stateData, selectedState, onStateClick }) {
|
||||
const [geoData, setGeoData] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,9 +48,6 @@ export default function MapPanel({ view, stateData, mapTab, setMapTab, onStateCl
|
||||
}, [])
|
||||
|
||||
const isMedical = view === 'medical'
|
||||
const penetrationLabel = isMedical
|
||||
? 'Medicare Advantage (MA) & Other Health Plans Penetration Rate'
|
||||
: 'Medicare Advantage Prescription Drug Plans (MAPD) Penetration Rate'
|
||||
const pctKey = isMedical ? 'MA' : 'MAPD'
|
||||
|
||||
const stateMap = {}
|
||||
@@ -59,12 +58,13 @@ export default function MapPanel({ view, stateData, mapTab, setMapTab, onStateCl
|
||||
const total = st ? Number(st.TOTAL) : 0
|
||||
const val = st ? Number(st[pctKey]) : 0
|
||||
const pct = total ? (val / total) * 100 : 0
|
||||
const isSelected = st && st.state === selectedState
|
||||
return {
|
||||
fillColor: getPenetrationColor(pct, view),
|
||||
weight: 1,
|
||||
weight: isSelected ? 3 : 1,
|
||||
opacity: 1,
|
||||
color: '#fff',
|
||||
fillOpacity: 0.8
|
||||
color: isSelected ? '#171717' : '#ffffff',
|
||||
fillOpacity: 0.85
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,55 +73,52 @@ export default function MapPanel({ view, stateData, mapTab, setMapTab, onStateCl
|
||||
if (st) {
|
||||
const total = Number(st.TOTAL)
|
||||
const val = Number(st[pctKey])
|
||||
const other = isMedical ? Number(st.FFS) : Number(st.PDP)
|
||||
const pct = total ? Math.round((val / total) * 100) : 0
|
||||
const otherPct = 100 - pct
|
||||
const otherLabel = isMedical ? 'FFS' : 'PDP'
|
||||
|
||||
layer.bindTooltip(`
|
||||
<div class="tooltip-box"><table>
|
||||
<tr><td>State</td><td><b>${feature.properties.name}</b></td></tr>
|
||||
<tr><td>${pctKey} %</td><td>${pct}%</td></tr>
|
||||
<tr><td>${pctKey}</td><td>${val?.toLocaleString()}</td></tr>
|
||||
<tr><td>${otherLabel} %</td><td>${otherPct}%</td></tr>
|
||||
<tr><td>${otherLabel}</td><td>${other?.toLocaleString()}</td></tr>
|
||||
<tr><td>TOTAL</td><td>${total?.toLocaleString()}</td></tr>
|
||||
</table></div>
|
||||
`, { sticky: true })
|
||||
|
||||
layer.bindTooltip(
|
||||
`<div style="font-family:Inter,sans-serif;font-size:12px;padding:4px 8px">
|
||||
<strong>${feature.properties.name}</strong><br/>
|
||||
${pctKey}: ${pct}% · ${val?.toLocaleString()}<br/>
|
||||
Total: ${total?.toLocaleString()}
|
||||
</div>`,
|
||||
{ sticky: true }
|
||||
)
|
||||
layer.on('click', () => onStateClick(st.state))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="map-panel">
|
||||
<div className="tab-bar" style={{ marginBottom: 8 }}>
|
||||
<button className={mapTab === 'all' ? 'active' : ''} onClick={() => setMapTab('all')}>All Areas</button>
|
||||
<button className={mapTab === 'counties' ? 'active' : ''} onClick={() => setMapTab('counties')}>
|
||||
{selectedState ? `${selectedState} Counties` : 'Counties'}
|
||||
</button>
|
||||
</div>
|
||||
<h3>{penetrationLabel}</h3>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<MapContainer center={[39.8, -98.5]} zoom={4} className="map-container" scrollWheelZoom={true}>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png"
|
||||
attribution='© OpenStreetMap, © CARTO'
|
||||
/>
|
||||
{geoData && <GeoJSON key={view} data={geoData} style={style} onEachFeature={onEachFeature} />}
|
||||
</MapContainer>
|
||||
<div className="penetration-legend">
|
||||
<h4>⊕ Penetration Rate</h4>
|
||||
<h4>{pctKey} %</h4>
|
||||
{getLegendItems(view).map(item => (
|
||||
<div className="legend-row" key={item.label}>
|
||||
<span className="legend-color" style={{ background: item.color }}></span>
|
||||
<span>{item.label}</span>
|
||||
<Card className="border border-gray-200">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="flex items-center gap-3 text-gray-900">
|
||||
<Map className="w-5 h-5 text-emerald-600" />
|
||||
{isMedical ? 'Medicare Advantage Penetration by State' : 'MAPD Penetration by State'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="relative">
|
||||
<MapContainer center={[39.8, -98.5]} zoom={4} style={{ height: 380, width: '100%', borderRadius: 8 }} scrollWheelZoom={true}>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png"
|
||||
attribution='© OpenStreetMap, © CARTO'
|
||||
/>
|
||||
{geoData && <GeoJSON key={`${view}-${selectedState}`} data={geoData} style={style} onEachFeature={onEachFeature} />}
|
||||
</MapContainer>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm border border-gray-200 rounded-lg p-3 shadow-sm z-[500]">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">{pctKey} Penetration</p>
|
||||
<div className="space-y-1">
|
||||
{getLegendItems(view).map(item => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-sm" style={{ background: item.color }} />
|
||||
<span className="text-xs text-gray-600">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="map-note">*Map includes data for all 50 United States plus Washington D.C. Click to view state level information. See "Grid" for additional US territories.</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-3">Click a state to view detailed breakdown →</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/card'
|
||||
import { MapPin, X, ChevronRight, BarChart3 } from 'lucide-react'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
|
||||
export default function StateDetail({ view, state, stateData, countyData, yearlyTrend, monthlyTrend, trendTab, setTrendTab, onClose }) {
|
||||
function formatNum(n) {
|
||||
if (!n) return '0'
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(0) + 'K'
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
export default function StateDetail({ view, state, stateData, countyData, yearlyTrend, onClose }) {
|
||||
const isMedical = view === 'medical'
|
||||
const cat1 = isMedical ? 'FFS' : 'PDP'
|
||||
const cat2 = isMedical ? 'MA' : 'MAPD'
|
||||
const cat1 = isMedical ? 'MA' : 'MAPD'
|
||||
const cat2 = isMedical ? 'FFS' : 'PDP'
|
||||
const color1 = '#0ea5e9'
|
||||
const color2 = '#ef4444'
|
||||
|
||||
const stateInfo = stateData?.find(s => s.state === state)
|
||||
const total = stateInfo ? Number(stateInfo.TOTAL) : 0
|
||||
const v1 = stateInfo ? Number(stateInfo[cat1]) : 0
|
||||
const v2 = stateInfo ? Number(stateInfo[cat2]) : 0
|
||||
const pct1 = total ? Math.round((v1 / total) * 100) : 0
|
||||
const pct2 = total ? Math.round((v2 / total) * 100) : 0
|
||||
const countyCount = countyData?.length || 0
|
||||
|
||||
const countyRows = useMemo(() => {
|
||||
if (!countyData) return []
|
||||
@@ -24,92 +30,126 @@ export default function StateDetail({ view, state, stateData, countyData, yearly
|
||||
total: t,
|
||||
val1: c1,
|
||||
val2: c2,
|
||||
pct1: t ? Math.round((c1 / t) * 100) : 0,
|
||||
pct2: t ? Math.round((c2 / t) * 100) : 0
|
||||
pct1: t ? Math.round((c1 / t) * 100) : 0
|
||||
}
|
||||
})
|
||||
}).sort((a, b) => b.total - a.total)
|
||||
}, [countyData, cat1, cat2])
|
||||
|
||||
const trendData = trendTab === 'yearly' ? yearlyTrend : monthlyTrend
|
||||
// Top states for the overview bar chart
|
||||
const topStates = useMemo(() => {
|
||||
if (!stateData) return []
|
||||
return stateData
|
||||
.map(s => ({ name: s.state, total: Number(s.TOTAL) || 0, pct: Number(s.TOTAL) ? Math.round((Number(s[cat1]) / Number(s.TOTAL)) * 100) : 0 }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 10)
|
||||
}, [stateData, cat1])
|
||||
|
||||
// No state selected - show overview
|
||||
if (!state) {
|
||||
return (
|
||||
<Card className="border border-gray-200">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="flex items-center gap-3 text-gray-900">
|
||||
<BarChart3 className="w-5 h-5 text-amber-600" />
|
||||
Top States by Enrollment
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={topStates} layout="vertical" margin={{ top: 0, right: 10, left: 0, bottom: 0 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#737373' }} tickFormatter={v => formatNum(v)} axisLine={false} tickLine={false} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#525252' }} width={35} axisLine={false} tickLine={false} />
|
||||
<Tooltip formatter={(value) => [Number(value).toLocaleString(), 'Total Enrollment']} contentStyle={{ backgroundColor: 'white', border: '1px solid #e5e5e5', borderRadius: '8px' }} />
|
||||
<Bar dataKey="total" radius={[0, 4, 4, 0]}>
|
||||
{topStates.map((entry, i) => (
|
||||
<Cell key={entry.name} fill={i === 0 ? '#0ea5e9' : i < 3 ? '#38bdf8' : '#bae6fd'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="text-xs text-gray-400 mt-3">Select a state on the map for detailed breakdown</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// State selected - show detail
|
||||
const total = stateInfo ? Number(stateInfo.TOTAL) : 0
|
||||
const v1 = stateInfo ? Number(stateInfo[cat1]) : 0
|
||||
const v2 = stateInfo ? Number(stateInfo[cat2]) : 0
|
||||
const pct1 = total ? Math.round((v1 / total) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="state-detail">
|
||||
<div className="state-detail-header">
|
||||
<span>
|
||||
{state} ({countyCount} counties) | TOTAL: {total.toLocaleString()} | {cat1}: {v1.toLocaleString()} ({pct1}% of total) | {cat2}: {v2.toLocaleString()} ({pct2}% of total)
|
||||
</span>
|
||||
<button onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="state-detail-body">
|
||||
<div className="county-table">
|
||||
<h4>{stateInfo?.name || state}</h4>
|
||||
<div style={{ maxHeight: 350, overflowY: 'auto' }}>
|
||||
<table className="data-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>County</th>
|
||||
<th>TOTAL</th>
|
||||
<th>{cat1}</th>
|
||||
<th>{cat2}</th>
|
||||
<th>{cat1} %</th>
|
||||
<th>{cat2} %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{countyRows.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>{row.county}</td>
|
||||
<td>{row.total.toLocaleString()}</td>
|
||||
<td>{row.val1.toLocaleString()}</td>
|
||||
<td>{row.val2.toLocaleString()}</td>
|
||||
<td>{row.pct1}%</td>
|
||||
<td>{row.pct2}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Card className="border border-gray-200">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-3 text-gray-900">
|
||||
<MapPin className="w-5 h-5 text-violet-600" />
|
||||
{stateInfo?.name || state}
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 space-y-5">
|
||||
{/* State Summary */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-lg font-semibold text-gray-900">{formatNum(total)}</p>
|
||||
<p className="text-xs text-gray-500">Total</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-center">
|
||||
<p className="text-lg font-semibold text-blue-700">{formatNum(v1)}</p>
|
||||
<p className="text-xs text-gray-500">{cat1}</p>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-3 text-center">
|
||||
<p className="text-lg font-semibold text-red-700">{formatNum(v2)}</p>
|
||||
<p className="text-xs text-gray-500">{cat2}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="state-trends">
|
||||
<div className="tab-bar">
|
||||
<button className={trendTab === 'yearly' ? 'active' : ''} onClick={() => setTrendTab('yearly')}>Yearly Trend</button>
|
||||
<button className={trendTab === 'monthly' ? 'active' : ''} onClick={() => setTrendTab('monthly')}>12-Month Trend</button>
|
||||
<button className="active">Grid</button>
|
||||
|
||||
{/* Penetration bar */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-600 mb-1">
|
||||
<span>{cat1} Penetration</span>
|
||||
<span className="font-medium">{pct1}%</span>
|
||||
</div>
|
||||
<h4>Enrollment Count {trendTab === 'yearly' ? 'Yearly' : '12-Month'} Trend: State Total</h4>
|
||||
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
|
||||
<table className="data-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
{trendTab === 'monthly' && <th>Month</th>}
|
||||
<th>TOTAL</th>
|
||||
<th>{cat1}</th>
|
||||
<th>{cat2}</th>
|
||||
<th>{cat1} %</th>
|
||||
<th>{cat2} %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trendData?.map((row, i) => {
|
||||
const t = Number(row.TOTAL) || 1
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td>{row.YEAR}</td>
|
||||
{trendTab === 'monthly' && <td>{row.MONTH}</td>}
|
||||
<td>{Number(row.TOTAL).toLocaleString()}</td>
|
||||
<td>{Number(row[cat1]).toLocaleString()}</td>
|
||||
<td>{Number(row[cat2]).toLocaleString()}</td>
|
||||
<td>{Math.round((Number(row[cat1]) / t) * 100)}%</td>
|
||||
<td>{Math.round((Number(row[cat2]) / t) * 100)}%</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full transition-all" style={{ width: `${pct1}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* County List */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">
|
||||
Counties ({countyRows.length})
|
||||
</p>
|
||||
<div className="max-h-[360px] overflow-y-auto space-y-1.5 pr-1">
|
||||
{countyRows.map(row => (
|
||||
<div key={row.county} className="flex items-center justify-between p-2.5 rounded-lg border border-gray-100 hover:border-gray-200 hover:bg-gray-50 transition-all">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{row.county}</p>
|
||||
<p className="text-xs text-gray-400">{formatNum(row.total)} total</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-blue-600">{row.pct1}%</p>
|
||||
<p className="text-xs text-gray-400">{cat1}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{countyRows.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-4">Loading counties...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
function formatNum(n) {
|
||||
if (!n) return '0'
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(0) + 'K'
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
export default function Summary({ data, view }) {
|
||||
if (!data) return <div className="summary">Loading...</div>
|
||||
|
||||
const total = Number(data.TOT_BENES) || 0
|
||||
let left, right, leftLabel, rightLabel, leftColor, rightColor
|
||||
|
||||
if (view === 'medical') {
|
||||
left = Number(data.MA) || 0
|
||||
right = Number(data.FFS) || 0
|
||||
leftLabel = 'MA'
|
||||
rightLabel = 'FFS'
|
||||
leftColor = '#6b3fa0'
|
||||
rightColor = '#d64b8a'
|
||||
} else {
|
||||
left = Number(data.MAPD) || 0
|
||||
right = Number(data.PDP) || 0
|
||||
leftLabel = 'MAPD'
|
||||
rightLabel = 'PDP'
|
||||
leftColor = '#2d8f6f'
|
||||
rightColor = '#2980b9'
|
||||
}
|
||||
|
||||
const leftPct = total ? Math.round((left / total) * 100) : 0
|
||||
const rightPct = total ? Math.round((right / total) * 100) : 0
|
||||
|
||||
const donutOption = {
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['55%', '80%'],
|
||||
data: [
|
||||
{ value: left, name: leftLabel, itemStyle: { color: leftColor } },
|
||||
{ value: right, name: rightLabel, itemStyle: { color: rightColor } }
|
||||
],
|
||||
label: { show: false },
|
||||
emphasis: { scale: false }
|
||||
}],
|
||||
graphic: [{
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: '40%',
|
||||
style: { text: formatNum(total), fontSize: 18, fontWeight: 'bold', fill: '#1a3a5c', textAlign: 'center' }
|
||||
}, {
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: '58%',
|
||||
style: { text: 'Total\nEnrollment', fontSize: 10, fill: '#666', textAlign: 'center' }
|
||||
}]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="summary">
|
||||
<div className="summary-side">
|
||||
<div className="value">{leftLabel}: {formatNum(left)}</div>
|
||||
<div className="pct">({leftPct}% of total)</div>
|
||||
</div>
|
||||
<div className="summary-center">
|
||||
<ReactECharts option={donutOption} style={{ width: 150, height: 150 }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
<div className="summary-side">
|
||||
<div className="value">{rightLabel}: {formatNum(right)}</div>
|
||||
<div className="pct">({rightPct}% of total)</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Card, CardContent } from './ui/card'
|
||||
import { Users, TrendingUp, PieChart, Activity } from 'lucide-react'
|
||||
|
||||
function formatNum(n) {
|
||||
if (!n) return '0'
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(0) + 'K'
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
export default function SummaryMetrics({ data, view }) {
|
||||
if (!data) return null
|
||||
|
||||
const total = Number(data.TOT_BENES) || 0
|
||||
const isMedical = view === 'medical'
|
||||
const cat1Val = isMedical ? Number(data.MA) || 0 : Number(data.MAPD) || 0
|
||||
const cat2Val = isMedical ? Number(data.FFS) || 0 : Number(data.PDP) || 0
|
||||
const cat1Label = isMedical ? 'Medicare Advantage' : 'MAPD'
|
||||
const cat2Label = isMedical ? 'Fee-for-Service' : 'PDP'
|
||||
const cat1Pct = total ? Math.round((cat1Val / total) * 100) : 0
|
||||
const cat2Pct = total ? Math.round((cat2Val / total) * 100) : 0
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
label: 'Total Enrollment',
|
||||
value: formatNum(total),
|
||||
sub: 'All beneficiaries',
|
||||
icon: Users,
|
||||
iconBg: 'bg-blue-100',
|
||||
iconColor: 'text-blue-600'
|
||||
},
|
||||
{
|
||||
label: cat1Label,
|
||||
value: formatNum(cat1Val),
|
||||
sub: `${cat1Pct}% of total`,
|
||||
icon: TrendingUp,
|
||||
iconBg: 'bg-emerald-100',
|
||||
iconColor: 'text-emerald-600'
|
||||
},
|
||||
{
|
||||
label: cat2Label,
|
||||
value: formatNum(cat2Val),
|
||||
sub: `${cat2Pct}% of total`,
|
||||
icon: PieChart,
|
||||
iconBg: 'bg-violet-100',
|
||||
iconColor: 'text-violet-600'
|
||||
},
|
||||
{
|
||||
label: 'Penetration Rate',
|
||||
value: `${cat1Pct}%`,
|
||||
sub: `${cat1Label} share`,
|
||||
icon: Activity,
|
||||
iconBg: 'bg-amber-100',
|
||||
iconColor: 'text-amber-600'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{metrics.map((m) => (
|
||||
<Card key={m.label} className="border border-gray-200">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">{m.label}</p>
|
||||
<p className="text-2xl font-semibold text-gray-900 mt-1">{m.value}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{m.sub}</p>
|
||||
</div>
|
||||
<div className={`w-10 h-10 ${m.iconBg} rounded-lg flex items-center justify-center`}>
|
||||
<m.icon className={`w-5 h-5 ${m.iconColor}`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useState } from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/card'
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import { TrendingUp } from 'lucide-react'
|
||||
|
||||
function formatAxis(val) {
|
||||
if (val >= 1e6) return (val / 1e6).toFixed(0) + 'M'
|
||||
@@ -6,69 +9,93 @@ function formatAxis(val) {
|
||||
return val
|
||||
}
|
||||
|
||||
export default function TrendPanel({ view, trendTab, setTrendTab, yearlyTrend, monthlyTrend, selectedState }) {
|
||||
const areaLabel = selectedState || 'All Areas'
|
||||
const data = trendTab === 'yearly' ? yearlyTrend : monthlyTrend?.slice().reverse()
|
||||
|
||||
export default function TrendPanel({ view, yearlyTrend, monthlyTrend, selectedState }) {
|
||||
const [tab, setTab] = useState('yearly')
|
||||
const isMedical = view === 'medical'
|
||||
const cat1 = isMedical ? 'FFS' : 'PDP'
|
||||
const cat2 = isMedical ? 'MA' : 'MAPD'
|
||||
const color1 = isMedical ? '#d64b8a' : '#2980b9'
|
||||
const color2 = isMedical ? '#6b3fa0' : '#2d8f6f'
|
||||
const color1 = '#ef4444'
|
||||
const color2 = '#0ea5e9'
|
||||
const areaLabel = selectedState || 'National'
|
||||
|
||||
const xLabels = data?.map(d => trendTab === 'yearly' ? d.YEAR : `${d.MONTH}/${d.YEAR?.slice(-2)}`) || []
|
||||
|
||||
const countOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 60, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'category', data: xLabels, axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: 'value', axisLabel: { formatter: formatAxis, fontSize: 10 } },
|
||||
series: [
|
||||
{ name: 'TOTAL', type: 'line', data: data?.map(d => Number(d.TOTAL)) || [], lineStyle: { color: '#222', width: 2 }, itemStyle: { color: '#222' }, symbol: 'circle', symbolSize: 6 },
|
||||
{ name: cat1, type: 'line', data: data?.map(d => Number(d[cat1])) || [], lineStyle: { color: color1, width: 2 }, itemStyle: { color: color1 }, symbol: 'circle', symbolSize: 5 },
|
||||
{ name: cat2, type: 'line', data: data?.map(d => Number(d[cat2])) || [], lineStyle: { color: color2, width: 2 }, itemStyle: { color: color2 }, symbol: 'circle', symbolSize: 5 }
|
||||
]
|
||||
}
|
||||
|
||||
const pctData = data?.map(d => {
|
||||
const total = Number(d.TOTAL) || 1
|
||||
return { pct1: (Number(d[cat1]) / total) * 100, pct2: (Number(d[cat2]) / total) * 100 }
|
||||
}) || []
|
||||
|
||||
const pctOption = {
|
||||
tooltip: { trigger: 'axis', formatter: (p) => p.map(s => `${s.seriesName}: ${s.value?.toFixed(0)}%`).join('<br/>') },
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'category', data: xLabels, axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%', fontSize: 10 } },
|
||||
series: [
|
||||
{ name: cat1, type: 'bar', stack: 'pct', data: pctData.map(d => d.pct1), itemStyle: { color: color1 }, barWidth: '60%' },
|
||||
{ name: cat2, type: 'bar', stack: 'pct', data: pctData.map(d => d.pct2), itemStyle: { color: color2 } }
|
||||
]
|
||||
}
|
||||
const rawData = tab === 'yearly' ? yearlyTrend : monthlyTrend?.slice().reverse()
|
||||
const chartData = rawData?.map(d => ({
|
||||
label: tab === 'yearly' ? d.YEAR : `${d.MONTH?.slice(0, 3)} ${d.YEAR?.slice(-2)}`,
|
||||
total: Number(d.TOTAL) || 0,
|
||||
cat1: Number(d[cat1]) || 0,
|
||||
cat2: Number(d[cat2]) || 0,
|
||||
pct1: Number(d.TOTAL) ? Math.round((Number(d[cat1]) / Number(d.TOTAL)) * 100) : 0,
|
||||
pct2: Number(d.TOTAL) ? Math.round((Number(d[cat2]) / Number(d.TOTAL)) * 100) : 0
|
||||
})) || []
|
||||
|
||||
return (
|
||||
<div className="trend-panel">
|
||||
<div className="tab-bar">
|
||||
<button className={trendTab === 'yearly' ? 'active' : ''} onClick={() => setTrendTab('yearly')}>Yearly Trend</button>
|
||||
<button className={trendTab === 'monthly' ? 'active' : ''} onClick={() => setTrendTab('monthly')}>12-Month Trend</button>
|
||||
</div>
|
||||
<div className="trend-section">
|
||||
<h3>Enrollment Count {trendTab === 'yearly' ? 'Yearly' : '12-Month'} Trend: {areaLabel}</h3>
|
||||
<ReactECharts option={countOption} style={{ height: 220 }} opts={{ renderer: 'svg' }} />
|
||||
<div className="legend">
|
||||
<span className="legend-item"><span className="legend-dot" style={{ background: color1 }}></span>{cat1}</span>
|
||||
<span className="legend-item"><span className="legend-dot" style={{ background: color2 }}></span>{cat2}</span>
|
||||
<span className="legend-item"><span className="legend-dot" style={{ background: '#222' }}></span>TOTAL</span>
|
||||
<Card className="border border-gray-200">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-3 text-gray-900">
|
||||
<TrendingUp className="w-5 h-5 text-blue-600" />
|
||||
Enrollment Trends · {areaLabel}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setTab('yearly')}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
tab === 'yearly' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Yearly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('monthly')}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
tab === 'monthly' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
12-Month
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="trend-section">
|
||||
<h3>Percent of Total Enrollment {trendTab === 'yearly' ? 'Yearly' : '12-Month'} Trend: {areaLabel}</h3>
|
||||
<ReactECharts option={pctOption} style={{ height: 220 }} opts={{ renderer: 'svg' }} />
|
||||
<div className="legend">
|
||||
<span className="legend-item"><span className="legend-dot" style={{ background: color1 }}></span>{cat1}</span>
|
||||
<span className="legend-item"><span className="legend-dot" style={{ background: color2 }}></span>{cat2}</span>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Line Chart */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">Enrollment Count</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: '#737373' }} axisLine={{ stroke: '#d4d4d4' }} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: '#737373' }} tickFormatter={formatAxis} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
formatter={(value, name) => [Number(value).toLocaleString(), name === 'total' ? 'Total' : name === 'cat1' ? cat1 : cat2]}
|
||||
contentStyle={{ backgroundColor: 'white', border: '1px solid #e5e5e5', borderRadius: '8px', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="total" stroke="#171717" strokeWidth={2} dot={{ r: 3 }} name="total" />
|
||||
<Line type="monotone" dataKey="cat1" stroke={color1} strokeWidth={2} dot={{ r: 3 }} name="cat1" />
|
||||
<Line type="monotone" dataKey="cat2" stroke={color2} strokeWidth={2} dot={{ r: 3 }} name="cat2" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex items-center justify-center gap-5 mt-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-600"><span className="w-3 h-0.5 bg-gray-900 rounded" />Total</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-600"><span className="w-3 h-0.5 rounded" style={{ background: color1 }} />{cat1}</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-600"><span className="w-3 h-0.5 rounded" style={{ background: color2 }} />{cat2}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stacked Bar Chart */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">Percent of Total</p>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: '#737373' }} axisLine={{ stroke: '#d4d4d4' }} tickLine={false} />
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 11, fill: '#737373' }} tickFormatter={v => `${v}%`} axisLine={false} tickLine={false} />
|
||||
<Tooltip formatter={(value) => [`${value}%`]} contentStyle={{ backgroundColor: 'white', border: '1px solid #e5e5e5', borderRadius: '8px' }} />
|
||||
<Bar dataKey="pct1" stackId="pct" fill={color1} radius={[0, 0, 0, 0]} name={cat1} />
|
||||
<Bar dataKey="pct2" stackId="pct" fill={color2} radius={[4, 4, 0, 0]} name={cat2} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cn } from "./utils";
|
||||
|
||||
export function Card({ className, ...props }) {
|
||||
return <div className={cn("bg-card text-card-foreground flex flex-col rounded-xl border shadow-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }) {
|
||||
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }) {
|
||||
return <h3 className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import 'tailwindcss' source(none);
|
||||
@source '../**/*.{js,jsx,ts,tsx}';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--font-size: 14px;
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--background: #fafafa;
|
||||
--foreground: #171717;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #171717;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #171717;
|
||||
--primary: #525252;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f5f5f5;
|
||||
--secondary-foreground: #525252;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #737373;
|
||||
--accent: #f5f5f5;
|
||||
--accent-foreground: #525252;
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #e5e5e5;
|
||||
--input: #ffffff;
|
||||
--input-border: #d4d4d4;
|
||||
--input-background: #f5f5f5;
|
||||
--ring: #525252;
|
||||
--chart-1: #0ea5e9;
|
||||
--chart-2: #10b981;
|
||||
--chart-3: #f59e0b;
|
||||
--chart-4: #ef4444;
|
||||
--chart-5: #8b5cf6;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-input-background: var(--input-background);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
font-size: var(--font-size);
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-family);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
/* Leaflet overrides */
|
||||
.leaflet-container { border-radius: 8px; }
|
||||
@@ -1,7 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './App.css'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
plugins: [react(), tailwindcss()]
|
||||
})
|
||||
Reference in new issue
Block a user