Spaces:
Sleeping
Sleeping
John Bowyer commited on
Commit Β·
facae46
1
Parent(s): 18cfd44
Deploy CommunityOne React app with organization card
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- Dockerfile +40 -0
- README.md +82 -6
- frontend/.env.example +15 -0
- frontend/.eslintrc.cjs +18 -0
- frontend/.gitignore +21 -0
- frontend/README.md +166 -0
- frontend/index.html +25 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +45 -0
- frontend/policy-dashboards/.gitignore +23 -0
- frontend/policy-dashboards/README.md +251 -0
- frontend/policy-dashboards/package-lock.json +0 -0
- frontend/policy-dashboards/package.json +36 -0
- frontend/policy-dashboards/public/communityone_logo.jpg +0 -0
- frontend/policy-dashboards/public/communityone_logo.svg +22 -0
- frontend/policy-dashboards/public/index.html +17 -0
- frontend/policy-dashboards/src/App.jsx +1355 -0
- frontend/policy-dashboards/src/App.jsx.backup +1 -0
- frontend/policy-dashboards/src/App.jsx.corrupted +553 -0
- frontend/policy-dashboards/src/components/EndlessStudyLoop.jsx +162 -0
- frontend/policy-dashboards/src/components/HomePage.jsx +291 -0
- frontend/policy-dashboards/src/components/ImpactDashboard.jsx +280 -0
- frontend/policy-dashboards/src/components/NonprofitCard.jsx +290 -0
- frontend/policy-dashboards/src/components/SplitScreenView.jsx +375 -0
- frontend/policy-dashboards/src/components/Summary.jsx +183 -0
- frontend/policy-dashboards/src/components/TopicNavigation.jsx +511 -0
- frontend/policy-dashboards/src/components/WhereMoneyWent.jsx +162 -0
- frontend/policy-dashboards/src/components/WhoIsInCharge.jsx +162 -0
- frontend/policy-dashboards/src/components/WordsVsDollars.jsx +151 -0
- frontend/policy-dashboards/src/components/shared/BarMeter.jsx +34 -0
- frontend/policy-dashboards/src/components/shared/Compare.jsx +58 -0
- frontend/policy-dashboards/src/components/shared/DashboardTile.jsx +171 -0
- frontend/policy-dashboards/src/components/shared/DecisionCard.jsx +253 -0
- frontend/policy-dashboards/src/components/shared/FilterPanel.jsx +240 -0
- frontend/policy-dashboards/src/components/shared/InsightBox.jsx +37 -0
- frontend/policy-dashboards/src/components/shared/MetricCard.jsx +36 -0
- frontend/policy-dashboards/src/data/dashboardData.js +321 -0
- frontend/policy-dashboards/src/index.css +31 -0
- frontend/policy-dashboards/src/index.js +11 -0
- frontend/postcss.config.js +6 -0
- frontend/public/communityone_logo.jpg +0 -0
- frontend/public/communityone_logo.png +0 -0
- frontend/public/communityone_logo.svg +22 -0
- frontend/public/communityone_logo_64.png +0 -0
- frontend/public/favicon.ico +0 -0
- frontend/public/privacyfacebook.html +276 -0
- frontend/src/App.tsx +66 -0
- frontend/src/components/AddressLookup.tsx +671 -0
- frontend/src/components/FollowButton.tsx +160 -0
- frontend/src/components/JurisdictionDiscovery.tsx +268 -0
Dockerfile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Simple React App Deployment for HuggingFace Spaces
|
| 2 |
+
FROM node:20-slim AS builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Copy frontend source
|
| 7 |
+
COPY frontend/package*.json ./
|
| 8 |
+
RUN npm ci
|
| 9 |
+
|
| 10 |
+
COPY frontend/ ./
|
| 11 |
+
|
| 12 |
+
# Build React app for production
|
| 13 |
+
ENV VITE_CANONICAL_DOMAIN=communityone-readme.hf.space
|
| 14 |
+
ENV VITE_API_URL=https://www.communityone.com/api
|
| 15 |
+
RUN npm run build
|
| 16 |
+
|
| 17 |
+
# Production stage with nginx
|
| 18 |
+
FROM nginx:alpine
|
| 19 |
+
|
| 20 |
+
# Copy built React app
|
| 21 |
+
COPY --from=builder /app/dist /usr/share/nginx/html
|
| 22 |
+
|
| 23 |
+
# Create nginx config for SPA
|
| 24 |
+
RUN echo 'server { \
|
| 25 |
+
listen 7860; \
|
| 26 |
+
root /usr/share/nginx/html; \
|
| 27 |
+
index index.html; \
|
| 28 |
+
location / { \
|
| 29 |
+
try_files $uri $uri/ /index.html; \
|
| 30 |
+
} \
|
| 31 |
+
}' > /etc/nginx/conf.d/default.conf
|
| 32 |
+
|
| 33 |
+
EXPOSE 7860
|
| 34 |
+
|
| 35 |
+
CMD ["nginx", "-g", "daemon off;"]
|
| 36 |
+
ENV LOG_LEVEL=INFO
|
| 37 |
+
ENV HF_SPACES=1
|
| 38 |
+
|
| 39 |
+
# Use supervisor to run all services
|
| 40 |
+
CMD ["/app/start.sh"]
|
README.md
CHANGED
|
@@ -1,10 +1,86 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: CommunityOne
|
| 3 |
+
emoji: ποΈ
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: static
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# ποΈ CommunityOne
|
| 11 |
+
|
| 12 |
+
**The open path to everything local**
|
| 13 |
+
|
| 14 |
+
Building tools to help communities understand and engage with local government, nonprofits, and civic processes.
|
| 15 |
+
|
| 16 |
+
## π What We Build
|
| 17 |
+
|
| 18 |
+
### [Open Navigator for Engagement](https://www.communityone.com)
|
| 19 |
+
An open-source platform for exploring and tracking:
|
| 20 |
+
|
| 21 |
+
- **90,000+ jurisdictions** (cities, counties, states)
|
| 22 |
+
- **3M+ nonprofit organizations** (complete IRS registry)
|
| 23 |
+
- **7,300+ state legislators** (all 50 states + DC + PR)
|
| 24 |
+
- **100,000+ state bills** (legislative tracking with full text)
|
| 25 |
+
- **Government meetings** with AI-extracted insights
|
| 26 |
+
- **Budget analysis** (municipal, county, state spending)
|
| 27 |
+
- **Public opinion data** (survey questions from Roper Center)
|
| 28 |
+
|
| 29 |
+
## π Open Datasets
|
| 30 |
+
|
| 31 |
+
All our data is freely available on HuggingFace:
|
| 32 |
+
|
| 33 |
+
- **[one-nonprofits-organizations](https://huggingface.co/datasets/CommunityOne/one-nonprofits-organizations)** - 3.9M nonprofits (IRS EO-BMF + ProPublica)
|
| 34 |
+
- **[one-nonprofits-financials](https://huggingface.co/datasets/CommunityOne/one-nonprofits-financials)** - Form 990 financial data
|
| 35 |
+
- **[one-meetings-calendar](https://huggingface.co/datasets/CommunityOne/one-meetings-calendar)** - Government meeting schedules
|
| 36 |
+
- **[reference-jurisdictions-cities](https://huggingface.co/datasets/CommunityOne/reference-jurisdictions-cities)** - 19K+ U.S. cities
|
| 37 |
+
- **[reference-jurisdictions-counties](https://huggingface.co/datasets/CommunityOne/reference-jurisdictions-counties)** - 3,144 U.S. counties
|
| 38 |
+
|
| 39 |
+
[View all 60+ datasets β](https://huggingface.co/CommunityOne/datasets)
|
| 40 |
+
|
| 41 |
+
## π οΈ Technology Stack
|
| 42 |
+
|
| 43 |
+
- **Frontend:** React + Vite + Tailwind CSS
|
| 44 |
+
- **Backend:** FastAPI + PostgreSQL + Delta Lake
|
| 45 |
+
- **AI/ML:** LangChain, OpenAI GPT-4, Anthropic Claude
|
| 46 |
+
- **Data:** Apache Spark, DuckDB, Polars, Pandas
|
| 47 |
+
- **Deployment:** HuggingFace Spaces, Docker
|
| 48 |
+
|
| 49 |
+
## π― Use Cases
|
| 50 |
+
|
| 51 |
+
### For Advocates & Organizers
|
| 52 |
+
- Track legislation on your issue (healthcare, education, housing)
|
| 53 |
+
- Find nonprofits working in your community
|
| 54 |
+
- Monitor government meetings and budgets
|
| 55 |
+
- Identify engagement opportunities
|
| 56 |
+
|
| 57 |
+
### For Researchers & Journalists
|
| 58 |
+
- Analyze policy trends across states
|
| 59 |
+
- Study nonprofit sector patterns
|
| 60 |
+
- Track government spending priorities
|
| 61 |
+
- Map community infrastructure
|
| 62 |
+
|
| 63 |
+
### For Developers
|
| 64 |
+
- Access clean, standardized civic data
|
| 65 |
+
- Build on open APIs and datasets
|
| 66 |
+
- Fork and extend the platform
|
| 67 |
+
- Contribute to open source tools
|
| 68 |
+
|
| 69 |
+
## π Links
|
| 70 |
+
|
| 71 |
+
- **π Live Application:** [www.communityone.com](https://www.communityone.com)
|
| 72 |
+
- **π Documentation:** [www.communityone.com/docs](https://www.communityone.com/docs)
|
| 73 |
+
- **π» GitHub:** [getcommunityone/open-navigator-for-engagement](https://github.com/getcommunityone/open-navigator-for-engagement)
|
| 74 |
+
- **π Datasets:** [All CommunityOne datasets](https://huggingface.co/CommunityOne/datasets)
|
| 75 |
+
|
| 76 |
+
## π License
|
| 77 |
+
|
| 78 |
+
MIT License - Free to use, modify, and distribute
|
| 79 |
+
|
| 80 |
+
## π€ Contributing
|
| 81 |
+
|
| 82 |
+
We welcome contributions! Check out our [GitHub repository](https://github.com/getcommunityone/open-navigator-for-engagement) to get started.
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
**Focus Areas:** Civic Tech β’ Open Data β’ Government Transparency β’ Nonprofit Sector β’ Legislative Tracking β’ Community Engagement
|
frontend/.env.example
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Example environment variables for frontend development
|
| 2 |
+
# Copy to .env.development.local to customize
|
| 3 |
+
|
| 4 |
+
# Documentation URL (Docusaurus runs on port 3000 in development)
|
| 5 |
+
# Default: http://localhost:3000
|
| 6 |
+
# VITE_DOCS_URL=http://localhost:3000
|
| 7 |
+
|
| 8 |
+
# API URL (FastAPI runs on port 8000)
|
| 9 |
+
# Default: http://localhost:8000
|
| 10 |
+
# VITE_API_URL=http://localhost:8000
|
| 11 |
+
|
| 12 |
+
# Google Analytics Measurement ID (same as docs site)
|
| 13 |
+
# Get from: https://analytics.google.com (GA4 property)
|
| 14 |
+
# Format: G-XXXXXXXXXX
|
| 15 |
+
VITE_GOOGLE_ANALYTICS_ID=G-5EQV815915
|
frontend/.eslintrc.cjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
root: true,
|
| 3 |
+
env: { browser: true, es2020: true },
|
| 4 |
+
extends: [
|
| 5 |
+
'eslint:recommended',
|
| 6 |
+
'plugin:@typescript-eslint/recommended',
|
| 7 |
+
'plugin:react-hooks/recommended',
|
| 8 |
+
],
|
| 9 |
+
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
| 10 |
+
parser: '@typescript-eslint/parser',
|
| 11 |
+
plugins: ['react-refresh'],
|
| 12 |
+
rules: {
|
| 13 |
+
'react-refresh/only-export-components': [
|
| 14 |
+
'warn',
|
| 15 |
+
{ allowConstantExport: true },
|
| 16 |
+
],
|
| 17 |
+
},
|
| 18 |
+
}
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Frontend dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
dist/
|
| 4 |
+
*.log
|
| 5 |
+
.DS_Store
|
| 6 |
+
|
| 7 |
+
# Build output
|
| 8 |
+
api/static/
|
| 9 |
+
|
| 10 |
+
# Environment
|
| 11 |
+
.env
|
| 12 |
+
.env.local
|
| 13 |
+
|
| 14 |
+
# IDE
|
| 15 |
+
.vscode/
|
| 16 |
+
.idea/
|
| 17 |
+
*.swp
|
| 18 |
+
*.swo
|
| 19 |
+
|
| 20 |
+
# Testing
|
| 21 |
+
coverage/
|
frontend/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Open Navigator for Engagement - Frontend
|
| 2 |
+
|
| 3 |
+
React + TypeScript web interface for the Open Navigator for Engagement application.
|
| 4 |
+
|
| 5 |
+
## Projects
|
| 6 |
+
|
| 7 |
+
### Main Application
|
| 8 |
+
React + TypeScript web interface with maps, charts, and data visualization.
|
| 9 |
+
|
| 10 |
+
**Location:** `frontend/` (this directory)
|
| 11 |
+
|
| 12 |
+
### Policy Accountability Dashboards
|
| 13 |
+
Evidence-based accountability dashboards for policy advocacy.
|
| 14 |
+
|
| 15 |
+
**Location:** `frontend/policy-dashboards/`
|
| 16 |
+
**Documentation:** [Policy Dashboards README](policy-dashboards/README.md)
|
| 17 |
+
|
| 18 |
+
## Tech Stack
|
| 19 |
+
|
| 20 |
+
- **React 18.2** - UI framework
|
| 21 |
+
- **TypeScript** - Type safety
|
| 22 |
+
- **Vite** - Build tool
|
| 23 |
+
- **Tailwind CSS** - Styling
|
| 24 |
+
- **React Router** - Navigation
|
| 25 |
+
- **TanStack Query** - Data fetching
|
| 26 |
+
- **Recharts** - Charts
|
| 27 |
+
- **Leaflet** - Interactive maps
|
| 28 |
+
|
| 29 |
+
## Development
|
| 30 |
+
|
| 31 |
+
```bash
|
| 32 |
+
# Install dependencies
|
| 33 |
+
npm install
|
| 34 |
+
|
| 35 |
+
# Run dev server (hot reload)
|
| 36 |
+
npm run dev
|
| 37 |
+
# Opens http://localhost:3000
|
| 38 |
+
|
| 39 |
+
# Build for production
|
| 40 |
+
npm run build
|
| 41 |
+
# Outputs to ../api/static/
|
| 42 |
+
|
| 43 |
+
# Type check
|
| 44 |
+
npm run type-check
|
| 45 |
+
|
| 46 |
+
# Lint
|
| 47 |
+
npm run lint
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## Project Structure
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
src/
|
| 54 |
+
βββ components/
|
| 55 |
+
β βββ Layout.tsx # Main layout with sidebar
|
| 56 |
+
βββ pages/
|
| 57 |
+
β βββ Dashboard.tsx # Statistics and charts
|
| 58 |
+
β βββ Heatmap.tsx # Interactive map
|
| 59 |
+
β βββ Documents.tsx # Document browser
|
| 60 |
+
β βββ Opportunities.tsx # Opportunity manager
|
| 61 |
+
β βββ Settings.tsx # Configuration
|
| 62 |
+
βββ App.tsx # Root component with routing
|
| 63 |
+
βββ main.tsx # Entry point
|
| 64 |
+
βββ index.css # Global styles
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
## Pages
|
| 68 |
+
|
| 69 |
+
### Dashboard (`/`)
|
| 70 |
+
- Total documents, opportunities, states monitored
|
| 71 |
+
- Topic distribution charts (bar and pie)
|
| 72 |
+
- Recent opportunities table
|
| 73 |
+
|
| 74 |
+
### Heatmap (`/heatmap`)
|
| 75 |
+
- Interactive Leaflet map
|
| 76 |
+
- Color-coded urgency markers
|
| 77 |
+
- State and topic filters
|
| 78 |
+
- Popup details
|
| 79 |
+
|
| 80 |
+
### Documents (`/documents`)
|
| 81 |
+
- Searchable document list
|
| 82 |
+
- Pagination
|
| 83 |
+
- Topic tags
|
| 84 |
+
- Source links
|
| 85 |
+
|
| 86 |
+
### Opportunities (`/opportunities`)
|
| 87 |
+
- Filterable by urgency
|
| 88 |
+
- Generate advocacy emails
|
| 89 |
+
- Talking points display
|
| 90 |
+
- Confidence scores
|
| 91 |
+
|
| 92 |
+
### Settings (`/settings`)
|
| 93 |
+
- Target state selection
|
| 94 |
+
- Policy topic configuration
|
| 95 |
+
- Notification preferences
|
| 96 |
+
- Agent status monitoring
|
| 97 |
+
|
| 98 |
+
## Environment
|
| 99 |
+
|
| 100 |
+
The frontend proxies API requests to the backend:
|
| 101 |
+
|
| 102 |
+
```typescript
|
| 103 |
+
// vite.config.ts
|
| 104 |
+
server: {
|
| 105 |
+
proxy: {
|
| 106 |
+
'/api': {
|
| 107 |
+
target: 'http://localhost:8000',
|
| 108 |
+
changeOrigin: true,
|
| 109 |
+
},
|
| 110 |
+
},
|
| 111 |
+
}
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
## Building
|
| 115 |
+
|
| 116 |
+
Production builds are output to `../api/static/` so the FastAPI backend can serve them:
|
| 117 |
+
|
| 118 |
+
```typescript
|
| 119 |
+
// vite.config.ts
|
| 120 |
+
build: {
|
| 121 |
+
outDir: '../api/static',
|
| 122 |
+
emptyOutDir: true,
|
| 123 |
+
}
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
## Styling
|
| 127 |
+
|
| 128 |
+
Uses Tailwind CSS with custom utility classes:
|
| 129 |
+
|
| 130 |
+
```css
|
| 131 |
+
/* index.css */
|
| 132 |
+
.card { @apply bg-white rounded-lg shadow-md p-6; }
|
| 133 |
+
.btn-primary { @apply bg-primary-600 hover:bg-primary-700 text-white ... }
|
| 134 |
+
.btn-secondary { @apply bg-gray-200 hover:bg-gray-300 text-gray-800 ... }
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## Data Fetching
|
| 138 |
+
|
| 139 |
+
Uses TanStack Query for server state management:
|
| 140 |
+
|
| 141 |
+
```typescript
|
| 142 |
+
const { data, isLoading } = useQuery({
|
| 143 |
+
queryKey: ['opportunities', state, topic],
|
| 144 |
+
queryFn: async () => {
|
| 145 |
+
const response = await axios.get('/api/opportunities', { params: { state, topic } })
|
| 146 |
+
return response.data
|
| 147 |
+
},
|
| 148 |
+
})
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
## Deployment
|
| 152 |
+
|
| 153 |
+
The built frontend is automatically included when deploying to Databricks Apps:
|
| 154 |
+
|
| 155 |
+
```bash
|
| 156 |
+
# Build frontend
|
| 157 |
+
npm run build
|
| 158 |
+
|
| 159 |
+
# Deploy entire app
|
| 160 |
+
cd ..
|
| 161 |
+
./scripts/deploy-databricks-app.sh
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
## License
|
| 165 |
+
|
| 166 |
+
MIT License - See LICENSE file for details
|
frontend/index.html
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
| 6 |
+
<link rel="icon" type="image/png" sizes="64x64" href="/communityone_logo_64.png" />
|
| 7 |
+
<link rel="icon" type="image/svg+xml" href="/communityone_logo.svg" />
|
| 8 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 9 |
+
<meta name="description" content="CommunityOne: The open path to everything local" />
|
| 10 |
+
<title>Open Navigator for Engagement</title>
|
| 11 |
+
|
| 12 |
+
<!-- Google tag (gtag.js) -->
|
| 13 |
+
<script async src="https://www.googletagmanager.com/gtag/js?id=G-5EQV815915"></script>
|
| 14 |
+
<script>
|
| 15 |
+
window.dataLayer = window.dataLayer || [];
|
| 16 |
+
function gtag(){dataLayer.push(arguments);}
|
| 17 |
+
gtag('js', new Date());
|
| 18 |
+
gtag('config', 'G-5EQV815915');
|
| 19 |
+
</script>
|
| 20 |
+
</head>
|
| 21 |
+
<body>
|
| 22 |
+
<div id="root"></div>
|
| 23 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 24 |
+
</body>
|
| 25 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "oral-health-policy-pulse-ui",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "CommunityOne: The open path to everything local",
|
| 5 |
+
"private": true,
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc && vite build",
|
| 9 |
+
"preview": "vite preview",
|
| 10 |
+
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
| 11 |
+
"type-check": "tsc --noEmit"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"react": "^18.2.0",
|
| 15 |
+
"react-dom": "^18.2.0",
|
| 16 |
+
"react-router-dom": "^6.20.0",
|
| 17 |
+
"axios": "^1.6.2",
|
| 18 |
+
"react-query": "^3.39.3",
|
| 19 |
+
"@tanstack/react-query": "^5.14.2",
|
| 20 |
+
"recharts": "^2.10.3",
|
| 21 |
+
"leaflet": "^1.9.4",
|
| 22 |
+
"react-leaflet": "^4.2.1",
|
| 23 |
+
"@headlessui/react": "^1.7.17",
|
| 24 |
+
"@heroicons/react": "^2.0.18",
|
| 25 |
+
"clsx": "^2.0.0",
|
| 26 |
+
"date-fns": "^2.30.0",
|
| 27 |
+
"zustand": "^4.4.7"
|
| 28 |
+
},
|
| 29 |
+
"devDependencies": {
|
| 30 |
+
"@types/react": "^18.2.43",
|
| 31 |
+
"@types/react-dom": "^18.2.17",
|
| 32 |
+
"@types/leaflet": "^1.9.8",
|
| 33 |
+
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
| 34 |
+
"@typescript-eslint/parser": "^6.14.0",
|
| 35 |
+
"@vitejs/plugin-react": "^4.2.1",
|
| 36 |
+
"autoprefixer": "^10.4.16",
|
| 37 |
+
"eslint": "^8.55.0",
|
| 38 |
+
"eslint-plugin-react-hooks": "^4.6.0",
|
| 39 |
+
"eslint-plugin-react-refresh": "^0.4.5",
|
| 40 |
+
"postcss": "^8.4.32",
|
| 41 |
+
"tailwindcss": "^3.3.6",
|
| 42 |
+
"typescript": "^5.2.2",
|
| 43 |
+
"vite": "^5.0.8"
|
| 44 |
+
}
|
| 45 |
+
}
|
frontend/policy-dashboards/.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.js
|
| 7 |
+
|
| 8 |
+
# testing
|
| 9 |
+
/coverage
|
| 10 |
+
|
| 11 |
+
# production
|
| 12 |
+
/build
|
| 13 |
+
|
| 14 |
+
# misc
|
| 15 |
+
.DS_Store
|
| 16 |
+
.env.local
|
| 17 |
+
.env.development.local
|
| 18 |
+
.env.test.local
|
| 19 |
+
.env.production.local
|
| 20 |
+
|
| 21 |
+
npm-debug.log*
|
| 22 |
+
yarn-debug.log*
|
| 23 |
+
yarn-error.log*
|
frontend/policy-dashboards/README.md
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Policy Accountability Dashboards
|
| 2 |
+
|
| 3 |
+
**Evidence-based accountability dashboards for local government policy advocacy**
|
| 4 |
+
|
| 5 |
+
Interactive web dashboards that expose gaps between rhetoric and reality, deferral tactics, and power imbalances in local government decision-making.
|
| 6 |
+
|
| 7 |
+
## Quick Start
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
# Install dependencies
|
| 11 |
+
cd frontend/policy-dashboards
|
| 12 |
+
npm install
|
| 13 |
+
|
| 14 |
+
# Start development server
|
| 15 |
+
npm start
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
The app will open at `http://localhost:3000`
|
| 19 |
+
|
| 20 |
+
## Overview
|
| 21 |
+
|
| 22 |
+
This React application visualizes accountability data from local government meetings and budgets. It presents four key dashboards:
|
| 23 |
+
|
| 24 |
+
1. **They cut health spending while praising wellness** - Rhetoric vs. reality gap
|
| 25 |
+
2. **Delayed 6 months and counting** - Sequential deferral patterns
|
| 26 |
+
3. **What got funded instead** - Budget displacement analysis
|
| 27 |
+
4. **One memo beat 240 residents** - Influence power mapping
|
| 28 |
+
|
| 29 |
+
Plus a **Summary** page that provides an overview and strategic guidance.
|
| 30 |
+
|
| 31 |
+
## Data Integration
|
| 32 |
+
|
| 33 |
+
### Automatic Data Export from Python
|
| 34 |
+
|
| 35 |
+
The Python accountability analysis automatically exports data for the frontend:
|
| 36 |
+
|
| 37 |
+
```bash
|
| 38 |
+
# Run the full analysis (includes frontend export)
|
| 39 |
+
python examples/tuscaloosa_accountability_report.py
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
This generates: `frontend/policy-dashboards/src/data/dashboardData.js`
|
| 43 |
+
|
| 44 |
+
### Manual Data Updates
|
| 45 |
+
|
| 46 |
+
To update dashboard data manually, edit `src/data/dashboardData.js`:
|
| 47 |
+
|
| 48 |
+
```javascript
|
| 49 |
+
export const rhetoricGapData = {
|
| 50 |
+
sentimentScore: 92, // Update this
|
| 51 |
+
budgetDelta: -120000, // And this
|
| 52 |
+
// ... more fields
|
| 53 |
+
};
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## Project Structure
|
| 57 |
+
|
| 58 |
+
```
|
| 59 |
+
frontend/policy-dashboards/
|
| 60 |
+
βββ public/
|
| 61 |
+
β βββ index.html
|
| 62 |
+
βββ src/
|
| 63 |
+
β βββ components/
|
| 64 |
+
β β βββ shared/ # Reusable components
|
| 65 |
+
β β β βββ BarMeter.jsx
|
| 66 |
+
β β β βββ MetricCard.jsx
|
| 67 |
+
β β β βββ Compare.jsx
|
| 68 |
+
β β β βββ InsightBox.jsx
|
| 69 |
+
β β βββ Summary.jsx # Summary dashboard
|
| 70 |
+
β β βββ WordsVsDollars.jsx # Dashboard 1
|
| 71 |
+
β β βββ EndlessStudyLoop.jsx # Dashboard 2
|
| 72 |
+
β β βββ WhereMoneyWent.jsx # Dashboard 3
|
| 73 |
+
β β βββ WhoIsInCharge.jsx # Dashboard 4
|
| 74 |
+
β βββ data/
|
| 75 |
+
β β βββ dashboardData.js # All dashboard data (UPDATE THIS)
|
| 76 |
+
β βββ App.jsx
|
| 77 |
+
β βββ index.js
|
| 78 |
+
βββ package.json
|
| 79 |
+
βββ README.md
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## Features
|
| 83 |
+
|
| 84 |
+
### Per-Capita Benchmarks
|
| 85 |
+
|
| 86 |
+
Each dashboard includes comparisons to:
|
| 87 |
+
- This District
|
| 88 |
+
- Republican Districts Average
|
| 89 |
+
- Democratic Districts Average
|
| 90 |
+
- National Average
|
| 91 |
+
|
| 92 |
+
This framing shows the issue transcends partisan politics.
|
| 93 |
+
|
| 94 |
+
### Interactive Navigation
|
| 95 |
+
|
| 96 |
+
- Click dashboard titles in tabs to switch views
|
| 97 |
+
- Click summary findings to jump to detailed dashboards
|
| 98 |
+
- Clean, print-friendly design
|
| 99 |
+
|
| 100 |
+
### Export to PDF
|
| 101 |
+
|
| 102 |
+
Add PDF export capability:
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
npm install html2canvas jspdf
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
Then add a download button (see component code for implementation).
|
| 109 |
+
|
| 110 |
+
## Customization
|
| 111 |
+
|
| 112 |
+
### Change Colors
|
| 113 |
+
|
| 114 |
+
Edit the color variables in each component:
|
| 115 |
+
|
| 116 |
+
```javascript
|
| 117 |
+
const colors = {
|
| 118 |
+
positive: "#1D9E75", // Green
|
| 119 |
+
negative: "#D85A30", // Red/orange
|
| 120 |
+
neutral: "#222" // Dark gray
|
| 121 |
+
};
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
### Add New Benchmarks
|
| 125 |
+
|
| 126 |
+
Update the `benchmarks` object in `dashboardData.js`:
|
| 127 |
+
|
| 128 |
+
```javascript
|
| 129 |
+
benchmarks: {
|
| 130 |
+
thisDistrict: { perStudent: 41, label: "This District" },
|
| 131 |
+
// Add more comparison groups here
|
| 132 |
+
}
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
### Customize Dashboard Names
|
| 136 |
+
|
| 137 |
+
Edit the `tabs` array in `src/App.jsx`:
|
| 138 |
+
|
| 139 |
+
```javascript
|
| 140 |
+
const tabs = [
|
| 141 |
+
{ id: 1, label: 'Your Custom Title Here', component: WordsVsDollars },
|
| 142 |
+
// ...
|
| 143 |
+
];
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
## Deployment
|
| 147 |
+
|
| 148 |
+
### Build for Production
|
| 149 |
+
|
| 150 |
+
```bash
|
| 151 |
+
npm run build
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
This creates an optimized build in `build/` folder.
|
| 155 |
+
|
| 156 |
+
### Deploy to GitHub Pages
|
| 157 |
+
|
| 158 |
+
```bash
|
| 159 |
+
# Install GitHub Pages package
|
| 160 |
+
npm install --save-dev gh-pages
|
| 161 |
+
|
| 162 |
+
# Add to package.json scripts:
|
| 163 |
+
"predeploy": "npm run build",
|
| 164 |
+
"deploy": "gh-pages -d build"
|
| 165 |
+
|
| 166 |
+
# Deploy
|
| 167 |
+
npm run deploy
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
### Deploy to Netlify/Vercel
|
| 171 |
+
|
| 172 |
+
1. Connect your repository to Netlify or Vercel
|
| 173 |
+
2. Set build command: `npm run build`
|
| 174 |
+
3. Set publish directory: `build`
|
| 175 |
+
4. Deploy!
|
| 176 |
+
|
| 177 |
+
## Presentation Mode
|
| 178 |
+
|
| 179 |
+
Add URL parameter support for presentation mode (stacks all dashboards vertically):
|
| 180 |
+
|
| 181 |
+
```javascript
|
| 182 |
+
// In App.jsx
|
| 183 |
+
const urlParams = new URLSearchParams(window.location.search);
|
| 184 |
+
const presentMode = urlParams.get('mode') === 'present';
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
Then visit: `http://localhost:3000?mode=present`
|
| 188 |
+
|
| 189 |
+
## Data Sources
|
| 190 |
+
|
| 191 |
+
The accountability dashboards use data from:
|
| 192 |
+
|
| 193 |
+
- **NCES Common Core of Data (CCD)** - School district financials
|
| 194 |
+
- **ASTDD State Oral Health Programs** - Program tracking
|
| 195 |
+
- **NCES F-33 Survey** - Capital outlay by function
|
| 196 |
+
- **National Association of School Nurses** - Liability data
|
| 197 |
+
- **ADA Health Policy Institute** - Dental health metrics
|
| 198 |
+
|
| 199 |
+
All sources are cited in dashboard components.
|
| 200 |
+
|
| 201 |
+
## Strategic Usage
|
| 202 |
+
|
| 203 |
+
### For Board Meetings
|
| 204 |
+
|
| 205 |
+
1. Start with **Summary** to set context
|
| 206 |
+
2. Move to **specific dashboard** based on board response
|
| 207 |
+
3. Use the "Ask them" boxes for direct questions
|
| 208 |
+
|
| 209 |
+
### For Media
|
| 210 |
+
|
| 211 |
+
Lead with **Dashboard 4** (Influence Radar) - it's the headline:
|
| 212 |
+
> "Risk Manager's One Memo Outweighed 240 Citizen Testimonies"
|
| 213 |
+
|
| 214 |
+
### For Advocacy Training
|
| 215 |
+
|
| 216 |
+
Show all four to demonstrate:
|
| 217 |
+
- Data-driven approach
|
| 218 |
+
- Understanding of political dynamics
|
| 219 |
+
- Ability to counter deflection tactics
|
| 220 |
+
|
| 221 |
+
## Development
|
| 222 |
+
|
| 223 |
+
### Available Scripts
|
| 224 |
+
|
| 225 |
+
- `npm start` - Development server
|
| 226 |
+
- `npm run build` - Production build
|
| 227 |
+
- `npm test` - Run tests
|
| 228 |
+
- `npm run eject` - Eject from Create React App
|
| 229 |
+
|
| 230 |
+
### Adding New Dashboards
|
| 231 |
+
|
| 232 |
+
1. Create component in `src/components/`
|
| 233 |
+
2. Add data to `src/data/dashboardData.js`
|
| 234 |
+
3. Import and add to tabs in `src/App.jsx`
|
| 235 |
+
4. Update Python export function
|
| 236 |
+
|
| 237 |
+
## Support
|
| 238 |
+
|
| 239 |
+
For questions or issues:
|
| 240 |
+
|
| 241 |
+
- **Documentation**: See `docs/ACCOUNTABILITY_DASHBOARD_STRATEGY.md`
|
| 242 |
+
- **Python Backend**: See `extraction/accountability_dashboards.py`
|
| 243 |
+
- **Examples**: See `examples/tuscaloosa_accountability_report.py`
|
| 244 |
+
|
| 245 |
+
## License
|
| 246 |
+
|
| 247 |
+
Part of the Open Navigator for Engagement project.
|
| 248 |
+
|
| 249 |
+
---
|
| 250 |
+
|
| 251 |
+
**Built with React** | **Designed for Policy Advocacy** | **Evidence-Based Accountability**
|
frontend/policy-dashboards/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/policy-dashboards/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "policy-accountability-dashboards",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"description": "Evidence-based accountability dashboards for local government policy advocacy",
|
| 6 |
+
"dependencies": {
|
| 7 |
+
"react": "^18.2.0",
|
| 8 |
+
"react-dom": "^18.2.0",
|
| 9 |
+
"react-scripts": "5.0.1",
|
| 10 |
+
"recharts": "^2.5.0",
|
| 11 |
+
"lucide-react": "^0.263.1"
|
| 12 |
+
},
|
| 13 |
+
"scripts": {
|
| 14 |
+
"start": "react-scripts start",
|
| 15 |
+
"build": "react-scripts build",
|
| 16 |
+
"test": "react-scripts test",
|
| 17 |
+
"eject": "react-scripts eject"
|
| 18 |
+
},
|
| 19 |
+
"eslintConfig": {
|
| 20 |
+
"extends": [
|
| 21 |
+
"react-app"
|
| 22 |
+
]
|
| 23 |
+
},
|
| 24 |
+
"browserslist": {
|
| 25 |
+
"production": [
|
| 26 |
+
">0.2%",
|
| 27 |
+
"not dead",
|
| 28 |
+
"not op_mini all"
|
| 29 |
+
],
|
| 30 |
+
"development": [
|
| 31 |
+
"last 1 chrome version",
|
| 32 |
+
"last 1 firefox version",
|
| 33 |
+
"last 1 safari version"
|
| 34 |
+
]
|
| 35 |
+
}
|
| 36 |
+
}
|
frontend/policy-dashboards/public/communityone_logo.jpg
ADDED
|
frontend/policy-dashboards/public/communityone_logo.svg
ADDED
|
|
frontend/policy-dashboards/public/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<meta name="theme-color" content="#000000" />
|
| 7 |
+
<meta
|
| 8 |
+
name="description"
|
| 9 |
+
content="Policy Accountability Dashboards - Evidence-based advocacy for local government transparency"
|
| 10 |
+
/>
|
| 11 |
+
<title>Policy Accountability Dashboards</title>
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<noscript>You need to enable JavaScript to run this app.</noscript>
|
| 15 |
+
<div id="root"></div>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
frontend/policy-dashboards/src/App.jsx
ADDED
|
@@ -0,0 +1,1355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import HomePage from './components/HomePage';
|
| 3 |
+
import ImpactDashboard from './components/ImpactDashboard';
|
| 4 |
+
import TopicNavigation from './components/TopicNavigation';
|
| 5 |
+
import DecisionCard from './components/shared/DecisionCard';
|
| 6 |
+
import SplitScreenView from './components/SplitScreenView';
|
| 7 |
+
import NonprofitCard from './components/NonprofitCard';
|
| 8 |
+
import { metadata } from './data/dashboardData';
|
| 9 |
+
import { Home, Grid, Building2, Heart, Church, Search, X, Calendar, Filter, MapPin, Edit2, Lightbulb } from 'lucide-react';
|
| 10 |
+
|
| 11 |
+
export default function App() {
|
| 12 |
+
const [viewMode, setViewMode] = useState('home'); // 'home', 'impact', 'browse', 'split-screen'
|
| 13 |
+
const [exploreMode, setExploreMode] = useState('decisions'); // 'decisions', 'organizations', or 'causes'
|
| 14 |
+
const [sectorView, setSectorView] = useState('all'); // 'all', 'public', 'nonprofits', 'churches'
|
| 15 |
+
const [selectedPersona, setSelectedPersona] = useState(null);
|
| 16 |
+
const [selectedTopic, setSelectedTopic] = useState(null);
|
| 17 |
+
const [selectedDecision, setSelectedDecision] = useState(null);
|
| 18 |
+
const [selectedTopics, setSelectedTopics] = useState([]);
|
| 19 |
+
const [selectedPatterns, setSelectedPatterns] = useState([]);
|
| 20 |
+
const [selectedResources, setSelectedResources] = useState([]);
|
| 21 |
+
const [startDate, setStartDate] = useState(null);
|
| 22 |
+
const [endDate, setEndDate] = useState(null);
|
| 23 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 24 |
+
const [quickDateFilter, setQuickDateFilter] = useState('all'); // 'all', '7days', '30days', '90days'
|
| 25 |
+
const [quickTopicFilter, setQuickTopicFilter] = useState('all'); // 'all', 'health', 'education', 'infrastructure'
|
| 26 |
+
const [jurisdictionType, setJurisdictionType] = useState('city'); // 'nation', 'state', 'county', 'city', 'school-district'
|
| 27 |
+
const [jurisdictionName, setJurisdictionName] = useState('Tuscaloosa');
|
| 28 |
+
const [jurisdictionState, setJurisdictionState] = useState('AL');
|
| 29 |
+
const [showLocationModal, setShowLocationModal] = useState(false);
|
| 30 |
+
const [tempJurisdictionType, setTempJurisdictionType] = useState('city');
|
| 31 |
+
const [tempJurisdictionName, setTempJurisdictionName] = useState('');
|
| 32 |
+
const [tempJurisdictionState, setTempJurisdictionState] = useState('');
|
| 33 |
+
|
| 34 |
+
const handlePersonaSelect = (persona, topic) => {
|
| 35 |
+
setSelectedPersona(persona);
|
| 36 |
+
setSelectedTopic(topic);
|
| 37 |
+
setViewMode('impact');
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const handleTopicSelect = (topicId) => {
|
| 41 |
+
setSelectedTopics([topicId]);
|
| 42 |
+
setViewMode('browse');
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const handleTopicToggle = (topicId) => {
|
| 46 |
+
setSelectedTopics(prev =>
|
| 47 |
+
prev.includes(topicId)
|
| 48 |
+
? prev.filter(t => t !== topicId)
|
| 49 |
+
: [...prev, topicId]
|
| 50 |
+
);
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
const handlePatternToggle = (patternId) => {
|
| 54 |
+
setSelectedPatterns(prev =>
|
| 55 |
+
prev.includes(patternId)
|
| 56 |
+
? prev.filter(p => p !== patternId)
|
| 57 |
+
: [...prev, patternId]
|
| 58 |
+
);
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const handleResourceToggle = (resourceId) => {
|
| 62 |
+
setSelectedResources(prev =>
|
| 63 |
+
prev.includes(resourceId)
|
| 64 |
+
? prev.filter(r => r !== resourceId)
|
| 65 |
+
: [...prev, resourceId]
|
| 66 |
+
);
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
const handleClearFilters = () => {
|
| 70 |
+
setSelectedTopics([]);
|
| 71 |
+
setSelectedPatterns([]);
|
| 72 |
+
setSelectedResources([]);
|
| 73 |
+
setStartDate(null);
|
| 74 |
+
setEndDate(null);
|
| 75 |
+
setSearchQuery('');
|
| 76 |
+
setQuickDateFilter('all');
|
| 77 |
+
setQuickTopicFilter('all');
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const handleQuickDateFilter = (filter) => {
|
| 81 |
+
setQuickDateFilter(filter);
|
| 82 |
+
const today = new Date();
|
| 83 |
+
|
| 84 |
+
switch(filter) {
|
| 85 |
+
case '7days':
|
| 86 |
+
const sevenDaysAgo = new Date(today);
|
| 87 |
+
sevenDaysAgo.setDate(today.getDate() - 7);
|
| 88 |
+
setStartDate(sevenDaysAgo.toISOString().split('T')[0]);
|
| 89 |
+
setEndDate(today.toISOString().split('T')[0]);
|
| 90 |
+
break;
|
| 91 |
+
case '30days':
|
| 92 |
+
const thirtyDaysAgo = new Date(today);
|
| 93 |
+
thirtyDaysAgo.setDate(today.getDate() - 30);
|
| 94 |
+
setStartDate(thirtyDaysAgo.toISOString().split('T')[0]);
|
| 95 |
+
setEndDate(today.toISOString().split('T')[0]);
|
| 96 |
+
break;
|
| 97 |
+
case '90days':
|
| 98 |
+
const ninetyDaysAgo = new Date(today);
|
| 99 |
+
ninetyDaysAgo.setDate(today.getDate() - 90);
|
| 100 |
+
setStartDate(ninetyDaysAgo.toISOString().split('T')[0]);
|
| 101 |
+
setEndDate(today.toISOString().split('T')[0]);
|
| 102 |
+
break;
|
| 103 |
+
case 'all':
|
| 104 |
+
default:
|
| 105 |
+
setStartDate(null);
|
| 106 |
+
setEndDate(null);
|
| 107 |
+
break;
|
| 108 |
+
}
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const handleQuickTopicFilter = (filter) => {
|
| 112 |
+
setQuickTopicFilter(filter);
|
| 113 |
+
|
| 114 |
+
if (filter === 'all') {
|
| 115 |
+
setSelectedTopics([]);
|
| 116 |
+
} else {
|
| 117 |
+
setSelectedTopics([filter]);
|
| 118 |
+
}
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
const handleBackToHome = () => {
|
| 122 |
+
setViewMode('home');
|
| 123 |
+
setSelectedPersona(null);
|
| 124 |
+
setSelectedTopic(null);
|
| 125 |
+
setSelectedDecision(null);
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const handleDecisionClick = (decision) => {
|
| 129 |
+
setSelectedDecision(decision);
|
| 130 |
+
setViewMode('split-screen');
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const handleSectorSelect = (sector) => {
|
| 134 |
+
setViewMode('browse');
|
| 135 |
+
|
| 136 |
+
// Set explore mode and sector based on selection
|
| 137 |
+
if (sector === 'public') {
|
| 138 |
+
setExploreMode('decisions');
|
| 139 |
+
setSectorView('public');
|
| 140 |
+
} else if (sector === 'nonprofits' || sector === 'churches') {
|
| 141 |
+
setExploreMode('organizations');
|
| 142 |
+
setSectorView(sector);
|
| 143 |
+
} else if (sector === 'all') {
|
| 144 |
+
setExploreMode('organizations');
|
| 145 |
+
setSectorView('all');
|
| 146 |
+
}
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
// Example decision data - would come from Python export
|
| 150 |
+
const exampleDecisions = [
|
| 151 |
+
{
|
| 152 |
+
decision_summary: "Approval of $850,000 athletic turf replacement project",
|
| 153 |
+
outcome: "Approved",
|
| 154 |
+
primary_rationale: "Athletic facilities are essential for student engagement and community pride. The current turf is beyond its useful life and poses safety concerns.",
|
| 155 |
+
supporters: [
|
| 156 |
+
{ name: "Board Member Johnson", role: "Board Member" },
|
| 157 |
+
{ name: "Athletic Director Smith", role: "Staff" }
|
| 158 |
+
],
|
| 159 |
+
opponents: [
|
| 160 |
+
{ name: "Parent Coalition for Health", role: "Public" }
|
| 161 |
+
],
|
| 162 |
+
vote_result: "6-1",
|
| 163 |
+
meeting_date: "2026-03-15",
|
| 164 |
+
tradeoffs_discussed: ["Athletic facilities vs. health screening programs"],
|
| 165 |
+
evidence_cited: [{ source: "Athletic Department Report" }],
|
| 166 |
+
policy_domain: "facilities",
|
| 167 |
+
ntee_code: "N20" // Recreation, Sports, and Athletics
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
decision_summary: "Tabled decision on dental screening partnership with West Alabama Dental Clinic",
|
| 171 |
+
outcome: "Tabled for further study",
|
| 172 |
+
primary_rationale: "Risk management concerns require additional legal review and liability analysis before proceeding with external health partnerships.",
|
| 173 |
+
supporters: [
|
| 174 |
+
{ name: "Patricia Johnson, Risk Manager", role: "Staff" }
|
| 175 |
+
],
|
| 176 |
+
opponents: [
|
| 177 |
+
{ name: "Dr. Sarah Martinez", role: "Public" },
|
| 178 |
+
{ name: "Parent Teacher Association", role: "Public" },
|
| 179 |
+
{ name: "Board Member Williams", role: "Board Member" }
|
| 180 |
+
],
|
| 181 |
+
vote_result: "5-2 to table",
|
| 182 |
+
meeting_date: "2026-01-18",
|
| 183 |
+
tradeoffs_discussed: ["Preventive care vs. perceived liability risk"],
|
| 184 |
+
evidence_cited: [{ source: "Risk Management Memo" }],
|
| 185 |
+
policy_domain: "health",
|
| 186 |
+
ntee_code: "E32", // School-Based Health Care
|
| 187 |
+
community_gap: {
|
| 188 |
+
description: "100% of surveyed parents want dental screenings for students",
|
| 189 |
+
nonprofit_filling_gap: true
|
| 190 |
+
}
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
decision_summary: "Reduction of nursing staff from 5 FTE to 3 FTE",
|
| 194 |
+
outcome: "Approved",
|
| 195 |
+
primary_rationale: "Budget constraints necessitate cost reductions. Nursing positions will be restructured to focus on emergency response rather than preventive services.",
|
| 196 |
+
supporters: [
|
| 197 |
+
{ name: "Superintendent Brown", role: "Staff" },
|
| 198 |
+
{ name: "Board Chair Thompson", role: "Board Member" }
|
| 199 |
+
],
|
| 200 |
+
opponents: [
|
| 201 |
+
{ name: "School Nurses Association", role: "Public" },
|
| 202 |
+
{ name: "Board Member Lee", role: "Board Member" }
|
| 203 |
+
],
|
| 204 |
+
vote_result: "4-3",
|
| 205 |
+
meeting_date: "2026-02-20",
|
| 206 |
+
tradeoffs_discussed: ["Cost savings vs. preventive health services"],
|
| 207 |
+
evidence_cited: [{ source: "FY2026 Budget Proposal" }],
|
| 208 |
+
policy_domain: "budget",
|
| 209 |
+
ntee_code: "E40", // Health - General and Rehabilitative
|
| 210 |
+
community_gap: {
|
| 211 |
+
description: "Students lost access to preventive health services",
|
| 212 |
+
nonprofit_filling_gap: true
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
];
|
| 216 |
+
|
| 217 |
+
// Example nonprofit data - would come from IRS/GuideStar API
|
| 218 |
+
const exampleNonprofits = [
|
| 219 |
+
{
|
| 220 |
+
name: "West Alabama Dental Initiative",
|
| 221 |
+
ein: "63-1234567",
|
| 222 |
+
ntee_code: "E32",
|
| 223 |
+
ntee_description: "School-Based Health Care",
|
| 224 |
+
mission: "Providing free dental screenings and preventive care to underserved students in West Alabama",
|
| 225 |
+
services: [
|
| 226 |
+
"Mobile dental unit visits to schools",
|
| 227 |
+
"Free toothbrush and fluoride kits",
|
| 228 |
+
"Dental education workshops for parents"
|
| 229 |
+
],
|
| 230 |
+
annual_budget: 125000,
|
| 231 |
+
students_served: 2400,
|
| 232 |
+
contact: {
|
| 233 |
+
website: "https://wadaldentalinitiative.org",
|
| 234 |
+
email: "info@wadaldentalinitiative.org",
|
| 235 |
+
phone: "(205) 555-0123"
|
| 236 |
+
},
|
| 237 |
+
volunteer_opportunities: true,
|
| 238 |
+
accepting_board_members: true
|
| 239 |
+
},
|
| 240 |
+
{
|
| 241 |
+
name: "Tuscaloosa Family Health Network",
|
| 242 |
+
ein: "63-7654321",
|
| 243 |
+
ntee_code: "E40",
|
| 244 |
+
ntee_description: "Health - General and Rehabilitative",
|
| 245 |
+
mission: "Connecting low-income families with preventive health services and wellness programs",
|
| 246 |
+
services: [
|
| 247 |
+
"Health screenings at community centers",
|
| 248 |
+
"Nutrition education programs",
|
| 249 |
+
"Mental health counseling referrals"
|
| 250 |
+
],
|
| 251 |
+
annual_budget: 280000,
|
| 252 |
+
families_served: 850,
|
| 253 |
+
contact: {
|
| 254 |
+
website: "https://tuscfamilyhealth.org",
|
| 255 |
+
email: "contact@tuscfamilyhealth.org",
|
| 256 |
+
phone: "(205) 555-0456"
|
| 257 |
+
},
|
| 258 |
+
volunteer_opportunities: true,
|
| 259 |
+
accepting_board_members: false
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
name: "After School Champions",
|
| 263 |
+
ein: "63-9876543",
|
| 264 |
+
ntee_code: "O50",
|
| 265 |
+
ntee_description: "Youth Development Programs",
|
| 266 |
+
mission: "Providing safe, enriching after-school programs that support academic success and healthy development",
|
| 267 |
+
services: [
|
| 268 |
+
"Homework help and tutoring",
|
| 269 |
+
"Healthy snacks and meals",
|
| 270 |
+
"Physical activity and sports",
|
| 271 |
+
"Health and wellness workshops"
|
| 272 |
+
],
|
| 273 |
+
annual_budget: 340000,
|
| 274 |
+
youth_served: 450,
|
| 275 |
+
contact: {
|
| 276 |
+
website: "https://afterschoolchamps.org",
|
| 277 |
+
email: "info@afterschoolchamps.org",
|
| 278 |
+
phone: "(205) 555-0789"
|
| 279 |
+
},
|
| 280 |
+
volunteer_opportunities: true,
|
| 281 |
+
accepting_board_members: true
|
| 282 |
+
},
|
| 283 |
+
{
|
| 284 |
+
name: "First Baptist Church Tuscaloosa - Health Ministry",
|
| 285 |
+
ein: "63-2345678",
|
| 286 |
+
ntee_code: "X20",
|
| 287 |
+
ntee_description: "Christian",
|
| 288 |
+
mission: "Faith-based health outreach serving Tuscaloosa families through free dental kits, health screenings, and nutrition education",
|
| 289 |
+
services: [
|
| 290 |
+
"Free dental hygiene kits distribution",
|
| 291 |
+
"Health screenings after Sunday service",
|
| 292 |
+
"Nutrition education classes",
|
| 293 |
+
"Mobile health unit partnership"
|
| 294 |
+
],
|
| 295 |
+
annual_budget: 45000,
|
| 296 |
+
families_served: 450,
|
| 297 |
+
contact: {
|
| 298 |
+
website: "https://fbctuscaloosa.org/health",
|
| 299 |
+
email: "health@fbctuscaloosa.org",
|
| 300 |
+
phone: "(205) 555-0200"
|
| 301 |
+
},
|
| 302 |
+
volunteer_opportunities: true,
|
| 303 |
+
accepting_board_members: false
|
| 304 |
+
},
|
| 305 |
+
{
|
| 306 |
+
name: "Tuscaloosa County Interfaith Dental Initiative",
|
| 307 |
+
ein: "63-3456789",
|
| 308 |
+
ntee_code: "X20",
|
| 309 |
+
ntee_description: "Christian",
|
| 310 |
+
mission: "Multi-faith collaboration providing free dental care to low-income students across Tuscaloosa County schools",
|
| 311 |
+
services: [
|
| 312 |
+
"Mobile dental unit serving Title I schools",
|
| 313 |
+
"Free toothbrush and fluoride programs",
|
| 314 |
+
"Parent education workshops",
|
| 315 |
+
"Dental emergency fund for families"
|
| 316 |
+
],
|
| 317 |
+
annual_budget: 180000,
|
| 318 |
+
students_served: 1600,
|
| 319 |
+
contact: {
|
| 320 |
+
website: "https://tuscaloosainterfaithdental.org",
|
| 321 |
+
email: "contact@tuscaloosainterfaithdental.org",
|
| 322 |
+
phone: "(205) 555-0300"
|
| 323 |
+
},
|
| 324 |
+
volunteer_opportunities: true,
|
| 325 |
+
accepting_board_members: true
|
| 326 |
+
},
|
| 327 |
+
{
|
| 328 |
+
name: "Catholic Social Services - Dental Outreach",
|
| 329 |
+
ein: "63-4567890",
|
| 330 |
+
ntee_code: "X20",
|
| 331 |
+
ntee_description: "Christian",
|
| 332 |
+
mission: "Diocese of Birmingham outreach providing dental care and health services to underserved communities",
|
| 333 |
+
services: [
|
| 334 |
+
"Quarterly dental clinics at parish hall",
|
| 335 |
+
"School dental screening partnerships",
|
| 336 |
+
"Dental supply distribution",
|
| 337 |
+
"Financial assistance for dental emergencies"
|
| 338 |
+
],
|
| 339 |
+
annual_budget: 95000,
|
| 340 |
+
families_served: 320,
|
| 341 |
+
contact: {
|
| 342 |
+
website: "https://cssalabama.org/dental",
|
| 343 |
+
email: "dental@cssalabama.org",
|
| 344 |
+
phone: "(205) 555-0400"
|
| 345 |
+
},
|
| 346 |
+
volunteer_opportunities: true,
|
| 347 |
+
accepting_board_members: false
|
| 348 |
+
}
|
| 349 |
+
];
|
| 350 |
+
|
| 351 |
+
// Filter decisions based on search, topics, patterns, resources, and date range
|
| 352 |
+
const filteredDecisions = exampleDecisions.filter(decision => {
|
| 353 |
+
const matchesSearch = searchQuery === '' ||
|
| 354 |
+
decision.decision_summary.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 355 |
+
decision.primary_rationale.toLowerCase().includes(searchQuery.toLowerCase());
|
| 356 |
+
|
| 357 |
+
// Map policy_domain to topic IDs
|
| 358 |
+
const topicMap = {
|
| 359 |
+
'health': 'public-health',
|
| 360 |
+
'facilities': 'infrastructure',
|
| 361 |
+
'budget': 'education' // Example mapping
|
| 362 |
+
};
|
| 363 |
+
const decisionTopic = topicMap[decision.policy_domain] || decision.policy_domain;
|
| 364 |
+
const matchesTopic = selectedTopics.length === 0 || selectedTopics.includes(decisionTopic);
|
| 365 |
+
|
| 366 |
+
// Date filtering
|
| 367 |
+
const decisionDate = new Date(decision.meeting_date);
|
| 368 |
+
const matchesStartDate = !startDate || decisionDate >= new Date(startDate);
|
| 369 |
+
const matchesEndDate = !endDate || decisionDate <= new Date(endDate);
|
| 370 |
+
|
| 371 |
+
// TODO: Add pattern matching based on decision.patterns field
|
| 372 |
+
// TODO: Add resource matching based on decision.available_resources field
|
| 373 |
+
|
| 374 |
+
return matchesSearch && matchesTopic && matchesStartDate && matchesEndDate;
|
| 375 |
+
});
|
| 376 |
+
|
| 377 |
+
// Filter nonprofits and churches based on search and topic
|
| 378 |
+
const filteredNonprofits = exampleNonprofits.filter(org => {
|
| 379 |
+
const matchesSearch = searchQuery === '' ||
|
| 380 |
+
org.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 381 |
+
org.mission.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 382 |
+
org.services.some(service => service.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
| 383 |
+
org.ntee_description.toLowerCase().includes(searchQuery.toLowerCase());
|
| 384 |
+
|
| 385 |
+
// Map NTEE codes to topic IDs
|
| 386 |
+
const nteeTopicMap = {
|
| 387 |
+
'E': 'public-health', // Health
|
| 388 |
+
'F': 'public-health', // Mental Health
|
| 389 |
+
'K': 'public-health', // Food/Nutrition
|
| 390 |
+
'O': 'education', // Youth Development
|
| 391 |
+
'P': 'education', // Human Services
|
| 392 |
+
'X': 'public-health' // Religious (often health ministries)
|
| 393 |
+
};
|
| 394 |
+
const orgTopic = nteeTopicMap[org.ntee_code?.[0]] || 'public-health';
|
| 395 |
+
const matchesTopic = selectedTopics.length === 0 || selectedTopics.includes(orgTopic);
|
| 396 |
+
|
| 397 |
+
return matchesSearch && matchesTopic;
|
| 398 |
+
});
|
| 399 |
+
|
| 400 |
+
return (
|
| 401 |
+
<div>
|
| 402 |
+
{/* Sticky Header */}
|
| 403 |
+
<div style={{
|
| 404 |
+
position: 'sticky',
|
| 405 |
+
top: 0,
|
| 406 |
+
zIndex: 1000,
|
| 407 |
+
background: 'white',
|
| 408 |
+
borderBottom: '1px solid #eee',
|
| 409 |
+
boxShadow: '0 2px 8px rgba(0,0,0,0.05)'
|
| 410 |
+
}}>
|
| 411 |
+
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '16px 24px' }}>
|
| 412 |
+
{/* Logo and Title Row */}
|
| 413 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 12 }}>
|
| 414 |
+
{/* CommunityOne Logo */}
|
| 415 |
+
<img
|
| 416 |
+
src="/communityone_logo.svg"
|
| 417 |
+
alt="CommunityOne"
|
| 418 |
+
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
| 419 |
+
style={{
|
| 420 |
+
height: 100,
|
| 421 |
+
cursor: 'pointer',
|
| 422 |
+
objectFit: 'contain'
|
| 423 |
+
}}
|
| 424 |
+
/>
|
| 425 |
+
|
| 426 |
+
{/* User Location Display */}
|
| 427 |
+
<div style={{
|
| 428 |
+
display: 'flex',
|
| 429 |
+
alignItems: 'center',
|
| 430 |
+
gap: 8,
|
| 431 |
+
padding: '8px 16px',
|
| 432 |
+
background: '#f5f5f5',
|
| 433 |
+
borderRadius: 8,
|
| 434 |
+
border: '1px solid #e0e0e0'
|
| 435 |
+
}}>
|
| 436 |
+
<MapPin size={18} style={{ color: '#059669' }} />
|
| 437 |
+
<div>
|
| 438 |
+
<div style={{ fontSize: 14, fontWeight: 600, color: '#111' }}>
|
| 439 |
+
{jurisdictionType === 'nation' ? 'United States' :
|
| 440 |
+
jurisdictionType === 'state' ? jurisdictionState :
|
| 441 |
+
jurisdictionType === 'county' ? `${jurisdictionName} County, ${jurisdictionState}` :
|
| 442 |
+
jurisdictionType === 'school-district' ? `${jurisdictionName} (${jurisdictionState})` :
|
| 443 |
+
`${jurisdictionName}, ${jurisdictionState}`}
|
| 444 |
+
</div>
|
| 445 |
+
<div style={{ fontSize: 12, color: '#666', textTransform: 'capitalize' }}>
|
| 446 |
+
{jurisdictionType === 'school-district' ? 'School District' : jurisdictionType}
|
| 447 |
+
</div>
|
| 448 |
+
</div>
|
| 449 |
+
<button
|
| 450 |
+
onClick={() => {
|
| 451 |
+
setTempJurisdictionType(jurisdictionType);
|
| 452 |
+
setTempJurisdictionName(jurisdictionName);
|
| 453 |
+
setTempJurisdictionState(jurisdictionState);
|
| 454 |
+
setShowLocationModal(true);
|
| 455 |
+
}}
|
| 456 |
+
style={{
|
| 457 |
+
background: 'none',
|
| 458 |
+
border: 'none',
|
| 459 |
+
cursor: 'pointer',
|
| 460 |
+
padding: 4,
|
| 461 |
+
display: 'flex',
|
| 462 |
+
alignItems: 'center',
|
| 463 |
+
color: '#059669'
|
| 464 |
+
}}
|
| 465 |
+
title="Change location"
|
| 466 |
+
>
|
| 467 |
+
<Edit2 size={16} />
|
| 468 |
+
</button>
|
| 469 |
+
</div>
|
| 470 |
+
|
| 471 |
+
<div style={{ flex: 1 }}>
|
| 472 |
+
<h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 4, color: '#111' }}>
|
| 473 |
+
{metadata.title}
|
| 474 |
+
</h1>
|
| 475 |
+
<p style={{ color: '#666', fontSize: 14, lineHeight: 1.4, margin: 0 }}>
|
| 476 |
+
{metadata.description}
|
| 477 |
+
</p>
|
| 478 |
+
</div>
|
| 479 |
+
</div>
|
| 480 |
+
|
| 481 |
+
{/* Global Search Bar */}
|
| 482 |
+
<div style={{
|
| 483 |
+
background: 'white',
|
| 484 |
+
border: '1px solid #eee',
|
| 485 |
+
borderRadius: 8,
|
| 486 |
+
padding: 12,
|
| 487 |
+
marginBottom: 12
|
| 488 |
+
}}>
|
| 489 |
+
<div style={{
|
| 490 |
+
position: 'relative',
|
| 491 |
+
display: 'flex',
|
| 492 |
+
alignItems: 'center'
|
| 493 |
+
}}>
|
| 494 |
+
<Search
|
| 495 |
+
size={16}
|
| 496 |
+
style={{
|
| 497 |
+
position: 'absolute',
|
| 498 |
+
left: 12,
|
| 499 |
+
color: '#999'
|
| 500 |
+
}}
|
| 501 |
+
/>
|
| 502 |
+
<input
|
| 503 |
+
type="text"
|
| 504 |
+
placeholder="Search decisions, nonprofits, churches, or topics..."
|
| 505 |
+
value={searchQuery}
|
| 506 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 507 |
+
style={{
|
| 508 |
+
width: '100%',
|
| 509 |
+
padding: '10px 40px 10px 40px',
|
| 510 |
+
fontSize: 14,
|
| 511 |
+
border: '1px solid #ddd',
|
| 512 |
+
borderRadius: 6,
|
| 513 |
+
outline: 'none',
|
| 514 |
+
transition: 'border-color 0.2s'
|
| 515 |
+
}}
|
| 516 |
+
onFocus={(e) => e.target.style.borderColor = '#059669'}
|
| 517 |
+
onBlur={(e) => e.target.style.borderColor = '#ddd'}
|
| 518 |
+
/>
|
| 519 |
+
{searchQuery && (
|
| 520 |
+
<button
|
| 521 |
+
onClick={() => setSearchQuery('')}
|
| 522 |
+
style={{
|
| 523 |
+
position: 'absolute',
|
| 524 |
+
right: 12,
|
| 525 |
+
background: 'none',
|
| 526 |
+
border: 'none',
|
| 527 |
+
cursor: 'pointer',
|
| 528 |
+
color: '#999',
|
| 529 |
+
display: 'flex',
|
| 530 |
+
alignItems: 'center',
|
| 531 |
+
padding: 4
|
| 532 |
+
}}
|
| 533 |
+
>
|
| 534 |
+
<X size={16} />
|
| 535 |
+
</button>
|
| 536 |
+
)}
|
| 537 |
+
</div>
|
| 538 |
+
</div>
|
| 539 |
+
|
| 540 |
+
{/* View Mode Toggle */}
|
| 541 |
+
<div style={{
|
| 542 |
+
display: 'flex',
|
| 543 |
+
justifyContent: 'space-between',
|
| 544 |
+
alignItems: 'center'
|
| 545 |
+
}}>
|
| 546 |
+
<div style={{ display: 'flex', gap: 6 }}>
|
| 547 |
+
<button
|
| 548 |
+
onClick={() => setViewMode('home')}
|
| 549 |
+
style={{
|
| 550 |
+
padding: '10px 20px',
|
| 551 |
+
borderRadius: 8,
|
| 552 |
+
fontSize: 15,
|
| 553 |
+
cursor: 'pointer',
|
| 554 |
+
border: '1px solid',
|
| 555 |
+
borderColor: viewMode === 'home' ? '#888' : '#ddd',
|
| 556 |
+
background: viewMode === 'home' ? '#f5f5f2' : 'white',
|
| 557 |
+
fontWeight: viewMode === 'home' ? 500 : 400,
|
| 558 |
+
color: viewMode === 'home' ? '#111' : '#666',
|
| 559 |
+
display: 'flex',
|
| 560 |
+
alignItems: 'center',
|
| 561 |
+
gap: 6
|
| 562 |
+
}}
|
| 563 |
+
>
|
| 564 |
+
<Home size={14} />
|
| 565 |
+
Home
|
| 566 |
+
</button>
|
| 567 |
+
<button
|
| 568 |
+
onClick={() => {
|
| 569 |
+
setViewMode('browse');
|
| 570 |
+
setExploreMode('causes');
|
| 571 |
+
setSectorView('all');
|
| 572 |
+
}}
|
| 573 |
+
style={{
|
| 574 |
+
padding: '10px 20px',
|
| 575 |
+
borderRadius: 8,
|
| 576 |
+
fontSize: 15,
|
| 577 |
+
cursor: 'pointer',
|
| 578 |
+
border: '1px solid',
|
| 579 |
+
borderColor: (viewMode === 'browse' && exploreMode === 'causes') ? '#888' : '#ddd',
|
| 580 |
+
background: (viewMode === 'browse' && exploreMode === 'causes') ? '#f5f5f2' : 'white',
|
| 581 |
+
fontWeight: (viewMode === 'browse' && exploreMode === 'causes') ? 500 : 400,
|
| 582 |
+
color: (viewMode === 'browse' && exploreMode === 'causes') ? '#111' : '#666',
|
| 583 |
+
display: 'flex',
|
| 584 |
+
alignItems: 'center',
|
| 585 |
+
gap: 6
|
| 586 |
+
}}
|
| 587 |
+
>
|
| 588 |
+
<Lightbulb size={14} />
|
| 589 |
+
Explore Causes
|
| 590 |
+
</button>
|
| 591 |
+
<button
|
| 592 |
+
onClick={() => {
|
| 593 |
+
setViewMode('browse');
|
| 594 |
+
setExploreMode('decisions');
|
| 595 |
+
setSectorView('public');
|
| 596 |
+
}}
|
| 597 |
+
style={{
|
| 598 |
+
padding: '10px 20px',
|
| 599 |
+
borderRadius: 8,
|
| 600 |
+
fontSize: 15,
|
| 601 |
+
cursor: 'pointer',
|
| 602 |
+
border: '1px solid',
|
| 603 |
+
borderColor: (viewMode === 'browse' && exploreMode === 'decisions') ? '#888' : '#ddd',
|
| 604 |
+
background: (viewMode === 'browse' && exploreMode === 'decisions') ? '#f5f5f2' : 'white',
|
| 605 |
+
fontWeight: (viewMode === 'browse' && exploreMode === 'decisions') ? 500 : 400,
|
| 606 |
+
color: (viewMode === 'browse' && exploreMode === 'decisions') ? '#111' : '#666',
|
| 607 |
+
display: 'flex',
|
| 608 |
+
alignItems: 'center',
|
| 609 |
+
gap: 6
|
| 610 |
+
}}
|
| 611 |
+
>
|
| 612 |
+
<Building2 size={14} />
|
| 613 |
+
Explore Decisions
|
| 614 |
+
</button>
|
| 615 |
+
<button
|
| 616 |
+
onClick={() => {
|
| 617 |
+
setViewMode('browse');
|
| 618 |
+
setExploreMode('organizations');
|
| 619 |
+
setSectorView('all');
|
| 620 |
+
}}
|
| 621 |
+
style={{
|
| 622 |
+
padding: '10px 20px',
|
| 623 |
+
borderRadius: 8,
|
| 624 |
+
fontSize: 15,
|
| 625 |
+
cursor: 'pointer',
|
| 626 |
+
border: '1px solid',
|
| 627 |
+
borderColor: (viewMode === 'browse' && exploreMode === 'organizations') ? '#888' : '#ddd',
|
| 628 |
+
background: (viewMode === 'browse' && exploreMode === 'organizations') ? '#f5f5f2' : 'white',
|
| 629 |
+
fontWeight: (viewMode === 'browse' && exploreMode === 'organizations') ? 500 : 400,
|
| 630 |
+
color: (viewMode === 'browse' && exploreMode === 'organizations') ? '#111' : '#666',
|
| 631 |
+
display: 'flex',
|
| 632 |
+
alignItems: 'center',
|
| 633 |
+
gap: 6
|
| 634 |
+
}}
|
| 635 |
+
>
|
| 636 |
+
<Heart size={14} />
|
| 637 |
+
Explore Organizations
|
| 638 |
+
</button>
|
| 639 |
+
</div>
|
| 640 |
+
</div>
|
| 641 |
+
</div>
|
| 642 |
+
|
| 643 |
+
{/* Main Content Area */}
|
| 644 |
+
<div style={{ maxWidth: 1200, margin: '0 auto', padding: 24 }}>
|
| 645 |
+
|
| 646 |
+
{/* Search Bar & Quick Filters (show in browse view) */}
|
| 647 |
+
{viewMode === 'browse' && (
|
| 648 |
+
<div style={{
|
| 649 |
+
background: 'white',
|
| 650 |
+
border: '1px solid #eee',
|
| 651 |
+
borderRadius: 12,
|
| 652 |
+
padding: 16,
|
| 653 |
+
marginBottom: 16,
|
| 654 |
+
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
|
| 655 |
+
}}>
|
| 656 |
+
{/* Quick Filters */}
|
| 657 |
+
<div style={{
|
| 658 |
+
display: 'flex',
|
| 659 |
+
gap: 16,
|
| 660 |
+
flexWrap: 'wrap',
|
| 661 |
+
alignItems: 'center'
|
| 662 |
+
}}>
|
| 663 |
+
{/* Date Range Quick Filter */}
|
| 664 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
| 665 |
+
<Calendar size={14} style={{ color: '#666' }} />
|
| 666 |
+
<span style={{ fontSize: 13, color: '#666', fontWeight: 500 }}>Date:</span>
|
| 667 |
+
<div style={{ display: 'flex', gap: 4 }}>
|
| 668 |
+
{['all', '7days', '30days', '90days'].map(filter => (
|
| 669 |
+
<button
|
| 670 |
+
key={filter}
|
| 671 |
+
onClick={() => handleQuickDateFilter(filter)}
|
| 672 |
+
style={{
|
| 673 |
+
padding: '6px 12px',
|
| 674 |
+
fontSize: 12,
|
| 675 |
+
fontWeight: 500,
|
| 676 |
+
border: '1px solid',
|
| 677 |
+
borderColor: quickDateFilter === filter ? '#059669' : '#ddd',
|
| 678 |
+
background: quickDateFilter === filter ? '#dcfce7' : 'white',
|
| 679 |
+
color: quickDateFilter === filter ? '#059669' : '#666',
|
| 680 |
+
borderRadius: 6,
|
| 681 |
+
cursor: 'pointer',
|
| 682 |
+
transition: 'all 0.2s'
|
| 683 |
+
}}
|
| 684 |
+
>
|
| 685 |
+
{filter === 'all' ? 'All Time' :
|
| 686 |
+
filter === '7days' ? 'Last 7 Days' :
|
| 687 |
+
filter === '30days' ? 'Last 30 Days' : 'Last 90 Days'}
|
| 688 |
+
</button>
|
| 689 |
+
))}
|
| 690 |
+
</div>
|
| 691 |
+
</div>
|
| 692 |
+
|
| 693 |
+
{/* Topic Quick Filter */}
|
| 694 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
| 695 |
+
<Filter size={14} style={{ color: '#666' }} />
|
| 696 |
+
<span style={{ fontSize: 13, color: '#666', fontWeight: 500 }}>Topic:</span>
|
| 697 |
+
<div style={{ display: 'flex', gap: 4 }}>
|
| 698 |
+
{[
|
| 699 |
+
{ id: 'all', label: 'All' },
|
| 700 |
+
{ id: 'public-health', label: 'Health' },
|
| 701 |
+
{ id: 'education', label: 'Education' },
|
| 702 |
+
{ id: 'infrastructure', label: 'Infrastructure' },
|
| 703 |
+
{ id: 'safety', label: 'Safety' }
|
| 704 |
+
].map(topic => (
|
| 705 |
+
<button
|
| 706 |
+
key={topic.id}
|
| 707 |
+
onClick={() => handleQuickTopicFilter(topic.id)}
|
| 708 |
+
style={{
|
| 709 |
+
padding: '6px 12px',
|
| 710 |
+
fontSize: 12,
|
| 711 |
+
fontWeight: 500,
|
| 712 |
+
border: '1px solid',
|
| 713 |
+
borderColor: quickTopicFilter === topic.id ? '#059669' : '#ddd',
|
| 714 |
+
background: quickTopicFilter === topic.id ? '#dcfce7' : 'white',
|
| 715 |
+
color: quickTopicFilter === topic.id ? '#059669' : '#666',
|
| 716 |
+
borderRadius: 6,
|
| 717 |
+
cursor: 'pointer',
|
| 718 |
+
transition: 'all 0.2s'
|
| 719 |
+
}}
|
| 720 |
+
>
|
| 721 |
+
{topic.label}
|
| 722 |
+
</button>
|
| 723 |
+
))}
|
| 724 |
+
</div>
|
| 725 |
+
</div>
|
| 726 |
+
|
| 727 |
+
{/* Clear All Filters */}
|
| 728 |
+
{(searchQuery || quickDateFilter !== 'all' || quickTopicFilter !== 'all' ||
|
| 729 |
+
selectedPatterns.length > 0 || selectedResources.length > 0) && (
|
| 730 |
+
<button
|
| 731 |
+
onClick={handleClearFilters}
|
| 732 |
+
style={{
|
| 733 |
+
padding: '6px 12px',
|
| 734 |
+
fontSize: 12,
|
| 735 |
+
fontWeight: 500,
|
| 736 |
+
border: '1px solid #D85A30',
|
| 737 |
+
background: 'white',
|
| 738 |
+
color: '#D85A30',
|
| 739 |
+
borderRadius: 6,
|
| 740 |
+
cursor: 'pointer',
|
| 741 |
+
marginLeft: 'auto'
|
| 742 |
+
}}
|
| 743 |
+
>
|
| 744 |
+
Clear All
|
| 745 |
+
</button>
|
| 746 |
+
)}
|
| 747 |
+
</div>
|
| 748 |
+
</div>
|
| 749 |
+
)}
|
| 750 |
+
|
| 751 |
+
{/* Filters (show in browse view for decisions only) */}
|
| 752 |
+
{viewMode === 'browse' && exploreMode === 'decisions' && (
|
| 753 |
+
<TopicNavigation
|
| 754 |
+
selectedTopics={selectedTopics}
|
| 755 |
+
selectedPatterns={selectedPatterns}
|
| 756 |
+
selectedResources={selectedResources}
|
| 757 |
+
startDate={startDate}
|
| 758 |
+
endDate={endDate}
|
| 759 |
+
onTopicToggle={handleTopicToggle}
|
| 760 |
+
onPatternToggle={handlePatternToggle}
|
| 761 |
+
onResourceToggle={handleResourceToggle}
|
| 762 |
+
onStartDateChange={setStartDate}
|
| 763 |
+
onEndDateChange={setEndDate}
|
| 764 |
+
onClearAll={handleClearFilters}
|
| 765 |
+
/>
|
| 766 |
+
)}
|
| 767 |
+
|
| 768 |
+
{/* Content */}
|
| 769 |
+
{viewMode === 'home' ? (
|
| 770 |
+
<HomePage
|
| 771 |
+
onPersonaSelect={handlePersonaSelect}
|
| 772 |
+
onTopicSelect={handleTopicSelect}
|
| 773 |
+
onSectorSelect={handleSectorSelect}
|
| 774 |
+
decisionsCount={exampleDecisions.length}
|
| 775 |
+
nonprofitsCount={exampleNonprofits.filter(org => !org.ntee_code.startsWith('X')).length}
|
| 776 |
+
churchesCount={exampleNonprofits.filter(org => org.ntee_code.startsWith('X')).length}
|
| 777 |
+
/>
|
| 778 |
+
) : viewMode === 'impact' ? (
|
| 779 |
+
<div style={{
|
| 780 |
+
background: 'white',
|
| 781 |
+
border: '1px solid #eee',
|
| 782 |
+
borderRadius: 12,
|
| 783 |
+
padding: '1.5rem',
|
| 784 |
+
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
|
| 785 |
+
}}>
|
| 786 |
+
<button
|
| 787 |
+
onClick={handleBackToHome}
|
| 788 |
+
style={{
|
| 789 |
+
fontSize: 13,
|
| 790 |
+
color: '#666',
|
| 791 |
+
background: 'none',
|
| 792 |
+
border: 'none',
|
| 793 |
+
cursor: 'pointer',
|
| 794 |
+
marginBottom: 16,
|
| 795 |
+
display: 'flex',
|
| 796 |
+
alignItems: 'center',
|
| 797 |
+
gap: 4
|
| 798 |
+
}}
|
| 799 |
+
>
|
| 800 |
+
β Back to Home
|
| 801 |
+
</button>
|
| 802 |
+
<ImpactDashboard persona={selectedPersona} topic={selectedTopic} />
|
| 803 |
+
</div>
|
| 804 |
+
) : viewMode === 'split-screen' ? (
|
| 805 |
+
<div style={{
|
| 806 |
+
background: 'white',
|
| 807 |
+
border: '1px solid #eee',
|
| 808 |
+
borderRadius: 12,
|
| 809 |
+
padding: '1.5rem',
|
| 810 |
+
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
|
| 811 |
+
}}>
|
| 812 |
+
<button
|
| 813 |
+
onClick={() => setViewMode('browse')}
|
| 814 |
+
style={{
|
| 815 |
+
fontSize: 13,
|
| 816 |
+
color: '#666',
|
| 817 |
+
background: 'none',
|
| 818 |
+
border: 'none',
|
| 819 |
+
cursor: 'pointer',
|
| 820 |
+
marginBottom: 16,
|
| 821 |
+
display: 'flex',
|
| 822 |
+
alignItems: 'center',
|
| 823 |
+
gap: 4
|
| 824 |
+
}}
|
| 825 |
+
>
|
| 826 |
+
β Back to Decisions
|
| 827 |
+
</button>
|
| 828 |
+
<div style={{
|
| 829 |
+
fontSize: 12,
|
| 830 |
+
fontWeight: 600,
|
| 831 |
+
color: '#059669',
|
| 832 |
+
textTransform: 'uppercase',
|
| 833 |
+
letterSpacing: '0.05em',
|
| 834 |
+
marginBottom: 8
|
| 835 |
+
}}>
|
| 836 |
+
Split-Screen Analysis
|
| 837 |
+
</div>
|
| 838 |
+
<h2 style={{
|
| 839 |
+
fontSize: 22,
|
| 840 |
+
fontWeight: 600,
|
| 841 |
+
color: '#111',
|
| 842 |
+
marginBottom: 16
|
| 843 |
+
}}>
|
| 844 |
+
Government Decision β Community Response
|
| 845 |
+
</h2>
|
| 846 |
+
<SplitScreenView
|
| 847 |
+
decision={selectedDecision}
|
| 848 |
+
nonprofits={exampleNonprofits}
|
| 849 |
+
/>
|
| 850 |
+
</div>
|
| 851 |
+
) : (
|
| 852 |
+
<div style={{
|
| 853 |
+
background: 'white',
|
| 854 |
+
border: '1px solid #eee',
|
| 855 |
+
borderRadius: 12,
|
| 856 |
+
padding: '1.5rem',
|
| 857 |
+
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
|
| 858 |
+
}}>
|
| 859 |
+
<h2 style={{
|
| 860 |
+
fontSize: 20,
|
| 861 |
+
fontWeight: 500,
|
| 862 |
+
marginBottom: 16
|
| 863 |
+
}}>
|
| 864 |
+
{exploreMode === 'decisions' ? 'Explore Decisions' : exploreMode === 'organizations' ? 'Explore Organizations' : 'Explore Causes'}
|
| 865 |
+
{sectorView !== 'all' && sectorView !== 'public' && exploreMode !== 'causes' && (
|
| 866 |
+
<span style={{ color: '#999', fontWeight: 400 }}>
|
| 867 |
+
{' '}βΊ {sectorView === 'nonprofits' ? 'Nonprofits' : 'Churches'}
|
| 868 |
+
</span>
|
| 869 |
+
)}
|
| 870 |
+
</h2>
|
| 871 |
+
|
| 872 |
+
{/* Sector Filter Buttons */}
|
| 873 |
+
{exploreMode === 'decisions' && (
|
| 874 |
+
<div style={{
|
| 875 |
+
display: 'flex',
|
| 876 |
+
gap: 8,
|
| 877 |
+
marginBottom: 20,
|
| 878 |
+
padding: 16,
|
| 879 |
+
background: '#f9fafb',
|
| 880 |
+
borderRadius: 8,
|
| 881 |
+
flexWrap: 'wrap'
|
| 882 |
+
}}>
|
| 883 |
+
<button
|
| 884 |
+
onClick={() => setSectorView('public')}
|
| 885 |
+
style={{
|
| 886 |
+
padding: '10px 16px',
|
| 887 |
+
borderRadius: 6,
|
| 888 |
+
fontSize: 14,
|
| 889 |
+
fontWeight: 500,
|
| 890 |
+
cursor: 'pointer',
|
| 891 |
+
border: '1px solid',
|
| 892 |
+
borderColor: sectorView === 'public' ? '#185FA5' : '#ddd',
|
| 893 |
+
background: sectorView === 'public' ? '#EFF6FF' : 'white',
|
| 894 |
+
color: sectorView === 'public' ? '#185FA5' : '#666',
|
| 895 |
+
display: 'flex',
|
| 896 |
+
alignItems: 'center',
|
| 897 |
+
gap: 6
|
| 898 |
+
}}
|
| 899 |
+
>
|
| 900 |
+
<Building2 size={14} />
|
| 901 |
+
Public Sector ({filteredDecisions.length})
|
| 902 |
+
</button>
|
| 903 |
+
</div>
|
| 904 |
+
)}
|
| 905 |
+
|
| 906 |
+
{exploreMode === 'organizations' && (
|
| 907 |
+
<div style={{
|
| 908 |
+
display: 'flex',
|
| 909 |
+
gap: 8,
|
| 910 |
+
marginBottom: 20,
|
| 911 |
+
padding: 16,
|
| 912 |
+
background: '#f9fafb',
|
| 913 |
+
borderRadius: 8,
|
| 914 |
+
flexWrap: 'wrap'
|
| 915 |
+
}}>
|
| 916 |
+
<button
|
| 917 |
+
onClick={() => setSectorView('all')}
|
| 918 |
+
style={{
|
| 919 |
+
padding: '10px 16px',
|
| 920 |
+
borderRadius: 6,
|
| 921 |
+
fontSize: 14,
|
| 922 |
+
fontWeight: 500,
|
| 923 |
+
cursor: 'pointer',
|
| 924 |
+
border: '1px solid',
|
| 925 |
+
borderColor: sectorView === 'all' ? '#059669' : '#ddd',
|
| 926 |
+
background: sectorView === 'all' ? '#dcfce7' : 'white',
|
| 927 |
+
color: sectorView === 'all' ? '#059669' : '#666',
|
| 928 |
+
display: 'flex',
|
| 929 |
+
alignItems: 'center',
|
| 930 |
+
gap: 6
|
| 931 |
+
}}
|
| 932 |
+
>
|
| 933 |
+
<Grid size={14} />
|
| 934 |
+
All Organizations
|
| 935 |
+
</button>
|
| 936 |
+
<button
|
| 937 |
+
onClick={() => setSectorView('nonprofits')}
|
| 938 |
+
style={{
|
| 939 |
+
padding: '10px 16px',
|
| 940 |
+
borderRadius: 6,
|
| 941 |
+
fontSize: 14,
|
| 942 |
+
fontWeight: 500,
|
| 943 |
+
cursor: 'pointer',
|
| 944 |
+
border: '1px solid',
|
| 945 |
+
borderColor: sectorView === 'nonprofits' ? '#059669' : '#ddd',
|
| 946 |
+
background: sectorView === 'nonprofits' ? '#dcfce7' : 'white',
|
| 947 |
+
color: sectorView === 'nonprofits' ? '#059669' : '#666',
|
| 948 |
+
display: 'flex',
|
| 949 |
+
alignItems: 'center',
|
| 950 |
+
gap: 6
|
| 951 |
+
}}
|
| 952 |
+
>
|
| 953 |
+
<Heart size={14} />
|
| 954 |
+
Nonprofits ({filteredNonprofits.filter(org => !org.ntee_code.startsWith('X')).length})
|
| 955 |
+
</button>
|
| 956 |
+
<button
|
| 957 |
+
onClick={() => setSectorView('churches')}
|
| 958 |
+
style={{
|
| 959 |
+
padding: '10px 16px',
|
| 960 |
+
borderRadius: 6,
|
| 961 |
+
fontSize: 14,
|
| 962 |
+
fontWeight: 500,
|
| 963 |
+
cursor: 'pointer',
|
| 964 |
+
border: '1px solid',
|
| 965 |
+
borderColor: sectorView === 'churches' ? '#A855F7' : '#ddd',
|
| 966 |
+
background: sectorView === 'churches' ? '#FAF5FF' : 'white',
|
| 967 |
+
color: sectorView === 'churches' ? '#A855F7' : '#666',
|
| 968 |
+
display: 'flex',
|
| 969 |
+
alignItems: 'center',
|
| 970 |
+
gap: 6
|
| 971 |
+
}}
|
| 972 |
+
>
|
| 973 |
+
<Church size={14} />
|
| 974 |
+
Churches ({filteredNonprofits.filter(org => org.ntee_code.startsWith('X')).length})
|
| 975 |
+
</button>
|
| 976 |
+
</div>
|
| 977 |
+
)}
|
| 978 |
+
|
| 979 |
+
{/* Public Sector Decisions */}
|
| 980 |
+
{exploreMode === 'decisions' && (
|
| 981 |
+
<div>
|
| 982 |
+
{filteredDecisions.length === 0 ? (
|
| 983 |
+
<div style={{
|
| 984 |
+
textAlign: 'center',
|
| 985 |
+
padding: '2rem',
|
| 986 |
+
color: '#999',
|
| 987 |
+
background: '#f9fafb',
|
| 988 |
+
borderRadius: 8
|
| 989 |
+
}}>
|
| 990 |
+
<p style={{ fontSize: 15, marginBottom: 8 }}>
|
| 991 |
+
No government decisions match your filters
|
| 992 |
+
</p>
|
| 993 |
+
<button
|
| 994 |
+
onClick={handleClearFilters}
|
| 995 |
+
style={{
|
| 996 |
+
fontSize: 14,
|
| 997 |
+
color: '#D85A30',
|
| 998 |
+
background: 'none',
|
| 999 |
+
border: 'none',
|
| 1000 |
+
cursor: 'pointer',
|
| 1001 |
+
textDecoration: 'underline'
|
| 1002 |
+
}}
|
| 1003 |
+
>
|
| 1004 |
+
Clear all filters
|
| 1005 |
+
</button>
|
| 1006 |
+
</div>
|
| 1007 |
+
) : (
|
| 1008 |
+
<>
|
| 1009 |
+
<div style={{ fontSize: 14, color: '#888', marginBottom: 12 }}>
|
| 1010 |
+
Showing {filteredDecisions.length} decision{filteredDecisions.length !== 1 ? 's' : ''}
|
| 1011 |
+
</div>
|
| 1012 |
+
{filteredDecisions.map((decision, i) => (
|
| 1013 |
+
<DecisionCard
|
| 1014 |
+
key={i}
|
| 1015 |
+
decision={decision}
|
| 1016 |
+
onClick={() => handleDecisionClick(decision)}
|
| 1017 |
+
/>
|
| 1018 |
+
))}
|
| 1019 |
+
</>
|
| 1020 |
+
)}
|
| 1021 |
+
</div>
|
| 1022 |
+
)}
|
| 1023 |
+
|
| 1024 |
+
{/* Nonprofits Section */}
|
| 1025 |
+
{exploreMode === 'organizations' && (sectorView === 'all' || sectorView === 'nonprofits') && (
|
| 1026 |
+
<div style={{ marginBottom: sectorView === 'all' ? 32 : 0 }}>
|
| 1027 |
+
{sectorView === 'all' && (
|
| 1028 |
+
<h3 style={{
|
| 1029 |
+
fontSize: 18,
|
| 1030 |
+
fontWeight: 600,
|
| 1031 |
+
color: '#059669',
|
| 1032 |
+
marginBottom: 16,
|
| 1033 |
+
display: 'flex',
|
| 1034 |
+
alignItems: 'center',
|
| 1035 |
+
gap: 8
|
| 1036 |
+
}}>
|
| 1037 |
+
<Heart size={20} />
|
| 1038 |
+
Nonprofit Organizations
|
| 1039 |
+
</h3>
|
| 1040 |
+
)}
|
| 1041 |
+
{filteredNonprofits.filter(org => !org.ntee_code.startsWith('X')).length === 0 ? (
|
| 1042 |
+
<div style={{
|
| 1043 |
+
textAlign: 'center',
|
| 1044 |
+
padding: '2rem',
|
| 1045 |
+
color: '#999',
|
| 1046 |
+
background: '#f9fafb',
|
| 1047 |
+
borderRadius: 8
|
| 1048 |
+
}}>
|
| 1049 |
+
<p style={{ fontSize: 15 }}>
|
| 1050 |
+
No nonprofits match your search
|
| 1051 |
+
</p>
|
| 1052 |
+
</div>
|
| 1053 |
+
) : (
|
| 1054 |
+
filteredNonprofits
|
| 1055 |
+
.filter(org => !org.ntee_code.startsWith('X'))
|
| 1056 |
+
.map((nonprofit, i) => (
|
| 1057 |
+
<NonprofitCard key={i} nonprofit={nonprofit} isChurch={false} />
|
| 1058 |
+
))
|
| 1059 |
+
)}
|
| 1060 |
+
</div>
|
| 1061 |
+
)}
|
| 1062 |
+
|
| 1063 |
+
{/* Churches Section */}
|
| 1064 |
+
{exploreMode === 'organizations' && (sectorView === 'all' || sectorView === 'churches') && (
|
| 1065 |
+
<div>
|
| 1066 |
+
{sectorView === 'all' && (
|
| 1067 |
+
<h3 style={{
|
| 1068 |
+
fontSize: 18,
|
| 1069 |
+
fontWeight: 600,
|
| 1070 |
+
color: '#A855F7',
|
| 1071 |
+
marginBottom: 16,
|
| 1072 |
+
display: 'flex',
|
| 1073 |
+
alignItems: 'center',
|
| 1074 |
+
gap: 8
|
| 1075 |
+
}}>
|
| 1076 |
+
<Church size={20} />
|
| 1077 |
+
Faith-Based Organizations
|
| 1078 |
+
</h3>
|
| 1079 |
+
)}
|
| 1080 |
+
{filteredNonprofits.filter(org => org.ntee_code.startsWith('X')).length === 0 ? (
|
| 1081 |
+
<div style={{
|
| 1082 |
+
textAlign: 'center',
|
| 1083 |
+
padding: '2rem',
|
| 1084 |
+
color: '#999',
|
| 1085 |
+
background: '#f9fafb',
|
| 1086 |
+
borderRadius: 8
|
| 1087 |
+
}}>
|
| 1088 |
+
<p style={{ fontSize: 15 }}>
|
| 1089 |
+
No churches match your search
|
| 1090 |
+
</p>
|
| 1091 |
+
</div>
|
| 1092 |
+
) : (
|
| 1093 |
+
filteredNonprofits
|
| 1094 |
+
.filter(org => org.ntee_code.startsWith('X'))
|
| 1095 |
+
.map((church, i) => (
|
| 1096 |
+
<NonprofitCard key={i} nonprofit={church} isChurch={true} />
|
| 1097 |
+
))
|
| 1098 |
+
)}
|
| 1099 |
+
</div>
|
| 1100 |
+
)}
|
| 1101 |
+
|
| 1102 |
+
{/* Causes Section */}
|
| 1103 |
+
{exploreMode === 'causes' && (
|
| 1104 |
+
<div>
|
| 1105 |
+
<div style={{
|
| 1106 |
+
textAlign: 'center',
|
| 1107 |
+
padding: '3rem 2rem',
|
| 1108 |
+
color: '#666',
|
| 1109 |
+
background: '#f9fafb',
|
| 1110 |
+
borderRadius: 12,
|
| 1111 |
+
border: '2px dashed #ddd'
|
| 1112 |
+
}}>
|
| 1113 |
+
<Lightbulb size={48} style={{ color: '#D85A30', marginBottom: 16 }} />
|
| 1114 |
+
<h3 style={{
|
| 1115 |
+
fontSize: 20,
|
| 1116 |
+
fontWeight: 600,
|
| 1117 |
+
color: '#111',
|
| 1118 |
+
marginBottom: 12
|
| 1119 |
+
}}>
|
| 1120 |
+
Explore Root Causes
|
| 1121 |
+
</h3>
|
| 1122 |
+
<p style={{ fontSize: 15, lineHeight: 1.6, maxWidth: 600, margin: '0 auto', marginBottom: 16 }}>
|
| 1123 |
+
Discover the underlying factors driving policy decisions in your community.
|
| 1124 |
+
Understand the connections between social determinants, community needs, and government actions.
|
| 1125 |
+
</p>
|
| 1126 |
+
<p style={{ fontSize: 14, color: '#999', fontStyle: 'italic' }}>
|
| 1127 |
+
Coming soon: Interactive cause analysis and trend mapping
|
| 1128 |
+
</p>
|
| 1129 |
+
</div>
|
| 1130 |
+
</div>
|
| 1131 |
+
)}
|
| 1132 |
+
</div>
|
| 1133 |
+
)}
|
| 1134 |
+
</div>
|
| 1135 |
+
</div>
|
| 1136 |
+
|
| 1137 |
+
{/* Location Selection Modal */}
|
| 1138 |
+
{showLocationModal && (
|
| 1139 |
+
<div style={{
|
| 1140 |
+
position: 'fixed',
|
| 1141 |
+
top: 0,
|
| 1142 |
+
left: 0,
|
| 1143 |
+
right: 0,
|
| 1144 |
+
bottom: 0,
|
| 1145 |
+
background: 'rgba(0,0,0,0.5)',
|
| 1146 |
+
display: 'flex',
|
| 1147 |
+
alignItems: 'center',
|
| 1148 |
+
justifyContent: 'center',
|
| 1149 |
+
zIndex: 2000
|
| 1150 |
+
}}>
|
| 1151 |
+
<div style={{
|
| 1152 |
+
background: 'white',
|
| 1153 |
+
borderRadius: 12,
|
| 1154 |
+
padding: 32,
|
| 1155 |
+
maxWidth: 500,
|
| 1156 |
+
width: '90%',
|
| 1157 |
+
boxShadow: '0 10px 40px rgba(0,0,0,0.2)'
|
| 1158 |
+
}}>
|
| 1159 |
+
<h2 style={{
|
| 1160 |
+
fontSize: 22,
|
| 1161 |
+
fontWeight: 600,
|
| 1162 |
+
marginBottom: 8,
|
| 1163 |
+
color: '#111'
|
| 1164 |
+
}}>
|
| 1165 |
+
Select Your Jurisdiction
|
| 1166 |
+
</h2>
|
| 1167 |
+
<p style={{
|
| 1168 |
+
color: '#666',
|
| 1169 |
+
fontSize: 14,
|
| 1170 |
+
marginBottom: 24
|
| 1171 |
+
}}>
|
| 1172 |
+
Choose the level and location to see relevant decisions and organizations.
|
| 1173 |
+
</p>
|
| 1174 |
+
|
| 1175 |
+
<div style={{ marginBottom: 16 }}>
|
| 1176 |
+
<label style={{
|
| 1177 |
+
display: 'block',
|
| 1178 |
+
fontSize: 14,
|
| 1179 |
+
fontWeight: 500,
|
| 1180 |
+
marginBottom: 8,
|
| 1181 |
+
color: '#111'
|
| 1182 |
+
}}>
|
| 1183 |
+
Jurisdiction Level
|
| 1184 |
+
</label>
|
| 1185 |
+
<select
|
| 1186 |
+
value={tempJurisdictionType}
|
| 1187 |
+
onChange={(e) => setTempJurisdictionType(e.target.value)}
|
| 1188 |
+
style={{
|
| 1189 |
+
width: '100%',
|
| 1190 |
+
padding: '10px 12px',
|
| 1191 |
+
fontSize: 15,
|
| 1192 |
+
border: '1px solid #ddd',
|
| 1193 |
+
borderRadius: 6,
|
| 1194 |
+
outline: 'none',
|
| 1195 |
+
cursor: 'pointer'
|
| 1196 |
+
}}
|
| 1197 |
+
onFocus={(e) => e.target.style.borderColor = '#059669'}
|
| 1198 |
+
onBlur={(e) => e.target.style.borderColor = '#ddd'}
|
| 1199 |
+
>
|
| 1200 |
+
<option value="nation">Nation</option>
|
| 1201 |
+
<option value="state">State</option>
|
| 1202 |
+
<option value="county">County</option>
|
| 1203 |
+
<option value="city">City</option>
|
| 1204 |
+
<option value="school-district">School District</option>
|
| 1205 |
+
</select>
|
| 1206 |
+
</div>
|
| 1207 |
+
|
| 1208 |
+
{tempJurisdictionType !== 'nation' && (
|
| 1209 |
+
<div style={{ marginBottom: 16 }}>
|
| 1210 |
+
<label style={{
|
| 1211 |
+
display: 'block',
|
| 1212 |
+
fontSize: 14,
|
| 1213 |
+
fontWeight: 500,
|
| 1214 |
+
marginBottom: 8,
|
| 1215 |
+
color: '#111'
|
| 1216 |
+
}}>
|
| 1217 |
+
{tempJurisdictionType === 'state' ? 'State' :
|
| 1218 |
+
tempJurisdictionType === 'county' ? 'County Name' :
|
| 1219 |
+
tempJurisdictionType === 'school-district' ? 'School District Name' :
|
| 1220 |
+
'City Name'}
|
| 1221 |
+
</label>
|
| 1222 |
+
<input
|
| 1223 |
+
type="text"
|
| 1224 |
+
value={tempJurisdictionName}
|
| 1225 |
+
onChange={(e) => setTempJurisdictionName(e.target.value)}
|
| 1226 |
+
placeholder={
|
| 1227 |
+
tempJurisdictionType === 'state' ? 'e.g., Alabama' :
|
| 1228 |
+
tempJurisdictionType === 'county' ? 'e.g., Tuscaloosa' :
|
| 1229 |
+
tempJurisdictionType === 'school-district' ? 'e.g., Tuscaloosa City Schools' :
|
| 1230 |
+
'e.g., Tuscaloosa'
|
| 1231 |
+
}
|
| 1232 |
+
style={{
|
| 1233 |
+
width: '100%',
|
| 1234 |
+
padding: '10px 12px',
|
| 1235 |
+
fontSize: 15,
|
| 1236 |
+
border: '1px solid #ddd',
|
| 1237 |
+
borderRadius: 6,
|
| 1238 |
+
outline: 'none'
|
| 1239 |
+
}}
|
| 1240 |
+
onFocus={(e) => e.target.style.borderColor = '#059669'}
|
| 1241 |
+
onBlur={(e) => e.target.style.borderColor = '#ddd'}
|
| 1242 |
+
/>
|
| 1243 |
+
</div>
|
| 1244 |
+
)}
|
| 1245 |
+
|
| 1246 |
+
{tempJurisdictionType !== 'nation' && tempJurisdictionType !== 'state' && (
|
| 1247 |
+
<div style={{ marginBottom: 24 }}>
|
| 1248 |
+
<label style={{
|
| 1249 |
+
display: 'block',
|
| 1250 |
+
fontSize: 14,
|
| 1251 |
+
fontWeight: 500,
|
| 1252 |
+
marginBottom: 8,
|
| 1253 |
+
color: '#111'
|
| 1254 |
+
}}>
|
| 1255 |
+
State
|
| 1256 |
+
</label>
|
| 1257 |
+
<input
|
| 1258 |
+
type="text"
|
| 1259 |
+
value={tempJurisdictionState}
|
| 1260 |
+
onChange={(e) => setTempJurisdictionState(e.target.value)}
|
| 1261 |
+
placeholder="e.g., AL"
|
| 1262 |
+
maxLength="2"
|
| 1263 |
+
style={{
|
| 1264 |
+
width: '100%',
|
| 1265 |
+
padding: '10px 12px',
|
| 1266 |
+
fontSize: 15,
|
| 1267 |
+
border: '1px solid #ddd',
|
| 1268 |
+
borderRadius: 6,
|
| 1269 |
+
outline: 'none',
|
| 1270 |
+
textTransform: 'uppercase'
|
| 1271 |
+
}}
|
| 1272 |
+
onFocus={(e) => e.target.style.borderColor = '#059669'}
|
| 1273 |
+
onBlur={(e) => e.target.style.borderColor = '#ddd'}
|
| 1274 |
+
/>
|
| 1275 |
+
</div>
|
| 1276 |
+
)}
|
| 1277 |
+
|
| 1278 |
+
{tempJurisdictionType === 'state' && (
|
| 1279 |
+
<div style={{ marginBottom: 24 }}>
|
| 1280 |
+
<label style={{
|
| 1281 |
+
display: 'block',
|
| 1282 |
+
fontSize: 14,
|
| 1283 |
+
fontWeight: 500,
|
| 1284 |
+
marginBottom: 8,
|
| 1285 |
+
color: '#111'
|
| 1286 |
+
}}>
|
| 1287 |
+
State Code
|
| 1288 |
+
</label>
|
| 1289 |
+
<input
|
| 1290 |
+
type="text"
|
| 1291 |
+
value={tempJurisdictionState}
|
| 1292 |
+
onChange={(e) => setTempJurisdictionState(e.target.value)}
|
| 1293 |
+
placeholder="e.g., AL"
|
| 1294 |
+
maxLength="2"
|
| 1295 |
+
style={{
|
| 1296 |
+
width: '100%',
|
| 1297 |
+
padding: '10px 12px',
|
| 1298 |
+
fontSize: 15,
|
| 1299 |
+
border: '1px solid #ddd',
|
| 1300 |
+
borderRadius: 6,
|
| 1301 |
+
outline: 'none',
|
| 1302 |
+
textTransform: 'uppercase'
|
| 1303 |
+
}}
|
| 1304 |
+
onFocus={(e) => e.target.style.borderColor = '#059669'}
|
| 1305 |
+
onBlur={(e) => e.target.style.borderColor = '#ddd'}
|
| 1306 |
+
/>
|
| 1307 |
+
</div>
|
| 1308 |
+
)}
|
| 1309 |
+
|
| 1310 |
+
<div style={{
|
| 1311 |
+
display: 'flex',
|
| 1312 |
+
gap: 12,
|
| 1313 |
+
justifyContent: 'flex-end'
|
| 1314 |
+
}}>
|
| 1315 |
+
<button
|
| 1316 |
+
onClick={() => setShowLocationModal(false)}
|
| 1317 |
+
style={{
|
| 1318 |
+
padding: '10px 20px',
|
| 1319 |
+
fontSize: 15,
|
| 1320 |
+
border: '1px solid #ddd',
|
| 1321 |
+
background: 'white',
|
| 1322 |
+
borderRadius: 6,
|
| 1323 |
+
cursor: 'pointer',
|
| 1324 |
+
color: '#666'
|
| 1325 |
+
}}
|
| 1326 |
+
>
|
| 1327 |
+
Cancel
|
| 1328 |
+
</button>
|
| 1329 |
+
<button
|
| 1330 |
+
onClick={() => {
|
| 1331 |
+
setJurisdictionType(tempJurisdictionType);
|
| 1332 |
+
setJurisdictionName(tempJurisdictionName);
|
| 1333 |
+
setJurisdictionState(tempJurisdictionState);
|
| 1334 |
+
setShowLocationModal(false);
|
| 1335 |
+
}}
|
| 1336 |
+
style={{
|
| 1337 |
+
padding: '10px 20px',
|
| 1338 |
+
fontSize: 15,
|
| 1339 |
+
border: 'none',
|
| 1340 |
+
background: '#059669',
|
| 1341 |
+
color: 'white',
|
| 1342 |
+
borderRadius: 6,
|
| 1343 |
+
cursor: 'pointer',
|
| 1344 |
+
fontWeight: 500
|
| 1345 |
+
}}
|
| 1346 |
+
>
|
| 1347 |
+
Save Location
|
| 1348 |
+
</button>
|
| 1349 |
+
</div>
|
| 1350 |
+
</div>
|
| 1351 |
+
</div>
|
| 1352 |
+
)}
|
| 1353 |
+
</div>
|
| 1354 |
+
);
|
| 1355 |
+
}
|
frontend/policy-dashboards/src/App.jsx.backup
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
// Backup of corrupted file - see App.jsx for clean version
|
frontend/policy-dashboards/src/App.jsx.corrupted
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import HomePage from './components/HomePage';
|
| 3 |
+
import ImpactDashboard from './components/ImpactDashboard';
|
| 4 |
+
import TopicNavigation from './components/TopicNavigation';
|
| 5 |
+
import Summary from './components/Summary';
|
| 6 |
+
import WordsVsDollars from './components/WordsVsDollars';
|
| 7 |
+
import EndlessStudyLoop from './components/EndlessStudyLoop';
|
| 8 |
+
import WhereMoneyWent from './components/WhereMoneyWent';
|
| 9 |
+
import WhoIsInCharge from './components/WhoIsInCharge';
|
| 10 |
+
import FilterPanel from './components/shared/FilterPanel';
|
| 11 |
+
import DecisionCard from './components/shared/DecisionCard';
|
| 12 |
+
import { metadata } from './data/dashboardData';
|
| 13 |
+
import { Home, Layout, Grid, List, BarChart3 } from 'lucide-react';
|
| 14 |
+
|
| 15 |
+
const tabs = [
|
| 16 |
+
{ id: 0, label: 'Summary', component: Summary },
|
| 17 |
+
{ id: 1, label: 'They cut health spending while praising wellness', component: WordsVsDollars },
|
| 18 |
+
{ id: 2, label: 'Delayed 6 months and counting', component: EndlessStudyLoop },
|
| 19 |
+
{ id: 3, label: 'What got funded instead', component: WhereMoneyWent },
|
| 20 |
+
{ id: 4, label: 'One memo beat 240 residents', component: WhoIsInCharge },
|
| 21 |
+
];
|
| 22 |
+
|
| 23 |
+
export default function App() {
|
| 24 |
+
const [active, setActive] = useState(0);
|
| 25 |
+
const [viewMode, setViewMode] = useState('home'); // 'home', 'impact', 'dashboards', 'decisions', 'browse'
|
| 26 |
+
const [selectedPersona, setSelectedPersona] = useState(null);
|
| 27 |
+
const [selectedTopic, setSelectedTopic] = useState(null);
|
| 28 |
+
const [selectedDomains, setSelectedDomains] = useState([]);
|
| 29 |
+
const [selectedTopics, setSelectedTopics] = useState([]);
|
| 30 |
+
const [selectedPatterns, setSelectedPatterns] = useState([]);
|
| 31 |
+
const [selectedResources, setSelectedResources] = useState([]);
|
| 32 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 33 |
+
|
| 34 |
+
const handlePersonaSelect = (persona, topic) => {
|
| 35 |
+
setSelectedPersona(persona);
|
| 36 |
+
setSelectedTopic(topic);
|
| 37 |
+
setViewMode('impact');
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const handleTopicSelect = (topicId) => {
|
| 41 |
+
setSelectedTopics([topicId]);
|
| 42 |
+
setViewMode('browse');
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const handleDomainToggle = (domainId) => {
|
| 46 |
+
setSelectedDomains(prev =>
|
| 47 |
+
prev.includes(domainId)
|
| 48 |
+
? prev.filter(d => d !== domainId)
|
| 49 |
+
: [...prev, domainId]
|
| 50 |
+
);
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
const handleTopicToggle = (topicId) => {
|
| 54 |
+
setSelectedTopics(prev =>
|
| 55 |
+
prev.includes(topicId)
|
| 56 |
+
? prev.filter(t => t !== topicId)
|
| 57 |
+
: [...prev, topicId]
|
| 58 |
+
);
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const handlePatternToggle = (patternId) => {
|
| 62 |
+
setSelectedPatterns(prev =>
|
| 63 |
+
prev.includes(patternId)
|
| 64 |
+
? prev.filter(p => p !== patternId)
|
| 65 |
+
: [...prev, patternId]
|
| 66 |
+
);
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
const handleResourceToggle = (resourceId) => {
|
| 70 |
+
setSelectedResources(prev =>
|
| 71 |
+
prev.includes(resourceId)
|
| 72 |
+
? prev.filter(r => r !== resourceId)
|
| 73 |
+
: [...prev, resourceId]
|
| 74 |
+
);
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
const handleClearFilters = () => {
|
| 78 |
+
setSelectedDomains([]);
|
| 79 |
+
setSelectedTopics([]);
|
| 80 |
+
setSelectedPatterns([]);
|
| 81 |
+
setSelectedResources([]);
|
| 82 |
+
setSearchQuery('');
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
const handleBackToHome = () => {
|
| 86 |
+
setViewMode('home');
|
| 87 |
+
setSelectedPersona(null);
|
| 88 |
+
setSelectedTopic(null);
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
// Example decision data - would come from Python export
|
| 92 |
+
const exampleDecisions = [
|
| 93 |
+
{
|
| 94 |
+
decision_summary: "Approval of $850,000 athletic turf replacement project",
|
| 95 |
+
outcome: "Approved",
|
| 96 |
+
primary_rationale: "Athletic facilities are essential for student engagement and community pride. The current turf is beyond its useful life and poses safety concerns.",
|
| 97 |
+
supporters: [
|
| 98 |
+
{ name: "Board Member Johnson", role: "Board Member" },
|
| 99 |
+
{ name: "Athletic Director Smith", role: "Staff" }
|
| 100 |
+
],
|
| 101 |
+
opponents: [
|
| 102 |
+
{ name: "Parent Coalition Representative", role: "Public Comment" }
|
| 103 |
+
],
|
| 104 |
+
vote_result: "5-2",
|
| 105 |
+
meeting_date: "2026-03-15",
|
| 106 |
+
tradeoffs_discussed: ["Capital funding vs. operational needs", "Visibility vs. academic priorities"],
|
| 107 |
+
evidence_cited: [{ source: "Facility Assessment Report 2025" }],
|
| 108 |
+
policy_domain: "facilities"
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
decision_summary: "Tabled: West Alabama Community Dental Clinic partnership proposal",
|
| 112 |
+
outcome: "Deferred",
|
| 113 |
+
primary_rationale: "Need additional time to review liability implications and insurance coverage requirements before making a final decision.",
|
| 114 |
+
supporters: [
|
| 115 |
+
{ name: "Health Department Representative", role: "External Partner" },
|
| 116 |
+
{ name: "240+ citizen comments", role: "Public Comment" }
|
| 117 |
+
],
|
| 118 |
+
opponents: [],
|
| 119 |
+
vote_result: "Motion to table: 6-1",
|
| 120 |
+
meeting_date: "2026-04-10",
|
| 121 |
+
tradeoffs_discussed: ["Liability concerns vs. student health needs"],
|
| 122 |
+
evidence_cited: [
|
| 123 |
+
{ source: "Risk Manager Patricia Johnson memo" },
|
| 124 |
+
{ source: "Insurance policy documentation" }
|
| 125 |
+
],
|
| 126 |
+
policy_domain: "health"
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
decision_summary: "Contracted health services budget reduction of $120,000",
|
| 130 |
+
outcome: "Approved as part of budget",
|
| 131 |
+
primary_rationale: "Administrative cost increases require reallocation from non-critical service contracts. Health screenings can be covered through existing nurse staff.",
|
| 132 |
+
supporters: [
|
| 133 |
+
{ name: "CFO Thompson", role: "Staff" },
|
| 134 |
+
{ name: "Budget Committee", role: "Committee" }
|
| 135 |
+
],
|
| 136 |
+
opponents: [
|
| 137 |
+
{ name: "School Nurses Association", role: "Public Comment" }
|
| 138 |
+
],
|
| 139 |
+
vote_result: "7-0",
|
| 140 |
+
meeting_date: "2026-02-20",
|
| 141 |
+
tradeoffs_discussed: ["Cost savings vs. preventive health services"],
|
| 142 |
+
evidence_cited: [{ source: "FY2026 Budget Proposal" }],
|
| 143 |
+
policy_domain: "budget"
|
| 144 |
+
}
|
| 145 |
+
];
|
| 146 |
+
|
| 147 |
+
// Filter decisions based on search and domains
|
| 148 |
+
const filteredDecisions = exampleDecisions.filter(decision => {
|
| 149 |
+
const matchesSearch = searchQuery === '' ||
|
| 150 |
+
decision.decision_summary.toLowerCase().includes(searchQuery.toLowerCase());
|
| 151 |
+
|
| 152 |
+
const matchesDomain = selectedDomains.length === 0 ||
|
| 153 |
+
selectedDomains.includes(decision.policy_domain);
|
| 154 |
+
|
| 155 |
+
return matchesSearch && matchesDomain;
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
const ActiveComponent = tabs[active].component;
|
| 159 |
+
|
| 160 |
+
return (
|
| 161 |
+
<div style={{ maxWidth: 1200, margin: '0 auto', padding: 24 }}>
|
| 162 |
+
{/* Header */}
|
| 163 |
+
<div style={{ marginBottom: 24 }}>
|
| 164 |
+
<h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 4, color: '#111' }}>
|
| 165 |
+
{metadata.title}
|
| 166 |
+
</h1>
|
| 167 |
+
<p style={{ color: '#666', fontSize: 14, lineHeight: 1.5 }}>
|
| 168 |
+
{metadata.description}
|
| 169 |
+
</p>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
{/* View Mode Toggle */}
|
| 173 |
+
<div style={{
|
| 174 |
+
display: 'flex',
|
| 175 |
+
justifyContent: 'space-between',
|
| 176 |
+
alignItems: 'center',
|
| 177 |
+
marginBottom: 16
|
| 178 |
+
}}>
|
| 179 |
+
<div style={{ display: 'flex', gap: 6 }}>
|
| 180 |
+
<button
|
| 181 |
+
onClick={() => setViewMode('home')}
|
| 182 |
+
style={{
|
| 183 |
+
padding: '8px 16px',
|
| 184 |
+
borderRadius: 8,
|
| 185 |
+
fontSize: 13,
|
| 186 |
+
cursor: 'pointer',
|
| 187 |
+
border: '1px solid',
|
| 188 |
+
borderColor: viewMode === 'home' ? '#888' : '#ddd',
|
| 189 |
+
background: viewMode === 'home' ? '#f5f5f2' : 'white',
|
| 190 |
+
fontWeight: viewMode === 'home' ? 500 : 400,
|
| 191 |
+
color: viewMode === 'home' ? '#111' : '#666',
|
| 192 |
+
display: 'flex',
|
| 193 |
+
alignItems: 'center',
|
| 194 |
+
gap: 6
|
| 195 |
+
}}
|
| 196 |
+
>
|
| 197 |
+
<Home size={14} />
|
| 198 |
+
Home
|
| 199 |
+
</button>
|
| 200 |
+
<button
|
| 201 |
+
onClick={() => setViewMode('browse')}
|
| 202 |
+
style={{
|
| 203 |
+
padding: '8px 16px',
|
| 204 |
+
borderRadius: 8,
|
| 205 |
+
fontSize: 13,
|
| 206 |
+
cursor: 'pointer',
|
| 207 |
+
border: '1px solid',
|
| 208 |
+
borderColor: viewMode === 'browse' ? '#888' : '#ddd',
|
| 209 |
+
background: viewMode === 'browse' ? '#f5f5f2' : 'white',
|
| 210 |
+
fontWeight: viewMode === 'browse' ? 500 : 400,
|
| 211 |
+
color: viewMode === 'browse' ? '#111' : '#666',
|
| 212 |
+
display: 'flex',
|
| 213 |
+
alignItems: 'center',
|
| 214 |
+
gap: 6
|
| 215 |
+
}}
|
| 216 |
+
>
|
| 217 |
+
<Grid size={14} />
|
| 218 |
+
Browse by Topic
|
| 219 |
+
</button>
|
| 220 |
+
<button
|
| 221 |
+
onClick={() => setViewMode('dashboards')}
|
| 222 |
+
style={{
|
| 223 |
+
padding: '8px 16px',
|
| 224 |
+
borderRadius: 8,
|
| 225 |
+
fontSize: 13,
|
| 226 |
+
cursor: 'pointer',
|
| 227 |
+
border: '1px solid',
|
| 228 |
+
borderColor: viewMode === 'dashboards' ? '#888' : '#ddd',
|
| 229 |
+
background: viewMode === 'dashboards' ? '#f5f5f2' : 'white',
|
| 230 |
+
fontWeight: viewMode === 'dashboards' ? 500 : 400,
|
| 231 |
+
color: viewMode === 'dashboards' ? '#111' : '#666',
|
| 232 |
+
display: 'flex',
|
| 233 |
+
alignItems: 'center',
|
| 234 |
+
gap: 6
|
| 235 |
+
}}
|
| 236 |
+
>
|
| 237 |
+
<BarChart3 size={14} />
|
| 238 |
+
Analysis Dashboards
|
| 239 |
+
</button>
|
| 240 |
+
<button
|
| 241 |
+
onClick={() => setViewMode('decisions')}
|
| 242 |
+
style={{
|
| 243 |
+
padding: '8px 16px',
|
| 244 |
+
borderRadius: 8,
|
| 245 |
+
fontSize: 13,
|
| 246 |
+
cursor: 'pointer',
|
| 247 |
+
border: '1px solid',
|
| 248 |
+
borderColor: viewMode === 'decisions' ? '#888' : '#ddd',
|
| 249 |
+
background: viewMode === 'decisions' ? '#f5f5f2' : 'white',
|
| 250 |
+
fontWeight: viewMode === 'decisions' ? 500 : 400,
|
| 251 |
+
color: viewMode === 'decisions' ? '#111' : '#666',
|
| 252 |
+
display: 'flex',
|
| 253 |
+
alignItems: 'center',
|
| 254 |
+
gap: 6
|
| 255 |
+
}}
|
| 256 |
+
>
|
| 257 |
+
<List size={14} />
|
| 258 |
+
Alx',
|
| 259 |
+
justifyContent: 'space-between',
|
| 260 |
+
alignItems: 'center',
|
| 261 |
+
marginBottom: 16
|
| 262 |
+
}}>
|
| 263 |
+
<div style={{ display: 'flex', gap: 6 }}>
|
| 264 |
+
<button
|
| 265 |
+
onClick={() => setViewMode('dashboards')}
|
| 266 |
+
style={{
|
| 267 |
+
Topic Navigation (show in browse view) */}
|
| 268 |
+
{viewMode === 'browse' && (
|
| 269 |
+
<TopicNavigation
|
| 270 |
+
selectedTopics={selectedTopics}
|
| 271 |
+
selectedPatterns={selectedPatterns}
|
| 272 |
+
selectedResources={selectedResources}
|
| 273 |
+
onTopicToggle={handleTopicToggle}
|
| 274 |
+
onPatternToggle={handlePatternToggle}
|
| 275 |
+
onResourceToggle={handleResourceToggle}
|
| 276 |
+
onClearAll={handleClearFilters}
|
| 277 |
+
/>
|
| 278 |
+
)}
|
| 279 |
+
|
| 280 |
+
{/* padding: '8px 16px',
|
| 281 |
+
borderRadius: 8,
|
| 282 |
+
fontSize: 13,
|
| 283 |
+
cursor: 'pointer',
|
| 284 |
+
border: '1px solid',
|
| 285 |
+
borderColor: viewMode === 'dashboards' ? '#888' : '#ddd',
|
| 286 |
+
background: viewMode === 'dashboards' ? '#f5f5f2' : 'white',
|
| 287 |
+
fontWeight: viewMode === 'dashboards' ? 500 : 400,
|
| 288 |
+
color: viewMode === 'dashboards' ? '#111' : '#666',
|
| 289 |
+
display: 'flex',
|
| 290 |
+
alignItems: 'center',
|
| 291 |
+
gap: 6
|
| 292 |
+
}}
|
| 293 |
+
>
|
| 294 |
+
<Layout size={14} />
|
| 295 |
+
Dashboards
|
| 296 |
+
</button>
|
| 297 |
+
<button
|
| 298 |
+
onClick={() => setViewMode('decisions')}
|
| 299 |
+
style={{
|
| 300 |
+
padding: '8px 16px',
|
| 301 |
+
borderRadius: 8,
|
| 302 |
+
fontSize: 13,
|
| 303 |
+
cursor: 'pointer',
|
| 304 |
+
border: '1px solid',
|
| 305 |
+
borderColor: viewMode === 'decisions' ? '#888' : '#ddd',
|
| 306 |
+
background: viewMode === 'decisions' ? '#f5f5f2' : 'white',
|
| 307 |
+
fontWeight: viewMode === 'decisions' ? 500 : 400,
|
| 308 |
+
color: viewMode === 'decisions' ? '#111' : '#666',
|
| 309 |
+
display: 'flex',
|
| 310 |
+
alignIthome' ? (
|
| 311 |
+
<HomePage
|
| 312 |
+
onPersonaSelect={handlePersonaSelect}
|
| 313 |
+
onTopicSelect={handleTopicSelect}
|
| 314 |
+
/>
|
| 315 |
+
) : viewMode === 'impact' ? (
|
| 316 |
+
<div style={{
|
| 317 |
+
background: 'white',
|
| 318 |
+
border: '1px solid #eee',
|
| 319 |
+
borderRadius: 12,
|
| 320 |
+
padding: '1.5rem',
|
| 321 |
+
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
|
| 322 |
+
}}>
|
| 323 |
+
<button
|
| 324 |
+
onClick={handleBackToHome}
|
| 325 |
+
style={{
|
| 326 |
+
fontSize: 13,
|
| 327 |
+
color: '#666',
|
| 328 |
+
background: 'none',
|
| 329 |
+
border: 'none',
|
| 330 |
+
cursor: 'pointer',
|
| 331 |
+
marginBottom: 16,
|
| 332 |
+
display: 'flex',
|
| 333 |
+
alignItems: 'center',
|
| 334 |
+
gap: 4
|
| 335 |
+
}}
|
| 336 |
+
>
|
| 337 |
+
β Back to Home
|
| 338 |
+
</button>
|
| 339 |
+
<ImpactDashboard persona={selectedPersona} topic={selectedTopic} />
|
| 340 |
+
</div>
|
| 341 |
+
) : viewMode === 'browse' ? (
|
| 342 |
+
<div style={{
|
| 343 |
+
background: 'white',
|
| 344 |
+
border: '1px solid #eee',
|
| 345 |
+
borderRadius: 12,
|
| 346 |
+
padding: '1.5rem',
|
| 347 |
+
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
|
| 348 |
+
}}>
|
| 349 |
+
<h2 style={{
|
| 350 |
+
fontSize: 16,
|
| 351 |
+
fontWeight: 500,
|
| 352 |
+
marginBottom: 16
|
| 353 |
+
}}>
|
| 354 |
+
Browse Decisions by Topic
|
| 355 |
+
</h2>
|
| 356 |
+
|
| 357 |
+
{filteredDecisions.length === 0 ? (
|
| 358 |
+
<div style={{
|
| 359 |
+
textAlign: 'center',
|
| 360 |
+
padding: '3rem',
|
| 361 |
+
color: '#999'
|
| 362 |
+
}}>
|
| 363 |
+
<p style={{ fontSize: 14, marginBottom: 8 }}>
|
| 364 |
+
No decisions match your filters
|
| 365 |
+
</p>
|
| 366 |
+
<button
|
| 367 |
+
onClick={handleClearFilters}
|
| 368 |
+
style={{
|
| 369 |
+
fontSize: 13,
|
| 370 |
+
color: '#D85A30',
|
| 371 |
+
background: 'none',
|
| 372 |
+
border: 'none',
|
| 373 |
+
cursor: 'pointer',
|
| 374 |
+
textDecoration: 'underline'
|
| 375 |
+
}}
|
| 376 |
+
>
|
| 377 |
+
Clear filters
|
| 378 |
+
</button>
|
| 379 |
+
</div>
|
| 380 |
+
) : (
|
| 381 |
+
<>
|
| 382 |
+
<div style={{ fontSize: 13, color: '#888', marginBottom: 16 }}>
|
| 383 |
+
{filteredDecisions.length} decision{filteredDecisions.length !== 1 ? 's' : ''} found
|
| 384 |
+
</div>
|
| 385 |
+
{filteredDecisions.map((decision, i) => (
|
| 386 |
+
<DecisionCard
|
| 387 |
+
key={i}
|
| 388 |
+
decision={decision}
|
| 389 |
+
onClick={() => console.log('View decision:', decision)}
|
| 390 |
+
/>
|
| 391 |
+
))}
|
| 392 |
+
</>
|
| 393 |
+
)}
|
| 394 |
+
</div>
|
| 395 |
+
) : viewMode === 'ems: 'center',
|
| 396 |
+
gap: 6
|
| 397 |
+
}}
|
| 398 |
+
>
|
| 399 |
+
<List size={14} />
|
| 400 |
+
Individual Decisions
|
| 401 |
+
</button>
|
| 402 |
+
</div>
|
| 403 |
+
</div>
|
| 404 |
+
|
| 405 |
+
{/* Filters (show in decisions view) */}
|
| 406 |
+
{viewMode === 'decisions' && (
|
| 407 |
+
<FilterPanel
|
| 408 |
+
selectedDomains={selectedDomains}
|
| 409 |
+
onDomainToggle={handleDomainToggle}
|
| 410 |
+
searchQuery={searchQuery}
|
| 411 |
+
onSearchChange={setSearchQuery}
|
| 412 |
+
onClear={handleClearFilters}
|
| 413 |
+
/>
|
| 414 |
+
)}
|
| 415 |
+
|
| 416 |
+
{/* Tab Navigation (show in dashboard view) */}
|
| 417 |
+
{viewMode === 'dashboards' && (
|
| 418 |
+
<div style={{
|
| 419 |
+
display: 'flex',
|
| 420 |
+
gap: 6,
|
| 421 |
+
flexWrap: 'wrap',
|
| 422 |
+
marginBottom: 24
|
| 423 |
+
}}>
|
| 424 |
+
{tabs.map((t) => (
|
| 425 |
+
<button
|
| 426 |
+
key={t.id}
|
| 427 |
+
onClick={() => setActive(t.id)}
|
| 428 |
+
style={{
|
| 429 |
+
padding: '6px 14px',
|
| 430 |
+
borderRadius: 8,
|
| 431 |
+
fontSize: 13,
|
| 432 |
+
cursor: 'pointer',
|
| 433 |
+
border: '1px solid',
|
| 434 |
+
borderColor: active === t.id ? '#888' : '#ddd',
|
| 435 |
+
background: active === t.id ? '#f5f5f2' : 'white',
|
| 436 |
+
fontWeight: active === t.id ? 500 : 400,
|
| 437 |
+
color: active === t.id ? '#111' : '#666',
|
| 438 |
+
transition: 'all 0.2s ease'
|
| 439 |
+
}}
|
| 440 |
+
>
|
| 441 |
+
{t.label}
|
| 442 |
+
</button>
|
| 443 |
+
))}
|
| 444 |
+
</div>
|
| 445 |
+
)}
|
| 446 |
+
|
| 447 |
+
{/* Content */}
|
| 448 |
+
{viewMode === 'dashboards' ? (
|
| 449 |
+
<div style={{
|
| 450 |
+
background: 'white',
|
| 451 |
+
border: '1px solid #eee',
|
| 452 |
+
borderRadius: 12,
|
| 453 |
+
padding: '1.5rem',
|
| 454 |
+
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
|
| 455 |
+
}}>
|
| 456 |
+
<h2 style={{
|
| 457 |
+
fontSize: 14,
|
| 458 |
+
textTransform: 'uppercase',
|
| 459 |
+
letterSpacing: '0.07em',
|
| 460 |
+
color: '#aaa',
|
| 461 |
+
marginBottom: 16
|
| 462 |
+
}}>
|
| 463 |
+
{tabs[active].label}
|
| 464 |
+
</h2>
|
| 465 |
+
|
| 466 |
+
{active === 0 ? (
|
| 467 |
+
<ActiveComponent onNavigate={handleNavigate} />
|
| 468 |
+
) : (
|
| 469 |
+
<ActiveComponent />
|
| 470 |
+
)}
|
| 471 |
+
</div>
|
| 472 |
+
) : (
|
| 473 |
+
<div style={{
|
| 474 |
+
background: 'white',
|
| 475 |
+
border: '1px solid #eee',
|
| 476 |
+
borderRadius: 12,
|
| 477 |
+
padding: '1.5rem',
|
| 478 |
+
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
|
| 479 |
+
}}>
|
| 480 |
+
<div style={{
|
| 481 |
+
display: 'flex',
|
| 482 |
+
justifyContent: 'space-between',
|
| 483 |
+
alignItems: 'center',
|
| 484 |
+
marginBottom: 16
|
| 485 |
+
}}>
|
| 486 |
+
<h2 style={{
|
| 487 |
+
fontSize: 14,
|
| 488 |
+
textTransform: 'uppercase',
|
| 489 |
+
letterSpacing: '0.07em',
|
| 490 |
+
color: '#aaa'
|
| 491 |
+
}}>
|
| 492 |
+
Individual Decisions
|
| 493 |
+
</h2>
|
| 494 |
+
<div style={{ fontSize: 13, color: '#888' }}>
|
| 495 |
+
{filteredDecisions.length} decision{filteredDecisions.length !== 1 ? 's' : ''}
|
| 496 |
+
</div>
|
| 497 |
+
</div>
|
| 498 |
+
|
| 499 |
+
{filteredDecisions.length === 0 ? (
|
| 500 |
+
<div style={{
|
| 501 |
+
textAlign: 'center',
|
| 502 |
+
padding: '3rem',
|
| 503 |
+
color: '#999'
|
| 504 |
+
}}>
|
| 505 |
+
<p style={{ fontSize: 14, marginBottom: 8 }}>
|
| 506 |
+
No decisions match your filters
|
| 507 |
+
</p>
|
| 508 |
+
<button
|
| 509 |
+
onClick={handleClearFilters}
|
| 510 |
+
style={{
|
| 511 |
+
fontSize: 13,
|
| 512 |
+
color: '#D85A30',
|
| 513 |
+
background: 'none',
|
| 514 |
+
border: 'none',
|
| 515 |
+
cursor: 'pointer',
|
| 516 |
+
textDecoration: 'underline'
|
| 517 |
+
}}
|
| 518 |
+
>
|
| 519 |
+
Clear filters
|
| 520 |
+
</button>
|
| 521 |
+
</div>
|
| 522 |
+
) : (
|
| 523 |
+
filteredDecisions.map((decision, i) => (
|
| 524 |
+
<DecisionCard
|
| 525 |
+
key={i}
|
| 526 |
+
decision={decision}
|
| 527 |
+
onClick={() => {
|
| 528 |
+
// Could open modal or navigate to detail view
|
| 529 |
+
console.log('View decision details:', decision);
|
| 530 |
+
}}
|
| 531 |
+
/>
|
| 532 |
+
))
|
| 533 |
+
)}
|
| 534 |
+
</div>
|
| 535 |
+
)}
|
| 536 |
+
|
| 537 |
+
{/* Footer */}
|
| 538 |
+
<div style={{
|
| 539 |
+
marginTop: 24,
|
| 540 |
+
textAlign: 'center',
|
| 541 |
+
fontSize: 11,
|
| 542 |
+
color: '#999'
|
| 543 |
+
}}>
|
| 544 |
+
<p>
|
| 545 |
+
Generated by <strong>Oral Health Policy Pulse</strong> β Evidence-based advocacy toolkit
|
| 546 |
+
</p>
|
| 547 |
+
<p style={{ marginTop: 4 }}>
|
| 548 |
+
Methodology: Accountability dashboards using public meeting records and budget data
|
| 549 |
+
</p>
|
| 550 |
+
</div>
|
| 551 |
+
</div>
|
| 552 |
+
);
|
| 553 |
+
}
|
frontend/policy-dashboards/src/components/EndlessStudyLoop.jsx
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import MetricCard from './shared/MetricCard';
|
| 3 |
+
import Compare from './shared/Compare';
|
| 4 |
+
import InsightBox from './shared/InsightBox';
|
| 5 |
+
import { logicChainData as d } from '../data/dashboardData';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* Dashboard 2: Delayed 6 months and counting
|
| 9 |
+
* (Logic Chain / Sequential Deferral)
|
| 10 |
+
*/
|
| 11 |
+
export default function EndlessStudyLoop() {
|
| 12 |
+
return (
|
| 13 |
+
<div>
|
| 14 |
+
<p style={{
|
| 15 |
+
fontSize: 14,
|
| 16 |
+
color: '#555',
|
| 17 |
+
borderLeft: '2px solid #D85A30',
|
| 18 |
+
paddingLeft: 12,
|
| 19 |
+
marginBottom: 20
|
| 20 |
+
}}>
|
| 21 |
+
{d.conclusion}
|
| 22 |
+
</p>
|
| 23 |
+
|
| 24 |
+
{/* Topic */}
|
| 25 |
+
<div style={{
|
| 26 |
+
background: '#f5f5f2',
|
| 27 |
+
padding: 12,
|
| 28 |
+
borderRadius: 8,
|
| 29 |
+
marginBottom: 16
|
| 30 |
+
}}>
|
| 31 |
+
<div style={{ fontSize: 11, color: '#999', textTransform: 'uppercase', marginBottom: 4 }}>
|
| 32 |
+
Policy Decision
|
| 33 |
+
</div>
|
| 34 |
+
<div style={{ fontSize: 14, fontWeight: 500, color: '#222' }}>
|
| 35 |
+
{d.topic}
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
{/* Key Metrics */}
|
| 40 |
+
<div style={{
|
| 41 |
+
display: 'grid',
|
| 42 |
+
gridTemplateColumns: '1fr 1fr',
|
| 43 |
+
gap: 12,
|
| 44 |
+
marginBottom: 20
|
| 45 |
+
}}>
|
| 46 |
+
<MetricCard
|
| 47 |
+
value={`${d.totalDeferrals}Γ`}
|
| 48 |
+
label={`Times deferred in ${d.monthsInLimbo} months`}
|
| 49 |
+
tone="negative"
|
| 50 |
+
/>
|
| 51 |
+
<MetricCard
|
| 52 |
+
value={new Set(d.justifications.map(j => j.reason)).size}
|
| 53 |
+
label="Distinct justifications used"
|
| 54 |
+
/>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
{/* The "Study" Loop Timeline */}
|
| 58 |
+
<div style={{ marginBottom: 20 }}>
|
| 59 |
+
<h4 style={{
|
| 60 |
+
fontSize: 12,
|
| 61 |
+
textTransform: 'uppercase',
|
| 62 |
+
letterSpacing: '0.05em',
|
| 63 |
+
color: '#999',
|
| 64 |
+
marginBottom: 12
|
| 65 |
+
}}>
|
| 66 |
+
Shifting Justifications
|
| 67 |
+
</h4>
|
| 68 |
+
|
| 69 |
+
<div style={{ position: 'relative', paddingLeft: 24 }}>
|
| 70 |
+
{/* Vertical timeline line */}
|
| 71 |
+
<div style={{
|
| 72 |
+
position: 'absolute',
|
| 73 |
+
left: 7,
|
| 74 |
+
top: 8,
|
| 75 |
+
bottom: 8,
|
| 76 |
+
width: 1,
|
| 77 |
+
background: '#ddd'
|
| 78 |
+
}} />
|
| 79 |
+
|
| 80 |
+
{d.justifications.map((item, i) => (
|
| 81 |
+
<div key={i} style={{ position: 'relative', marginBottom: 18 }}>
|
| 82 |
+
{/* Timeline dot */}
|
| 83 |
+
<div style={{
|
| 84 |
+
position: 'absolute',
|
| 85 |
+
left: -20,
|
| 86 |
+
top: 4,
|
| 87 |
+
width: 8,
|
| 88 |
+
height: 8,
|
| 89 |
+
borderRadius: '50%',
|
| 90 |
+
background: item.status === 'deferred' ? '#D85A30' : '#BA7517',
|
| 91 |
+
border: '1.5px solid',
|
| 92 |
+
borderColor: item.status === 'deferred' ? '#D85A30' : '#BA7517'
|
| 93 |
+
}} />
|
| 94 |
+
|
| 95 |
+
{/* Timeline content */}
|
| 96 |
+
<div style={{ fontSize: 11, color: '#999', marginBottom: 2 }}>
|
| 97 |
+
{item.month} β <strong>{item.status}</strong>
|
| 98 |
+
</div>
|
| 99 |
+
<div style={{ fontSize: 13, color: '#555' }}>
|
| 100 |
+
"{item.reason}"
|
| 101 |
+
</div>
|
| 102 |
+
{item.speaker && item.speaker !== 'N/A' && (
|
| 103 |
+
<div style={{ fontSize: 11, color: '#888', fontStyle: 'italic', marginTop: 2 }}>
|
| 104 |
+
β {item.speaker}
|
| 105 |
+
</div>
|
| 106 |
+
)}
|
| 107 |
+
</div>
|
| 108 |
+
))}
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
{/* Benchmark Comparison */}
|
| 113 |
+
<div style={{ marginTop: 20 }}>
|
| 114 |
+
<h4 style={{
|
| 115 |
+
fontSize: 12,
|
| 116 |
+
textTransform: 'uppercase',
|
| 117 |
+
letterSpacing: '0.05em',
|
| 118 |
+
color: '#999',
|
| 119 |
+
marginBottom: 10
|
| 120 |
+
}}>
|
| 121 |
+
School-Linked Dental Programs by State Type
|
| 122 |
+
</h4>
|
| 123 |
+
<Compare
|
| 124 |
+
benchmarks={d.benchmarks}
|
| 125 |
+
metric="activePrograms"
|
| 126 |
+
prefix=""
|
| 127 |
+
suffix=" states"
|
| 128 |
+
/>
|
| 129 |
+
<p style={{
|
| 130 |
+
fontSize: 11,
|
| 131 |
+
color: '#888',
|
| 132 |
+
marginTop: 8,
|
| 133 |
+
textAlign: 'center'
|
| 134 |
+
}}>
|
| 135 |
+
Source: ASTDD State Oral Health Program Database, 2025
|
| 136 |
+
</p>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
{/* The Logic */}
|
| 140 |
+
<InsightBox type="critical">
|
| 141 |
+
<strong>{d.patternType}:</strong> {d.inference}
|
| 142 |
+
</InsightBox>
|
| 143 |
+
|
| 144 |
+
{/* Question for the Room */}
|
| 145 |
+
<div style={{
|
| 146 |
+
marginTop: 16,
|
| 147 |
+
padding: 14,
|
| 148 |
+
background: '#fff',
|
| 149 |
+
border: '2px solid #D85A30',
|
| 150 |
+
borderRadius: 8
|
| 151 |
+
}}>
|
| 152 |
+
<strong style={{ fontSize: 13, color: '#D85A30' }}>Ask them:</strong>
|
| 153 |
+
<p style={{ fontSize: 13, color: '#222', marginTop: 6 }}>
|
| 154 |
+
"This proposal has been 'under review' for {d.monthsInLimbo} months with {d.totalDeferrals} deferrals.
|
| 155 |
+
Each time, you give a different reason. {d.benchmarks.republicanAvg.activePrograms} Republican-led states
|
| 156 |
+
and {d.benchmarks.democraticAvg.activePrograms} Democratic-led states already have active programs.
|
| 157 |
+
What analysis are you waiting for that 35 states haven't already completed?"
|
| 158 |
+
</p>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
);
|
| 162 |
+
}
|
frontend/policy-dashboards/src/components/HomePage.jsx
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { TrendingUp, AlertCircle, Users, Scale, Droplet, Building2, Heart, Church, Grid } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* HomePage Component - Policy Accountability Platform
|
| 6 |
+
* Sector-based landing page for citizens
|
| 7 |
+
*/
|
| 8 |
+
export default function HomePage({ onPersonaSelect, onTopicSelect, onSectorSelect }) {
|
| 9 |
+
return (
|
| 10 |
+
<div>
|
| 11 |
+
{/* Top Section: Explore by Sector */}
|
| 12 |
+
<div style={{
|
| 13 |
+
background: 'linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)',
|
| 14 |
+
color: 'white',
|
| 15 |
+
borderRadius: 12,
|
| 16 |
+
padding: '2rem',
|
| 17 |
+
marginBottom: 24
|
| 18 |
+
}}>
|
| 19 |
+
<div style={{ marginBottom: 16 }}>
|
| 20 |
+
<div style={{ fontSize: 13, color: '#aaa', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 8 }}>
|
| 21 |
+
Policy Accountability Platform
|
| 22 |
+
</div>
|
| 23 |
+
<h1 style={{ fontSize: 28, fontWeight: 500, marginBottom: 12, lineHeight: 1.3 }}>
|
| 24 |
+
Track Government Decisions & Community Solutions
|
| 25 |
+
</h1>
|
| 26 |
+
<p style={{ fontSize: 16, color: '#ccc', lineHeight: 1.6 }}>
|
| 27 |
+
Explore policy decisions, track accountability, and discover community resources
|
| 28 |
+
</p>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
{/* Sector Navigation Cards */}
|
| 32 |
+
<div style={{
|
| 33 |
+
display: 'grid',
|
| 34 |
+
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
| 35 |
+
gap: 12,
|
| 36 |
+
marginTop: 24
|
| 37 |
+
}}>
|
| 38 |
+
<SectorCard
|
| 39 |
+
icon={<Grid size={24} />}
|
| 40 |
+
title="All Sectors"
|
| 41 |
+
description="View everything together"
|
| 42 |
+
color="#059669"
|
| 43 |
+
onClick={() => onSectorSelect('all')}
|
| 44 |
+
/>
|
| 45 |
+
<SectorCard
|
| 46 |
+
icon={<Building2 size={24} />}
|
| 47 |
+
title="Public Sector"
|
| 48 |
+
description="Government decisions"
|
| 49 |
+
color="#185FA5"
|
| 50 |
+
onClick={() => onSectorSelect('public')}
|
| 51 |
+
/>
|
| 52 |
+
<SectorCard
|
| 53 |
+
icon={<Heart size={24} />}
|
| 54 |
+
title="Nonprofits"
|
| 55 |
+
description="Community organizations"
|
| 56 |
+
color="#059669"
|
| 57 |
+
onClick={() => onSectorSelect('nonprofits')}
|
| 58 |
+
/>
|
| 59 |
+
<SectorCard
|
| 60 |
+
icon={<Church size={24} />}
|
| 61 |
+
title="Churches"
|
| 62 |
+
description="Faith-based ministries"
|
| 63 |
+
color="#A855F7"
|
| 64 |
+
onClick={() => onSectorSelect('churches')}
|
| 65 |
+
/>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
{/* Middle Section: Find Your Impact (Persona Filters) */}
|
| 70 |
+
<div style={{ marginBottom: 24 }}>
|
| 71 |
+
<h2 style={{ fontSize: 22, fontWeight: 500, marginBottom: 8 }}>
|
| 72 |
+
Find Your Impact
|
| 73 |
+
</h2>
|
| 74 |
+
<p style={{ fontSize: 16, color: '#666', marginBottom: 16 }}>
|
| 75 |
+
How are these decisions affecting you?
|
| 76 |
+
</p>
|
| 77 |
+
|
| 78 |
+
<div style={{
|
| 79 |
+
display: 'grid',
|
| 80 |
+
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
| 81 |
+
gap: 16
|
| 82 |
+
}}>
|
| 83 |
+
<PersonaCard
|
| 84 |
+
icon="π "
|
| 85 |
+
persona="Parent"
|
| 86 |
+
concern="Student Dental Health"
|
| 87 |
+
action="The Learning Barrier Map"
|
| 88 |
+
onClick={() => onPersonaSelect('parent', 'dental-health')}
|
| 89 |
+
/>
|
| 90 |
+
<PersonaCard
|
| 91 |
+
icon="π’"
|
| 92 |
+
persona="Advocate"
|
| 93 |
+
concern="Transparency & Vetoes"
|
| 94 |
+
action="The Influence Radar"
|
| 95 |
+
onClick={() => onPersonaSelect('advocate', 'transparency')}
|
| 96 |
+
/>
|
| 97 |
+
<PersonaCard
|
| 98 |
+
icon="π°"
|
| 99 |
+
persona="Resident"
|
| 100 |
+
concern="Water & Infrastructure"
|
| 101 |
+
action="The Lifetime Health Tax"
|
| 102 |
+
onClick={() => onPersonaSelect('resident', 'water-infrastructure')}
|
| 103 |
+
/>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
{/* Bottom Section: Topic Navigation */}
|
| 108 |
+
<div>
|
| 109 |
+
<h2 style={{ fontSize: 18, fontWeight: 500, marginBottom: 16 }}>
|
| 110 |
+
Browse by Topic
|
| 111 |
+
</h2>
|
| 112 |
+
|
| 113 |
+
<div style={{
|
| 114 |
+
display: 'grid',
|
| 115 |
+
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
| 116 |
+
gap: 12
|
| 117 |
+
}}>
|
| 118 |
+
<TopicCard
|
| 119 |
+
icon={<Scale size={24} />}
|
| 120 |
+
title="Public Health"
|
| 121 |
+
subtitle="Dental, Water, Mental Health"
|
| 122 |
+
count={24}
|
| 123 |
+
color="#1D9E75"
|
| 124 |
+
onClick={() => onTopicSelect('public-health')}
|
| 125 |
+
/>
|
| 126 |
+
<TopicCard
|
| 127 |
+
icon={<Users size={24} />}
|
| 128 |
+
title="Education & Youth"
|
| 129 |
+
subtitle="School Board, Pre-K"
|
| 130 |
+
count={18}
|
| 131 |
+
color="#185FA5"
|
| 132 |
+
onClick={() => onTopicSelect('education')}
|
| 133 |
+
/>
|
| 134 |
+
<TopicCard
|
| 135 |
+
icon={<TrendingUp size={24} />}
|
| 136 |
+
title="Infrastructure"
|
| 137 |
+
subtitle="Roads, Utilities, Construction"
|
| 138 |
+
count={32}
|
| 139 |
+
color="#BA7517"
|
| 140 |
+
onClick={() => onTopicSelect('infrastructure')}
|
| 141 |
+
/>
|
| 142 |
+
<TopicCard
|
| 143 |
+
icon={<Droplet size={24} />}
|
| 144 |
+
title="Public Safety"
|
| 145 |
+
subtitle="Police, Fire, EMS"
|
| 146 |
+
count={15}
|
| 147 |
+
color="#E24B4A"
|
| 148 |
+
onClick={() => onTopicSelect('public-safety')}
|
| 149 |
+
/>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
function SectorCard({ icon, title, description, color, onClick }) {
|
| 157 |
+
return (
|
| 158 |
+
<div
|
| 159 |
+
onClick={onClick}
|
| 160 |
+
style={{
|
| 161 |
+
background: 'rgba(255, 255, 255, 0.1)',
|
| 162 |
+
border: '1px solid rgba(255, 255, 255, 0.2)',
|
| 163 |
+
borderRadius: 8,
|
| 164 |
+
padding: 20,
|
| 165 |
+
cursor: 'pointer',
|
| 166 |
+
transition: 'all 0.2s ease',
|
| 167 |
+
textAlign: 'center'
|
| 168 |
+
}}
|
| 169 |
+
onMouseEnter={(e) => {
|
| 170 |
+
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
| 171 |
+
e.currentTarget.style.borderColor = color;
|
| 172 |
+
e.currentTarget.style.transform = 'translateY(-4px)';
|
| 173 |
+
}}
|
| 174 |
+
onMouseLeave={(e) => {
|
| 175 |
+
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
| 176 |
+
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
|
| 177 |
+
e.currentTarget.style.transform = 'translateY(0)';
|
| 178 |
+
}}
|
| 179 |
+
>
|
| 180 |
+
<div style={{ color: color, marginBottom: 12 }}>
|
| 181 |
+
{icon}
|
| 182 |
+
</div>
|
| 183 |
+
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 4, color: 'white' }}>
|
| 184 |
+
{title}
|
| 185 |
+
</div>
|
| 186 |
+
<div style={{ fontSize: 13, color: '#aaa' }}>
|
| 187 |
+
{description}
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function PersonaCard({ icon, persona, concern, action, onClick }) {
|
| 194 |
+
return (
|
| 195 |
+
<div
|
| 196 |
+
onClick={onClick}
|
| 197 |
+
style={{
|
| 198 |
+
background: 'white',
|
| 199 |
+
border: '2px solid #eee',
|
| 200 |
+
borderRadius: 12,
|
| 201 |
+
padding: 20,
|
| 202 |
+
cursor: 'pointer',
|
| 203 |
+
transition: 'all 0.2s ease'
|
| 204 |
+
}}
|
| 205 |
+
onMouseEnter={(e) => {
|
| 206 |
+
e.currentTarget.style.borderColor = '#D85A30';
|
| 207 |
+
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
|
| 208 |
+
}}
|
| 209 |
+
onMouseLeave={(e) => {
|
| 210 |
+
e.currentTarget.style.borderColor = '#eee';
|
| 211 |
+
e.currentTarget.style.boxShadow = 'none';
|
| 212 |
+
}}
|
| 213 |
+
>
|
| 214 |
+
<div style={{ fontSize: 32, marginBottom: 12 }}>{icon}</div>
|
| 215 |
+
<div style={{ marginBottom: 8 }}>
|
| 216 |
+
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>
|
| 217 |
+
I am a...
|
| 218 |
+
</div>
|
| 219 |
+
<div style={{ fontSize: 16, fontWeight: 500, color: '#222' }}>
|
| 220 |
+
{persona}
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
<div style={{ marginBottom: 12 }}>
|
| 224 |
+
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>
|
| 225 |
+
I care about...
|
| 226 |
+
</div>
|
| 227 |
+
<div style={{ fontSize: 14, color: '#555' }}>
|
| 228 |
+
{concern}
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
<div style={{
|
| 232 |
+
fontSize: 13,
|
| 233 |
+
color: '#D85A30',
|
| 234 |
+
fontWeight: 500,
|
| 235 |
+
display: 'flex',
|
| 236 |
+
alignItems: 'center',
|
| 237 |
+
gap: 6
|
| 238 |
+
}}>
|
| 239 |
+
Show me β {action}
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
function TopicCard({ icon, title, subtitle, count, color, onClick }) {
|
| 246 |
+
return (
|
| 247 |
+
<div
|
| 248 |
+
onClick={onClick}
|
| 249 |
+
style={{
|
| 250 |
+
background: 'white',
|
| 251 |
+
border: '1px solid #eee',
|
| 252 |
+
borderRadius: 8,
|
| 253 |
+
padding: 16,
|
| 254 |
+
cursor: 'pointer',
|
| 255 |
+
transition: 'all 0.2s ease',
|
| 256 |
+
display: 'flex',
|
| 257 |
+
alignItems: 'center',
|
| 258 |
+
gap: 12
|
| 259 |
+
}}
|
| 260 |
+
onMouseEnter={(e) => {
|
| 261 |
+
e.currentTarget.style.borderColor = color;
|
| 262 |
+
e.currentTarget.style.transform = 'translateY(-2px)';
|
| 263 |
+
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
| 264 |
+
}}
|
| 265 |
+
onMouseLeave={(e) => {
|
| 266 |
+
e.currentTarget.style.borderColor = '#eee';
|
| 267 |
+
e.currentTarget.style.transform = 'translateY(0)';
|
| 268 |
+
e.currentTarget.style.boxShadow = 'none';
|
| 269 |
+
}}
|
| 270 |
+
>
|
| 271 |
+
<div style={{ color }}>{icon}</div>
|
| 272 |
+
<div style={{ flex: 1 }}>
|
| 273 |
+
<div style={{ fontSize: 14, fontWeight: 500, color: '#222', marginBottom: 2 }}>
|
| 274 |
+
{title}
|
| 275 |
+
</div>
|
| 276 |
+
<div style={{ fontSize: 11, color: '#888' }}>
|
| 277 |
+
{subtitle}
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
<div style={{
|
| 281 |
+
fontSize: 18,
|
| 282 |
+
fontWeight: 500,
|
| 283 |
+
color,
|
| 284 |
+
minWidth: 32,
|
| 285 |
+
textAlign: 'right'
|
| 286 |
+
}}>
|
| 287 |
+
{count}
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
);
|
| 291 |
+
}
|
frontend/policy-dashboards/src/components/ImpactDashboard.jsx
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { XCircle, AlertTriangle, MapPin } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* ImpactDashboard Component
|
| 6 |
+
* Visual impact story for specific persona + topic combination
|
| 7 |
+
*/
|
| 8 |
+
export default function ImpactDashboard({ persona, topic }) {
|
| 9 |
+
// Example: Parent + Dental Health
|
| 10 |
+
if (persona === 'parent' && topic === 'dental-health') {
|
| 11 |
+
return <DentalHealthImpact />;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
// Example: Advocate + Transparency
|
| 15 |
+
if (persona === 'advocate' && topic === 'transparency') {
|
| 16 |
+
return <TransparencyImpact />;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// Default fallback
|
| 20 |
+
return <GenericImpact persona={persona} topic={topic} />;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function DentalHealthImpact() {
|
| 24 |
+
return (
|
| 25 |
+
<div>
|
| 26 |
+
{/* Header */}
|
| 27 |
+
<div style={{ marginBottom: 24 }}>
|
| 28 |
+
<div style={{ fontSize: 11, color: '#999', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>
|
| 29 |
+
Impact Story: Parent β Student Dental Health
|
| 30 |
+
</div>
|
| 31 |
+
<h2 style={{ fontSize: 20, fontWeight: 500, color: '#222', marginBottom: 8 }}>
|
| 32 |
+
The School Dental Screening Veto
|
| 33 |
+
</h2>
|
| 34 |
+
<p style={{ fontSize: 14, color: '#D85A30', fontWeight: 500 }}>
|
| 35 |
+
A legal "Risk Rationale" is blocking healthcare for 5,000 students
|
| 36 |
+
</p>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
{/* The Visual Split */}
|
| 40 |
+
<div style={{
|
| 41 |
+
display: 'grid',
|
| 42 |
+
gridTemplateColumns: '1fr 1fr',
|
| 43 |
+
gap: 20,
|
| 44 |
+
marginBottom: 24
|
| 45 |
+
}}>
|
| 46 |
+
{/* Left: The Human Cost */}
|
| 47 |
+
<div style={{
|
| 48 |
+
background: 'white',
|
| 49 |
+
border: '2px solid #eee',
|
| 50 |
+
borderRadius: 12,
|
| 51 |
+
padding: 20
|
| 52 |
+
}}>
|
| 53 |
+
<div style={{
|
| 54 |
+
display: 'flex',
|
| 55 |
+
alignItems: 'center',
|
| 56 |
+
gap: 8,
|
| 57 |
+
marginBottom: 16
|
| 58 |
+
}}>
|
| 59 |
+
<MapPin size={20} color="#D85A30" />
|
| 60 |
+
<h3 style={{ fontSize: 16, fontWeight: 500, margin: 0 }}>
|
| 61 |
+
The Human Cost
|
| 62 |
+
</h3>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
{/* School Map Placeholder */}
|
| 66 |
+
<div style={{
|
| 67 |
+
background: '#f5f5f2',
|
| 68 |
+
borderRadius: 8,
|
| 69 |
+
padding: 16,
|
| 70 |
+
marginBottom: 16,
|
| 71 |
+
minHeight: 200,
|
| 72 |
+
display: 'flex',
|
| 73 |
+
flexDirection: 'column',
|
| 74 |
+
justifyContent: 'center',
|
| 75 |
+
alignItems: 'center',
|
| 76 |
+
color: '#888'
|
| 77 |
+
}}>
|
| 78 |
+
<MapPin size={32} color="#D85A30" style={{ marginBottom: 8 }} />
|
| 79 |
+
<div style={{ fontSize: 13, textAlign: 'center' }}>
|
| 80 |
+
Map of Tuscaloosa Schools
|
| 81 |
+
</div>
|
| 82 |
+
<div style={{ fontSize: 11, textAlign: 'center', marginTop: 4 }}>
|
| 83 |
+
Red = High dental pain absence rates
|
| 84 |
+
</div>
|
| 85 |
+
<div style={{ fontSize: 11, textAlign: 'center', color: '#185FA5' }}>
|
| 86 |
+
Blue dots = Mobile clinics (currently: 0)
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
{/* Stats */}
|
| 91 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
| 92 |
+
<div style={{ background: '#ffe6e6', borderRadius: 6, padding: 12 }}>
|
| 93 |
+
<div style={{ fontSize: 20, fontWeight: 500, color: '#D85A30' }}>
|
| 94 |
+
48%
|
| 95 |
+
</div>
|
| 96 |
+
<div style={{ fontSize: 11, color: '#666' }}>
|
| 97 |
+
Students with no dental visit last year
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
<div style={{ background: '#fff4e6', borderRadius: 6, padding: 12 }}>
|
| 101 |
+
<div style={{ fontSize: 20, fontWeight: 500, color: '#BA7517' }}>
|
| 102 |
+
127
|
| 103 |
+
</div>
|
| 104 |
+
<div style={{ fontSize: 11, color: '#666' }}>
|
| 105 |
+
Days missed due to dental pain (2025)
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{/* Right: The Veto */}
|
| 112 |
+
<div style={{
|
| 113 |
+
background: 'white',
|
| 114 |
+
border: '2px solid #eee',
|
| 115 |
+
borderRadius: 12,
|
| 116 |
+
padding: 20
|
| 117 |
+
}}>
|
| 118 |
+
<div style={{
|
| 119 |
+
display: 'flex',
|
| 120 |
+
alignItems: 'center',
|
| 121 |
+
gap: 8,
|
| 122 |
+
marginBottom: 16
|
| 123 |
+
}}>
|
| 124 |
+
<XCircle size={20} color="#E24B4A" />
|
| 125 |
+
<h3 style={{ fontSize: 16, fontWeight: 500, margin: 0 }}>
|
| 126 |
+
The Veto Chain
|
| 127 |
+
</h3>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
{/* Flowchart */}
|
| 131 |
+
<div style={{ position: 'relative' }}>
|
| 132 |
+
{/* Step 1: Public Demand */}
|
| 133 |
+
<div style={{
|
| 134 |
+
background: '#e6f4f1',
|
| 135 |
+
border: '2px solid #1D9E75',
|
| 136 |
+
borderRadius: 8,
|
| 137 |
+
padding: 12,
|
| 138 |
+
marginBottom: 16
|
| 139 |
+
}}>
|
| 140 |
+
<div style={{ fontSize: 12, fontWeight: 500, color: '#1D9E75', marginBottom: 4 }}>
|
| 141 |
+
β Public Demand
|
| 142 |
+
</div>
|
| 143 |
+
<div style={{ fontSize: 13, color: '#222' }}>
|
| 144 |
+
1,200 Signed Petitions
|
| 145 |
+
</div>
|
| 146 |
+
<div style={{ fontSize: 11, color: '#666', marginTop: 4 }}>
|
| 147 |
+
240+ Testimonies at board meetings
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{/* Arrow */}
|
| 152 |
+
<div style={{
|
| 153 |
+
textAlign: 'center',
|
| 154 |
+
margin: '-8px 0',
|
| 155 |
+
fontSize: 20,
|
| 156 |
+
color: '#D85A30',
|
| 157 |
+
fontWeight: 'bold'
|
| 158 |
+
}}>
|
| 159 |
+
β BLOCKED
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
{/* Step 2: The Blocker */}
|
| 163 |
+
<div style={{
|
| 164 |
+
background: '#ffe6e6',
|
| 165 |
+
border: '2px solid #E24B4A',
|
| 166 |
+
borderRadius: 8,
|
| 167 |
+
padding: 12,
|
| 168 |
+
marginBottom: 16,
|
| 169 |
+
marginTop: 8
|
| 170 |
+
}}>
|
| 171 |
+
<div style={{ fontSize: 12, fontWeight: 500, color: '#E24B4A', marginBottom: 4 }}>
|
| 172 |
+
β The Blocker
|
| 173 |
+
</div>
|
| 174 |
+
<div style={{ fontSize: 13, color: '#222', fontWeight: 500 }}>
|
| 175 |
+
Risk Management Memo
|
| 176 |
+
</div>
|
| 177 |
+
<div style={{ fontSize: 11, color: '#666', marginTop: 4 }}>
|
| 178 |
+
From: Patricia Johnson, District Legal Office
|
| 179 |
+
</div>
|
| 180 |
+
<div style={{ fontSize: 11, color: '#666' }}>
|
| 181 |
+
Concern: "Insurance Liability"
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
{/* Arrow */}
|
| 186 |
+
<div style={{
|
| 187 |
+
textAlign: 'center',
|
| 188 |
+
margin: '-8px 0',
|
| 189 |
+
fontSize: 20,
|
| 190 |
+
color: '#888'
|
| 191 |
+
}}>
|
| 192 |
+
β
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
{/* Step 3: The Result */}
|
| 196 |
+
<div style={{
|
| 197 |
+
background: '#f5f5f2',
|
| 198 |
+
border: '2px solid #888',
|
| 199 |
+
borderRadius: 8,
|
| 200 |
+
padding: 12,
|
| 201 |
+
marginTop: 8
|
| 202 |
+
}}>
|
| 203 |
+
<div style={{ fontSize: 12, fontWeight: 500, color: '#888', marginBottom: 4 }}>
|
| 204 |
+
The Result
|
| 205 |
+
</div>
|
| 206 |
+
<div style={{ fontSize: 13, color: '#222' }}>
|
| 207 |
+
Board votes to "Table" initiative
|
| 208 |
+
</div>
|
| 209 |
+
<div style={{ fontSize: 11, color: '#D85A30', marginTop: 4 }}>
|
| 210 |
+
Status: Deferred for 152 days
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
{/* Bottom: Key Fact */}
|
| 218 |
+
<div style={{
|
| 219 |
+
background: '#fff4e6',
|
| 220 |
+
border: '2px solid #BA7517',
|
| 221 |
+
borderRadius: 12,
|
| 222 |
+
padding: 16,
|
| 223 |
+
display: 'flex',
|
| 224 |
+
alignItems: 'flex-start',
|
| 225 |
+
gap: 12
|
| 226 |
+
}}>
|
| 227 |
+
<AlertTriangle size={24} color="#BA7517" />
|
| 228 |
+
<div>
|
| 229 |
+
<div style={{ fontSize: 14, fontWeight: 500, color: '#222', marginBottom: 4 }}>
|
| 230 |
+
Key Finding
|
| 231 |
+
</div>
|
| 232 |
+
<div style={{ fontSize: 13, color: '#555' }}>
|
| 233 |
+
Zero successful liability lawsuits in any of the 35 states with active school dental screening programs.
|
| 234 |
+
The "risk" cited in the memo has no empirical basis.
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
);
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
function TransparencyImpact() {
|
| 243 |
+
return (
|
| 244 |
+
<div>
|
| 245 |
+
<div style={{ marginBottom: 16 }}>
|
| 246 |
+
<div style={{ fontSize: 11, color: '#999', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>
|
| 247 |
+
Impact Story: Advocate β Transparency & Vetoes
|
| 248 |
+
</div>
|
| 249 |
+
<h2 style={{ fontSize: 20, fontWeight: 500, color: '#222', marginBottom: 8 }}>
|
| 250 |
+
Who Really Decides?
|
| 251 |
+
</h2>
|
| 252 |
+
<p style={{ fontSize: 14, color: '#D85A30', fontWeight: 500 }}>
|
| 253 |
+
Unelected staff have veto power that outweighs public input
|
| 254 |
+
</p>
|
| 255 |
+
</div>
|
| 256 |
+
|
| 257 |
+
<div style={{ fontSize: 13, color: '#666', marginBottom: 16 }}>
|
| 258 |
+
See the Influence Radar dashboard for detailed analysis β
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
function GenericImpact({ persona, topic }) {
|
| 265 |
+
return (
|
| 266 |
+
<div>
|
| 267 |
+
<div style={{ marginBottom: 16 }}>
|
| 268 |
+
<div style={{ fontSize: 11, color: '#999', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>
|
| 269 |
+
Impact Story: {persona} β {topic}
|
| 270 |
+
</div>
|
| 271 |
+
<h2 style={{ fontSize: 20, fontWeight: 500, color: '#222', marginBottom: 8 }}>
|
| 272 |
+
Coming Soon
|
| 273 |
+
</h2>
|
| 274 |
+
<p style={{ fontSize: 14, color: '#666' }}>
|
| 275 |
+
This impact story is being developed. Check back soon or browse other topics.
|
| 276 |
+
</p>
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
);
|
| 280 |
+
}
|
frontend/policy-dashboards/src/components/NonprofitCard.jsx
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Heart, Mail, Phone, Globe, Users, DollarSign } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* NonprofitCard Component
|
| 6 |
+
* Displays individual nonprofit or church organization
|
| 7 |
+
*/
|
| 8 |
+
export default function NonprofitCard({ nonprofit, isChurch = false }) {
|
| 9 |
+
const {
|
| 10 |
+
name,
|
| 11 |
+
ein,
|
| 12 |
+
ntee_code,
|
| 13 |
+
ntee_description,
|
| 14 |
+
mission,
|
| 15 |
+
services = [],
|
| 16 |
+
annual_budget,
|
| 17 |
+
students_served,
|
| 18 |
+
families_served,
|
| 19 |
+
youth_served,
|
| 20 |
+
contact,
|
| 21 |
+
volunteer_opportunities,
|
| 22 |
+
accepting_board_members
|
| 23 |
+
} = nonprofit;
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div style={{
|
| 27 |
+
background: 'white',
|
| 28 |
+
border: '1px solid',
|
| 29 |
+
borderColor: isChurch ? '#A855F7' : '#10b981',
|
| 30 |
+
borderLeft: `4px solid ${isChurch ? '#A855F7' : '#10b981'}`,
|
| 31 |
+
borderRadius: 12,
|
| 32 |
+
padding: 20,
|
| 33 |
+
marginBottom: 16,
|
| 34 |
+
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
|
| 35 |
+
}}>
|
| 36 |
+
{/* Header */}
|
| 37 |
+
<div style={{ marginBottom: 16 }}>
|
| 38 |
+
<div style={{
|
| 39 |
+
display: 'flex',
|
| 40 |
+
justifyContent: 'space-between',
|
| 41 |
+
alignItems: 'flex-start',
|
| 42 |
+
marginBottom: 8
|
| 43 |
+
}}>
|
| 44 |
+
<h3 style={{
|
| 45 |
+
fontSize: 18,
|
| 46 |
+
fontWeight: 600,
|
| 47 |
+
color: '#111',
|
| 48 |
+
lineHeight: 1.4
|
| 49 |
+
}}>
|
| 50 |
+
{name}
|
| 51 |
+
</h3>
|
| 52 |
+
{isChurch && (
|
| 53 |
+
<span style={{
|
| 54 |
+
padding: '4px 10px',
|
| 55 |
+
borderRadius: 12,
|
| 56 |
+
background: '#F3E8FF',
|
| 57 |
+
color: '#A855F7',
|
| 58 |
+
fontSize: 11,
|
| 59 |
+
fontWeight: 500
|
| 60 |
+
}}>
|
| 61 |
+
βͺ Faith-Based
|
| 62 |
+
</span>
|
| 63 |
+
)}
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div style={{
|
| 67 |
+
fontSize: 12,
|
| 68 |
+
color: '#666',
|
| 69 |
+
marginBottom: 8
|
| 70 |
+
}}>
|
| 71 |
+
NTEE {ntee_code}: {ntee_description}
|
| 72 |
+
{ein && <span style={{ marginLeft: 8, color: '#999' }}>β’ EIN: {ein}</span>}
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div style={{
|
| 76 |
+
fontSize: 15,
|
| 77 |
+
color: '#444',
|
| 78 |
+
lineHeight: 1.6,
|
| 79 |
+
marginBottom: 16
|
| 80 |
+
}}>
|
| 81 |
+
{mission}
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{/* Services */}
|
| 86 |
+
{services.length > 0 && (
|
| 87 |
+
<div style={{ marginBottom: 16 }}>
|
| 88 |
+
<div style={{
|
| 89 |
+
fontSize: 13,
|
| 90 |
+
fontWeight: 500,
|
| 91 |
+
color: isChurch ? '#A855F7' : '#059669',
|
| 92 |
+
marginBottom: 8
|
| 93 |
+
}}>
|
| 94 |
+
Services Provided:
|
| 95 |
+
</div>
|
| 96 |
+
<ul style={{
|
| 97 |
+
margin: 0,
|
| 98 |
+
paddingLeft: 20,
|
| 99 |
+
fontSize: 14,
|
| 100 |
+
color: '#555',
|
| 101 |
+
lineHeight: 1.8
|
| 102 |
+
}}>
|
| 103 |
+
{services.map((service, i) => (
|
| 104 |
+
<li key={i}>{service}</li>
|
| 105 |
+
))}
|
| 106 |
+
</ul>
|
| 107 |
+
</div>
|
| 108 |
+
)}
|
| 109 |
+
|
| 110 |
+
{/* Impact Metrics */}
|
| 111 |
+
<div style={{
|
| 112 |
+
display: 'grid',
|
| 113 |
+
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
| 114 |
+
gap: 12,
|
| 115 |
+
marginBottom: 16,
|
| 116 |
+
padding: 16,
|
| 117 |
+
background: isChurch ? '#FAF5FF' : '#f0fdf4',
|
| 118 |
+
borderRadius: 8
|
| 119 |
+
}}>
|
| 120 |
+
{students_served > 0 && (
|
| 121 |
+
<div>
|
| 122 |
+
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>Students Served</div>
|
| 123 |
+
<div style={{
|
| 124 |
+
fontSize: 18,
|
| 125 |
+
fontWeight: 600,
|
| 126 |
+
color: isChurch ? '#A855F7' : '#059669',
|
| 127 |
+
display: 'flex',
|
| 128 |
+
alignItems: 'center',
|
| 129 |
+
gap: 6
|
| 130 |
+
}}>
|
| 131 |
+
<Users size={16} />
|
| 132 |
+
{students_served.toLocaleString()}
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
{families_served > 0 && (
|
| 137 |
+
<div>
|
| 138 |
+
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>Families Served</div>
|
| 139 |
+
<div style={{
|
| 140 |
+
fontSize: 18,
|
| 141 |
+
fontWeight: 600,
|
| 142 |
+
color: isChurch ? '#A855F7' : '#059669',
|
| 143 |
+
display: 'flex',
|
| 144 |
+
alignItems: 'center',
|
| 145 |
+
gap: 6
|
| 146 |
+
}}>
|
| 147 |
+
<Heart size={16} />
|
| 148 |
+
{families_served.toLocaleString()}
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
)}
|
| 152 |
+
{youth_served > 0 && (
|
| 153 |
+
<div>
|
| 154 |
+
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>Youth Served</div>
|
| 155 |
+
<div style={{
|
| 156 |
+
fontSize: 18,
|
| 157 |
+
fontWeight: 600,
|
| 158 |
+
color: isChurch ? '#A855F7' : '#059669',
|
| 159 |
+
display: 'flex',
|
| 160 |
+
alignItems: 'center',
|
| 161 |
+
gap: 6
|
| 162 |
+
}}>
|
| 163 |
+
<Users size={16} />
|
| 164 |
+
{youth_served.toLocaleString()}
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
)}
|
| 168 |
+
<div>
|
| 169 |
+
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>Annual Budget</div>
|
| 170 |
+
<div style={{
|
| 171 |
+
fontSize: 18,
|
| 172 |
+
fontWeight: 600,
|
| 173 |
+
color: isChurch ? '#A855F7' : '#059669',
|
| 174 |
+
display: 'flex',
|
| 175 |
+
alignItems: 'center',
|
| 176 |
+
gap: 6
|
| 177 |
+
}}>
|
| 178 |
+
<DollarSign size={16} />
|
| 179 |
+
{(annual_budget / 1000).toFixed(0)}K
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
{/* Contact & Actions */}
|
| 185 |
+
<div style={{
|
| 186 |
+
display: 'flex',
|
| 187 |
+
flexWrap: 'wrap',
|
| 188 |
+
gap: 8,
|
| 189 |
+
marginBottom: 12
|
| 190 |
+
}}>
|
| 191 |
+
{contact?.website && (
|
| 192 |
+
<a
|
| 193 |
+
href={contact.website}
|
| 194 |
+
target="_blank"
|
| 195 |
+
rel="noopener noreferrer"
|
| 196 |
+
style={{
|
| 197 |
+
fontSize: 13,
|
| 198 |
+
padding: '8px 16px',
|
| 199 |
+
background: isChurch ? '#A855F7' : '#059669',
|
| 200 |
+
color: 'white',
|
| 201 |
+
borderRadius: 6,
|
| 202 |
+
textDecoration: 'none',
|
| 203 |
+
display: 'flex',
|
| 204 |
+
alignItems: 'center',
|
| 205 |
+
gap: 6,
|
| 206 |
+
fontWeight: 500
|
| 207 |
+
}}
|
| 208 |
+
>
|
| 209 |
+
<Globe size={14} />
|
| 210 |
+
Website
|
| 211 |
+
</a>
|
| 212 |
+
)}
|
| 213 |
+
{contact?.email && (
|
| 214 |
+
<a
|
| 215 |
+
href={`mailto:${contact.email}`}
|
| 216 |
+
style={{
|
| 217 |
+
fontSize: 13,
|
| 218 |
+
padding: '8px 16px',
|
| 219 |
+
background: 'white',
|
| 220 |
+
color: isChurch ? '#A855F7' : '#059669',
|
| 221 |
+
border: `1px solid ${isChurch ? '#A855F7' : '#059669'}`,
|
| 222 |
+
borderRadius: 6,
|
| 223 |
+
textDecoration: 'none',
|
| 224 |
+
display: 'flex',
|
| 225 |
+
alignItems: 'center',
|
| 226 |
+
gap: 6,
|
| 227 |
+
fontWeight: 500
|
| 228 |
+
}}
|
| 229 |
+
>
|
| 230 |
+
<Mail size={14} />
|
| 231 |
+
Email
|
| 232 |
+
</a>
|
| 233 |
+
)}
|
| 234 |
+
{contact?.phone && (
|
| 235 |
+
<a
|
| 236 |
+
href={`tel:${contact.phone}`}
|
| 237 |
+
style={{
|
| 238 |
+
fontSize: 13,
|
| 239 |
+
padding: '8px 16px',
|
| 240 |
+
background: 'white',
|
| 241 |
+
color: isChurch ? '#A855F7' : '#059669',
|
| 242 |
+
border: `1px solid ${isChurch ? '#A855F7' : '#059669'}`,
|
| 243 |
+
borderRadius: 6,
|
| 244 |
+
textDecoration: 'none',
|
| 245 |
+
display: 'flex',
|
| 246 |
+
alignItems: 'center',
|
| 247 |
+
gap: 6,
|
| 248 |
+
fontWeight: 500
|
| 249 |
+
}}
|
| 250 |
+
>
|
| 251 |
+
<Phone size={14} />
|
| 252 |
+
Call
|
| 253 |
+
</a>
|
| 254 |
+
)}
|
| 255 |
+
</div>
|
| 256 |
+
|
| 257 |
+
{/* Opportunities */}
|
| 258 |
+
<div style={{
|
| 259 |
+
display: 'flex',
|
| 260 |
+
gap: 8,
|
| 261 |
+
flexWrap: 'wrap'
|
| 262 |
+
}}>
|
| 263 |
+
{volunteer_opportunities && (
|
| 264 |
+
<span style={{
|
| 265 |
+
fontSize: 12,
|
| 266 |
+
padding: '6px 12px',
|
| 267 |
+
background: isChurch ? '#FAF5FF' : '#dcfce7',
|
| 268 |
+
color: isChurch ? '#A855F7' : '#059669',
|
| 269 |
+
borderRadius: 6,
|
| 270 |
+
fontWeight: 500
|
| 271 |
+
}}>
|
| 272 |
+
β Accepting Volunteers
|
| 273 |
+
</span>
|
| 274 |
+
)}
|
| 275 |
+
{accepting_board_members && (
|
| 276 |
+
<span style={{
|
| 277 |
+
fontSize: 12,
|
| 278 |
+
padding: '6px 12px',
|
| 279 |
+
background: '#fef3c7',
|
| 280 |
+
color: '#d97706',
|
| 281 |
+
borderRadius: 6,
|
| 282 |
+
fontWeight: 500
|
| 283 |
+
}}>
|
| 284 |
+
β Board Seats Available
|
| 285 |
+
</span>
|
| 286 |
+
)}
|
| 287 |
+
</div>
|
| 288 |
+
</div>
|
| 289 |
+
);
|
| 290 |
+
}
|
frontend/policy-dashboards/src/components/SplitScreenView.jsx
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { ExternalLink, Users, DollarSign, Heart, Mail, Phone, Globe } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* SplitScreenView Component
|
| 6 |
+
* Shows government decisions on the left, community nonprofits on the right
|
| 7 |
+
* Demonstrates the accountability gap and community response
|
| 8 |
+
*/
|
| 9 |
+
export default function SplitScreenView({ decision, nonprofits = [] }) {
|
| 10 |
+
// Find nonprofits matching this decision's NTEE code
|
| 11 |
+
const matchingNonprofits = nonprofits.filter(np =>
|
| 12 |
+
np.ntee_code === decision.ntee_code ||
|
| 13 |
+
(decision.ntee_code && np.ntee_code?.startsWith(decision.ntee_code[0])) // Match category
|
| 14 |
+
);
|
| 15 |
+
|
| 16 |
+
const hasGap = decision.community_gap?.nonprofit_filling_gap;
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<div style={{
|
| 20 |
+
display: 'grid',
|
| 21 |
+
gridTemplateColumns: '1fr 1fr',
|
| 22 |
+
gap: 24,
|
| 23 |
+
marginTop: 20
|
| 24 |
+
}}>
|
| 25 |
+
{/* LEFT RAIL: The Public Sector (Government Decision) */}
|
| 26 |
+
<div style={{
|
| 27 |
+
background: 'white',
|
| 28 |
+
border: '1px solid #eee',
|
| 29 |
+
borderRadius: 12,
|
| 30 |
+
padding: 24
|
| 31 |
+
}}>
|
| 32 |
+
<div style={{
|
| 33 |
+
fontSize: 12,
|
| 34 |
+
fontWeight: 600,
|
| 35 |
+
color: '#666',
|
| 36 |
+
textTransform: 'uppercase',
|
| 37 |
+
letterSpacing: '0.05em',
|
| 38 |
+
marginBottom: 16
|
| 39 |
+
}}>
|
| 40 |
+
ποΈ Public Sector Decision
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<h3 style={{
|
| 44 |
+
fontSize: 18,
|
| 45 |
+
fontWeight: 600,
|
| 46 |
+
color: '#111',
|
| 47 |
+
marginBottom: 12,
|
| 48 |
+
lineHeight: 1.4
|
| 49 |
+
}}>
|
| 50 |
+
{decision.decision_summary}
|
| 51 |
+
</h3>
|
| 52 |
+
|
| 53 |
+
<div style={{
|
| 54 |
+
background: '#f9f9f7',
|
| 55 |
+
borderRadius: 8,
|
| 56 |
+
padding: 16,
|
| 57 |
+
marginBottom: 16
|
| 58 |
+
}}>
|
| 59 |
+
<div style={{
|
| 60 |
+
fontSize: 13,
|
| 61 |
+
fontWeight: 500,
|
| 62 |
+
color: '#BA7517',
|
| 63 |
+
marginBottom: 8
|
| 64 |
+
}}>
|
| 65 |
+
Official Rationale:
|
| 66 |
+
</div>
|
| 67 |
+
<div style={{
|
| 68 |
+
fontSize: 15,
|
| 69 |
+
color: '#444',
|
| 70 |
+
lineHeight: 1.5
|
| 71 |
+
}}>
|
| 72 |
+
"{decision.primary_rationale}"
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
{hasGap && (
|
| 77 |
+
<div style={{
|
| 78 |
+
background: '#FFF5F0',
|
| 79 |
+
border: '1px solid #D85A30',
|
| 80 |
+
borderRadius: 8,
|
| 81 |
+
padding: 16,
|
| 82 |
+
marginBottom: 16
|
| 83 |
+
}}>
|
| 84 |
+
<div style={{
|
| 85 |
+
fontSize: 14,
|
| 86 |
+
fontWeight: 500,
|
| 87 |
+
color: '#D85A30',
|
| 88 |
+
marginBottom: 6,
|
| 89 |
+
display: 'flex',
|
| 90 |
+
alignItems: 'center',
|
| 91 |
+
gap: 6
|
| 92 |
+
}}>
|
| 93 |
+
β οΈ The Accountability Gap
|
| 94 |
+
</div>
|
| 95 |
+
<div style={{
|
| 96 |
+
fontSize: 14,
|
| 97 |
+
color: '#666',
|
| 98 |
+
lineHeight: 1.5
|
| 99 |
+
}}>
|
| 100 |
+
{decision.community_gap.description}
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
)}
|
| 104 |
+
|
| 105 |
+
<div style={{
|
| 106 |
+
fontSize: 13,
|
| 107 |
+
color: '#888',
|
| 108 |
+
paddingTop: 12,
|
| 109 |
+
borderTop: '1px solid #eee'
|
| 110 |
+
}}>
|
| 111 |
+
<div>Outcome: <strong>{decision.outcome}</strong></div>
|
| 112 |
+
<div>Vote: {decision.vote_result}</div>
|
| 113 |
+
<div>Date: {new Date(decision.meeting_date).toLocaleDateString()}</div>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
{/* RIGHT RAIL: The Community Sector (Nonprofits) */}
|
| 118 |
+
<div style={{
|
| 119 |
+
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
|
| 120 |
+
border: '1px solid #10b981',
|
| 121 |
+
borderRadius: 12,
|
| 122 |
+
padding: 24
|
| 123 |
+
}}>
|
| 124 |
+
<div style={{
|
| 125 |
+
fontSize: 12,
|
| 126 |
+
fontWeight: 600,
|
| 127 |
+
color: '#059669',
|
| 128 |
+
textTransform: 'uppercase',
|
| 129 |
+
letterSpacing: '0.05em',
|
| 130 |
+
marginBottom: 16
|
| 131 |
+
}}>
|
| 132 |
+
π€ Community Sector Response
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
{matchingNonprofits.length === 0 ? (
|
| 136 |
+
<div style={{
|
| 137 |
+
textAlign: 'center',
|
| 138 |
+
padding: '2rem',
|
| 139 |
+
color: '#666'
|
| 140 |
+
}}>
|
| 141 |
+
<p style={{ fontSize: 15, marginBottom: 12 }}>
|
| 142 |
+
No nonprofits found filling this gap yet.
|
| 143 |
+
</p>
|
| 144 |
+
<p style={{ fontSize: 13, color: '#888' }}>
|
| 145 |
+
NTEE Code: {decision.ntee_code || 'Not classified'}
|
| 146 |
+
</p>
|
| 147 |
+
</div>
|
| 148 |
+
) : (
|
| 149 |
+
<>
|
| 150 |
+
<div style={{
|
| 151 |
+
fontSize: 15,
|
| 152 |
+
fontWeight: 500,
|
| 153 |
+
color: '#059669',
|
| 154 |
+
marginBottom: 16
|
| 155 |
+
}}>
|
| 156 |
+
{matchingNonprofits.length} organization{matchingNonprofits.length !== 1 ? 's' : ''} filling this gap:
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
| 160 |
+
{matchingNonprofits.map((nonprofit, i) => (
|
| 161 |
+
<NonprofitCard key={i} nonprofit={nonprofit} />
|
| 162 |
+
))}
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<div style={{
|
| 166 |
+
marginTop: 20,
|
| 167 |
+
padding: 16,
|
| 168 |
+
background: 'white',
|
| 169 |
+
borderRadius: 8,
|
| 170 |
+
fontSize: 13,
|
| 171 |
+
color: '#666'
|
| 172 |
+
}}>
|
| 173 |
+
<div style={{ fontWeight: 500, color: '#059669', marginBottom: 8 }}>
|
| 174 |
+
π‘ Bridge the Gap
|
| 175 |
+
</div>
|
| 176 |
+
<ul style={{ margin: 0, paddingLeft: 20, lineHeight: 1.6 }}>
|
| 177 |
+
<li>Support these organizations with donations or volunteering</li>
|
| 178 |
+
<li>Join their boards to influence systemic change</li>
|
| 179 |
+
<li>Cite their work in public meetings to show solutions exist</li>
|
| 180 |
+
</ul>
|
| 181 |
+
</div>
|
| 182 |
+
</>
|
| 183 |
+
)}
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function NonprofitCard({ nonprofit }) {
|
| 190 |
+
return (
|
| 191 |
+
<div style={{
|
| 192 |
+
background: 'white',
|
| 193 |
+
borderRadius: 8,
|
| 194 |
+
padding: 16,
|
| 195 |
+
border: '1px solid #10b981'
|
| 196 |
+
}}>
|
| 197 |
+
<div style={{ marginBottom: 12 }}>
|
| 198 |
+
<h4 style={{
|
| 199 |
+
fontSize: 16,
|
| 200 |
+
fontWeight: 600,
|
| 201 |
+
color: '#111',
|
| 202 |
+
marginBottom: 4
|
| 203 |
+
}}>
|
| 204 |
+
{nonprofit.name}
|
| 205 |
+
</h4>
|
| 206 |
+
<div style={{
|
| 207 |
+
fontSize: 12,
|
| 208 |
+
color: '#666',
|
| 209 |
+
marginBottom: 8
|
| 210 |
+
}}>
|
| 211 |
+
NTEE {nonprofit.ntee_code}: {nonprofit.ntee_description}
|
| 212 |
+
</div>
|
| 213 |
+
<div style={{
|
| 214 |
+
fontSize: 14,
|
| 215 |
+
color: '#444',
|
| 216 |
+
lineHeight: 1.5,
|
| 217 |
+
marginBottom: 12
|
| 218 |
+
}}>
|
| 219 |
+
{nonprofit.mission}
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
{/* Services */}
|
| 224 |
+
<div style={{ marginBottom: 12 }}>
|
| 225 |
+
<div style={{
|
| 226 |
+
fontSize: 12,
|
| 227 |
+
fontWeight: 500,
|
| 228 |
+
color: '#059669',
|
| 229 |
+
marginBottom: 6
|
| 230 |
+
}}>
|
| 231 |
+
Services Provided:
|
| 232 |
+
</div>
|
| 233 |
+
<ul style={{
|
| 234 |
+
margin: 0,
|
| 235 |
+
paddingLeft: 20,
|
| 236 |
+
fontSize: 13,
|
| 237 |
+
color: '#666',
|
| 238 |
+
lineHeight: 1.6
|
| 239 |
+
}}>
|
| 240 |
+
{nonprofit.services.slice(0, 3).map((service, i) => (
|
| 241 |
+
<li key={i}>{service}</li>
|
| 242 |
+
))}
|
| 243 |
+
</ul>
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
{/* Impact */}
|
| 247 |
+
<div style={{
|
| 248 |
+
display: 'grid',
|
| 249 |
+
gridTemplateColumns: '1fr 1fr',
|
| 250 |
+
gap: 12,
|
| 251 |
+
marginBottom: 12,
|
| 252 |
+
padding: 12,
|
| 253 |
+
background: '#f0fdf4',
|
| 254 |
+
borderRadius: 6
|
| 255 |
+
}}>
|
| 256 |
+
{nonprofit.students_served && (
|
| 257 |
+
<div>
|
| 258 |
+
<div style={{ fontSize: 11, color: '#666' }}>Impact</div>
|
| 259 |
+
<div style={{ fontSize: 15, fontWeight: 600, color: '#059669' }}>
|
| 260 |
+
<Users size={14} style={{ display: 'inline', marginRight: 4 }} />
|
| 261 |
+
{nonprofit.students_served.toLocaleString()} students
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
)}
|
| 265 |
+
{nonprofit.families_served && (
|
| 266 |
+
<div>
|
| 267 |
+
<div style={{ fontSize: 11, color: '#666' }}>Impact</div>
|
| 268 |
+
<div style={{ fontSize: 15, fontWeight: 600, color: '#059669' }}>
|
| 269 |
+
<Heart size={14} style={{ display: 'inline', marginRight: 4 }} />
|
| 270 |
+
{nonprofit.families_served.toLocaleString()} families
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
)}
|
| 274 |
+
{nonprofit.youth_served && (
|
| 275 |
+
<div>
|
| 276 |
+
<div style={{ fontSize: 11, color: '#666' }}>Impact</div>
|
| 277 |
+
<div style={{ fontSize: 15, fontWeight: 600, color: '#059669' }}>
|
| 278 |
+
<Users size={14} style={{ display: 'inline', marginRight: 4 }} />
|
| 279 |
+
{nonprofit.youth_served.toLocaleString()} youth
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
)}
|
| 283 |
+
<div>
|
| 284 |
+
<div style={{ fontSize: 11, color: '#666' }}>Annual Budget</div>
|
| 285 |
+
<div style={{ fontSize: 15, fontWeight: 600, color: '#059669' }}>
|
| 286 |
+
<DollarSign size={14} style={{ display: 'inline', marginRight: 2 }} />
|
| 287 |
+
{(nonprofit.annual_budget / 1000).toFixed(0)}K
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
|
| 292 |
+
{/* Contact & Actions */}
|
| 293 |
+
<div style={{
|
| 294 |
+
display: 'flex',
|
| 295 |
+
flexWrap: 'wrap',
|
| 296 |
+
gap: 8,
|
| 297 |
+
marginBottom: 12
|
| 298 |
+
}}>
|
| 299 |
+
{nonprofit.contact.website && (
|
| 300 |
+
<a
|
| 301 |
+
href={nonprofit.contact.website}
|
| 302 |
+
target="_blank"
|
| 303 |
+
rel="noopener noreferrer"
|
| 304 |
+
style={{
|
| 305 |
+
fontSize: 12,
|
| 306 |
+
padding: '6px 12px',
|
| 307 |
+
background: '#059669',
|
| 308 |
+
color: 'white',
|
| 309 |
+
borderRadius: 6,
|
| 310 |
+
textDecoration: 'none',
|
| 311 |
+
display: 'flex',
|
| 312 |
+
alignItems: 'center',
|
| 313 |
+
gap: 4
|
| 314 |
+
}}
|
| 315 |
+
>
|
| 316 |
+
<Globe size={12} />
|
| 317 |
+
Website
|
| 318 |
+
</a>
|
| 319 |
+
)}
|
| 320 |
+
{nonprofit.contact.email && (
|
| 321 |
+
<a
|
| 322 |
+
href={`mailto:${nonprofit.contact.email}`}
|
| 323 |
+
style={{
|
| 324 |
+
fontSize: 12,
|
| 325 |
+
padding: '6px 12px',
|
| 326 |
+
background: 'white',
|
| 327 |
+
color: '#059669',
|
| 328 |
+
border: '1px solid #059669',
|
| 329 |
+
borderRadius: 6,
|
| 330 |
+
textDecoration: 'none',
|
| 331 |
+
display: 'flex',
|
| 332 |
+
alignItems: 'center',
|
| 333 |
+
gap: 4
|
| 334 |
+
}}
|
| 335 |
+
>
|
| 336 |
+
<Mail size={12} />
|
| 337 |
+
Email
|
| 338 |
+
</a>
|
| 339 |
+
)}
|
| 340 |
+
</div>
|
| 341 |
+
|
| 342 |
+
{/* Opportunities */}
|
| 343 |
+
<div style={{
|
| 344 |
+
display: 'flex',
|
| 345 |
+
gap: 8,
|
| 346 |
+
flexWrap: 'wrap'
|
| 347 |
+
}}>
|
| 348 |
+
{nonprofit.volunteer_opportunities && (
|
| 349 |
+
<span style={{
|
| 350 |
+
fontSize: 11,
|
| 351 |
+
padding: '4px 8px',
|
| 352 |
+
background: '#dcfce7',
|
| 353 |
+
color: '#059669',
|
| 354 |
+
borderRadius: 4,
|
| 355 |
+
fontWeight: 500
|
| 356 |
+
}}>
|
| 357 |
+
β Accepting Volunteers
|
| 358 |
+
</span>
|
| 359 |
+
)}
|
| 360 |
+
{nonprofit.accepting_board_members && (
|
| 361 |
+
<span style={{
|
| 362 |
+
fontSize: 11,
|
| 363 |
+
padding: '4px 8px',
|
| 364 |
+
background: '#fef3c7',
|
| 365 |
+
color: '#d97706',
|
| 366 |
+
borderRadius: 4,
|
| 367 |
+
fontWeight: 500
|
| 368 |
+
}}>
|
| 369 |
+
β Board Seats Available
|
| 370 |
+
</span>
|
| 371 |
+
)}
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
);
|
| 375 |
+
}
|
frontend/policy-dashboards/src/components/Summary.jsx
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { summaryData as d } from '../data/dashboardData';
|
| 3 |
+
import { DashboardGrid } from './shared/DashboardTile';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Summary Dashboard
|
| 7 |
+
* Overview of all four findings with tile-based navigation
|
| 8 |
+
*/
|
| 9 |
+
export default function Summary({ onNavigate }) {
|
| 10 |
+
return (
|
| 11 |
+
<div>
|
| 12 |
+
{/* Headline */}
|
| 13 |
+
<div style={{ marginBottom: 24 }}>
|
| 14 |
+
<h2 style={{ fontSize: 18, fontWeight: 500, color: '#222', marginBottom: 8 }}>
|
| 15 |
+
{d.headline}
|
| 16 |
+
</h2>
|
| 17 |
+
<p style={{ fontSize: 14, color: '#666' }}>
|
| 18 |
+
{d.subheadline}
|
| 19 |
+
</p>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
{/* Dashboard Tiles */}
|
| 23 |
+
<DashboardGrid
|
| 24 |
+
dashboards={d.findings}
|
| 25 |
+
onNavigate={onNavigate}
|
| 26 |
+
/>
|
| 27 |
+
|
| 28 |
+
{/* Legacy Finding Cards - Keep for compact view */}
|
| 29 |
+
<details style={{ marginBottom: 24 }}>
|
| 30 |
+
<summary style={{
|
| 31 |
+
cursor: 'pointer',
|
| 32 |
+
fontSize: 13,
|
| 33 |
+
color: '#888',
|
| 34 |
+
marginBottom: 12
|
| 35 |
+
}}>
|
| 36 |
+
Show compact list view
|
| 37 |
+
</summary>
|
| 38 |
+
<div style={{
|
| 39 |
+
display: 'grid',
|
| 40 |
+
gap: 12
|
| 41 |
+
}}>
|
| 42 |
+
{d.findings.map((finding, i) => (
|
| 43 |
+
<div
|
| 44 |
+
key={finding.id}
|
| 45 |
+
onClick={() => onNavigate && onNavigate(finding.id)}
|
| 46 |
+
style={{
|
| 47 |
+
background: '#fff',
|
| 48 |
+
border: '1px solid #eee',
|
| 49 |
+
borderLeft: `4px solid ${finding.discomfort >= 9 ? '#D85A30' : '#BA7517'}`,
|
| 50 |
+
borderRadius: 8,
|
| 51 |
+
padding: 16,
|
| 52 |
+
cursor: onNavigate ? 'pointer' : 'default',
|
| 53 |
+
transition: 'all 0.2s ease'
|
| 54 |
+
}}
|
| 55 |
+
onMouseEnter={(e) => {
|
| 56 |
+
if (onNavigate) {
|
| 57 |
+
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
| 58 |
+
e.currentTarget.style.borderLeftWidth = '6px';
|
| 59 |
+
}
|
| 60 |
+
}}
|
| 61 |
+
onMouseLeave={(e) => {
|
| 62 |
+
if (onNavigate) {
|
| 63 |
+
e.currentTarget.style.boxShadow = 'none';
|
| 64 |
+
e.currentTarget.style.borderLeftWidth = '4px';
|
| 65 |
+
}
|
| 66 |
+
}}
|
| 67 |
+
>
|
| 68 |
+
<div style={{
|
| 69 |
+
display: 'flex',
|
| 70 |
+
justifyContent: 'space-between',
|
| 71 |
+
alignItems: 'flex-start',
|
| 72 |
+
marginBottom: 8
|
| 73 |
+
}}>
|
| 74 |
+
<div>
|
| 75 |
+
<div style={{
|
| 76 |
+
fontSize: 11,
|
| 77 |
+
color: '#999',
|
| 78 |
+
textTransform: 'uppercase',
|
| 79 |
+
letterSpacing: '0.05em',
|
| 80 |
+
marginBottom: 4
|
| 81 |
+
}}>
|
| 82 |
+
Dashboard {finding.id}
|
| 83 |
+
</div>
|
| 84 |
+
<div style={{ fontSize: 15, fontWeight: 500, color: '#222' }}>
|
| 85 |
+
{finding.title}
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
<div style={{ textAlign: 'right' }}>
|
| 89 |
+
<div style={{
|
| 90 |
+
fontSize: 18,
|
| 91 |
+
fontWeight: 500,
|
| 92 |
+
color: finding.discomfort >= 9 ? '#D85A30' : '#BA7517'
|
| 93 |
+
}}>
|
| 94 |
+
{finding.metric}
|
| 95 |
+
</div>
|
| 96 |
+
<div style={{ fontSize: 11, color: '#888' }}>
|
| 97 |
+
{finding.context}
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
<p style={{ fontSize: 13, color: '#666', lineHeight: 1.5 }}>
|
| 102 |
+
{finding.summary}
|
| 103 |
+
</p>
|
| 104 |
+
{finding.discomfort >= 9 && (
|
| 105 |
+
<div style={{
|
| 106 |
+
marginTop: 8,
|
| 107 |
+
fontSize: 11,
|
| 108 |
+
color: '#D85A30',
|
| 109 |
+
fontWeight: 500
|
| 110 |
+
}}>
|
| 111 |
+
β οΈ High accountability impact
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
))}
|
| 116 |
+
</div>
|
| 117 |
+
</details>
|
| 118 |
+
|
| 119 |
+
{/* How to Use Section */}
|
| 120 |
+
<div style={{
|
| 121 |
+
background: '#f5f5f2',
|
| 122 |
+
borderRadius: 8,
|
| 123 |
+
padding: 18,
|
| 124 |
+
marginBottom: 20
|
| 125 |
+
}}>
|
| 126 |
+
<h3 style={{
|
| 127 |
+
fontSize: 14,
|
| 128 |
+
fontWeight: 500,
|
| 129 |
+
color: '#222',
|
| 130 |
+
marginBottom: 12
|
| 131 |
+
}}>
|
| 132 |
+
{d.howToUse.title}
|
| 133 |
+
</h3>
|
| 134 |
+
|
| 135 |
+
<div style={{ display: 'grid', gap: 12 }}>
|
| 136 |
+
{d.howToUse.strategies.map((strategy, i) => (
|
| 137 |
+
<div key={i} style={{
|
| 138 |
+
display: 'grid',
|
| 139 |
+
gridTemplateColumns: '1fr 1fr',
|
| 140 |
+
gap: 12
|
| 141 |
+
}}>
|
| 142 |
+
<div>
|
| 143 |
+
<div style={{
|
| 144 |
+
fontSize: 11,
|
| 145 |
+
color: '#E24B4A',
|
| 146 |
+
fontWeight: 500,
|
| 147 |
+
marginBottom: 4
|
| 148 |
+
}}>
|
| 149 |
+
β DON'T: {strategy.dont}
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
<div>
|
| 153 |
+
<div style={{
|
| 154 |
+
fontSize: 11,
|
| 155 |
+
color: '#1D9E75',
|
| 156 |
+
fontWeight: 500,
|
| 157 |
+
marginBottom: 4
|
| 158 |
+
}}>
|
| 159 |
+
β
DO: {strategy.do}
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
))}
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
{/* Bottom Navigation Hint */}
|
| 168 |
+
{onNavigate && (
|
| 169 |
+
<div style={{
|
| 170 |
+
textAlign: 'center',
|
| 171 |
+
fontSize: 12,
|
| 172 |
+
color: '#888',
|
| 173 |
+
padding: 12,
|
| 174 |
+
background: '#fff',
|
| 175 |
+
border: '1px dashed #ddd',
|
| 176 |
+
borderRadius: 8
|
| 177 |
+
}}>
|
| 178 |
+
π‘ Click any finding above to see the detailed dashboard
|
| 179 |
+
</div>
|
| 180 |
+
)}
|
| 181 |
+
</div>
|
| 182 |
+
);
|
| 183 |
+
}
|
frontend/policy-dashboards/src/components/TopicNavigation.jsx
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Filter, Video, FileText, DollarSign, BarChart3 } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* TopicNavigation Component
|
| 6 |
+
* Primary and secondary filters for browsing decisions
|
| 7 |
+
*/
|
| 8 |
+
export default function TopicNavigation({
|
| 9 |
+
selectedTopics = [],
|
| 10 |
+
selectedPatterns = [],
|
| 11 |
+
selectedResources = [],
|
| 12 |
+
startDate,
|
| 13 |
+
endDate,
|
| 14 |
+
onTopicToggle,
|
| 15 |
+
onPatternToggle,
|
| 16 |
+
onResourceToggle,
|
| 17 |
+
onStartDateChange,
|
| 18 |
+
onEndDateChange,
|
| 19 |
+
onClearAll
|
| 20 |
+
}) {
|
| 21 |
+
const [showFilters, setShowFilters] = useState(true);
|
| 22 |
+
|
| 23 |
+
const topics = [
|
| 24 |
+
{ id: 'public-health', label: 'Public Health', sublabel: 'Dental, Water, Mental Health', color: '#1D9E75' },
|
| 25 |
+
{ id: 'education', label: 'Education & Youth', sublabel: 'School Board, Pre-K', color: '#185FA5' },
|
| 26 |
+
{ id: 'infrastructure', label: 'Infrastructure', sublabel: 'Roads, Utilities, Construction', color: '#BA7517' },
|
| 27 |
+
{ id: 'public-safety', label: 'Public Safety', sublabel: 'Police, Fire, EMS', color: '#E24B4A' }
|
| 28 |
+
];
|
| 29 |
+
|
| 30 |
+
const patterns = [
|
| 31 |
+
{ id: 'technocratic-veto', label: 'Technocratic Veto', description: 'Legal/risk managers blocking decisions' },
|
| 32 |
+
{ id: 'sequential-deferral', label: 'Sequential Deferral', description: 'Repeated "tabling for study"' },
|
| 33 |
+
{ id: 'performance-rationale', label: 'Performance Rationale', description: 'Rhetoric not matching funding' }
|
| 34 |
+
];
|
| 35 |
+
|
| 36 |
+
const resources = [
|
| 37 |
+
{ id: 'video', label: 'Video Recap', icon: Video },
|
| 38 |
+
{ id: 'budget', label: 'Budget PDF', icon: DollarSign },
|
| 39 |
+
{ id: 'dashboard', label: 'Impact Dashboard', icon: BarChart3 },
|
| 40 |
+
{ id: 'summary', label: 'Summary Notes', icon: FileText }
|
| 41 |
+
];
|
| 42 |
+
|
| 43 |
+
const hasActiveFilters = selectedTopics.length > 0 ||
|
| 44 |
+
selectedPatterns.length > 0 ||
|
| 45 |
+
selectedResources.length > 0 ||
|
| 46 |
+
startDate !== null ||
|
| 47 |
+
endDate !== null;
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<div style={{ marginBottom: 20 }}>
|
| 51 |
+
{/* Header */}
|
| 52 |
+
<div style={{
|
| 53 |
+
display: 'flex',
|
| 54 |
+
justifyContent: 'space-between',
|
| 55 |
+
alignItems: 'center',
|
| 56 |
+
marginBottom: 12
|
| 57 |
+
}}>
|
| 58 |
+
<button
|
| 59 |
+
onClick={() => setShowFilters(!showFilters)}
|
| 60 |
+
style={{
|
| 61 |
+
padding: '10px 18px',
|
| 62 |
+
border: '1px solid',
|
| 63 |
+
borderColor: showFilters ? '#888' : '#ddd',
|
| 64 |
+
borderRadius: 8,
|
| 65 |
+
background: showFilters ? '#f5f5f2' : 'white',
|
| 66 |
+
cursor: 'pointer',
|
| 67 |
+
fontSize: 15,
|
| 68 |
+
fontWeight: 500,
|
| 69 |
+
display: 'flex',
|
| 70 |
+
alignItems: 'center',
|
| 71 |
+
gap: 6,
|
| 72 |
+
color: showFilters ? '#222' : '#666'
|
| 73 |
+
}}
|
| 74 |
+
>
|
| 75 |
+
<Filter size={14} />
|
| 76 |
+
Filter & Browse
|
| 77 |
+
{hasActiveFilters && (
|
| 78 |
+
<span style={{
|
| 79 |
+
background: '#D85A30',
|
| 80 |
+
color: 'white',
|
| 81 |
+
borderRadius: '50%',
|
| 82 |
+
width: 18,
|
| 83 |
+
height: 18,
|
| 84 |
+
display: 'flex',
|
| 85 |
+
alignItems: 'center',
|
| 86 |
+
justifyContent: 'center',
|
| 87 |
+
fontSize: 10,
|
| 88 |
+
fontWeight: 600
|
| 89 |
+
}}>
|
| 90 |
+
{selectedTopics.length + selectedPatterns.length + selectedResources.length}
|
| 91 |
+
</span>
|
| 92 |
+
)}
|
| 93 |
+
</button>
|
| 94 |
+
|
| 95 |
+
{hasActiveFilters && (
|
| 96 |
+
<button
|
| 97 |
+
onClick={onClearAll}
|
| 98 |
+
style={{
|
| 99 |
+
padding: '10px 18px',
|
| 100 |
+
border: '1px solid #ddd',
|
| 101 |
+
borderRadius: 8,
|
| 102 |
+
background: 'white',
|
| 103 |
+
cursor: 'pointer',
|
| 104 |
+
fontSize: 15,
|
| 105 |
+
color: '#D85A30'
|
| 106 |
+
}}
|
| 107 |
+
>
|
| 108 |
+
Clear All Filters
|
| 109 |
+
</button>
|
| 110 |
+
)}
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
{/* Filter Panel */}
|
| 114 |
+
{showFilters && (
|
| 115 |
+
<div style={{
|
| 116 |
+
background: '#f5f5f2',
|
| 117 |
+
borderRadius: 12,
|
| 118 |
+
padding: 18,
|
| 119 |
+
display: 'grid',
|
| 120 |
+
gridTemplateColumns: '2fr 1fr 1fr 1fr',
|
| 121 |
+
gap: 20
|
| 122 |
+
}}>
|
| 123 |
+
{/* Primary Navigation: Topics */}
|
| 124 |
+
<div>
|
| 125 |
+
<div style={{
|
| 126 |
+
fontSize: 12,
|
| 127 |
+
fontWeight: 500,
|
| 128 |
+
color: '#666',
|
| 129 |
+
textTransform: 'uppercase',
|
| 130 |
+
letterSpacing: '0.05em',
|
| 131 |
+
marginBottom: 12
|
| 132 |
+
}}>
|
| 133 |
+
Primary Topic / Domain
|
| 134 |
+
</div>
|
| 135 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
| 136 |
+
{topics.map(topic => {
|
| 137 |
+
const isSelected = selectedTopics.includes(topic.id);
|
| 138 |
+
return (
|
| 139 |
+
<button
|
| 140 |
+
key={topic.id}
|
| 141 |
+
onClick={() => onTopicToggle(topic.id)}
|
| 142 |
+
style={{
|
| 143 |
+
padding: '12px 16px',
|
| 144 |
+
borderRadius: 8,
|
| 145 |
+
border: '2px solid',
|
| 146 |
+
borderColor: isSelected ? topic.color : '#ddd',
|
| 147 |
+
background: isSelected ? `${topic.color}15` : 'white',
|
| 148 |
+
color: '#222',
|
| 149 |
+
fontSize: 15,
|
| 150 |
+
cursor: 'pointer',
|
| 151 |
+
textAlign: 'left',
|
| 152 |
+
fontWeight: isSelected ? 500 : 400,
|
| 153 |
+
transition: 'all 0.2s ease'
|
| 154 |
+
}}
|
| 155 |
+
>
|
| 156 |
+
<div style={{ fontWeight: 500 }}>{topic.label}</div>
|
| 157 |
+
<div style={{ fontSize: 13, color: '#888', marginTop: 2 }}>
|
| 158 |
+
{topic.sublabel}
|
| 159 |
+
</div>
|
| 160 |
+
</button>
|
| 161 |
+
);
|
| 162 |
+
})}
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
{/* Secondary Filter: Patterns */}
|
| 167 |
+
<div>
|
| 168 |
+
<div style={{
|
| 169 |
+
fontSize: 12,
|
| 170 |
+
fontWeight: 500,
|
| 171 |
+
color: '#666',
|
| 172 |
+
textTransform: 'uppercase',
|
| 173 |
+
letterSpacing: '0.05em',
|
| 174 |
+
marginBottom: 12
|
| 175 |
+
}}>
|
| 176 |
+
Filter by Pattern
|
| 177 |
+
</div>
|
| 178 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
| 179 |
+
{patterns.map(pattern => {
|
| 180 |
+
const isSelected = selectedPatterns.includes(pattern.id);
|
| 181 |
+
return (
|
| 182 |
+
<label
|
| 183 |
+
key={pattern.id}
|
| 184 |
+
style={{
|
| 185 |
+
display: 'flex',
|
| 186 |
+
alignItems: 'flex-start',
|
| 187 |
+
gap: 8,
|
| 188 |
+
padding: 8,
|
| 189 |
+
borderRadius: 6,
|
| 190 |
+
background: isSelected ? 'white' : 'transparent',
|
| 191 |
+
cursor: 'pointer',
|
| 192 |
+
transition: 'background 0.2s ease'
|
| 193 |
+
}}
|
| 194 |
+
>
|
| 195 |
+
<input
|
| 196 |
+
type="checkbox"
|
| 197 |
+
checked={isSelected}
|
| 198 |
+
onChange={() => onPatternToggle(pattern.id)}
|
| 199 |
+
style={{ marginTop: 2 }}
|
| 200 |
+
/>
|
| 201 |
+
<div style={{ flex: 1 }}>
|
| 202 |
+
<div style={{ fontSize: 12, fontWeight: 500, color: '#222' }}>
|
| 203 |
+
{pattern.label}
|
| 204 |
+
</div>
|
| 205 |
+
<div style={{ fontSize: 10, color: '#888', marginTop: 2 }}>
|
| 206 |
+
{pattern.description}
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</label>
|
| 210 |
+
);
|
| 211 |
+
})}
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
{/* Tertiary Filter: Resources */}
|
| 216 |
+
<div>
|
| 217 |
+
<div style={{
|
| 218 |
+
fontSize: 12,
|
| 219 |
+
fontWeight: 500,
|
| 220 |
+
color: '#666',
|
| 221 |
+
textTransform: 'uppercase',
|
| 222 |
+
letterSpacing: '0.05em',
|
| 223 |
+
marginBottom: 12
|
| 224 |
+
}}>
|
| 225 |
+
Filter by Resource
|
| 226 |
+
</div>
|
| 227 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
| 228 |
+
{resources.map(resource => {
|
| 229 |
+
const isSelected = selectedResources.includes(resource.id);
|
| 230 |
+
const Icon = resource.icon;
|
| 231 |
+
return (
|
| 232 |
+
<label
|
| 233 |
+
key={resource.id}
|
| 234 |
+
style={{
|
| 235 |
+
display: 'flex',
|
| 236 |
+
alignItems: 'center',
|
| 237 |
+
gap: 8,
|
| 238 |
+
padding: 8,
|
| 239 |
+
borderRadius: 6,
|
| 240 |
+
background: isSelected ? 'white' : 'transparent',
|
| 241 |
+
cursor: 'pointer',
|
| 242 |
+
transition: 'background 0.2s ease'
|
| 243 |
+
}}
|
| 244 |
+
>
|
| 245 |
+
<input
|
| 246 |
+
type="checkbox"
|
| 247 |
+
checked={isSelected}
|
| 248 |
+
onChange={() => onResourceToggle(resource.id)}
|
| 249 |
+
/>
|
| 250 |
+
<Icon size={16} color={isSelected ? '#D85A30' : '#888'} />
|
| 251 |
+
<div style={{
|
| 252 |
+
fontSize: 14,
|
| 253 |
+
color: isSelected ? '#222' : '#666',
|
| 254 |
+
fontWeight: isSelected ? 500 : 400
|
| 255 |
+
}}>
|
| 256 |
+
{resource.label}
|
| 257 |
+
</div>
|
| 258 |
+
</label>
|
| 259 |
+
);
|
| 260 |
+
})}
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
{/* Time Window Filter */}
|
| 265 |
+
<div>
|
| 266 |
+
<div style={{
|
| 267 |
+
fontSize: 12,
|
| 268 |
+
fontWeight: 500,
|
| 269 |
+
color: '#666',
|
| 270 |
+
textTransform: 'uppercase',
|
| 271 |
+
letterSpacing: '0.05em',
|
| 272 |
+
marginBottom: 12
|
| 273 |
+
}}>
|
| 274 |
+
Time Window
|
| 275 |
+
</div>
|
| 276 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
| 277 |
+
<div>
|
| 278 |
+
<label style={{
|
| 279 |
+
fontSize: 10,
|
| 280 |
+
color: '#888',
|
| 281 |
+
display: 'block',
|
| 282 |
+
marginBottom: 4
|
| 283 |
+
}}>
|
| 284 |
+
From
|
| 285 |
+
</label>
|
| 286 |
+
<input
|
| 287 |
+
type="date"
|
| 288 |
+
value={startDate || ''}
|
| 289 |
+
onChange={(e) => onStartDateChange(e.target.value || null)}
|
| 290 |
+
style={{
|
| 291 |
+
width: '100%',
|
| 292 |
+
padding: '6px 8px',
|
| 293 |
+
borderRadius: 6,
|
| 294 |
+
border: '1px solid',
|
| 295 |
+
borderColor: startDate ? '#D85A30' : '#ddd',
|
| 296 |
+
fontSize: 12,
|
| 297 |
+
background: 'white'
|
| 298 |
+
}}
|
| 299 |
+
/>
|
| 300 |
+
</div>
|
| 301 |
+
<div>
|
| 302 |
+
<label style={{
|
| 303 |
+
fontSize: 10,
|
| 304 |
+
color: '#888',
|
| 305 |
+
display: 'block',
|
| 306 |
+
marginBottom: 4
|
| 307 |
+
}}>
|
| 308 |
+
To
|
| 309 |
+
</label>
|
| 310 |
+
<input
|
| 311 |
+
type="date"
|
| 312 |
+
value={endDate || ''}
|
| 313 |
+
onChange={(e) => onEndDateChange(e.target.value || null)}
|
| 314 |
+
style={{
|
| 315 |
+
width: '100%',
|
| 316 |
+
padding: '6px 8px',
|
| 317 |
+
borderRadius: 6,
|
| 318 |
+
border: '1px solid',
|
| 319 |
+
borderColor: endDate ? '#D85A30' : '#ddd',
|
| 320 |
+
fontSize: 12,
|
| 321 |
+
background: 'white'
|
| 322 |
+
}}
|
| 323 |
+
/>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
)}
|
| 329 |
+
|
| 330 |
+
{/* Active Filters Display */}
|
| 331 |
+
{hasActiveFilters && (
|
| 332 |
+
<div style={{
|
| 333 |
+
marginTop: 12,
|
| 334 |
+
display: 'flex',
|
| 335 |
+
flexWrap: 'wrap',
|
| 336 |
+
gap: 6,
|
| 337 |
+
alignItems: 'center'
|
| 338 |
+
}}>
|
| 339 |
+
<span style={{ fontSize: 11, color: '#888' }}>Active filters:</span>
|
| 340 |
+
{selectedTopics.map(topicId => {
|
| 341 |
+
const topic = topics.find(t => t.id === topicId);
|
| 342 |
+
return (
|
| 343 |
+
<span
|
| 344 |
+
key={topicId}
|
| 345 |
+
style={{
|
| 346 |
+
fontSize: 11,
|
| 347 |
+
padding: '4px 10px',
|
| 348 |
+
borderRadius: 12,
|
| 349 |
+
background: topic.color,
|
| 350 |
+
color: 'white',
|
| 351 |
+
fontWeight: 500,
|
| 352 |
+
display: 'flex',
|
| 353 |
+
alignItems: 'center',
|
| 354 |
+
gap: 4
|
| 355 |
+
}}
|
| 356 |
+
>
|
| 357 |
+
{topic.label}
|
| 358 |
+
<button
|
| 359 |
+
onClick={() => onTopicToggle(topicId)}
|
| 360 |
+
style={{
|
| 361 |
+
background: 'none',
|
| 362 |
+
border: 'none',
|
| 363 |
+
color: 'white',
|
| 364 |
+
cursor: 'pointer',
|
| 365 |
+
padding: 0,
|
| 366 |
+
fontSize: 14,
|
| 367 |
+
lineHeight: 1
|
| 368 |
+
}}
|
| 369 |
+
>
|
| 370 |
+
Γ
|
| 371 |
+
</button>
|
| 372 |
+
</span>
|
| 373 |
+
);
|
| 374 |
+
})}
|
| 375 |
+
{selectedPatterns.map(patternId => {
|
| 376 |
+
const pattern = patterns.find(p => p.id === patternId);
|
| 377 |
+
return (
|
| 378 |
+
<span
|
| 379 |
+
key={patternId}
|
| 380 |
+
style={{
|
| 381 |
+
fontSize: 11,
|
| 382 |
+
padding: '4px 10px',
|
| 383 |
+
borderRadius: 12,
|
| 384 |
+
background: '#BA7517',
|
| 385 |
+
color: 'white',
|
| 386 |
+
fontWeight: 500,
|
| 387 |
+
display: 'flex',
|
| 388 |
+
alignItems: 'center',
|
| 389 |
+
gap: 4
|
| 390 |
+
}}
|
| 391 |
+
>
|
| 392 |
+
{pattern.label}
|
| 393 |
+
<button
|
| 394 |
+
onClick={() => onPatternToggle(patternId)}
|
| 395 |
+
style={{
|
| 396 |
+
background: 'none',
|
| 397 |
+
border: 'none',
|
| 398 |
+
color: 'white',
|
| 399 |
+
cursor: 'pointer',
|
| 400 |
+
padding: 0,
|
| 401 |
+
fontSize: 14,
|
| 402 |
+
lineHeight: 1
|
| 403 |
+
}}
|
| 404 |
+
>
|
| 405 |
+
Γ
|
| 406 |
+
</button>
|
| 407 |
+
</span>
|
| 408 |
+
);
|
| 409 |
+
})}
|
| 410 |
+
{selectedResources.map(resourceId => {
|
| 411 |
+
const resource = resources.find(r => r.id === resourceId);
|
| 412 |
+
return (
|
| 413 |
+
<span
|
| 414 |
+
key={resourceId}
|
| 415 |
+
style={{
|
| 416 |
+
fontSize: 11,
|
| 417 |
+
padding: '4px 10px',
|
| 418 |
+
borderRadius: 12,
|
| 419 |
+
background: '#185FA5',
|
| 420 |
+
color: 'white',
|
| 421 |
+
fontWeight: 500,
|
| 422 |
+
display: 'flex',
|
| 423 |
+
alignItems: 'center',
|
| 424 |
+
gap: 4
|
| 425 |
+
}}
|
| 426 |
+
>
|
| 427 |
+
{resource.label}
|
| 428 |
+
<button
|
| 429 |
+
onClick={() => onResourceToggle(resourceId)}
|
| 430 |
+
style={{
|
| 431 |
+
background: 'none',
|
| 432 |
+
border: 'none',
|
| 433 |
+
color: 'white',
|
| 434 |
+
cursor: 'pointer',
|
| 435 |
+
padding: 0,
|
| 436 |
+
fontSize: 14,
|
| 437 |
+
lineHeight: 1
|
| 438 |
+
}}
|
| 439 |
+
>
|
| 440 |
+
Γ
|
| 441 |
+
</button>
|
| 442 |
+
</span>
|
| 443 |
+
);
|
| 444 |
+
})}
|
| 445 |
+
{startDate && (
|
| 446 |
+
<span
|
| 447 |
+
style={{
|
| 448 |
+
fontSize: 11,
|
| 449 |
+
padding: '4px 10px',
|
| 450 |
+
borderRadius: 12,
|
| 451 |
+
background: '#666',
|
| 452 |
+
color: 'white',
|
| 453 |
+
fontWeight: 500,
|
| 454 |
+
display: 'flex',
|
| 455 |
+
alignItems: 'center',
|
| 456 |
+
gap: 4
|
| 457 |
+
}}
|
| 458 |
+
>
|
| 459 |
+
From: {new Date(startDate).toLocaleDateString()}
|
| 460 |
+
<button
|
| 461 |
+
onClick={() => onStartDateChange(null)}
|
| 462 |
+
style={{
|
| 463 |
+
background: 'none',
|
| 464 |
+
border: 'none',
|
| 465 |
+
color: 'white',
|
| 466 |
+
cursor: 'pointer',
|
| 467 |
+
padding: 0,
|
| 468 |
+
fontSize: 14,
|
| 469 |
+
lineHeight: 1
|
| 470 |
+
}}
|
| 471 |
+
>
|
| 472 |
+
Γ
|
| 473 |
+
</button>
|
| 474 |
+
</span>
|
| 475 |
+
)}
|
| 476 |
+
{endDate && (
|
| 477 |
+
<span
|
| 478 |
+
style={{
|
| 479 |
+
fontSize: 11,
|
| 480 |
+
padding: '4px 10px',
|
| 481 |
+
borderRadius: 12,
|
| 482 |
+
background: '#666',
|
| 483 |
+
color: 'white',
|
| 484 |
+
fontWeight: 500,
|
| 485 |
+
display: 'flex',
|
| 486 |
+
alignItems: 'center',
|
| 487 |
+
gap: 4
|
| 488 |
+
}}
|
| 489 |
+
>
|
| 490 |
+
To: {new Date(endDate).toLocaleDateString()}
|
| 491 |
+
<button
|
| 492 |
+
onClick={() => onEndDateChange(null)}
|
| 493 |
+
style={{
|
| 494 |
+
background: 'none',
|
| 495 |
+
border: 'none',
|
| 496 |
+
color: 'white',
|
| 497 |
+
cursor: 'pointer',
|
| 498 |
+
padding: 0,
|
| 499 |
+
fontSize: 14,
|
| 500 |
+
lineHeight: 1
|
| 501 |
+
}}
|
| 502 |
+
>
|
| 503 |
+
Γ
|
| 504 |
+
</button>
|
| 505 |
+
</span>
|
| 506 |
+
)}
|
| 507 |
+
</div>
|
| 508 |
+
)}
|
| 509 |
+
</div>
|
| 510 |
+
);
|
| 511 |
+
}
|
frontend/policy-dashboards/src/components/WhereMoneyWent.jsx
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import Compare from './shared/Compare';
|
| 3 |
+
import InsightBox from './shared/InsightBox';
|
| 4 |
+
import { displacementData as d } from '../data/dashboardData';
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Dashboard 3: What got funded instead
|
| 8 |
+
* (Displacement Matrix)
|
| 9 |
+
*/
|
| 10 |
+
export default function WhereMoneyWent() {
|
| 11 |
+
return (
|
| 12 |
+
<div>
|
| 13 |
+
<p style={{
|
| 14 |
+
fontSize: 14,
|
| 15 |
+
color: '#555',
|
| 16 |
+
borderLeft: '2px solid #D85A30',
|
| 17 |
+
paddingLeft: 12,
|
| 18 |
+
marginBottom: 20
|
| 19 |
+
}}>
|
| 20 |
+
{d.conclusion}
|
| 21 |
+
</p>
|
| 22 |
+
|
| 23 |
+
{/* Topic */}
|
| 24 |
+
<div style={{
|
| 25 |
+
background: '#f5f5f2',
|
| 26 |
+
padding: 12,
|
| 27 |
+
borderRadius: 8,
|
| 28 |
+
marginBottom: 16
|
| 29 |
+
}}>
|
| 30 |
+
<div style={{ fontSize: 11, color: '#999', textTransform: 'uppercase', marginBottom: 4 }}>
|
| 31 |
+
Budget Cycle
|
| 32 |
+
</div>
|
| 33 |
+
<div style={{ fontSize: 14, fontWeight: 500, color: '#222' }}>
|
| 34 |
+
{d.topic}
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
{/* The Matrix Table */}
|
| 39 |
+
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, marginBottom: 20 }}>
|
| 40 |
+
<thead>
|
| 41 |
+
<tr>
|
| 42 |
+
{['Funded (winner)', 'Stagnant (loser)', 'Trade-off factor'].map(h => (
|
| 43 |
+
<th key={h} style={{
|
| 44 |
+
fontSize: 11,
|
| 45 |
+
textTransform: 'uppercase',
|
| 46 |
+
letterSpacing: '0.07em',
|
| 47 |
+
color: '#999',
|
| 48 |
+
padding: '8px 10px',
|
| 49 |
+
borderBottom: '1px solid #eee',
|
| 50 |
+
textAlign: 'left'
|
| 51 |
+
}}>
|
| 52 |
+
{h}
|
| 53 |
+
</th>
|
| 54 |
+
))}
|
| 55 |
+
</tr>
|
| 56 |
+
</thead>
|
| 57 |
+
<tbody>
|
| 58 |
+
{d.displacements.map((row, i) => (
|
| 59 |
+
<tr key={i}>
|
| 60 |
+
<td style={{
|
| 61 |
+
padding: '10px 10px',
|
| 62 |
+
borderBottom: '1px solid #f0f0ee',
|
| 63 |
+
color: '#1D9E75',
|
| 64 |
+
fontWeight: 500
|
| 65 |
+
}}>
|
| 66 |
+
{row.winner}
|
| 67 |
+
{row.winnerAmount && ` β $${(row.winnerAmount / 1000).toFixed(0)}k`}
|
| 68 |
+
</td>
|
| 69 |
+
<td style={{
|
| 70 |
+
padding: '10px 10px',
|
| 71 |
+
borderBottom: '1px solid #f0f0ee',
|
| 72 |
+
color: '#D85A30',
|
| 73 |
+
fontWeight: 500
|
| 74 |
+
}}>
|
| 75 |
+
{row.loser}
|
| 76 |
+
{row.loserAmount > 0 && ` β $${(row.loserAmount / 1000).toFixed(0)}k`}
|
| 77 |
+
</td>
|
| 78 |
+
<td style={{ padding: '10px 10px', borderBottom: '1px solid #f0f0ee' }}>
|
| 79 |
+
<span style={{
|
| 80 |
+
fontSize: 11,
|
| 81 |
+
padding: '2px 8px',
|
| 82 |
+
borderRadius: 99,
|
| 83 |
+
background: '#f0f0ee',
|
| 84 |
+
color: '#666'
|
| 85 |
+
}}>
|
| 86 |
+
{row.tradeoffFactor}
|
| 87 |
+
</span>
|
| 88 |
+
</td>
|
| 89 |
+
</tr>
|
| 90 |
+
))}
|
| 91 |
+
</tbody>
|
| 92 |
+
</table>
|
| 93 |
+
|
| 94 |
+
{/* Per-Student Spending Breakdown */}
|
| 95 |
+
<div style={{ marginBottom: 20 }}>
|
| 96 |
+
<h4 style={{
|
| 97 |
+
fontSize: 12,
|
| 98 |
+
textTransform: 'uppercase',
|
| 99 |
+
letterSpacing: '0.05em',
|
| 100 |
+
color: '#999',
|
| 101 |
+
marginBottom: 10
|
| 102 |
+
}}>
|
| 103 |
+
Health Capital Spending (Per Student)
|
| 104 |
+
</h4>
|
| 105 |
+
<Compare
|
| 106 |
+
benchmarks={d.benchmarks}
|
| 107 |
+
metric="healthCapital"
|
| 108 |
+
prefix="$"
|
| 109 |
+
suffix="/student"
|
| 110 |
+
/>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div style={{ marginBottom: 20 }}>
|
| 114 |
+
<h4 style={{
|
| 115 |
+
fontSize: 12,
|
| 116 |
+
textTransform: 'uppercase',
|
| 117 |
+
letterSpacing: '0.05em',
|
| 118 |
+
color: '#999',
|
| 119 |
+
marginBottom: 10
|
| 120 |
+
}}>
|
| 121 |
+
Athletic Capital Spending (Per Student)
|
| 122 |
+
</h4>
|
| 123 |
+
<Compare
|
| 124 |
+
benchmarks={d.benchmarks}
|
| 125 |
+
metric="athleticCapital"
|
| 126 |
+
prefix="$"
|
| 127 |
+
suffix="/student"
|
| 128 |
+
/>
|
| 129 |
+
<p style={{
|
| 130 |
+
fontSize: 11,
|
| 131 |
+
color: '#888',
|
| 132 |
+
marginTop: 8,
|
| 133 |
+
textAlign: 'center'
|
| 134 |
+
}}>
|
| 135 |
+
Source: NCES F-33 Survey, Capital Outlay by Function, FY2025
|
| 136 |
+
</p>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
{/* The Logic */}
|
| 140 |
+
<InsightBox type="critical">
|
| 141 |
+
<strong>{d.priorityPattern}:</strong> {d.inference}
|
| 142 |
+
</InsightBox>
|
| 143 |
+
|
| 144 |
+
{/* Question for the Room */}
|
| 145 |
+
<div style={{
|
| 146 |
+
marginTop: 16,
|
| 147 |
+
padding: 14,
|
| 148 |
+
background: '#fff',
|
| 149 |
+
border: '2px solid #D85A30',
|
| 150 |
+
borderRadius: 8
|
| 151 |
+
}}>
|
| 152 |
+
<strong style={{ fontSize: 13, color: '#D85A30' }}>Ask them:</strong>
|
| 153 |
+
<p style={{ fontSize: 13, color: '#222', marginTop: 6 }}>
|
| 154 |
+
"This budget year, you spent $
|
| 155 |
+
{(d.displacements[0].winnerAmount / 1000).toFixed(0)}k on {d.displacements[0].winner.toLowerCase()}
|
| 156 |
+
and $0 on {d.displacements[0].loser.toLowerCase()}. Can you explain why turf is worth more than
|
| 157 |
+
the dental health of 5,000 students?"
|
| 158 |
+
</p>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
);
|
| 162 |
+
}
|
frontend/policy-dashboards/src/components/WhoIsInCharge.jsx
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import BarMeter from './shared/BarMeter';
|
| 3 |
+
import MetricCard from './shared/MetricCard';
|
| 4 |
+
import Compare from './shared/Compare';
|
| 5 |
+
import InsightBox from './shared/InsightBox';
|
| 6 |
+
import { influenceData as d } from '../data/dashboardData';
|
| 7 |
+
|
| 8 |
+
const colors = { blocker: '#E24B4A', public: '#185FA5' };
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Dashboard 4: One memo beat 240 residents
|
| 12 |
+
* (Influence Radar)
|
| 13 |
+
*/
|
| 14 |
+
export default function WhoIsInCharge() {
|
| 15 |
+
return (
|
| 16 |
+
<div>
|
| 17 |
+
<p style={{
|
| 18 |
+
fontSize: 14,
|
| 19 |
+
color: '#555',
|
| 20 |
+
borderLeft: '2px solid #D85A30',
|
| 21 |
+
paddingLeft: 12,
|
| 22 |
+
marginBottom: 20
|
| 23 |
+
}}>
|
| 24 |
+
{d.conclusion}
|
| 25 |
+
</p>
|
| 26 |
+
|
| 27 |
+
{/* Topic */}
|
| 28 |
+
<div style={{
|
| 29 |
+
background: '#f5f5f2',
|
| 30 |
+
padding: 12,
|
| 31 |
+
borderRadius: 8,
|
| 32 |
+
marginBottom: 16
|
| 33 |
+
}}>
|
| 34 |
+
<div style={{ fontSize: 11, color: '#999', textTransform: 'uppercase', marginBottom: 4 }}>
|
| 35 |
+
Policy Decision
|
| 36 |
+
</div>
|
| 37 |
+
<div style={{ fontSize: 14, fontWeight: 500, color: '#222' }}>
|
| 38 |
+
{d.topic}
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
{/* Influence Bars */}
|
| 43 |
+
<div style={{ marginBottom: 20 }}>
|
| 44 |
+
<h4 style={{
|
| 45 |
+
fontSize: 12,
|
| 46 |
+
textTransform: 'uppercase',
|
| 47 |
+
letterSpacing: '0.05em',
|
| 48 |
+
color: '#999',
|
| 49 |
+
marginBottom: 12
|
| 50 |
+
}}>
|
| 51 |
+
Influence on Final Decision
|
| 52 |
+
</h4>
|
| 53 |
+
|
| 54 |
+
{d.actors.map((item, i) => (
|
| 55 |
+
<div key={i} style={{ marginBottom: 12 }}>
|
| 56 |
+
<BarMeter
|
| 57 |
+
label={item.actor}
|
| 58 |
+
value={item.influence}
|
| 59 |
+
color={colors[item.type]}
|
| 60 |
+
/>
|
| 61 |
+
<div style={{
|
| 62 |
+
fontSize: 11,
|
| 63 |
+
color: '#888',
|
| 64 |
+
marginLeft: 12,
|
| 65 |
+
marginTop: -8,
|
| 66 |
+
marginBottom: 8
|
| 67 |
+
}}>
|
| 68 |
+
{item.contactName && `Contact: ${item.contactName}`}
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
))}
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
{/* Key Metrics */}
|
| 75 |
+
<div style={{
|
| 76 |
+
display: 'grid',
|
| 77 |
+
gridTemplateColumns: '1fr 1fr',
|
| 78 |
+
gap: 12,
|
| 79 |
+
marginBottom: 20
|
| 80 |
+
}}>
|
| 81 |
+
<MetricCard
|
| 82 |
+
value={`${d.publicComments}+`}
|
| 83 |
+
label="Public comments in support"
|
| 84 |
+
/>
|
| 85 |
+
<MetricCard
|
| 86 |
+
value="1"
|
| 87 |
+
label="Memo that blocked the policy"
|
| 88 |
+
tone="negative"
|
| 89 |
+
/>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
{/* Veto Holder Callout */}
|
| 93 |
+
<div style={{
|
| 94 |
+
background: '#ffe6e6',
|
| 95 |
+
border: '2px solid #E24B4A',
|
| 96 |
+
borderRadius: 8,
|
| 97 |
+
padding: 14,
|
| 98 |
+
marginBottom: 20
|
| 99 |
+
}}>
|
| 100 |
+
<div style={{ fontSize: 11, color: '#999', textTransform: 'uppercase', marginBottom: 4 }}>
|
| 101 |
+
Effective Veto Holder
|
| 102 |
+
</div>
|
| 103 |
+
<div style={{ fontSize: 16, fontWeight: 500, color: '#E24B4A' }}>
|
| 104 |
+
{d.vetoHolder}
|
| 105 |
+
</div>
|
| 106 |
+
<div style={{ fontSize: 12, color: '#666', marginTop: 6 }}>
|
| 107 |
+
One liability memo had {d.actors.find(a => a.type === 'blocker').influence}% influence
|
| 108 |
+
despite {d.publicComments}+ citizen testimonies
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
{/* Liability Benchmark */}
|
| 113 |
+
<div style={{ marginTop: 20 }}>
|
| 114 |
+
<h4 style={{
|
| 115 |
+
fontSize: 12,
|
| 116 |
+
textTransform: 'uppercase',
|
| 117 |
+
letterSpacing: '0.05em',
|
| 118 |
+
color: '#999',
|
| 119 |
+
marginBottom: 10
|
| 120 |
+
}}>
|
| 121 |
+
Successful Liability Suits in States with Screening Programs
|
| 122 |
+
</h4>
|
| 123 |
+
<Compare
|
| 124 |
+
benchmarks={d.benchmarks}
|
| 125 |
+
metric="liabilitySuits"
|
| 126 |
+
prefix=""
|
| 127 |
+
suffix=""
|
| 128 |
+
/>
|
| 129 |
+
<p style={{
|
| 130 |
+
fontSize: 11,
|
| 131 |
+
color: '#888',
|
| 132 |
+
marginTop: 8,
|
| 133 |
+
textAlign: 'center'
|
| 134 |
+
}}>
|
| 135 |
+
Source: National Association of School Nurses, ADA Health Policy Institute
|
| 136 |
+
</p>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
{/* The Logic */}
|
| 140 |
+
<InsightBox type="critical">
|
| 141 |
+
<strong>{d.powerStructure}:</strong> {d.inference}
|
| 142 |
+
</InsightBox>
|
| 143 |
+
|
| 144 |
+
{/* Question for the Room */}
|
| 145 |
+
<div style={{
|
| 146 |
+
marginTop: 16,
|
| 147 |
+
padding: 14,
|
| 148 |
+
background: '#fff',
|
| 149 |
+
border: '2px solid #D85A30',
|
| 150 |
+
borderRadius: 8
|
| 151 |
+
}}>
|
| 152 |
+
<strong style={{ fontSize: 13, color: '#D85A30' }}>Ask them:</strong>
|
| 153 |
+
<p style={{ fontSize: 13, color: '#222', marginTop: 6 }}>
|
| 154 |
+
"{d.vetoHolder}, can you please stand and explain to these {d.publicComments} citizens
|
| 155 |
+
why your one memo expressing 'liability concerns' outweighed their collective voice?
|
| 156 |
+
And can you cite a single successful lawsuit in any of the 35 states with active
|
| 157 |
+
school dental screening programs?"
|
| 158 |
+
</p>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
);
|
| 162 |
+
}
|
frontend/policy-dashboards/src/components/WordsVsDollars.jsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import BarMeter from './shared/BarMeter';
|
| 3 |
+
import MetricCard from './shared/MetricCard';
|
| 4 |
+
import Compare from './shared/Compare';
|
| 5 |
+
import InsightBox from './shared/InsightBox';
|
| 6 |
+
import { rhetoricGapData as d } from '../data/dashboardData';
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Dashboard 1: They cut health spending while praising wellness
|
| 10 |
+
* (Rhetoric Gap Monitor)
|
| 11 |
+
*/
|
| 12 |
+
export default function WordsVsDollars() {
|
| 13 |
+
return (
|
| 14 |
+
<div>
|
| 15 |
+
<p style={{
|
| 16 |
+
fontSize: 14,
|
| 17 |
+
color: '#555',
|
| 18 |
+
borderLeft: '2px solid #D85A30',
|
| 19 |
+
paddingLeft: 12,
|
| 20 |
+
marginBottom: 20
|
| 21 |
+
}}>
|
| 22 |
+
{d.conclusion}
|
| 23 |
+
</p>
|
| 24 |
+
|
| 25 |
+
{/* Key Metrics */}
|
| 26 |
+
<div style={{
|
| 27 |
+
display: 'grid',
|
| 28 |
+
gridTemplateColumns: '1fr 1fr',
|
| 29 |
+
gap: 12,
|
| 30 |
+
marginBottom: 20
|
| 31 |
+
}}>
|
| 32 |
+
<MetricCard
|
| 33 |
+
value={`${d.sentimentScore}%`}
|
| 34 |
+
label='Positive sentiment re: "wellness"'
|
| 35 |
+
tone="positive"
|
| 36 |
+
/>
|
| 37 |
+
<MetricCard
|
| 38 |
+
value={`-$${(Math.abs(d.budgetDelta) / 1000).toFixed(0)}k`}
|
| 39 |
+
label="Budget delta: contracted dental/vision"
|
| 40 |
+
tone="negative"
|
| 41 |
+
/>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
{/* What They SAY */}
|
| 45 |
+
<div style={{ marginBottom: 16 }}>
|
| 46 |
+
<h4 style={{
|
| 47 |
+
fontSize: 12,
|
| 48 |
+
textTransform: 'uppercase',
|
| 49 |
+
letterSpacing: '0.05em',
|
| 50 |
+
color: '#999',
|
| 51 |
+
marginBottom: 10
|
| 52 |
+
}}>
|
| 53 |
+
What They SAY
|
| 54 |
+
</h4>
|
| 55 |
+
<BarMeter
|
| 56 |
+
label="Wellness language in meeting minutes"
|
| 57 |
+
value={d.sentimentScore}
|
| 58 |
+
color="#1D9E75"
|
| 59 |
+
/>
|
| 60 |
+
<div style={{
|
| 61 |
+
fontSize: 12,
|
| 62 |
+
color: '#888',
|
| 63 |
+
fontStyle: 'italic',
|
| 64 |
+
marginTop: 8,
|
| 65 |
+
paddingLeft: 12,
|
| 66 |
+
borderLeft: '2px solid #f0f0ee'
|
| 67 |
+
}}>
|
| 68 |
+
<p style={{ marginBottom: 6 }}>Sample quotes ({d.totalMentions} total mentions):</p>
|
| 69 |
+
{d.sampleQuotes.slice(0, 2).map((quote, i) => (
|
| 70 |
+
<p key={i} style={{ marginBottom: 4 }}>"{quote}"</p>
|
| 71 |
+
))}
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
{/* What They FUND */}
|
| 76 |
+
<div style={{ marginBottom: 16 }}>
|
| 77 |
+
<h4 style={{
|
| 78 |
+
fontSize: 12,
|
| 79 |
+
textTransform: 'uppercase',
|
| 80 |
+
letterSpacing: '0.05em',
|
| 81 |
+
color: '#999',
|
| 82 |
+
marginBottom: 10
|
| 83 |
+
}}>
|
| 84 |
+
What They FUND
|
| 85 |
+
</h4>
|
| 86 |
+
<BarMeter
|
| 87 |
+
label={`${d.budgetCategory} vs. prior year`}
|
| 88 |
+
value={100 + d.budgetDeltaPercent}
|
| 89 |
+
max={100}
|
| 90 |
+
color="#D85A30"
|
| 91 |
+
suffix="% of last year"
|
| 92 |
+
/>
|
| 93 |
+
<BarMeter
|
| 94 |
+
label="Admin cost growth (same period)"
|
| 95 |
+
value={d.adminCostGrowth}
|
| 96 |
+
max={100}
|
| 97 |
+
color="#BA7517"
|
| 98 |
+
suffix="% increase"
|
| 99 |
+
/>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
{/* Benchmark Comparison */}
|
| 103 |
+
<div style={{ marginTop: 20 }}>
|
| 104 |
+
<h4 style={{
|
| 105 |
+
fontSize: 12,
|
| 106 |
+
textTransform: 'uppercase',
|
| 107 |
+
letterSpacing: '0.05em',
|
| 108 |
+
color: '#999',
|
| 109 |
+
marginBottom: 10
|
| 110 |
+
}}>
|
| 111 |
+
Per-Student Health Spending Comparison
|
| 112 |
+
</h4>
|
| 113 |
+
<Compare
|
| 114 |
+
benchmarks={d.benchmarks}
|
| 115 |
+
metric="perStudent"
|
| 116 |
+
prefix="$"
|
| 117 |
+
suffix="/student"
|
| 118 |
+
/>
|
| 119 |
+
<p style={{
|
| 120 |
+
fontSize: 11,
|
| 121 |
+
color: '#888',
|
| 122 |
+
marginTop: 8,
|
| 123 |
+
textAlign: 'center'
|
| 124 |
+
}}>
|
| 125 |
+
Source: NCES Common Core of Data (CCD), FY2025
|
| 126 |
+
</p>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
{/* The Logic */}
|
| 130 |
+
<InsightBox type="critical">
|
| 131 |
+
{d.inference}
|
| 132 |
+
</InsightBox>
|
| 133 |
+
|
| 134 |
+
{/* Question for the Room */}
|
| 135 |
+
<div style={{
|
| 136 |
+
marginTop: 16,
|
| 137 |
+
padding: 14,
|
| 138 |
+
background: '#fff',
|
| 139 |
+
border: '2px solid #D85A30',
|
| 140 |
+
borderRadius: 8
|
| 141 |
+
}}>
|
| 142 |
+
<strong style={{ fontSize: 13, color: '#D85A30' }}>Ask them:</strong>
|
| 143 |
+
<p style={{ fontSize: 13, color: '#222', marginTop: 6 }}>
|
| 144 |
+
"You've praised student wellness {d.totalMentions} times this year with {d.sentimentScore}%
|
| 145 |
+
positive sentiment. But you cut the health budget by ${Math.abs(d.budgetDelta).toLocaleString()}.
|
| 146 |
+
Which statement is true: your words or your wallet?"
|
| 147 |
+
</p>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
);
|
| 151 |
+
}
|
frontend/policy-dashboards/src/components/shared/BarMeter.jsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* BarMeter Component
|
| 5 |
+
* Reusable horizontal bar chart for showing metrics
|
| 6 |
+
*/
|
| 7 |
+
export default function BarMeter({ label, value, max = 100, color = "#D85A30", suffix = "%" }) {
|
| 8 |
+
const pct = Math.min((value / max) * 100, 100);
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<div style={{ marginBottom: 14 }}>
|
| 12 |
+
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 5 }}>
|
| 13 |
+
<span style={{ fontSize: 13, color: "#555" }}>{label}</span>
|
| 14 |
+
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
| 15 |
+
{value}{suffix}
|
| 16 |
+
</span>
|
| 17 |
+
</div>
|
| 18 |
+
<div style={{
|
| 19 |
+
height: 8,
|
| 20 |
+
background: "#f0f0ee",
|
| 21 |
+
borderRadius: 99,
|
| 22 |
+
overflow: "hidden"
|
| 23 |
+
}}>
|
| 24 |
+
<div style={{
|
| 25 |
+
height: "100%",
|
| 26 |
+
width: `${pct}%`,
|
| 27 |
+
background: color,
|
| 28 |
+
borderRadius: 99,
|
| 29 |
+
transition: "width 0.6s ease"
|
| 30 |
+
}} />
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
);
|
| 34 |
+
}
|
frontend/policy-dashboards/src/components/shared/Compare.jsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Compare Component
|
| 5 |
+
* Four-column comparison: This District β Republican β Democratic β National
|
| 6 |
+
*/
|
| 7 |
+
export default function Compare({ benchmarks, metric = "value", prefix = "$", suffix = "" }) {
|
| 8 |
+
const buckets = [
|
| 9 |
+
{ key: 'thisDistrict', color: '#D85A30' },
|
| 10 |
+
{ key: 'republicanAvg', color: '#BA7517' },
|
| 11 |
+
{ key: 'democraticAvg', color: '#185FA5' },
|
| 12 |
+
{ key: 'nationalAvg', color: '#1D9E75' }
|
| 13 |
+
];
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div style={{
|
| 17 |
+
display: 'grid',
|
| 18 |
+
gridTemplateColumns: 'repeat(4, 1fr)',
|
| 19 |
+
gap: 10,
|
| 20 |
+
marginTop: 16
|
| 21 |
+
}}>
|
| 22 |
+
{buckets.map(bucket => {
|
| 23 |
+
const data = benchmarks[bucket.key];
|
| 24 |
+
const value = typeof data === 'object' ? data[metric] : data;
|
| 25 |
+
const label = typeof data === 'object' ? data.label : bucket.key;
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div
|
| 29 |
+
key={bucket.key}
|
| 30 |
+
style={{
|
| 31 |
+
background: '#f5f5f2',
|
| 32 |
+
borderRadius: 8,
|
| 33 |
+
padding: 12,
|
| 34 |
+
borderTop: `3px solid ${bucket.color}`
|
| 35 |
+
}}
|
| 36 |
+
>
|
| 37 |
+
<div style={{
|
| 38 |
+
fontSize: 18,
|
| 39 |
+
fontWeight: 500,
|
| 40 |
+
color: bucket.color,
|
| 41 |
+
marginBottom: 4
|
| 42 |
+
}}>
|
| 43 |
+
{prefix}{value}{suffix}
|
| 44 |
+
</div>
|
| 45 |
+
<div style={{
|
| 46 |
+
fontSize: 10,
|
| 47 |
+
color: '#888',
|
| 48 |
+
textTransform: 'uppercase',
|
| 49 |
+
letterSpacing: '0.05em'
|
| 50 |
+
}}>
|
| 51 |
+
{label}
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
);
|
| 55 |
+
})}
|
| 56 |
+
</div>
|
| 57 |
+
);
|
| 58 |
+
}
|
frontend/policy-dashboards/src/components/shared/DashboardTile.jsx
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { ArrowRight, TrendingUp, Clock, DollarSign, Users } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* DashboardTile Component
|
| 6 |
+
* Tile-based navigation for dashboards
|
| 7 |
+
*/
|
| 8 |
+
export default function DashboardTile({
|
| 9 |
+
id,
|
| 10 |
+
title,
|
| 11 |
+
metric,
|
| 12 |
+
context,
|
| 13 |
+
summary,
|
| 14 |
+
discomfort,
|
| 15 |
+
icon: Icon,
|
| 16 |
+
onClick
|
| 17 |
+
}) {
|
| 18 |
+
const getDiscomfortColor = (score) => {
|
| 19 |
+
if (score >= 9) return '#D85A30';
|
| 20 |
+
if (score >= 7) return '#BA7517';
|
| 21 |
+
return '#888';
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div
|
| 26 |
+
onClick={() => onClick(id)}
|
| 27 |
+
style={{
|
| 28 |
+
background: 'white',
|
| 29 |
+
border: '1px solid #eee',
|
| 30 |
+
borderRadius: 12,
|
| 31 |
+
padding: 18,
|
| 32 |
+
cursor: 'pointer',
|
| 33 |
+
transition: 'all 0.2s ease',
|
| 34 |
+
position: 'relative',
|
| 35 |
+
overflow: 'hidden'
|
| 36 |
+
}}
|
| 37 |
+
onMouseEnter={(e) => {
|
| 38 |
+
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
|
| 39 |
+
e.currentTarget.style.transform = 'translateY(-2px)';
|
| 40 |
+
}}
|
| 41 |
+
onMouseLeave={(e) => {
|
| 42 |
+
e.currentTarget.style.boxShadow = 'none';
|
| 43 |
+
e.currentTarget.style.transform = 'translateY(0)';
|
| 44 |
+
}}
|
| 45 |
+
>
|
| 46 |
+
{/* Discomfort indicator */}
|
| 47 |
+
<div style={{
|
| 48 |
+
position: 'absolute',
|
| 49 |
+
top: 0,
|
| 50 |
+
right: 0,
|
| 51 |
+
width: 60,
|
| 52 |
+
height: 60,
|
| 53 |
+
background: getDiscomfortColor(discomfort),
|
| 54 |
+
borderBottomLeft: '60px solid transparent',
|
| 55 |
+
opacity: 0.1
|
| 56 |
+
}} />
|
| 57 |
+
|
| 58 |
+
{/* Icon */}
|
| 59 |
+
{Icon && (
|
| 60 |
+
<div style={{
|
| 61 |
+
width: 40,
|
| 62 |
+
height: 40,
|
| 63 |
+
borderRadius: 8,
|
| 64 |
+
background: '#f5f5f2',
|
| 65 |
+
display: 'flex',
|
| 66 |
+
alignItems: 'center',
|
| 67 |
+
justifyContent: 'center',
|
| 68 |
+
marginBottom: 12,
|
| 69 |
+
color: getDiscomfortColor(discomfort)
|
| 70 |
+
}}>
|
| 71 |
+
<Icon size={20} />
|
| 72 |
+
</div>
|
| 73 |
+
)}
|
| 74 |
+
|
| 75 |
+
{/* Title */}
|
| 76 |
+
<h3 style={{
|
| 77 |
+
fontSize: 15,
|
| 78 |
+
fontWeight: 500,
|
| 79 |
+
color: '#222',
|
| 80 |
+
marginBottom: 8,
|
| 81 |
+
lineHeight: 1.3
|
| 82 |
+
}}>
|
| 83 |
+
{title}
|
| 84 |
+
</h3>
|
| 85 |
+
|
| 86 |
+
{/* Metrics */}
|
| 87 |
+
<div style={{
|
| 88 |
+
display: 'flex',
|
| 89 |
+
gap: 12,
|
| 90 |
+
marginBottom: 10
|
| 91 |
+
}}>
|
| 92 |
+
<div>
|
| 93 |
+
<div style={{
|
| 94 |
+
fontSize: 20,
|
| 95 |
+
fontWeight: 500,
|
| 96 |
+
color: getDiscomfortColor(discomfort)
|
| 97 |
+
}}>
|
| 98 |
+
{metric}
|
| 99 |
+
</div>
|
| 100 |
+
<div style={{
|
| 101 |
+
fontSize: 11,
|
| 102 |
+
color: '#888'
|
| 103 |
+
}}>
|
| 104 |
+
{context}
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
{/* Summary */}
|
| 110 |
+
<p style={{
|
| 111 |
+
fontSize: 12,
|
| 112 |
+
color: '#666',
|
| 113 |
+
lineHeight: 1.5,
|
| 114 |
+
marginBottom: 12
|
| 115 |
+
}}>
|
| 116 |
+
{summary}
|
| 117 |
+
</p>
|
| 118 |
+
|
| 119 |
+
{/* Footer */}
|
| 120 |
+
<div style={{
|
| 121 |
+
display: 'flex',
|
| 122 |
+
justifyContent: 'space-between',
|
| 123 |
+
alignItems: 'center'
|
| 124 |
+
}}>
|
| 125 |
+
<div style={{
|
| 126 |
+
fontSize: 11,
|
| 127 |
+
color: getDiscomfortColor(discomfort),
|
| 128 |
+
fontWeight: 500
|
| 129 |
+
}}>
|
| 130 |
+
{discomfort >= 9 ? 'β οΈ High Impact' : discomfort >= 7 ? 'β‘ Medium Impact' : 'π Analysis'}
|
| 131 |
+
</div>
|
| 132 |
+
<div style={{
|
| 133 |
+
fontSize: 12,
|
| 134 |
+
color: '#666',
|
| 135 |
+
display: 'flex',
|
| 136 |
+
alignItems: 'center',
|
| 137 |
+
gap: 4
|
| 138 |
+
}}>
|
| 139 |
+
View Details
|
| 140 |
+
<ArrowRight size={14} />
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/**
|
| 148 |
+
* DashboardGrid Component
|
| 149 |
+
* Grid layout for dashboard tiles
|
| 150 |
+
*/
|
| 151 |
+
export function DashboardGrid({ dashboards, onNavigate }) {
|
| 152 |
+
const icons = [TrendingUp, Clock, DollarSign, Users];
|
| 153 |
+
|
| 154 |
+
return (
|
| 155 |
+
<div style={{
|
| 156 |
+
display: 'grid',
|
| 157 |
+
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
| 158 |
+
gap: 16,
|
| 159 |
+
marginBottom: 24
|
| 160 |
+
}}>
|
| 161 |
+
{dashboards.map((dashboard, i) => (
|
| 162 |
+
<DashboardTile
|
| 163 |
+
key={dashboard.id}
|
| 164 |
+
{...dashboard}
|
| 165 |
+
icon={icons[i % icons.length]}
|
| 166 |
+
onClick={onNavigate}
|
| 167 |
+
/>
|
| 168 |
+
))}
|
| 169 |
+
</div>
|
| 170 |
+
);
|
| 171 |
+
}
|
frontend/policy-dashboards/src/components/shared/DecisionCard.jsx
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Users, MessageSquare, FileText, Calendar, CheckCircle, XCircle } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* DecisionCard Component
|
| 6 |
+
* Shows individual decision with speakers, rationale, and details
|
| 7 |
+
*/
|
| 8 |
+
export default function DecisionCard({ decision, onClick }) {
|
| 9 |
+
const {
|
| 10 |
+
decision_summary,
|
| 11 |
+
outcome,
|
| 12 |
+
primary_rationale,
|
| 13 |
+
supporters = [],
|
| 14 |
+
opponents = [],
|
| 15 |
+
vote_result,
|
| 16 |
+
meeting_date,
|
| 17 |
+
tradeoffs_discussed = [],
|
| 18 |
+
evidence_cited = [],
|
| 19 |
+
policy_domain = 'general',
|
| 20 |
+
community_gap,
|
| 21 |
+
ntee_code
|
| 22 |
+
} = decision;
|
| 23 |
+
|
| 24 |
+
const domainColors = {
|
| 25 |
+
health: '#1D9E75',
|
| 26 |
+
education: '#185FA5',
|
| 27 |
+
facilities: '#BA7517',
|
| 28 |
+
budget: '#D85A30',
|
| 29 |
+
personnel: '#6B4C9A',
|
| 30 |
+
safety: '#E24B4A',
|
| 31 |
+
community: '#2C7A7B',
|
| 32 |
+
policy: '#744210',
|
| 33 |
+
general: '#888'
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const isApproved = outcome?.toLowerCase().includes('approved') ||
|
| 37 |
+
outcome?.toLowerCase().includes('passed');
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<div
|
| 41 |
+
onClick={onClick}
|
| 42 |
+
style={{
|
| 43 |
+
background: 'white',
|
| 44 |
+
border: '1px solid #eee',
|
| 45 |
+
borderLeft: `4px solid ${domainColors[policy_domain] || '#888'}`,
|
| 46 |
+
borderRadius: 8,
|
| 47 |
+
padding: 20,
|
| 48 |
+
cursor: 'pointer',
|
| 49 |
+
transition: 'all 0.2s ease',
|
| 50 |
+
marginBottom: 12
|
| 51 |
+
}}
|
| 52 |
+
onMouseEnter={(e) => {
|
| 53 |
+
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
| 54 |
+
e.currentTarget.style.borderLeftWidth = '6px';
|
| 55 |
+
}}
|
| 56 |
+
onMouseLeave={(e) => {
|
| 57 |
+
e.currentTarget.style.boxShadow = 'none';
|
| 58 |
+
e.currentTarget.style.borderLeftWidth = '4px';
|
| 59 |
+
}}
|
| 60 |
+
>
|
| 61 |
+
{/* Header */}
|
| 62 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
| 63 |
+
<div style={{ flex: 1 }}>
|
| 64 |
+
<div style={{ fontSize: 16, fontWeight: 500, color: '#222', lineHeight: 1.5, marginBottom: 8 }}>
|
| 65 |
+
{decision_summary}
|
| 66 |
+
</div>
|
| 67 |
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
| 68 |
+
<span style={{
|
| 69 |
+
fontSize: 13,
|
| 70 |
+
color: '#888',
|
| 71 |
+
display: 'flex',
|
| 72 |
+
alignItems: 'center',
|
| 73 |
+
gap: 4
|
| 74 |
+
}}>
|
| 75 |
+
<Calendar size={14} />
|
| 76 |
+
{meeting_date ? new Date(meeting_date).toLocaleDateString() : 'Date unknown'}
|
| 77 |
+
</span>
|
| 78 |
+
{vote_result && (
|
| 79 |
+
<span style={{ fontSize: 13, color: '#888' }}>
|
| 80 |
+
Vote: {vote_result}
|
| 81 |
+
</span>
|
| 82 |
+
)}
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div style={{
|
| 87 |
+
display: 'flex',
|
| 88 |
+
alignItems: 'center',
|
| 89 |
+
gap: 6,
|
| 90 |
+
padding: '4px 10px',
|
| 91 |
+
borderRadius: 12,
|
| 92 |
+
background: isApproved ? '#e6f4f1' : '#fff4e6',
|
| 93 |
+
color: isApproved ? '#1D9E75' : '#BA7517',
|
| 94 |
+
fontSize: 11,
|
| 95 |
+
fontWeight: 500
|
| 96 |
+
}}>
|
| 97 |
+
{isApproved ? <CheckCircle size={12} /> : <XCircle size={12} />}
|
| 98 |
+
{outcome || 'Unknown'}
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
{/* Rationale */}
|
| 103 |
+
{primary_rationale && (
|
| 104 |
+
<div style={{
|
| 105 |
+
background: '#f5f5f2',
|
| 106 |
+
borderRadius: 6,
|
| 107 |
+
padding: 10,
|
| 108 |
+
marginBottom: 10
|
| 109 |
+
}}>
|
| 110 |
+
<div style={{
|
| 111 |
+
fontSize: 10,
|
| 112 |
+
color: '#999',
|
| 113 |
+
textTransform: 'uppercase',
|
| 114 |
+
letterSpacing: '0.05em',
|
| 115 |
+
marginBottom: 4,
|
| 116 |
+
display: 'flex',
|
| 117 |
+
alignItems: 'center',
|
| 118 |
+
gap: 4
|
| 119 |
+
}}>
|
| 120 |
+
<MessageSquare size={10} />
|
| 121 |
+
Primary Rationale
|
| 122 |
+
</div>
|
| 123 |
+
<div style={{ fontSize: 12, color: '#555', lineHeight: 1.5 }}>
|
| 124 |
+
"{primary_rationale}"
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
)}
|
| 128 |
+
|
| 129 |
+
{/* Speakers */}
|
| 130 |
+
{(supporters.length > 0 || opponents.length > 0) && (
|
| 131 |
+
<div style={{ display: 'flex', gap: 12, marginBottom: 10 }}>
|
| 132 |
+
{supporters.length > 0 && (
|
| 133 |
+
<div style={{ flex: 1 }}>
|
| 134 |
+
<div style={{
|
| 135 |
+
fontSize: 10,
|
| 136 |
+
color: '#1D9E75',
|
| 137 |
+
fontWeight: 500,
|
| 138 |
+
marginBottom: 4,
|
| 139 |
+
display: 'flex',
|
| 140 |
+
alignItems: 'center',
|
| 141 |
+
gap: 4
|
| 142 |
+
}}>
|
| 143 |
+
<Users size={10} />
|
| 144 |
+
Supporters ({supporters.length})
|
| 145 |
+
</div>
|
| 146 |
+
{supporters.slice(0, 2).map((supporter, i) => (
|
| 147 |
+
<div key={i} style={{ fontSize: 11, color: '#666', marginBottom: 2 }}>
|
| 148 |
+
β’ {typeof supporter === 'string' ? supporter : supporter.name || 'Unknown'}
|
| 149 |
+
{supporter.role && <span style={{ color: '#999' }}> ({supporter.role})</span>}
|
| 150 |
+
</div>
|
| 151 |
+
))}
|
| 152 |
+
{supporters.length > 2 && (
|
| 153 |
+
<div style={{ fontSize: 10, color: '#999', fontStyle: 'italic' }}>
|
| 154 |
+
+{supporters.length - 2} more
|
| 155 |
+
</div>
|
| 156 |
+
)}
|
| 157 |
+
</div>
|
| 158 |
+
)}
|
| 159 |
+
|
| 160 |
+
{opponents.length > 0 && (
|
| 161 |
+
<div style={{ flex: 1 }}>
|
| 162 |
+
<div style={{
|
| 163 |
+
fontSize: 10,
|
| 164 |
+
color: '#D85A30',
|
| 165 |
+
fontWeight: 500,
|
| 166 |
+
marginBottom: 4,
|
| 167 |
+
display: 'flex',
|
| 168 |
+
alignItems: 'center',
|
| 169 |
+
gap: 4
|
| 170 |
+
}}>
|
| 171 |
+
<Users size={10} />
|
| 172 |
+
Opponents ({opponents.length})
|
| 173 |
+
</div>
|
| 174 |
+
{opponents.slice(0, 2).map((opponent, i) => (
|
| 175 |
+
<div key={i} style={{ fontSize: 11, color: '#666', marginBottom: 2 }}>
|
| 176 |
+
β’ {typeof opponent === 'string' ? opponent : opponent.name || 'Unknown'}
|
| 177 |
+
{opponent.role && <span style={{ color: '#999' }}> ({opponent.role})</span>}
|
| 178 |
+
</div>
|
| 179 |
+
))}
|
| 180 |
+
{opponents.length > 2 && (
|
| 181 |
+
<div style={{ fontSize: 10, color: '#999', fontStyle: 'italic' }}>
|
| 182 |
+
+{opponents.length - 2} more
|
| 183 |
+
</div>
|
| 184 |
+
)}
|
| 185 |
+
</div>
|
| 186 |
+
)}
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
|
| 190 |
+
{/* Tradeoffs & Evidence */}
|
| 191 |
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
| 192 |
+
{tradeoffs_discussed.length > 0 && (
|
| 193 |
+
<span style={{
|
| 194 |
+
fontSize: 10,
|
| 195 |
+
padding: '2px 8px',
|
| 196 |
+
borderRadius: 10,
|
| 197 |
+
background: '#fff4e6',
|
| 198 |
+
color: '#BA7517',
|
| 199 |
+
display: 'flex',
|
| 200 |
+
alignItems: 'center',
|
| 201 |
+
gap: 4
|
| 202 |
+
}}>
|
| 203 |
+
{tradeoffs_discussed.length} tradeoff{tradeoffs_discussed.length > 1 ? 's' : ''}
|
| 204 |
+
</span>
|
| 205 |
+
)}
|
| 206 |
+
{evidence_cited.length > 0 && (
|
| 207 |
+
<span style={{
|
| 208 |
+
fontSize: 10,
|
| 209 |
+
padding: '2px 8px',
|
| 210 |
+
borderRadius: 10,
|
| 211 |
+
background: '#e6f4f1',
|
| 212 |
+
color: '#1D9E75',
|
| 213 |
+
display: 'flex',
|
| 214 |
+
alignItems: 'center',
|
| 215 |
+
gap: 4
|
| 216 |
+
}}>
|
| 217 |
+
<FileText size={10} />
|
| 218 |
+
{evidence_cited.length} source{evidence_cited.length > 1 ? 's' : ''}
|
| 219 |
+
</span>
|
| 220 |
+
)}
|
| 221 |
+
{community_gap?.nonprofit_filling_gap && (
|
| 222 |
+
<span style={{
|
| 223 |
+
fontSize: 10,
|
| 224 |
+
padding: '2px 8px',
|
| 225 |
+
borderRadius: 10,
|
| 226 |
+
background: '#dcfce7',
|
| 227 |
+
color: '#059669',
|
| 228 |
+
display: 'flex',
|
| 229 |
+
alignItems: 'center',
|
| 230 |
+
gap: 4,
|
| 231 |
+
fontWeight: 500
|
| 232 |
+
}}>
|
| 233 |
+
π€ Community filling gap
|
| 234 |
+
</span>
|
| 235 |
+
)}
|
| 236 |
+
{ntee_code && (
|
| 237 |
+
<span style={{
|
| 238 |
+
fontSize: 10,
|
| 239 |
+
padding: '2px 8px',
|
| 240 |
+
borderRadius: 10,
|
| 241 |
+
background: '#f3f4f6',
|
| 242 |
+
color: '#6b7280',
|
| 243 |
+
display: 'flex',
|
| 244 |
+
alignItems: 'center',
|
| 245 |
+
gap: 4
|
| 246 |
+
}}>
|
| 247 |
+
NTEE: {ntee_code}
|
| 248 |
+
</span>
|
| 249 |
+
)}
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
);
|
| 253 |
+
}
|
frontend/policy-dashboards/src/components/shared/FilterPanel.jsx
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Search, Filter, X } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* FilterPanel Component
|
| 6 |
+
* Allows filtering by policy domains/keywords and search
|
| 7 |
+
*/
|
| 8 |
+
export default function FilterPanel({
|
| 9 |
+
selectedDomains = [],
|
| 10 |
+
onDomainToggle,
|
| 11 |
+
searchQuery = "",
|
| 12 |
+
onSearchChange,
|
| 13 |
+
onClear
|
| 14 |
+
}) {
|
| 15 |
+
const [showFilters, setShowFilters] = useState(false);
|
| 16 |
+
|
| 17 |
+
const policyDomains = [
|
| 18 |
+
{ id: 'health', label: 'Health & Wellness', color: '#1D9E75' },
|
| 19 |
+
{ id: 'education', label: 'Education & Curriculum', color: '#185FA5' },
|
| 20 |
+
{ id: 'facilities', label: 'Facilities & Infrastructure', color: '#BA7517' },
|
| 21 |
+
{ id: 'budget', label: 'Budget & Finance', color: '#D85A30' },
|
| 22 |
+
{ id: 'personnel', label: 'Personnel & Staffing', color: '#6B4C9A' },
|
| 23 |
+
{ id: 'safety', label: 'Safety & Security', color: '#E24B4A' },
|
| 24 |
+
{ id: 'community', label: 'Community & Partnerships', color: '#2C7A7B' },
|
| 25 |
+
{ id: 'policy', label: 'Policy & Governance', color: '#744210' }
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
const keywords = [
|
| 29 |
+
'dental', 'health', 'screening', 'wellness', 'nurse',
|
| 30 |
+
'budget', 'funding', 'capital', 'expenditure',
|
| 31 |
+
'facility', 'building', 'construction', 'renovation',
|
| 32 |
+
'teacher', 'salary', 'contract', 'hiring',
|
| 33 |
+
'curriculum', 'textbook', 'program', 'academic',
|
| 34 |
+
'safety', 'security', 'police', 'emergency',
|
| 35 |
+
'community', 'partnership', 'grant', 'donation'
|
| 36 |
+
];
|
| 37 |
+
|
| 38 |
+
const hasActiveFilters = selectedDomains.length > 0 || searchQuery.length > 0;
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<div style={{ marginBottom: 20 }}>
|
| 42 |
+
{/* Search Bar */}
|
| 43 |
+
<div style={{
|
| 44 |
+
display: 'flex',
|
| 45 |
+
gap: 8,
|
| 46 |
+
marginBottom: 12
|
| 47 |
+
}}>
|
| 48 |
+
<div style={{
|
| 49 |
+
position: 'relative',
|
| 50 |
+
flex: 1
|
| 51 |
+
}}>
|
| 52 |
+
<Search
|
| 53 |
+
size={16}
|
| 54 |
+
style={{
|
| 55 |
+
position: 'absolute',
|
| 56 |
+
left: 12,
|
| 57 |
+
top: '50%',
|
| 58 |
+
transform: 'translateY(-50%)',
|
| 59 |
+
color: '#999'
|
| 60 |
+
}}
|
| 61 |
+
/>
|
| 62 |
+
<input
|
| 63 |
+
type="text"
|
| 64 |
+
placeholder="Search decisions, speakers, rationales..."
|
| 65 |
+
value={searchQuery}
|
| 66 |
+
onChange={(e) => onSearchChange(e.target.value)}
|
| 67 |
+
style={{
|
| 68 |
+
width: '100%',
|
| 69 |
+
padding: '8px 12px 8px 36px',
|
| 70 |
+
border: '1px solid #ddd',
|
| 71 |
+
borderRadius: 8,
|
| 72 |
+
fontSize: 13,
|
| 73 |
+
fontFamily: 'inherit'
|
| 74 |
+
}}
|
| 75 |
+
/>
|
| 76 |
+
{searchQuery && (
|
| 77 |
+
<button
|
| 78 |
+
onClick={() => onSearchChange('')}
|
| 79 |
+
style={{
|
| 80 |
+
position: 'absolute',
|
| 81 |
+
right: 8,
|
| 82 |
+
top: '50%',
|
| 83 |
+
transform: 'translateY(-50%)',
|
| 84 |
+
background: 'none',
|
| 85 |
+
border: 'none',
|
| 86 |
+
cursor: 'pointer',
|
| 87 |
+
color: '#999',
|
| 88 |
+
padding: 4
|
| 89 |
+
}}
|
| 90 |
+
>
|
| 91 |
+
<X size={14} />
|
| 92 |
+
</button>
|
| 93 |
+
)}
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<button
|
| 97 |
+
onClick={() => setShowFilters(!showFilters)}
|
| 98 |
+
style={{
|
| 99 |
+
padding: '8px 16px',
|
| 100 |
+
border: '1px solid',
|
| 101 |
+
borderColor: showFilters ? '#888' : '#ddd',
|
| 102 |
+
borderRadius: 8,
|
| 103 |
+
background: showFilters ? '#f5f5f2' : 'white',
|
| 104 |
+
cursor: 'pointer',
|
| 105 |
+
fontSize: 13,
|
| 106 |
+
fontWeight: 500,
|
| 107 |
+
display: 'flex',
|
| 108 |
+
alignItems: 'center',
|
| 109 |
+
gap: 6,
|
| 110 |
+
color: showFilters ? '#222' : '#666'
|
| 111 |
+
}}
|
| 112 |
+
>
|
| 113 |
+
<Filter size={14} />
|
| 114 |
+
Filters
|
| 115 |
+
{selectedDomains.length > 0 && (
|
| 116 |
+
<span style={{
|
| 117 |
+
background: '#D85A30',
|
| 118 |
+
color: 'white',
|
| 119 |
+
borderRadius: '50%',
|
| 120 |
+
width: 18,
|
| 121 |
+
height: 18,
|
| 122 |
+
display: 'flex',
|
| 123 |
+
alignItems: 'center',
|
| 124 |
+
justifyContent: 'center',
|
| 125 |
+
fontSize: 10,
|
| 126 |
+
fontWeight: 600
|
| 127 |
+
}}>
|
| 128 |
+
{selectedDomains.length}
|
| 129 |
+
</span>
|
| 130 |
+
)}
|
| 131 |
+
</button>
|
| 132 |
+
|
| 133 |
+
{hasActiveFilters && (
|
| 134 |
+
<button
|
| 135 |
+
onClick={onClear}
|
| 136 |
+
style={{
|
| 137 |
+
padding: '8px 16px',
|
| 138 |
+
border: '1px solid #ddd',
|
| 139 |
+
borderRadius: 8,
|
| 140 |
+
background: 'white',
|
| 141 |
+
cursor: 'pointer',
|
| 142 |
+
fontSize: 13,
|
| 143 |
+
color: '#D85A30'
|
| 144 |
+
}}
|
| 145 |
+
>
|
| 146 |
+
Clear All
|
| 147 |
+
</button>
|
| 148 |
+
)}
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{/* Filter Panel */}
|
| 152 |
+
{showFilters && (
|
| 153 |
+
<div style={{
|
| 154 |
+
background: '#f5f5f2',
|
| 155 |
+
borderRadius: 8,
|
| 156 |
+
padding: 16,
|
| 157 |
+
marginTop: 8
|
| 158 |
+
}}>
|
| 159 |
+
<div style={{ marginBottom: 12 }}>
|
| 160 |
+
<div style={{
|
| 161 |
+
fontSize: 12,
|
| 162 |
+
fontWeight: 500,
|
| 163 |
+
color: '#666',
|
| 164 |
+
textTransform: 'uppercase',
|
| 165 |
+
letterSpacing: '0.05em',
|
| 166 |
+
marginBottom: 10
|
| 167 |
+
}}>
|
| 168 |
+
Policy Domains
|
| 169 |
+
</div>
|
| 170 |
+
<div style={{
|
| 171 |
+
display: 'flex',
|
| 172 |
+
flexWrap: 'wrap',
|
| 173 |
+
gap: 8
|
| 174 |
+
}}>
|
| 175 |
+
{policyDomains.map(domain => {
|
| 176 |
+
const isSelected = selectedDomains.includes(domain.id);
|
| 177 |
+
return (
|
| 178 |
+
<button
|
| 179 |
+
key={domain.id}
|
| 180 |
+
onClick={() => onDomainToggle(domain.id)}
|
| 181 |
+
style={{
|
| 182 |
+
padding: '6px 12px',
|
| 183 |
+
borderRadius: 16,
|
| 184 |
+
border: '1px solid',
|
| 185 |
+
borderColor: isSelected ? domain.color : '#ddd',
|
| 186 |
+
background: isSelected ? domain.color : 'white',
|
| 187 |
+
color: isSelected ? 'white' : '#666',
|
| 188 |
+
fontSize: 12,
|
| 189 |
+
cursor: 'pointer',
|
| 190 |
+
fontWeight: isSelected ? 500 : 400,
|
| 191 |
+
transition: 'all 0.2s ease'
|
| 192 |
+
}}
|
| 193 |
+
>
|
| 194 |
+
{domain.label}
|
| 195 |
+
</button>
|
| 196 |
+
);
|
| 197 |
+
})}
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
|
| 201 |
+
<div>
|
| 202 |
+
<div style={{
|
| 203 |
+
fontSize: 12,
|
| 204 |
+
fontWeight: 500,
|
| 205 |
+
color: '#666',
|
| 206 |
+
textTransform: 'uppercase',
|
| 207 |
+
letterSpacing: '0.05em',
|
| 208 |
+
marginBottom: 10
|
| 209 |
+
}}>
|
| 210 |
+
Common Keywords
|
| 211 |
+
</div>
|
| 212 |
+
<div style={{
|
| 213 |
+
display: 'flex',
|
| 214 |
+
flexWrap: 'wrap',
|
| 215 |
+
gap: 6
|
| 216 |
+
}}>
|
| 217 |
+
{keywords.map(keyword => (
|
| 218 |
+
<button
|
| 219 |
+
key={keyword}
|
| 220 |
+
onClick={() => onSearchChange(keyword)}
|
| 221 |
+
style={{
|
| 222 |
+
padding: '4px 10px',
|
| 223 |
+
borderRadius: 12,
|
| 224 |
+
border: '1px solid #ddd',
|
| 225 |
+
background: searchQuery === keyword ? '#f0f0ee' : 'white',
|
| 226 |
+
color: '#666',
|
| 227 |
+
fontSize: 11,
|
| 228 |
+
cursor: 'pointer'
|
| 229 |
+
}}
|
| 230 |
+
>
|
| 231 |
+
{keyword}
|
| 232 |
+
</button>
|
| 233 |
+
))}
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
)}
|
| 238 |
+
</div>
|
| 239 |
+
);
|
| 240 |
+
}
|
frontend/policy-dashboards/src/components/shared/InsightBox.jsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* InsightBox Component
|
| 5 |
+
* Bottom summary box with "The logic" explanation
|
| 6 |
+
*/
|
| 7 |
+
export default function InsightBox({ title = "The logic", children, type = "default" }) {
|
| 8 |
+
const styles = {
|
| 9 |
+
default: {
|
| 10 |
+
background: '#f5f5f2',
|
| 11 |
+
borderLeft: 'none'
|
| 12 |
+
},
|
| 13 |
+
warning: {
|
| 14 |
+
background: '#fff4e6',
|
| 15 |
+
borderLeft: '3px solid #BA7517'
|
| 16 |
+
},
|
| 17 |
+
critical: {
|
| 18 |
+
background: '#ffe6e6',
|
| 19 |
+
borderLeft: '3px solid #D85A30'
|
| 20 |
+
}
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const style = styles[type] || styles.default;
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div style={{
|
| 27 |
+
...style,
|
| 28 |
+
borderRadius: 8,
|
| 29 |
+
padding: 14,
|
| 30 |
+
fontSize: 13,
|
| 31 |
+
color: '#555',
|
| 32 |
+
marginTop: 16
|
| 33 |
+
}}>
|
| 34 |
+
<strong style={{ color: '#222' }}>{title}:</strong> {children}
|
| 35 |
+
</div>
|
| 36 |
+
);
|
| 37 |
+
}
|
frontend/policy-dashboards/src/components/shared/MetricCard.jsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* MetricCard Component
|
| 5 |
+
* Display key metrics with optional positive/negative/neutral tone
|
| 6 |
+
*/
|
| 7 |
+
export default function MetricCard({ value, label, tone = "neutral" }) {
|
| 8 |
+
const colors = {
|
| 9 |
+
positive: "#1D9E75",
|
| 10 |
+
negative: "#D85A30",
|
| 11 |
+
neutral: "#222"
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div style={{
|
| 16 |
+
background: "#f5f5f2",
|
| 17 |
+
borderRadius: 8,
|
| 18 |
+
padding: 14
|
| 19 |
+
}}>
|
| 20 |
+
<div style={{
|
| 21 |
+
fontSize: 22,
|
| 22 |
+
fontWeight: 500,
|
| 23 |
+
color: colors[tone]
|
| 24 |
+
}}>
|
| 25 |
+
{value}
|
| 26 |
+
</div>
|
| 27 |
+
<div style={{
|
| 28 |
+
fontSize: 12,
|
| 29 |
+
color: "#888",
|
| 30 |
+
marginTop: 3
|
| 31 |
+
}}>
|
| 32 |
+
{label}
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
);
|
| 36 |
+
}
|
frontend/policy-dashboards/src/data/dashboardData.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Dashboard Data Configuration
|
| 3 |
+
*
|
| 4 |
+
* This file contains all the data for the accountability dashboards.
|
| 5 |
+
* UPDATE THIS FILE with real data from your Python analysis output.
|
| 6 |
+
*
|
| 7 |
+
* Data source: output/tuscaloosa_accountability_dashboards.json
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
export const metadata = {
|
| 11 |
+
title: "P.A.T.H. β Policy Accountability and Tracking Hub",
|
| 12 |
+
description: "Track government decisions and discover community solutions",
|
| 13 |
+
analysisDate: "2026-04-24",
|
| 14 |
+
maxDiscomfortScore: 10
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
// ================================================================
|
| 18 |
+
// DASHBOARD 1: Words vs. Dollars (Rhetoric Gap)
|
| 19 |
+
// ================================================================
|
| 20 |
+
export const rhetoricGapData = {
|
| 21 |
+
// What they SAY
|
| 22 |
+
sentimentScore: 92, // % positive sentiment
|
| 23 |
+
totalMentions: 50,
|
| 24 |
+
sampleQuotes: [
|
| 25 |
+
"Student wellness is a top priority for this district",
|
| 26 |
+
"We are deeply committed to the health and wellbeing of every child",
|
| 27 |
+
"Social-emotional and physical health are foundational to learning"
|
| 28 |
+
],
|
| 29 |
+
|
| 30 |
+
// What they FUND
|
| 31 |
+
budgetCategory: "Contracted Health Services (Dental/Vision)",
|
| 32 |
+
priorYearAmount: 320000,
|
| 33 |
+
currentYearAmount: 200000,
|
| 34 |
+
budgetDelta: -120000,
|
| 35 |
+
budgetDeltaPercent: -37.5,
|
| 36 |
+
|
| 37 |
+
// Context
|
| 38 |
+
adminCostGrowth: 31, // % increase in admin costs same period
|
| 39 |
+
|
| 40 |
+
// Benchmarks - UPDATE WITH REAL DATA
|
| 41 |
+
benchmarks: {
|
| 42 |
+
thisDistrict: {
|
| 43 |
+
perStudent: 41,
|
| 44 |
+
label: "This District"
|
| 45 |
+
},
|
| 46 |
+
republicanAvg: {
|
| 47 |
+
perStudent: 74,
|
| 48 |
+
label: "Republican Districts Avg"
|
| 49 |
+
},
|
| 50 |
+
democraticAvg: {
|
| 51 |
+
perStudent: 98,
|
| 52 |
+
label: "Democratic Districts Avg"
|
| 53 |
+
},
|
| 54 |
+
nationalAvg: {
|
| 55 |
+
perStudent: 112,
|
| 56 |
+
label: "National Average"
|
| 57 |
+
}
|
| 58 |
+
},
|
| 59 |
+
|
| 60 |
+
// The Logic
|
| 61 |
+
gapType: "Marketing Rationale",
|
| 62 |
+
conclusion: "Verbal commitment to student health is high β fiscal priority is declining.",
|
| 63 |
+
inference: "Words go up, dollars go down. Health funds are being used as a buffer for rising admin costs."
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
// ================================================================
|
| 67 |
+
// DASHBOARD 2: Delayed 6 Months and Counting (Logic Chain)
|
| 68 |
+
// ================================================================
|
| 69 |
+
export const logicChainData = {
|
| 70 |
+
topic: "West Alabama Community Dental Clinic Partnership",
|
| 71 |
+
|
| 72 |
+
// Timeline
|
| 73 |
+
firstMentioned: "2025-10-15",
|
| 74 |
+
monthsInLimbo: 6,
|
| 75 |
+
totalDeferrals: 4,
|
| 76 |
+
|
| 77 |
+
// Justification history
|
| 78 |
+
justifications: [
|
| 79 |
+
{
|
| 80 |
+
month: "Month 1 (Oct 2025)",
|
| 81 |
+
status: "deferred",
|
| 82 |
+
reason: "Waiting for state tax revenue projections",
|
| 83 |
+
speaker: "Board Member Johnson"
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
month: "Month 2 (Nov 2025)",
|
| 87 |
+
status: "work session",
|
| 88 |
+
reason: "Moved to subcommittee for further review",
|
| 89 |
+
speaker: "Superintendent Smith"
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
month: "Month 3 (Dec 2025)",
|
| 93 |
+
status: "deferred",
|
| 94 |
+
reason: "No quorum reached on agenda item",
|
| 95 |
+
speaker: "Board Chair Davis"
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
month: "Month 4 (Jan 2026)",
|
| 99 |
+
status: "deferred",
|
| 100 |
+
reason: "Waiting for legal clarity on liability",
|
| 101 |
+
speaker: "Risk Manager Patricia Johnson"
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
month: "Month 5-6 (Feb-Apr 2026)",
|
| 105 |
+
status: "not scheduled",
|
| 106 |
+
reason: "Election cycle approaching β not scheduled for vote",
|
| 107 |
+
speaker: "N/A"
|
| 108 |
+
}
|
| 109 |
+
],
|
| 110 |
+
|
| 111 |
+
// Benchmarks
|
| 112 |
+
benchmarks: {
|
| 113 |
+
thisDistrict: {
|
| 114 |
+
activePrograms: 0,
|
| 115 |
+
label: "This District"
|
| 116 |
+
},
|
| 117 |
+
republicanAvg: {
|
| 118 |
+
activePrograms: 14,
|
| 119 |
+
label: "Republican States"
|
| 120 |
+
},
|
| 121 |
+
democraticAvg: {
|
| 122 |
+
activePrograms: 21,
|
| 123 |
+
label: "Democratic States"
|
| 124 |
+
},
|
| 125 |
+
nationalAvg: {
|
| 126 |
+
activePrograms: 35,
|
| 127 |
+
label: "States with Programs"
|
| 128 |
+
}
|
| 129 |
+
},
|
| 130 |
+
|
| 131 |
+
// The Logic
|
| 132 |
+
patternType: "Rationale of Attrition",
|
| 133 |
+
conclusion: "Administrative 'analysis' is a strategic tool to avoid a final yes or no.",
|
| 134 |
+
inference: "The board isn't debating merit β they're waiting for momentum to fade before the election cycle."
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
+
// ================================================================
|
| 138 |
+
// DASHBOARD 3: What Got Funded Instead (Displacement Matrix)
|
| 139 |
+
// ================================================================
|
| 140 |
+
export const displacementData = {
|
| 141 |
+
topic: "2026 Capital Project Prioritization",
|
| 142 |
+
|
| 143 |
+
// The matrix
|
| 144 |
+
displacements: [
|
| 145 |
+
{
|
| 146 |
+
winner: "Athletic Turf Replacement",
|
| 147 |
+
winnerAmount: 850000,
|
| 148 |
+
loser: "Fluoride Water System Upgrade",
|
| 149 |
+
loserAmount: 0,
|
| 150 |
+
tradeoffFactor: "Visibility β Turf is a PR win; fluoride is hidden"
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
winner: "Admin Building HVAC System",
|
| 154 |
+
winnerAmount: 2000000,
|
| 155 |
+
loser: "Dental Screening Pilot Program",
|
| 156 |
+
loserAmount: 0,
|
| 157 |
+
tradeoffFactor: "Asset Maintenance β Building upkeep over health services"
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
winner: "Police Vehicle Fleet Upgrade",
|
| 161 |
+
winnerAmount: 450000,
|
| 162 |
+
loser: "School Nurse Salary Supplements",
|
| 163 |
+
loserAmount: 0,
|
| 164 |
+
tradeoffFactor: "Public Safety β Police are a 'primary' rationale"
|
| 165 |
+
}
|
| 166 |
+
],
|
| 167 |
+
|
| 168 |
+
// Benchmarks - per student spending
|
| 169 |
+
benchmarks: {
|
| 170 |
+
thisDistrict: {
|
| 171 |
+
healthCapital: 0,
|
| 172 |
+
athleticCapital: 170,
|
| 173 |
+
label: "This District"
|
| 174 |
+
},
|
| 175 |
+
republicanAvg: {
|
| 176 |
+
healthCapital: 29,
|
| 177 |
+
athleticCapital: 95,
|
| 178 |
+
label: "Republican Districts"
|
| 179 |
+
},
|
| 180 |
+
democraticAvg: {
|
| 181 |
+
healthCapital: 48,
|
| 182 |
+
athleticCapital: 85,
|
| 183 |
+
label: "Democratic Districts"
|
| 184 |
+
},
|
| 185 |
+
nationalAvg: {
|
| 186 |
+
healthCapital: 42,
|
| 187 |
+
athleticCapital: 88,
|
| 188 |
+
label: "National Average"
|
| 189 |
+
}
|
| 190 |
+
},
|
| 191 |
+
|
| 192 |
+
// The Logic
|
| 193 |
+
priorityPattern: "Legacy Rationale",
|
| 194 |
+
conclusion: "The Board prioritizes visible assets over invisible health infrastructure.",
|
| 195 |
+
inference: "Officials fund ribbon-cuttings. The community trades dental health for political visibility."
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
// ================================================================
|
| 199 |
+
// DASHBOARD 4: One Memo Beat 240 Residents (Influence Radar)
|
| 200 |
+
// ================================================================
|
| 201 |
+
export const influenceData = {
|
| 202 |
+
topic: "School-Based Dental Screening Policy",
|
| 203 |
+
|
| 204 |
+
// Influence actors
|
| 205 |
+
actors: [
|
| 206 |
+
{
|
| 207 |
+
actor: "Risk / Legal memo (1 document)",
|
| 208 |
+
influence: 92,
|
| 209 |
+
type: "blocker",
|
| 210 |
+
contactName: "Patricia Johnson, Risk Manager",
|
| 211 |
+
documents: 1
|
| 212 |
+
},
|
| 213 |
+
{
|
| 214 |
+
actor: "External consultant report",
|
| 215 |
+
influence: 85,
|
| 216 |
+
type: "blocker",
|
| 217 |
+
contactName: "Education Finance Group LLC",
|
| 218 |
+
documents: 1
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
actor: "240+ citizen comments in favor",
|
| 222 |
+
influence: 4,
|
| 223 |
+
type: "public",
|
| 224 |
+
contactName: "Public testimony",
|
| 225 |
+
documents: 240
|
| 226 |
+
}
|
| 227 |
+
],
|
| 228 |
+
|
| 229 |
+
// Summary metrics
|
| 230 |
+
publicComments: 240,
|
| 231 |
+
publicSupportRatio: 98, // % in favor
|
| 232 |
+
legalMemos: 1,
|
| 233 |
+
consultantReports: 1,
|
| 234 |
+
|
| 235 |
+
// Benchmarks - liability context
|
| 236 |
+
benchmarks: {
|
| 237 |
+
thisDistrict: {
|
| 238 |
+
liabilitySuits: "Program Blocked",
|
| 239 |
+
label: "This District"
|
| 240 |
+
},
|
| 241 |
+
republicanAvg: {
|
| 242 |
+
liabilitySuits: 0,
|
| 243 |
+
label: "Republican States"
|
| 244 |
+
},
|
| 245 |
+
democraticAvg: {
|
| 246 |
+
liabilitySuits: 0,
|
| 247 |
+
label: "Democratic States"
|
| 248 |
+
},
|
| 249 |
+
nationalAvg: {
|
| 250 |
+
liabilitySuits: 0,
|
| 251 |
+
label: "All States Combined"
|
| 252 |
+
}
|
| 253 |
+
},
|
| 254 |
+
|
| 255 |
+
// The Logic
|
| 256 |
+
powerStructure: "Technocratic Rationale",
|
| 257 |
+
vetoHolder: "Patricia Johnson, Risk Manager",
|
| 258 |
+
conclusion: "Internal risk management holds veto power that outweighs 100% of public input.",
|
| 259 |
+
inference: "The district's lawyers and CFO are writing public health policy. One liability memo has 92% influence despite zero successful suits in any state with screening programs."
|
| 260 |
+
};
|
| 261 |
+
|
| 262 |
+
// ================================================================
|
| 263 |
+
// SUMMARY PAGE DATA
|
| 264 |
+
// ================================================================
|
| 265 |
+
export const summaryData = {
|
| 266 |
+
headline: "This isn't a left-vs-right debate. It's a pattern.",
|
| 267 |
+
subheadline: "Four ways decision-making in Tuscaloosa diverges from both Republican and Democratic averages",
|
| 268 |
+
|
| 269 |
+
findings: [
|
| 270 |
+
{
|
| 271 |
+
id: 1,
|
| 272 |
+
title: "They cut health spending while praising wellness",
|
| 273 |
+
metric: "$41/student",
|
| 274 |
+
context: "vs. $112 national avg",
|
| 275 |
+
discomfort: 9,
|
| 276 |
+
summary: "92% positive sentiment about 'wellness' in meetings, but $120k budget cut to dental/vision services"
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
id: 2,
|
| 280 |
+
title: "Delayed 6 months and counting",
|
| 281 |
+
metric: "4 deferrals",
|
| 282 |
+
context: "4 different excuses",
|
| 283 |
+
discomfort: 10,
|
| 284 |
+
summary: "Dental clinic partnership has been 'under review' with shifting justifications since October 2025"
|
| 285 |
+
},
|
| 286 |
+
{
|
| 287 |
+
id: 3,
|
| 288 |
+
title: "What got funded instead",
|
| 289 |
+
metric: "$850k turf",
|
| 290 |
+
context: "vs. $0 dental screening",
|
| 291 |
+
discomfort: 9,
|
| 292 |
+
summary: "Visible projects (athletic fields, HVAC) prioritized over invisible health infrastructure"
|
| 293 |
+
},
|
| 294 |
+
{
|
| 295 |
+
id: 4,
|
| 296 |
+
title: "One memo beat 240 residents",
|
| 297 |
+
metric: "92% influence",
|
| 298 |
+
context: "from 1 risk manager",
|
| 299 |
+
discomfort: 10,
|
| 300 |
+
summary: "Patricia Johnson's liability memo had 23x more influence than 240 citizen testimonies"
|
| 301 |
+
}
|
| 302 |
+
],
|
| 303 |
+
|
| 304 |
+
howToUse: {
|
| 305 |
+
title: "How to use this in the room",
|
| 306 |
+
strategies: [
|
| 307 |
+
{
|
| 308 |
+
dont: "Argue the 'need'",
|
| 309 |
+
do: "Show the rhetoric gap β they already agree health matters (50 mentions, 92% positive)"
|
| 310 |
+
},
|
| 311 |
+
{
|
| 312 |
+
dont: "Accept 'budget constraints'",
|
| 313 |
+
do: "Show the displacement β they funded $850k for turf in the same budget cycle"
|
| 314 |
+
},
|
| 315 |
+
{
|
| 316 |
+
dont: "Let them hide behind 'the board decided'",
|
| 317 |
+
do: "Name the veto holder β Patricia Johnson's memo had 92% influence vs. 4% for public input"
|
| 318 |
+
}
|
| 319 |
+
]
|
| 320 |
+
}
|
| 321 |
+
};
|
frontend/policy-dashboards/src/index.css
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
body {
|
| 2 |
+
margin: 0;
|
| 3 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
| 4 |
+
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
| 5 |
+
sans-serif;
|
| 6 |
+
-webkit-font-smoothing: antialiased;
|
| 7 |
+
-moz-osx-font-smoothing: grayscale;
|
| 8 |
+
font-size: 16px;
|
| 9 |
+
line-height: 1.6;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
html {
|
| 13 |
+
overflow-x: hidden;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
code {
|
| 17 |
+
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
| 18 |
+
monospace;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
* {
|
| 22 |
+
box-sizing: border-box;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
button {
|
| 26 |
+
font-family: inherit;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
input {
|
| 30 |
+
font-family: inherit;
|
| 31 |
+
}
|
frontend/policy-dashboards/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactDOM from 'react-dom/client';
|
| 3 |
+
import './index.css';
|
| 4 |
+
import App from './App';
|
| 5 |
+
|
| 6 |
+
const root = ReactDOM.createRoot(document.getElementById('root'));
|
| 7 |
+
root.render(
|
| 8 |
+
<React.StrictMode>
|
| 9 |
+
<App />
|
| 10 |
+
</React.StrictMode>
|
| 11 |
+
);
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
frontend/public/communityone_logo.jpg
ADDED
|
frontend/public/communityone_logo.png
ADDED
|
frontend/public/communityone_logo.svg
ADDED
|
|
frontend/public/communityone_logo_64.png
ADDED
|
frontend/public/favicon.ico
ADDED
|
|
frontend/public/privacyfacebook.html
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Privacy Policy - Open Navigator for Engagement</title>
|
| 7 |
+
<style>
|
| 8 |
+
* {
|
| 9 |
+
margin: 0;
|
| 10 |
+
padding: 0;
|
| 11 |
+
box-sizing: border-box;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
body {
|
| 15 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
| 16 |
+
line-height: 1.6;
|
| 17 |
+
color: #333;
|
| 18 |
+
background-color: #f9fafb;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.container {
|
| 22 |
+
max-width: 800px;
|
| 23 |
+
margin: 0 auto;
|
| 24 |
+
padding: 40px 20px;
|
| 25 |
+
background-color: white;
|
| 26 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
header {
|
| 30 |
+
border-bottom: 3px solid #2563eb;
|
| 31 |
+
padding-bottom: 20px;
|
| 32 |
+
margin-bottom: 30px;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
h1 {
|
| 36 |
+
color: #1e40af;
|
| 37 |
+
font-size: 2.5rem;
|
| 38 |
+
margin-bottom: 10px;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.last-updated {
|
| 42 |
+
color: #6b7280;
|
| 43 |
+
font-size: 0.9rem;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
h2 {
|
| 47 |
+
color: #1e40af;
|
| 48 |
+
font-size: 1.75rem;
|
| 49 |
+
margin-top: 30px;
|
| 50 |
+
margin-bottom: 15px;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
h3 {
|
| 54 |
+
color: #374151;
|
| 55 |
+
font-size: 1.25rem;
|
| 56 |
+
margin-top: 20px;
|
| 57 |
+
margin-bottom: 10px;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
p {
|
| 61 |
+
margin-bottom: 15px;
|
| 62 |
+
color: #4b5563;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
ul {
|
| 66 |
+
margin-left: 20px;
|
| 67 |
+
margin-bottom: 15px;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
li {
|
| 71 |
+
margin-bottom: 8px;
|
| 72 |
+
color: #4b5563;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.highlight {
|
| 76 |
+
background-color: #dbeafe;
|
| 77 |
+
padding: 15px;
|
| 78 |
+
border-left: 4px solid #2563eb;
|
| 79 |
+
margin: 20px 0;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
footer {
|
| 83 |
+
margin-top: 50px;
|
| 84 |
+
padding-top: 20px;
|
| 85 |
+
border-top: 1px solid #e5e7eb;
|
| 86 |
+
text-align: center;
|
| 87 |
+
color: #6b7280;
|
| 88 |
+
font-size: 0.9rem;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
a {
|
| 92 |
+
color: #2563eb;
|
| 93 |
+
text-decoration: none;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
a:hover {
|
| 97 |
+
text-decoration: underline;
|
| 98 |
+
}
|
| 99 |
+
</style>
|
| 100 |
+
</head>
|
| 101 |
+
<body>
|
| 102 |
+
<div class="container">
|
| 103 |
+
<header>
|
| 104 |
+
<h1>ποΈ Privacy Policy</h1>
|
| 105 |
+
<p class="last-updated">Last Updated: April 26, 2026</p>
|
| 106 |
+
</header>
|
| 107 |
+
|
| 108 |
+
<section>
|
| 109 |
+
<p><strong>Open Navigator for Engagement</strong> ("we," "our," or "us") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our platform.</p>
|
| 110 |
+
</section>
|
| 111 |
+
|
| 112 |
+
<section>
|
| 113 |
+
<h2>1. Information We Collect</h2>
|
| 114 |
+
|
| 115 |
+
<h3>1.1 Information You Provide</h3>
|
| 116 |
+
<p>When you create an account or use our services, we may collect:</p>
|
| 117 |
+
<ul>
|
| 118 |
+
<li><strong>Account Information:</strong> Email address, name, and profile information</li>
|
| 119 |
+
<li><strong>OAuth Provider Data:</strong> When you log in via Google, Facebook, GitHub, or HuggingFace, we receive your public profile information and email address</li>
|
| 120 |
+
<li><strong>User Preferences:</strong> Settings and preferences you configure within the application</li>
|
| 121 |
+
</ul>
|
| 122 |
+
|
| 123 |
+
<h3>1.2 Automatically Collected Information</h3>
|
| 124 |
+
<ul>
|
| 125 |
+
<li><strong>Usage Data:</strong> Pages visited, features used, and interactions with the platform</li>
|
| 126 |
+
<li><strong>Device Information:</strong> Browser type, operating system, IP address</li>
|
| 127 |
+
<li><strong>Cookies:</strong> We use essential cookies for authentication and session management</li>
|
| 128 |
+
</ul>
|
| 129 |
+
</section>
|
| 130 |
+
|
| 131 |
+
<section>
|
| 132 |
+
<h2>2. How We Use Your Information</h2>
|
| 133 |
+
<p>We use the collected information to:</p>
|
| 134 |
+
<ul>
|
| 135 |
+
<li>Provide, maintain, and improve our services</li>
|
| 136 |
+
<li>Authenticate your account and maintain security</li>
|
| 137 |
+
<li>Personalize your experience on the platform</li>
|
| 138 |
+
<li>Send important updates about the service</li>
|
| 139 |
+
<li>Analyze usage patterns to improve functionality</li>
|
| 140 |
+
<li>Comply with legal obligations</li>
|
| 141 |
+
</ul>
|
| 142 |
+
</section>
|
| 143 |
+
|
| 144 |
+
<section>
|
| 145 |
+
<h2>3. Third-Party Authentication</h2>
|
| 146 |
+
<div class="highlight">
|
| 147 |
+
<p><strong>OAuth Providers:</strong> We support login via Google, Facebook, GitHub, and HuggingFace. When you use these services:</p>
|
| 148 |
+
<ul>
|
| 149 |
+
<li>We only request access to your email address and basic profile information</li>
|
| 150 |
+
<li>We do not store your social media passwords</li>
|
| 151 |
+
<li>We do not post to your social media accounts</li>
|
| 152 |
+
<li>You can revoke our access at any time through your provider's settings</li>
|
| 153 |
+
</ul>
|
| 154 |
+
</div>
|
| 155 |
+
</section>
|
| 156 |
+
|
| 157 |
+
<section>
|
| 158 |
+
<h2>4. Data Storage and Security</h2>
|
| 159 |
+
<p>We implement appropriate technical and organizational measures to protect your information:</p>
|
| 160 |
+
<ul>
|
| 161 |
+
<li><strong>Encryption:</strong> Data is encrypted in transit using HTTPS/TLS</li>
|
| 162 |
+
<li><strong>Access Controls:</strong> Strict access controls limit who can access your data</li>
|
| 163 |
+
<li><strong>Secure Authentication:</strong> JWT tokens with secure secret keys</li>
|
| 164 |
+
<li><strong>Regular Updates:</strong> We keep our systems updated with security patches</li>
|
| 165 |
+
</ul>
|
| 166 |
+
<p>However, no method of transmission over the internet is 100% secure, and we cannot guarantee absolute security.</p>
|
| 167 |
+
</section>
|
| 168 |
+
|
| 169 |
+
<section>
|
| 170 |
+
<h2>5. Data Sharing and Disclosure</h2>
|
| 171 |
+
<p>We do not sell your personal information. We may share your information only in the following circumstances:</p>
|
| 172 |
+
<ul>
|
| 173 |
+
<li><strong>With Your Consent:</strong> When you explicitly authorize us to share information</li>
|
| 174 |
+
<li><strong>Service Providers:</strong> With trusted third-party services that help us operate (e.g., hosting providers)</li>
|
| 175 |
+
<li><strong>Legal Requirements:</strong> When required by law, court order, or governmental authority</li>
|
| 176 |
+
<li><strong>Business Transfers:</strong> In connection with a merger, acquisition, or sale of assets</li>
|
| 177 |
+
</ul>
|
| 178 |
+
</section>
|
| 179 |
+
|
| 180 |
+
<section>
|
| 181 |
+
<h2>6. Public Data Sources</h2>
|
| 182 |
+
<p>Our platform aggregates publicly available information from:</p>
|
| 183 |
+
<ul>
|
| 184 |
+
<li>City council meeting minutes and transcripts</li>
|
| 185 |
+
<li>Government public records and budgets</li>
|
| 186 |
+
<li>Nonprofit organization databases (IRS Form 990 data)</li>
|
| 187 |
+
<li>Legislative information from state and local governments</li>
|
| 188 |
+
</ul>
|
| 189 |
+
<p>This public information is not considered personal data and is used to provide civic engagement insights.</p>
|
| 190 |
+
</section>
|
| 191 |
+
|
| 192 |
+
<section>
|
| 193 |
+
<h2>7. Your Rights and Choices</h2>
|
| 194 |
+
<p>You have the following rights regarding your personal information:</p>
|
| 195 |
+
<ul>
|
| 196 |
+
<li><strong>Access:</strong> Request a copy of the personal information we hold about you</li>
|
| 197 |
+
<li><strong>Correction:</strong> Request correction of inaccurate information</li>
|
| 198 |
+
<li><strong>Deletion:</strong> Request deletion of your account and personal data</li>
|
| 199 |
+
<li><strong>Data Portability:</strong> Request your data in a portable format</li>
|
| 200 |
+
<li><strong>Opt-Out:</strong> Unsubscribe from non-essential communications</li>
|
| 201 |
+
</ul>
|
| 202 |
+
<p>To exercise these rights, contact us at the email address provided below.</p>
|
| 203 |
+
</section>
|
| 204 |
+
|
| 205 |
+
<section>
|
| 206 |
+
<h2>8. Children's Privacy</h2>
|
| 207 |
+
<p>Our service is not intended for children under 13 years of age. We do not knowingly collect personal information from children under 13. If you become aware that a child has provided us with personal information, please contact us, and we will delete such information.</p>
|
| 208 |
+
</section>
|
| 209 |
+
|
| 210 |
+
<section>
|
| 211 |
+
<h2>9. Data Retention</h2>
|
| 212 |
+
<p>We retain your personal information for as long as necessary to:</p>
|
| 213 |
+
<ul>
|
| 214 |
+
<li>Provide our services to you</li>
|
| 215 |
+
<li>Comply with legal obligations</li>
|
| 216 |
+
<li>Resolve disputes and enforce our agreements</li>
|
| 217 |
+
</ul>
|
| 218 |
+
<p>When you delete your account, we will delete or anonymize your personal information within 30 days, except where required to retain it by law.</p>
|
| 219 |
+
</section>
|
| 220 |
+
|
| 221 |
+
<section>
|
| 222 |
+
<h2>10. International Data Transfers</h2>
|
| 223 |
+
<p>Your information may be transferred to and processed in countries other than your country of residence. We ensure appropriate safeguards are in place to protect your information in accordance with this Privacy Policy.</p>
|
| 224 |
+
</section>
|
| 225 |
+
|
| 226 |
+
<section>
|
| 227 |
+
<h2>11. Changes to This Privacy Policy</h2>
|
| 228 |
+
<p>We may update this Privacy Policy from time to time. We will notify you of any changes by:</p>
|
| 229 |
+
<ul>
|
| 230 |
+
<li>Posting the new Privacy Policy on this page</li>
|
| 231 |
+
<li>Updating the "Last Updated" date</li>
|
| 232 |
+
<li>Sending you an email notification (for material changes)</li>
|
| 233 |
+
</ul>
|
| 234 |
+
<p>Your continued use of our services after changes constitutes acceptance of the updated policy.</p>
|
| 235 |
+
</section>
|
| 236 |
+
|
| 237 |
+
<section>
|
| 238 |
+
<h2>12. Contact Us</h2>
|
| 239 |
+
<p>If you have questions about this Privacy Policy or our privacy practices, please contact us:</p>
|
| 240 |
+
<ul>
|
| 241 |
+
<li><strong>Email:</strong> privacy@communityone.com</li>
|
| 242 |
+
<li><strong>Website:</strong> <a href="https://www.communityone.com">www.communityone.com</a></li>
|
| 243 |
+
<li><strong>GitHub:</strong> <a href="https://github.com/getcommunityone/open-navigator-for-engagement">github.com/getcommunityone/open-navigator-for-engagement</a></li>
|
| 244 |
+
</ul>
|
| 245 |
+
</section>
|
| 246 |
+
|
| 247 |
+
<section>
|
| 248 |
+
<h2>13. Additional Information for EU/UK Users (GDPR)</h2>
|
| 249 |
+
<p>If you are located in the European Union or United Kingdom, you have additional rights under GDPR:</p>
|
| 250 |
+
<ul>
|
| 251 |
+
<li><strong>Legal Basis:</strong> We process your data based on consent, contract performance, and legitimate interests</li>
|
| 252 |
+
<li><strong>Data Protection Officer:</strong> You may contact our DPO at privacy@communityone.com</li>
|
| 253 |
+
<li><strong>Supervisory Authority:</strong> You have the right to lodge a complaint with your local data protection authority</li>
|
| 254 |
+
<li><strong>Automated Decision-Making:</strong> We do not use automated decision-making or profiling that produces legal effects</li>
|
| 255 |
+
</ul>
|
| 256 |
+
</section>
|
| 257 |
+
|
| 258 |
+
<section>
|
| 259 |
+
<h2>14. California Privacy Rights (CCPA)</h2>
|
| 260 |
+
<p>If you are a California resident, you have specific rights under the California Consumer Privacy Act (CCPA):</p>
|
| 261 |
+
<ul>
|
| 262 |
+
<li><strong>Right to Know:</strong> What personal information we collect, use, and share</li>
|
| 263 |
+
<li><strong>Right to Delete:</strong> Request deletion of your personal information</li>
|
| 264 |
+
<li><strong>Right to Opt-Out:</strong> Opt-out of the sale of personal information (we do not sell your data)</li>
|
| 265 |
+
<li><strong>Non-Discrimination:</strong> We will not discriminate against you for exercising your rights</li>
|
| 266 |
+
</ul>
|
| 267 |
+
</section>
|
| 268 |
+
|
| 269 |
+
<footer>
|
| 270 |
+
<p>© 2026 Community One. All rights reserved.</p>
|
| 271 |
+
<p>Open Navigator for Engagement is an open-source project licensed under the MIT License.</p>
|
| 272 |
+
<p><a href="https://www.communityone.com">Return to Home</a> | <a href="https://github.com/getcommunityone/open-navigator-for-engagement">View on GitHub</a></p>
|
| 273 |
+
</footer>
|
| 274 |
+
</div>
|
| 275 |
+
</body>
|
| 276 |
+
</html>
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Routes, Route } from 'react-router-dom'
|
| 2 |
+
import Layout from './components/Layout'
|
| 3 |
+
import Home from './pages/Home'
|
| 4 |
+
import HomeModern from './pages/HomeModern'
|
| 5 |
+
import Dashboard from './pages/Dashboard'
|
| 6 |
+
import Analytics from './pages/Analytics'
|
| 7 |
+
import Heatmap from './pages/Heatmap'
|
| 8 |
+
import Documents from './pages/Documents'
|
| 9 |
+
import Opportunities from './pages/Opportunities'
|
| 10 |
+
import Nonprofits from './pages/Nonprofits'
|
| 11 |
+
import NonprofitsHF from './pages/NonprofitsHF'
|
| 12 |
+
import Settings from './pages/Settings'
|
| 13 |
+
import PeopleFinder from './pages/PeopleFinder'
|
| 14 |
+
import DebateFinder from './pages/DebateGrader'
|
| 15 |
+
import Profile from './pages/Profile'
|
| 16 |
+
import Explore from './pages/Explore'
|
| 17 |
+
import Events from './pages/Events'
|
| 18 |
+
import Services from './pages/Services'
|
| 19 |
+
import Developers from './pages/Developers'
|
| 20 |
+
import Hackathons from './pages/Hackathons'
|
| 21 |
+
import OpenSource from './pages/OpenSource'
|
| 22 |
+
import AdvocacyTopics from './pages/AdvocacyTopics'
|
| 23 |
+
import FactChecking from './pages/FactChecking'
|
| 24 |
+
import UnifiedSearch from './pages/UnifiedSearch'
|
| 25 |
+
import JurisdictionsSearch from './pages/JurisdictionsSearch'
|
| 26 |
+
|
| 27 |
+
function App() {
|
| 28 |
+
return (
|
| 29 |
+
<Routes>
|
| 30 |
+
{/* Modern home page without Layout (has its own header) */}
|
| 31 |
+
<Route path="/" element={<HomeModern />} />
|
| 32 |
+
|
| 33 |
+
{/* Classic home page (if needed) */}
|
| 34 |
+
<Route path="/classic" element={<Layout />}>
|
| 35 |
+
<Route index element={<Home />} />
|
| 36 |
+
</Route>
|
| 37 |
+
|
| 38 |
+
{/* All other pages with sidebar layout */}
|
| 39 |
+
<Route path="/" element={<Layout />}>
|
| 40 |
+
<Route path="explore" element={<Explore />} />
|
| 41 |
+
<Route path="search" element={<UnifiedSearch />} />
|
| 42 |
+
<Route path="jurisdictions" element={<JurisdictionsSearch />} />
|
| 43 |
+
<Route path="dashboard" element={<Dashboard />} />
|
| 44 |
+
<Route path="analytics" element={<Analytics />} />
|
| 45 |
+
<Route path="people" element={<PeopleFinder />} />
|
| 46 |
+
<Route path="heatmap" element={<Heatmap />} />
|
| 47 |
+
<Route path="documents" element={<Documents />} />
|
| 48 |
+
<Route path="opportunities" element={<Opportunities />} />
|
| 49 |
+
<Route path="nonprofits" element={<Nonprofits />} />
|
| 50 |
+
<Route path="nonprofits-hf" element={<NonprofitsHF />} />
|
| 51 |
+
<Route path="debate-grader" element={<DebateFinder />} />
|
| 52 |
+
<Route path="profile" element={<Profile />} />
|
| 53 |
+
<Route path="settings" element={<Settings />} />
|
| 54 |
+
<Route path="events" element={<Events />} />
|
| 55 |
+
<Route path="services" element={<Services />} />
|
| 56 |
+
<Route path="developers" element={<Developers />} />
|
| 57 |
+
<Route path="hackathons" element={<Hackathons />} />
|
| 58 |
+
<Route path="opensource" element={<OpenSource />} />
|
| 59 |
+
<Route path="advocacy-topics" element={<AdvocacyTopics />} />
|
| 60 |
+
<Route path="fact-checking" element={<FactChecking />} />
|
| 61 |
+
</Route>
|
| 62 |
+
</Routes>
|
| 63 |
+
)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export default App
|
frontend/src/components/AddressLookup.tsx
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from 'react'
|
| 2 |
+
import { MapPinIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'
|
| 3 |
+
import { stateNameToCode } from '../utils/stateMapping'
|
| 4 |
+
import { useLocation as useLocationContext } from '../contexts/LocationContext'
|
| 5 |
+
|
| 6 |
+
interface LocationData {
|
| 7 |
+
address: string
|
| 8 |
+
state: string
|
| 9 |
+
county: string
|
| 10 |
+
city: string
|
| 11 |
+
latitude?: number
|
| 12 |
+
longitude?: number
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
interface AddressLookupProps {
|
| 16 |
+
onLocationFound: (location: LocationData) => void
|
| 17 |
+
initialAddress?: string
|
| 18 |
+
compact?: boolean
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default function AddressLookup({ onLocationFound, initialAddress = '', compact = false }: AddressLookupProps) {
|
| 22 |
+
const { clearLocation } = useLocationContext()
|
| 23 |
+
const [address, setAddress] = useState(initialAddress)
|
| 24 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 25 |
+
const [error, setError] = useState<string | null>(null)
|
| 26 |
+
const [suggestions, setSuggestions] = useState<any[]>([])
|
| 27 |
+
const [foundLocation, setFoundLocation] = useState<LocationData | null>(null)
|
| 28 |
+
const [showSuggestions, setShowSuggestions] = useState(false)
|
| 29 |
+
const [selectedIndex, setSelectedIndex] = useState(-1)
|
| 30 |
+
const debounceTimer = useRef<number | null>(null)
|
| 31 |
+
const inputRef = useRef<HTMLInputElement>(null)
|
| 32 |
+
|
| 33 |
+
// Fetch suggestions as user types
|
| 34 |
+
const fetchSuggestions = async (query: string) => {
|
| 35 |
+
if (query.trim().length < 3) {
|
| 36 |
+
setSuggestions([])
|
| 37 |
+
setShowSuggestions(false)
|
| 38 |
+
return
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
try {
|
| 42 |
+
const response = await fetch(
|
| 43 |
+
`https://nominatim.openstreetmap.org/search?` +
|
| 44 |
+
`q=${encodeURIComponent(query)}&` +
|
| 45 |
+
`format=json&` +
|
| 46 |
+
`addressdetails=1&` +
|
| 47 |
+
`countrycodes=us&` +
|
| 48 |
+
`limit=5`,
|
| 49 |
+
{
|
| 50 |
+
headers: {
|
| 51 |
+
'User-Agent': 'CommunityOne-Navigator/1.0'
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
if (!response.ok) {
|
| 57 |
+
return
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const data = await response.json()
|
| 61 |
+
|
| 62 |
+
// Deduplicate results using OSM unique IDs
|
| 63 |
+
const uniqueResults = data.reduce((acc: any[], current: any) => {
|
| 64 |
+
const osmKey = `${current.osm_type}_${current.osm_id}`
|
| 65 |
+
const exists = acc.some((item) => {
|
| 66 |
+
const itemKey = `${item.osm_type}_${item.osm_id}`
|
| 67 |
+
return itemKey === osmKey
|
| 68 |
+
})
|
| 69 |
+
if (!exists) {
|
| 70 |
+
acc.push(current)
|
| 71 |
+
}
|
| 72 |
+
return acc
|
| 73 |
+
}, [])
|
| 74 |
+
|
| 75 |
+
setSuggestions(uniqueResults)
|
| 76 |
+
setShowSuggestions(uniqueResults.length > 0)
|
| 77 |
+
setSelectedIndex(-1)
|
| 78 |
+
} catch (err) {
|
| 79 |
+
console.error('Autocomplete error:', err)
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Handle address input change with debouncing
|
| 84 |
+
const handleAddressChange = (value: string) => {
|
| 85 |
+
setAddress(value)
|
| 86 |
+
setError(null)
|
| 87 |
+
|
| 88 |
+
// Clear previous timer
|
| 89 |
+
if (debounceTimer.current) {
|
| 90 |
+
clearTimeout(debounceTimer.current)
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Set new timer
|
| 94 |
+
debounceTimer.current = setTimeout(() => {
|
| 95 |
+
fetchSuggestions(value)
|
| 96 |
+
}, 300)
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Cleanup timer on unmount
|
| 100 |
+
useEffect(() => {
|
| 101 |
+
return () => {
|
| 102 |
+
if (debounceTimer.current) {
|
| 103 |
+
clearTimeout(debounceTimer.current)
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
}, [])
|
| 107 |
+
|
| 108 |
+
const lookupAddress = async (addressToLookup: string) => {
|
| 109 |
+
if (!addressToLookup.trim()) {
|
| 110 |
+
setError('Please enter an address')
|
| 111 |
+
return
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
setIsLoading(true)
|
| 115 |
+
setError(null)
|
| 116 |
+
setSuggestions([])
|
| 117 |
+
setShowSuggestions(false)
|
| 118 |
+
|
| 119 |
+
try {
|
| 120 |
+
// Use Nominatim (OpenStreetMap) geocoding service
|
| 121 |
+
const response = await fetch(
|
| 122 |
+
`https://nominatim.openstreetmap.org/search?` +
|
| 123 |
+
`q=${encodeURIComponent(addressToLookup)}&` +
|
| 124 |
+
`format=json&` +
|
| 125 |
+
`addressdetails=1&` +
|
| 126 |
+
`countrycodes=us&` +
|
| 127 |
+
`limit=5`,
|
| 128 |
+
{
|
| 129 |
+
headers: {
|
| 130 |
+
'User-Agent': 'CommunityOne-Navigator/1.0'
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
if (!response.ok) {
|
| 136 |
+
throw new Error('Failed to lookup address')
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const data = await response.json()
|
| 140 |
+
|
| 141 |
+
if (data.length === 0) {
|
| 142 |
+
setError('Address not found. Please try a different address or be more specific.')
|
| 143 |
+
return
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// Deduplicate results using OSM unique IDs
|
| 147 |
+
const uniqueResults = data.reduce((acc: any[], current: any) => {
|
| 148 |
+
// Use OSM type + ID as unique key (most reliable)
|
| 149 |
+
const osmKey = `${current.osm_type}_${current.osm_id}`
|
| 150 |
+
|
| 151 |
+
const exists = acc.some((item) => {
|
| 152 |
+
const itemKey = `${item.osm_type}_${item.osm_id}`
|
| 153 |
+
return itemKey === osmKey
|
| 154 |
+
})
|
| 155 |
+
|
| 156 |
+
if (!exists) {
|
| 157 |
+
acc.push(current)
|
| 158 |
+
}
|
| 159 |
+
return acc
|
| 160 |
+
}, [])
|
| 161 |
+
|
| 162 |
+
// If we have multiple unique results, show suggestions
|
| 163 |
+
if (uniqueResults.length > 1) {
|
| 164 |
+
setSuggestions(uniqueResults)
|
| 165 |
+
setShowSuggestions(true)
|
| 166 |
+
return
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// Single result - process it
|
| 170 |
+
processResult(uniqueResults[0])
|
| 171 |
+
} catch (err) {
|
| 172 |
+
console.error('Address lookup error:', err)
|
| 173 |
+
setError('Failed to lookup address. Please try again.')
|
| 174 |
+
} finally {
|
| 175 |
+
setIsLoading(false)
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
const processResult = (result: any) => {
|
| 180 |
+
const addr = result.address
|
| 181 |
+
|
| 182 |
+
// Convert state name to 2-letter code
|
| 183 |
+
const stateName = addr.state || ''
|
| 184 |
+
const stateCode = stateNameToCode(stateName)
|
| 185 |
+
console.log(`πΊοΈ [AddressLookup] State conversion: "${stateName}" β "${stateCode}"`)
|
| 186 |
+
|
| 187 |
+
const locationData: LocationData = {
|
| 188 |
+
address: result.display_name,
|
| 189 |
+
state: stateCode,
|
| 190 |
+
county: addr.county || '',
|
| 191 |
+
city: addr.city || addr.town || addr.village || addr.municipality || '',
|
| 192 |
+
latitude: parseFloat(result.lat),
|
| 193 |
+
longitude: parseFloat(result.lon),
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// Validate we got the essential data
|
| 197 |
+
if (!locationData.state || !locationData.city) {
|
| 198 |
+
setError('Could not determine city and state from this address. Please be more specific.')
|
| 199 |
+
setSuggestions([])
|
| 200 |
+
setShowSuggestions(false)
|
| 201 |
+
return
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
console.log('π [AddressLookup] Location found:', locationData)
|
| 205 |
+
setSuggestions([])
|
| 206 |
+
setShowSuggestions(false)
|
| 207 |
+
setFoundLocation(locationData)
|
| 208 |
+
onLocationFound(locationData)
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
const handleSubmit = (e: React.FormEvent) => {
|
| 212 |
+
e.preventDefault()
|
| 213 |
+
|
| 214 |
+
// If a suggestion is selected, use that
|
| 215 |
+
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
|
| 216 |
+
processResult(suggestions[selectedIndex])
|
| 217 |
+
} else {
|
| 218 |
+
lookupAddress(address)
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
const handleSuggestionClick = (suggestion: any) => {
|
| 223 |
+
setAddress(suggestion.display_name)
|
| 224 |
+
processResult(suggestion)
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
| 228 |
+
if (!showSuggestions || suggestions.length === 0) return
|
| 229 |
+
|
| 230 |
+
switch (e.key) {
|
| 231 |
+
case 'ArrowDown':
|
| 232 |
+
e.preventDefault()
|
| 233 |
+
setSelectedIndex(prev =>
|
| 234 |
+
prev < suggestions.length - 1 ? prev + 1 : prev
|
| 235 |
+
)
|
| 236 |
+
break
|
| 237 |
+
case 'ArrowUp':
|
| 238 |
+
e.preventDefault()
|
| 239 |
+
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1)
|
| 240 |
+
break
|
| 241 |
+
case 'Enter':
|
| 242 |
+
if (selectedIndex >= 0) {
|
| 243 |
+
e.preventDefault()
|
| 244 |
+
processResult(suggestions[selectedIndex])
|
| 245 |
+
}
|
| 246 |
+
break
|
| 247 |
+
case 'Escape':
|
| 248 |
+
setShowSuggestions(false)
|
| 249 |
+
setSelectedIndex(-1)
|
| 250 |
+
break
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
const useMyLocation = () => {
|
| 255 |
+
if (!navigator.geolocation) {
|
| 256 |
+
setError('Geolocation is not supported by your browser')
|
| 257 |
+
return
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
setIsLoading(true)
|
| 261 |
+
setError(null)
|
| 262 |
+
setSuggestions([])
|
| 263 |
+
|
| 264 |
+
navigator.geolocation.getCurrentPosition(
|
| 265 |
+
async (position) => {
|
| 266 |
+
const { latitude, longitude } = position.coords
|
| 267 |
+
|
| 268 |
+
try {
|
| 269 |
+
// Reverse geocode using Nominatim
|
| 270 |
+
const response = await fetch(
|
| 271 |
+
`https://nominatim.openstreetmap.org/reverse?` +
|
| 272 |
+
`lat=${latitude}&` +
|
| 273 |
+
`lon=${longitude}&` +
|
| 274 |
+
`format=json&` +
|
| 275 |
+
`addressdetails=1`,
|
| 276 |
+
{
|
| 277 |
+
headers: {
|
| 278 |
+
'User-Agent': 'CommunityOne-Navigator/1.0'
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
if (!response.ok) {
|
| 284 |
+
throw new Error('Failed to reverse geocode location')
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
const data = await response.json()
|
| 288 |
+
|
| 289 |
+
// Update the address input field
|
| 290 |
+
setAddress(data.display_name)
|
| 291 |
+
|
| 292 |
+
// Process the result
|
| 293 |
+
processResult(data)
|
| 294 |
+
} catch (err) {
|
| 295 |
+
console.error('Reverse geocoding error:', err)
|
| 296 |
+
setError('Failed to determine your location. Please enter your address manually.')
|
| 297 |
+
} finally {
|
| 298 |
+
setIsLoading(false)
|
| 299 |
+
}
|
| 300 |
+
},
|
| 301 |
+
(error) => {
|
| 302 |
+
console.error('Geolocation error:', error)
|
| 303 |
+
setIsLoading(false)
|
| 304 |
+
|
| 305 |
+
switch (error.code) {
|
| 306 |
+
case error.PERMISSION_DENIED:
|
| 307 |
+
setError('Location access denied. Please enter your address manually or enable location permissions.')
|
| 308 |
+
break
|
| 309 |
+
case error.POSITION_UNAVAILABLE:
|
| 310 |
+
setError('Location information unavailable. Please enter your address manually.')
|
| 311 |
+
break
|
| 312 |
+
case error.TIMEOUT:
|
| 313 |
+
setError('Location request timed out. Please try again or enter your address manually.')
|
| 314 |
+
break
|
| 315 |
+
default:
|
| 316 |
+
setError('An error occurred while getting your location. Please enter your address manually.')
|
| 317 |
+
}
|
| 318 |
+
},
|
| 319 |
+
{
|
| 320 |
+
enableHighAccuracy: false, // Use fast network-based location instead of GPS
|
| 321 |
+
timeout: 5000, // Reduced timeout since network location is faster
|
| 322 |
+
maximumAge: 30000 // Allow 30s cached location for faster response
|
| 323 |
+
}
|
| 324 |
+
)
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
if (compact) {
|
| 328 |
+
return (
|
| 329 |
+
<form onSubmit={handleSubmit} className="w-full">
|
| 330 |
+
<div className="relative">
|
| 331 |
+
<input
|
| 332 |
+
key="address-input-compact"
|
| 333 |
+
ref={inputRef}
|
| 334 |
+
type="text"
|
| 335 |
+
value={address}
|
| 336 |
+
onChange={(e) => handleAddressChange(e.target.value)}
|
| 337 |
+
onKeyDown={handleKeyDown}
|
| 338 |
+
placeholder="Enter your address..."
|
| 339 |
+
className="w-full px-4 py-2 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-gray-900"
|
| 340 |
+
disabled={isLoading}
|
| 341 |
+
autoComplete="off"
|
| 342 |
+
/>
|
| 343 |
+
<MapPinIcon className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
|
| 344 |
+
<button
|
| 345 |
+
type="submit"
|
| 346 |
+
disabled={isLoading}
|
| 347 |
+
className="absolute right-2 top-1.5 px-3 py-1 text-white rounded-md transition-colors text-sm disabled:opacity-50"
|
| 348 |
+
style={{ backgroundColor: '#354F52' }}
|
| 349 |
+
onMouseEnter={(e) => !isLoading && (e.currentTarget.style.backgroundColor = '#2e4346')}
|
| 350 |
+
onMouseLeave={(e) => !isLoading && (e.currentTarget.style.backgroundColor = '#354F52')}
|
| 351 |
+
>
|
| 352 |
+
{isLoading ? 'Finding...' : 'Find'}
|
| 353 |
+
</button>
|
| 354 |
+
|
| 355 |
+
{/* Autocomplete suggestions dropdown */}
|
| 356 |
+
{showSuggestions && suggestions.length > 0 && (
|
| 357 |
+
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
| 358 |
+
{suggestions.map((suggestion, index) => {
|
| 359 |
+
const addr = suggestion.address
|
| 360 |
+
const locationName = addr.city || addr.town || addr.village || addr.county || 'Unknown'
|
| 361 |
+
return (
|
| 362 |
+
<button
|
| 363 |
+
key={`${suggestion.osm_type}_${suggestion.osm_id}`}
|
| 364 |
+
type="button"
|
| 365 |
+
onClick={() => handleSuggestionClick(suggestion)}
|
| 366 |
+
className={`w-full px-4 py-2 text-left hover:bg-gray-100 transition-colors ${
|
| 367 |
+
index === selectedIndex ? 'bg-gray-100' : ''
|
| 368 |
+
}`}
|
| 369 |
+
>
|
| 370 |
+
<p className="text-sm font-medium text-gray-900">
|
| 371 |
+
{suggestion.display_name}
|
| 372 |
+
</p>
|
| 373 |
+
<p className="text-xs text-gray-500">
|
| 374 |
+
{locationName}, {addr.state}
|
| 375 |
+
</p>
|
| 376 |
+
</button>
|
| 377 |
+
)
|
| 378 |
+
})}
|
| 379 |
+
</div>
|
| 380 |
+
)}
|
| 381 |
+
</div>
|
| 382 |
+
{error && (
|
| 383 |
+
<p className="mt-2 text-sm text-red-600">{error}</p>
|
| 384 |
+
)}
|
| 385 |
+
</form>
|
| 386 |
+
)
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
return (
|
| 390 |
+
<div className="w-full">
|
| 391 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 392 |
+
<div>
|
| 393 |
+
<label htmlFor="address" className="block text-sm font-medium text-gray-700 mb-2">
|
| 394 |
+
<span className="flex items-center gap-2">
|
| 395 |
+
<MapPinIcon className="h-5 w-5" />
|
| 396 |
+
Enter Your Address
|
| 397 |
+
</span>
|
| 398 |
+
</label>
|
| 399 |
+
<div className="relative">
|
| 400 |
+
<input
|
| 401 |
+
key="address-input"
|
| 402 |
+
ref={inputRef}
|
| 403 |
+
type="text"
|
| 404 |
+
id="address"
|
| 405 |
+
name="addresslookup"
|
| 406 |
+
value={address}
|
| 407 |
+
onChange={(e) => handleAddressChange(e.target.value)}
|
| 408 |
+
onKeyDown={handleKeyDown}
|
| 409 |
+
placeholder="123 Main St, Los Angeles, CA 90001"
|
| 410 |
+
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-base text-gray-900"
|
| 411 |
+
disabled={isLoading}
|
| 412 |
+
autoComplete="off"
|
| 413 |
+
/>
|
| 414 |
+
|
| 415 |
+
{/* Autocomplete suggestions dropdown */}
|
| 416 |
+
{showSuggestions && suggestions.length > 0 && (
|
| 417 |
+
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
| 418 |
+
{suggestions.map((suggestion, index) => {
|
| 419 |
+
const addr = suggestion.address
|
| 420 |
+
const locationName = addr.city || addr.town || addr.village || addr.county || 'Unknown'
|
| 421 |
+
return (
|
| 422 |
+
<button
|
| 423 |
+
key={`${suggestion.osm_type}_${suggestion.osm_id}`}
|
| 424 |
+
type="button"
|
| 425 |
+
onClick={() => handleSuggestionClick(suggestion)}
|
| 426 |
+
className={`w-full px-4 py-3 text-left hover:bg-gray-100 transition-colors border-b border-gray-100 last:border-b-0 ${
|
| 427 |
+
index === selectedIndex ? 'bg-gray-100' : ''
|
| 428 |
+
}`}
|
| 429 |
+
>
|
| 430 |
+
<p className="text-sm font-medium text-gray-900">
|
| 431 |
+
{suggestion.display_name}
|
| 432 |
+
</p>
|
| 433 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 434 |
+
{locationName}, {addr.state}
|
| 435 |
+
</p>
|
| 436 |
+
</button>
|
| 437 |
+
)
|
| 438 |
+
})}
|
| 439 |
+
</div>
|
| 440 |
+
)}
|
| 441 |
+
</div>
|
| 442 |
+
<p className="mt-1 text-xs text-gray-500">
|
| 443 |
+
We'll find your local organizations based on your address
|
| 444 |
+
</p>
|
| 445 |
+
|
| 446 |
+
{/* Use My Location Button */}
|
| 447 |
+
<div className="mt-3">
|
| 448 |
+
<button
|
| 449 |
+
type="button"
|
| 450 |
+
onClick={useMyLocation}
|
| 451 |
+
disabled={isLoading}
|
| 452 |
+
className="w-full px-4 py-2 bg-white border-2 border-primary-300 text-primary-700 rounded-lg hover:bg-primary-50 hover:border-primary-500 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
| 453 |
+
>
|
| 454 |
+
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 455 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
| 456 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 457 |
+
</svg>
|
| 458 |
+
<span>Use My Current Location</span>
|
| 459 |
+
</button>
|
| 460 |
+
</div>
|
| 461 |
+
|
| 462 |
+
{/* Divider */}
|
| 463 |
+
<div className="relative my-4">
|
| 464 |
+
<div className="absolute inset-0 flex items-center">
|
| 465 |
+
<div className="w-full border-t border-gray-300"></div>
|
| 466 |
+
</div>
|
| 467 |
+
<div className="relative flex justify-center text-xs">
|
| 468 |
+
<span className="px-2 bg-white text-gray-500">or enter manually</span>
|
| 469 |
+
</div>
|
| 470 |
+
</div>
|
| 471 |
+
</div>
|
| 472 |
+
|
| 473 |
+
<button
|
| 474 |
+
type="submit"
|
| 475 |
+
disabled={isLoading}
|
| 476 |
+
className="w-full px-6 py-3 text-white rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
| 477 |
+
style={{ backgroundColor: '#354F52' }}
|
| 478 |
+
onMouseEnter={(e) => !isLoading && (e.currentTarget.style.backgroundColor = '#2e4346')}
|
| 479 |
+
onMouseLeave={(e) => !isLoading && (e.currentTarget.style.backgroundColor = '#354F52')}
|
| 480 |
+
>
|
| 481 |
+
{isLoading ? (
|
| 482 |
+
<>
|
| 483 |
+
<div className="animate-spin h-5 w-5 border-2 border-white border-t-transparent rounded-full"></div>
|
| 484 |
+
<span>Looking up address...</span>
|
| 485 |
+
</>
|
| 486 |
+
) : (
|
| 487 |
+
<>
|
| 488 |
+
<MagnifyingGlassIcon className="h-5 w-5" />
|
| 489 |
+
<span>Find My Community</span>
|
| 490 |
+
</>
|
| 491 |
+
)}
|
| 492 |
+
</button>
|
| 493 |
+
</form>
|
| 494 |
+
|
| 495 |
+
{/* Error Message */}
|
| 496 |
+
{error && (
|
| 497 |
+
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
| 498 |
+
<p className="text-sm text-red-800">{error}</p>
|
| 499 |
+
</div>
|
| 500 |
+
)}
|
| 501 |
+
|
| 502 |
+
{/* Note: Suggestions now appear as autocomplete dropdown above */}
|
| 503 |
+
|
| 504 |
+
{/* Location Results */}
|
| 505 |
+
{foundLocation && !compact && (
|
| 506 |
+
<div className="mt-6 border-2 border-primary-200 rounded-lg overflow-hidden bg-primary-50">
|
| 507 |
+
<div className="bg-primary-600 px-4 py-3 flex items-center justify-between">
|
| 508 |
+
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
| 509 |
+
<MapPinIcon className="h-5 w-5" />
|
| 510 |
+
Your Local Community
|
| 511 |
+
</h3>
|
| 512 |
+
<button
|
| 513 |
+
onClick={() => window.location.href = '/'}
|
| 514 |
+
className="text-sm text-white hover:text-primary-100 underline font-medium"
|
| 515 |
+
>
|
| 516 |
+
β Back to Home
|
| 517 |
+
</button>
|
| 518 |
+
</div>
|
| 519 |
+
<div className="p-6 space-y-4">
|
| 520 |
+
<p className="text-sm text-gray-700 mb-4">
|
| 521 |
+
Select a jurisdiction level below to explore organizations, meeting minutes, and contacts:
|
| 522 |
+
</p>
|
| 523 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 524 |
+
{/* City */}
|
| 525 |
+
{foundLocation.city && (
|
| 526 |
+
<button
|
| 527 |
+
onClick={() => {
|
| 528 |
+
window.location.href = `/?scope=city`
|
| 529 |
+
}}
|
| 530 |
+
className="bg-white rounded-lg p-4 shadow-sm hover:shadow-md hover:border-2 hover:border-blue-500 transition-all text-left w-full group"
|
| 531 |
+
>
|
| 532 |
+
<div className="flex items-start gap-3">
|
| 533 |
+
<div className="p-2 bg-blue-100 rounded-lg group-hover:bg-blue-200 transition-colors">
|
| 534 |
+
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 535 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
| 536 |
+
</svg>
|
| 537 |
+
</div>
|
| 538 |
+
<div className="flex-1">
|
| 539 |
+
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">City</p>
|
| 540 |
+
<p className="text-lg font-semibold text-gray-900 mt-1 group-hover:text-blue-600">{foundLocation.city}</p>
|
| 541 |
+
<p className="text-sm text-gray-600 mt-1">City Council</p>
|
| 542 |
+
<p className="text-xs text-blue-600 mt-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 543 |
+
Click to explore β
|
| 544 |
+
</p>
|
| 545 |
+
</div>
|
| 546 |
+
</div>
|
| 547 |
+
</button>
|
| 548 |
+
)}
|
| 549 |
+
|
| 550 |
+
{/* County */}
|
| 551 |
+
{foundLocation.county && (
|
| 552 |
+
<button
|
| 553 |
+
onClick={() => {
|
| 554 |
+
window.location.href = `/?scope=county`
|
| 555 |
+
}}
|
| 556 |
+
className="bg-white rounded-lg p-4 shadow-sm hover:shadow-md hover:border-2 hover:border-green-500 transition-all text-left w-full group"
|
| 557 |
+
>
|
| 558 |
+
<div className="flex items-start gap-3">
|
| 559 |
+
<div className="p-2 bg-green-100 rounded-lg group-hover:bg-green-200 transition-colors">
|
| 560 |
+
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 561 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
| 562 |
+
</svg>
|
| 563 |
+
</div>
|
| 564 |
+
<div className="flex-1">
|
| 565 |
+
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">County</p>
|
| 566 |
+
<p className="text-lg font-semibold text-gray-900 mt-1 group-hover:text-green-600">{foundLocation.county}</p>
|
| 567 |
+
<p className="text-sm text-gray-600 mt-1">County Board</p>
|
| 568 |
+
<p className="text-xs text-green-600 mt-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 569 |
+
Click to explore β
|
| 570 |
+
</p>
|
| 571 |
+
</div>
|
| 572 |
+
</div>
|
| 573 |
+
</button>
|
| 574 |
+
)}
|
| 575 |
+
|
| 576 |
+
{/* State */}
|
| 577 |
+
{foundLocation.state && (
|
| 578 |
+
<button
|
| 579 |
+
onClick={() => {
|
| 580 |
+
window.location.href = `/?scope=state`
|
| 581 |
+
}}
|
| 582 |
+
className="bg-white rounded-lg p-4 shadow-sm hover:shadow-md hover:border-2 hover:border-purple-500 transition-all text-left w-full group"
|
| 583 |
+
>
|
| 584 |
+
<div className="flex items-start gap-3">
|
| 585 |
+
<div className="p-2 bg-purple-100 rounded-lg group-hover:bg-purple-200 transition-colors">
|
| 586 |
+
<svg className="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 587 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
|
| 588 |
+
</svg>
|
| 589 |
+
</div>
|
| 590 |
+
<div className="flex-1">
|
| 591 |
+
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">State</p>
|
| 592 |
+
<p className="text-lg font-semibold text-gray-900 mt-1 group-hover:text-purple-600">{foundLocation.state}</p>
|
| 593 |
+
<p className="text-sm text-gray-600 mt-1">State Legislature</p>
|
| 594 |
+
<p className="text-xs text-purple-600 mt-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 595 |
+
Click to explore β
|
| 596 |
+
</p>
|
| 597 |
+
</div>
|
| 598 |
+
</div>
|
| 599 |
+
</button>
|
| 600 |
+
)}
|
| 601 |
+
|
| 602 |
+
{/* School District */}
|
| 603 |
+
{foundLocation.city && (
|
| 604 |
+
<button
|
| 605 |
+
onClick={() => {
|
| 606 |
+
window.location.href = `/?scope=community`
|
| 607 |
+
}}
|
| 608 |
+
className="bg-white rounded-lg p-4 shadow-sm hover:shadow-md hover:border-2 hover:border-amber-500 transition-all text-left w-full group"
|
| 609 |
+
>
|
| 610 |
+
<div className="flex items-start gap-3">
|
| 611 |
+
<div className="p-2 bg-amber-100 rounded-lg group-hover:bg-amber-200 transition-colors">
|
| 612 |
+
<svg className="h-6 w-6 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 613 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
| 614 |
+
</svg>
|
| 615 |
+
</div>
|
| 616 |
+
<div className="flex-1">
|
| 617 |
+
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">School District</p>
|
| 618 |
+
<p className="text-lg font-semibold text-gray-900 mt-1 group-hover:text-amber-600">{foundLocation.city} Unified</p>
|
| 619 |
+
<p className="text-sm text-gray-600 mt-1">School Board</p>
|
| 620 |
+
<p className="text-xs text-amber-600 mt-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 621 |
+
Click to explore β
|
| 622 |
+
</p>
|
| 623 |
+
</div>
|
| 624 |
+
</div>
|
| 625 |
+
</button>
|
| 626 |
+
)}
|
| 627 |
+
</div>
|
| 628 |
+
|
| 629 |
+
{/* Action Buttons */}
|
| 630 |
+
<div className="pt-4 border-t border-primary-200">
|
| 631 |
+
<p className="text-sm text-gray-600 mb-3">Quick access to all local resources:</p>
|
| 632 |
+
<div className="flex flex-wrap gap-3">
|
| 633 |
+
<button
|
| 634 |
+
onClick={() => {
|
| 635 |
+
window.location.href = `/documents?state=${foundLocation.state}&city=${foundLocation.city}`
|
| 636 |
+
}}
|
| 637 |
+
className="flex-1 min-w-[200px] px-4 py-2 bg-white border-2 border-primary-600 text-primary-700 rounded-lg hover:bg-primary-50 transition-colors font-medium"
|
| 638 |
+
>
|
| 639 |
+
π All Meeting Minutes
|
| 640 |
+
</button>
|
| 641 |
+
<button
|
| 642 |
+
onClick={() => {
|
| 643 |
+
window.location.href = `/nonprofits?state=${foundLocation.state}&city=${foundLocation.city}`
|
| 644 |
+
}}
|
| 645 |
+
className="flex-1 min-w-[200px] px-4 py-2 bg-white border-2 border-primary-600 text-primary-700 rounded-lg hover:bg-primary-50 transition-colors font-medium"
|
| 646 |
+
>
|
| 647 |
+
π’ All Local Organizations
|
| 648 |
+
</button>
|
| 649 |
+
</div>
|
| 650 |
+
</div>
|
| 651 |
+
|
| 652 |
+
{/* Start Over */}
|
| 653 |
+
<div className="text-center pt-2">
|
| 654 |
+
<button
|
| 655 |
+
onClick={() => {
|
| 656 |
+
setFoundLocation(null)
|
| 657 |
+
setAddress('')
|
| 658 |
+
setError(null)
|
| 659 |
+
clearLocation() // Clear the global location context
|
| 660 |
+
}}
|
| 661 |
+
className="text-sm text-primary-600 hover:text-primary-700 font-medium underline"
|
| 662 |
+
>
|
| 663 |
+
Search Different Address
|
| 664 |
+
</button>
|
| 665 |
+
</div>
|
| 666 |
+
</div>
|
| 667 |
+
</div>
|
| 668 |
+
)}
|
| 669 |
+
</div>
|
| 670 |
+
)
|
| 671 |
+
}
|
frontend/src/components/FollowButton.tsx
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
+
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
| 3 |
+
import axios from 'axios'
|
| 4 |
+
import { UserPlusIcon, UserMinusIcon } from '@heroicons/react/24/outline'
|
| 5 |
+
import { CheckIcon } from '@heroicons/react/24/solid'
|
| 6 |
+
|
| 7 |
+
interface FollowButtonProps {
|
| 8 |
+
type: 'user' | 'leader' | 'organization' | 'cause'
|
| 9 |
+
id: number
|
| 10 |
+
initialFollowing?: boolean
|
| 11 |
+
initialCount?: number
|
| 12 |
+
showCount?: boolean
|
| 13 |
+
compact?: boolean
|
| 14 |
+
onFollowChange?: (following: boolean, count: number) => void
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export default function FollowButton({
|
| 18 |
+
type,
|
| 19 |
+
id,
|
| 20 |
+
initialFollowing = false,
|
| 21 |
+
initialCount = 0,
|
| 22 |
+
showCount = true,
|
| 23 |
+
compact = false,
|
| 24 |
+
onFollowChange
|
| 25 |
+
}: FollowButtonProps) {
|
| 26 |
+
const [isFollowing, setIsFollowing] = useState(initialFollowing)
|
| 27 |
+
const [followerCount, setFollowerCount] = useState(initialCount)
|
| 28 |
+
const [isHovered, setIsHovered] = useState(false)
|
| 29 |
+
const queryClient = useQueryClient()
|
| 30 |
+
|
| 31 |
+
const followMutation = useMutation({
|
| 32 |
+
mutationFn: async () => {
|
| 33 |
+
const endpoint = `/api/social/follow/${type}/${id}`
|
| 34 |
+
const response = await axios.post(endpoint)
|
| 35 |
+
return response.data
|
| 36 |
+
},
|
| 37 |
+
onSuccess: (data) => {
|
| 38 |
+
setIsFollowing(true)
|
| 39 |
+
setFollowerCount(data.follower_count)
|
| 40 |
+
onFollowChange?.(true, data.follower_count)
|
| 41 |
+
// Invalidate relevant queries
|
| 42 |
+
queryClient.invalidateQueries({ queryKey: ['social', 'stats'] })
|
| 43 |
+
queryClient.invalidateQueries({ queryKey: ['following', type] })
|
| 44 |
+
}
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
const unfollowMutation = useMutation({
|
| 48 |
+
mutationFn: async () => {
|
| 49 |
+
const endpoint = `/api/social/follow/${type}/${id}`
|
| 50 |
+
const response = await axios.delete(endpoint)
|
| 51 |
+
return response.data
|
| 52 |
+
},
|
| 53 |
+
onSuccess: (data) => {
|
| 54 |
+
setIsFollowing(false)
|
| 55 |
+
setFollowerCount(data.follower_count)
|
| 56 |
+
onFollowChange?.(false, data.follower_count)
|
| 57 |
+
// Invalidate relevant queries
|
| 58 |
+
queryClient.invalidateQueries({ queryKey: ['social', 'stats'] })
|
| 59 |
+
queryClient.invalidateQueries({ queryKey: ['following', type] })
|
| 60 |
+
}
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
const handleClick = () => {
|
| 64 |
+
if (isFollowing) {
|
| 65 |
+
unfollowMutation.mutate()
|
| 66 |
+
} else {
|
| 67 |
+
followMutation.mutate()
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const isLoading = followMutation.isPending || unfollowMutation.isPending
|
| 72 |
+
|
| 73 |
+
// LinkedIn/Facebook style button
|
| 74 |
+
if (compact) {
|
| 75 |
+
return (
|
| 76 |
+
<button
|
| 77 |
+
onClick={handleClick}
|
| 78 |
+
disabled={isLoading}
|
| 79 |
+
onMouseEnter={() => setIsHovered(true)}
|
| 80 |
+
onMouseLeave={() => setIsHovered(false)}
|
| 81 |
+
className={`
|
| 82 |
+
inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium
|
| 83 |
+
transition-all duration-200 border
|
| 84 |
+
${isFollowing
|
| 85 |
+
? isHovered
|
| 86 |
+
? 'bg-red-50 border-red-300 text-red-700 hover:bg-red-100'
|
| 87 |
+
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
| 88 |
+
: 'bg-blue-600 border-blue-600 text-white hover:bg-blue-700'
|
| 89 |
+
}
|
| 90 |
+
disabled:opacity-50 disabled:cursor-not-allowed
|
| 91 |
+
`}
|
| 92 |
+
>
|
| 93 |
+
{isLoading ? (
|
| 94 |
+
<span className="h-4 w-4 border-2 border-t-transparent border-current rounded-full animate-spin" />
|
| 95 |
+
) : isFollowing ? (
|
| 96 |
+
<>
|
| 97 |
+
{isHovered ? (
|
| 98 |
+
<UserMinusIcon className="h-4 w-4" />
|
| 99 |
+
) : (
|
| 100 |
+
<CheckIcon className="h-4 w-4" />
|
| 101 |
+
)}
|
| 102 |
+
<span>{isHovered ? 'Unfollow' : 'Following'}</span>
|
| 103 |
+
</>
|
| 104 |
+
) : (
|
| 105 |
+
<>
|
| 106 |
+
<UserPlusIcon className="h-4 w-4" />
|
| 107 |
+
<span>Follow</span>
|
| 108 |
+
</>
|
| 109 |
+
)}
|
| 110 |
+
</button>
|
| 111 |
+
)
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// Full button with count
|
| 115 |
+
return (
|
| 116 |
+
<div className="inline-flex items-center gap-3">
|
| 117 |
+
<button
|
| 118 |
+
onClick={handleClick}
|
| 119 |
+
disabled={isLoading}
|
| 120 |
+
onMouseEnter={() => setIsHovered(true)}
|
| 121 |
+
onMouseLeave={() => setIsHovered(false)}
|
| 122 |
+
className={`
|
| 123 |
+
inline-flex items-center gap-2 px-5 py-2 rounded-lg text-sm font-semibold
|
| 124 |
+
transition-all duration-200 border-2
|
| 125 |
+
${isFollowing
|
| 126 |
+
? isHovered
|
| 127 |
+
? 'bg-red-50 border-red-400 text-red-700 hover:bg-red-100'
|
| 128 |
+
: 'bg-white border-gray-300 text-gray-700 hover:border-gray-400'
|
| 129 |
+
: 'bg-blue-600 border-blue-600 text-white hover:bg-blue-700'
|
| 130 |
+
}
|
| 131 |
+
disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md
|
| 132 |
+
`}
|
| 133 |
+
>
|
| 134 |
+
{isLoading ? (
|
| 135 |
+
<span className="h-5 w-5 border-2 border-t-transparent border-current rounded-full animate-spin" />
|
| 136 |
+
) : isFollowing ? (
|
| 137 |
+
<>
|
| 138 |
+
{isHovered ? (
|
| 139 |
+
<UserMinusIcon className="h-5 w-5" />
|
| 140 |
+
) : (
|
| 141 |
+
<CheckIcon className="h-5 w-5" />
|
| 142 |
+
)}
|
| 143 |
+
<span>{isHovered ? 'Unfollow' : 'Following'}</span>
|
| 144 |
+
</>
|
| 145 |
+
) : (
|
| 146 |
+
<>
|
| 147 |
+
<UserPlusIcon className="h-5 w-5" />
|
| 148 |
+
<span>Follow</span>
|
| 149 |
+
</>
|
| 150 |
+
)}
|
| 151 |
+
</button>
|
| 152 |
+
|
| 153 |
+
{showCount && (
|
| 154 |
+
<span className="text-sm text-gray-600">
|
| 155 |
+
{followerCount.toLocaleString()} {followerCount === 1 ? 'follower' : 'followers'}
|
| 156 |
+
</span>
|
| 157 |
+
)}
|
| 158 |
+
</div>
|
| 159 |
+
)
|
| 160 |
+
}
|
frontend/src/components/JurisdictionDiscovery.tsx
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
+
import {
|
| 3 |
+
ChevronDownIcon,
|
| 4 |
+
ChevronUpIcon,
|
| 5 |
+
CheckCircleIcon,
|
| 6 |
+
GlobeAltIcon,
|
| 7 |
+
VideoCameraIcon,
|
| 8 |
+
DocumentTextIcon,
|
| 9 |
+
ShareIcon
|
| 10 |
+
} from '@heroicons/react/24/outline'
|
| 11 |
+
|
| 12 |
+
interface JurisdictionDiscoveryProps {
|
| 13 |
+
jurisdiction: {
|
| 14 |
+
name: string
|
| 15 |
+
state: string
|
| 16 |
+
website?: string
|
| 17 |
+
youtube_channels?: string[]
|
| 18 |
+
facebook?: string
|
| 19 |
+
twitter?: string
|
| 20 |
+
agenda_portal?: string
|
| 21 |
+
meeting_platform?: string
|
| 22 |
+
completeness: number
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export default function JurisdictionDiscovery({ jurisdiction }: JurisdictionDiscoveryProps) {
|
| 27 |
+
const [isExpanded, setIsExpanded] = useState(false)
|
| 28 |
+
|
| 29 |
+
const hasData = jurisdiction.website || jurisdiction.youtube_channels?.length || jurisdiction.facebook
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<div className="border border-gray-200 rounded-lg bg-white hover:shadow-md transition-shadow">
|
| 33 |
+
{/* Header - Always Visible */}
|
| 34 |
+
<div className="p-4">
|
| 35 |
+
<div className="flex items-center justify-between">
|
| 36 |
+
<div className="flex-1">
|
| 37 |
+
<div className="flex items-center gap-2">
|
| 38 |
+
<CheckCircleIcon className="h-5 w-5 text-green-600" />
|
| 39 |
+
<h3 className="font-bold text-gray-900 uppercase">
|
| 40 |
+
{jurisdiction.name}, {jurisdiction.state} - DISCOVERY COMPLETE!
|
| 41 |
+
</h3>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
{/* Summary Stats */}
|
| 45 |
+
<div className="mt-2 flex flex-wrap gap-3 text-sm text-gray-600">
|
| 46 |
+
{jurisdiction.website && (
|
| 47 |
+
<span className="flex items-center gap-1">
|
| 48 |
+
<GlobeAltIcon className="h-4 w-4" />
|
| 49 |
+
Website
|
| 50 |
+
</span>
|
| 51 |
+
)}
|
| 52 |
+
{jurisdiction.youtube_channels && jurisdiction.youtube_channels.length > 0 && (
|
| 53 |
+
<span className="flex items-center gap-1">
|
| 54 |
+
<VideoCameraIcon className="h-4 w-4" />
|
| 55 |
+
{jurisdiction.youtube_channels.length} YouTube Channel{jurisdiction.youtube_channels.length > 1 ? 's' : ''}
|
| 56 |
+
</span>
|
| 57 |
+
)}
|
| 58 |
+
{jurisdiction.agenda_portal && (
|
| 59 |
+
<span className="flex items-center gap-1">
|
| 60 |
+
<DocumentTextIcon className="h-4 w-4" />
|
| 61 |
+
Agenda Portal
|
| 62 |
+
</span>
|
| 63 |
+
)}
|
| 64 |
+
{(jurisdiction.facebook || jurisdiction.twitter) && (
|
| 65 |
+
<span className="flex items-center gap-1">
|
| 66 |
+
<ShareIcon className="h-4 w-4" />
|
| 67 |
+
Social Media
|
| 68 |
+
</span>
|
| 69 |
+
)}
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
{/* Completeness Bar */}
|
| 73 |
+
{hasData && (
|
| 74 |
+
<div className="mt-3">
|
| 75 |
+
<div className="flex items-center gap-2">
|
| 76 |
+
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
| 77 |
+
<div
|
| 78 |
+
className="bg-green-600 h-2 rounded-full transition-all"
|
| 79 |
+
style={{ width: `${jurisdiction.completeness}%` }}
|
| 80 |
+
/>
|
| 81 |
+
</div>
|
| 82 |
+
<span className="text-sm font-medium text-gray-700">
|
| 83 |
+
{jurisdiction.completeness}%
|
| 84 |
+
</span>
|
| 85 |
+
</div>
|
| 86 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 87 |
+
Completeness: ~{Math.round(jurisdiction.completeness)}% - {
|
| 88 |
+
jurisdiction.completeness >= 75 ? 'Good' :
|
| 89 |
+
jurisdiction.completeness >= 50 ? 'Fair' :
|
| 90 |
+
'Limited'
|
| 91 |
+
} digital infrastructure!
|
| 92 |
+
</p>
|
| 93 |
+
</div>
|
| 94 |
+
)}
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
{/* Expand/Collapse Button */}
|
| 98 |
+
{hasData && (
|
| 99 |
+
<button
|
| 100 |
+
onClick={() => setIsExpanded(!isExpanded)}
|
| 101 |
+
className="ml-4 p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
| 102 |
+
>
|
| 103 |
+
{isExpanded ? (
|
| 104 |
+
<ChevronUpIcon className="h-5 w-5 text-gray-600" />
|
| 105 |
+
) : (
|
| 106 |
+
<ChevronDownIcon className="h-5 w-5 text-gray-600" />
|
| 107 |
+
)}
|
| 108 |
+
</button>
|
| 109 |
+
)}
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
{/* Expandable Details */}
|
| 114 |
+
{isExpanded && hasData && (
|
| 115 |
+
<div className="border-t border-gray-200 p-4 bg-gray-50">
|
| 116 |
+
<h4 className="text-lg font-bold text-gray-900 mb-4">
|
| 117 |
+
π― {jurisdiction.name.toUpperCase()}, {jurisdiction.state} FINDINGS
|
| 118 |
+
</h4>
|
| 119 |
+
|
| 120 |
+
<div className="space-y-4">
|
| 121 |
+
{/* Website */}
|
| 122 |
+
{jurisdiction.website && (
|
| 123 |
+
<div>
|
| 124 |
+
<h5 className="font-semibold text-gray-700 mb-2">π Official Website:</h5>
|
| 125 |
+
<a
|
| 126 |
+
href={jurisdiction.website}
|
| 127 |
+
target="_blank"
|
| 128 |
+
rel="noopener noreferrer"
|
| 129 |
+
className="text-blue-600 hover:underline flex items-center gap-2"
|
| 130 |
+
>
|
| 131 |
+
β
{jurisdiction.website}
|
| 132 |
+
</a>
|
| 133 |
+
</div>
|
| 134 |
+
)}
|
| 135 |
+
|
| 136 |
+
{/* Agenda Portal */}
|
| 137 |
+
{jurisdiction.agenda_portal && (
|
| 138 |
+
<div>
|
| 139 |
+
<h5 className="font-semibold text-gray-700 mb-2">π Meeting/Agenda Portal:</h5>
|
| 140 |
+
<a
|
| 141 |
+
href={jurisdiction.agenda_portal}
|
| 142 |
+
target="_blank"
|
| 143 |
+
rel="noopener noreferrer"
|
| 144 |
+
className="text-blue-600 hover:underline flex items-center gap-2"
|
| 145 |
+
>
|
| 146 |
+
β
{jurisdiction.agenda_portal}
|
| 147 |
+
</a>
|
| 148 |
+
</div>
|
| 149 |
+
)}
|
| 150 |
+
|
| 151 |
+
{/* YouTube Channels */}
|
| 152 |
+
{jurisdiction.youtube_channels && jurisdiction.youtube_channels.length > 0 && (
|
| 153 |
+
<div>
|
| 154 |
+
<h5 className="font-semibold text-gray-700 mb-2">πΊ YouTube Channels:</h5>
|
| 155 |
+
{jurisdiction.youtube_channels.map((channel, idx) => (
|
| 156 |
+
<div key={idx} className="ml-4 text-blue-600 hover:underline">
|
| 157 |
+
β
@{channel}
|
| 158 |
+
</div>
|
| 159 |
+
))}
|
| 160 |
+
</div>
|
| 161 |
+
)}
|
| 162 |
+
|
| 163 |
+
{/* Social Media */}
|
| 164 |
+
{(jurisdiction.facebook || jurisdiction.twitter) && (
|
| 165 |
+
<div>
|
| 166 |
+
<h5 className="font-semibold text-gray-700 mb-2">π± Social Media:</h5>
|
| 167 |
+
<div className="ml-4 space-y-1">
|
| 168 |
+
{jurisdiction.facebook && (
|
| 169 |
+
<div className="text-blue-600">
|
| 170 |
+
β
Facebook: {jurisdiction.facebook}
|
| 171 |
+
</div>
|
| 172 |
+
)}
|
| 173 |
+
{jurisdiction.twitter && (
|
| 174 |
+
<div className="text-blue-600">
|
| 175 |
+
β
Twitter: {jurisdiction.twitter}
|
| 176 |
+
</div>
|
| 177 |
+
)}
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
)}
|
| 181 |
+
|
| 182 |
+
{/* Meeting Platform */}
|
| 183 |
+
{jurisdiction.meeting_platform && (
|
| 184 |
+
<div>
|
| 185 |
+
<h5 className="font-semibold text-gray-700 mb-2">ποΈ Meeting Platform:</h5>
|
| 186 |
+
<div className="ml-4">
|
| 187 |
+
{jurisdiction.meeting_platform}
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
)}
|
| 191 |
+
|
| 192 |
+
{/* Summary Table */}
|
| 193 |
+
<div className="mt-6 border-t border-gray-300 pt-4">
|
| 194 |
+
<h5 className="font-semibold text-gray-700 mb-3">π {jurisdiction.name.toUpperCase()} SUMMARY</h5>
|
| 195 |
+
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
| 196 |
+
<thead>
|
| 197 |
+
<tr className="bg-gray-100">
|
| 198 |
+
<th className="px-3 py-2 text-left font-semibold">Category</th>
|
| 199 |
+
<th className="px-3 py-2 text-left font-semibold">Found</th>
|
| 200 |
+
<th className="px-3 py-2 text-left font-semibold">Details</th>
|
| 201 |
+
</tr>
|
| 202 |
+
</thead>
|
| 203 |
+
<tbody className="divide-y divide-gray-200">
|
| 204 |
+
<tr>
|
| 205 |
+
<td className="px-3 py-2">Website</td>
|
| 206 |
+
<td className="px-3 py-2">{jurisdiction.website ? 'β
' : 'β'}</td>
|
| 207 |
+
<td className="px-3 py-2 text-gray-600">
|
| 208 |
+
{jurisdiction.website ? new URL(jurisdiction.website).hostname : 'Not found'}
|
| 209 |
+
</td>
|
| 210 |
+
</tr>
|
| 211 |
+
<tr>
|
| 212 |
+
<td className="px-3 py-2">YouTube</td>
|
| 213 |
+
<td className="px-3 py-2">{jurisdiction.youtube_channels?.length ? 'β
' : 'β'}</td>
|
| 214 |
+
<td className="px-3 py-2 text-gray-600">
|
| 215 |
+
{jurisdiction.youtube_channels?.length || 0} channel{jurisdiction.youtube_channels?.length !== 1 ? 's' : ''}
|
| 216 |
+
</td>
|
| 217 |
+
</tr>
|
| 218 |
+
<tr>
|
| 219 |
+
<td className="px-3 py-2">Agendas</td>
|
| 220 |
+
<td className="px-3 py-2">{jurisdiction.agenda_portal ? 'β
' : 'β'}</td>
|
| 221 |
+
<td className="px-3 py-2 text-gray-600">
|
| 222 |
+
{jurisdiction.agenda_portal ? 'Portal found' : 'Not available'}
|
| 223 |
+
</td>
|
| 224 |
+
</tr>
|
| 225 |
+
<tr>
|
| 226 |
+
<td className="px-3 py-2">Social</td>
|
| 227 |
+
<td className="px-3 py-2">{jurisdiction.facebook || jurisdiction.twitter ? 'β
' : 'β'}</td>
|
| 228 |
+
<td className="px-3 py-2 text-gray-600">
|
| 229 |
+
{[jurisdiction.facebook && 'Facebook', jurisdiction.twitter && 'Twitter'].filter(Boolean).join(', ') || 'None'}
|
| 230 |
+
</td>
|
| 231 |
+
</tr>
|
| 232 |
+
<tr>
|
| 233 |
+
<td className="px-3 py-2">Platform</td>
|
| 234 |
+
<td className="px-3 py-2">{jurisdiction.meeting_platform ? 'β
' : 'β'}</td>
|
| 235 |
+
<td className="px-3 py-2 text-gray-600">
|
| 236 |
+
{jurisdiction.meeting_platform || 'Unknown'}
|
| 237 |
+
</td>
|
| 238 |
+
</tr>
|
| 239 |
+
</tbody>
|
| 240 |
+
</table>
|
| 241 |
+
</div>
|
| 242 |
+
|
| 243 |
+
{/* Key Takeaway */}
|
| 244 |
+
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
| 245 |
+
<h5 className="font-semibold text-blue-900 mb-2">π‘ KEY TAKEAWAY</h5>
|
| 246 |
+
<p className="text-sm text-blue-800">
|
| 247 |
+
The automation successfully discovered:
|
| 248 |
+
</p>
|
| 249 |
+
<ul className="mt-2 space-y-1 text-sm text-blue-800">
|
| 250 |
+
{jurisdiction.website && <li>β
Official website (automatic)</li>}
|
| 251 |
+
{(jurisdiction.youtube_channels?.length ?? 0) > 0 && <li>β
YouTube channels (automatic)</li>}
|
| 252 |
+
{jurisdiction.agenda_portal && <li>β
Agenda portal (found via link scanning)</li>}
|
| 253 |
+
{(jurisdiction.facebook || jurisdiction.twitter) && <li>β
Social media (automatic)</li>}
|
| 254 |
+
</ul>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
)}
|
| 259 |
+
|
| 260 |
+
{/* No Data Message */}
|
| 261 |
+
{!hasData && (
|
| 262 |
+
<div className="border-t border-gray-200 p-4 bg-gray-50 text-center text-gray-500">
|
| 263 |
+
<p>No discovery data available yet. Run discovery pipeline to populate.</p>
|
| 264 |
+
</div>
|
| 265 |
+
)}
|
| 266 |
+
</div>
|
| 267 |
+
)
|
| 268 |
+
}
|