Skip to main content

Build a 483 Compliance Dashboard

Build a real-time compliance monitoring dashboard using CTWise 483 Intelligence APIs. This tutorial covers both backend (Python) and frontend (React) implementations.


What You'll Build

A multi-page dashboard that displays:

  1. Executive Summary - Key metrics and risk distribution
  2. Watchlist Monitor - Real-time facility risk tracking
  3. Trending Citations - CFR sections with increasing frequency
  4. Facility Deep Dive - Detailed inspection history and risk scores
  5. Search Interface - Natural language search across 483 observations

Tech Stack:

  • Backend: Python + Flask + CTWise API
  • Frontend: React + Chart.js + Tailwind CSS
  • Optional: Database for caching (SQLite or PostgreSQL)

Prerequisites

# Install Python dependencies
pip install flask requests pandas python-dotenv

# Install Node.js dependencies (for React frontend)
npm install axios chart.js react-chartjs-2 tailwindcss
# Set environment variables
export CTWISE_API_KEY="your_api_key_here"
export FLASK_ENV=development

Part 1: Backend API (Python + Flask)

Step 1: Create Flask Application

Create app.py:

from flask import Flask, jsonify, request
from flask_cors import CORS
import requests
import os
from datetime import datetime
from functools import lru_cache

app = Flask(__name__)
CORS(app) # Enable CORS for React frontend

API_KEY = os.getenv('CTWISE_API_KEY')
BASE_URL = "https://api.ctwise.ai/v1"

def ctwise_api_call(endpoint, method='GET', json_data=None, params=None):
"""Helper function for CTWise API calls"""
headers = {"X-Api-Key": API_KEY}
if json_data:
headers["Content-Type"] = "application/json"

url = f"{BASE_URL}{endpoint}"

if method == 'GET':
response = requests.get(url, headers=headers, params=params)
elif method == 'POST':
response = requests.post(url, headers=headers, json=json_data)

response.raise_for_status()
return response.json()

@app.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint"""
return jsonify({"status": "healthy", "timestamp": datetime.now().isoformat()})

if __name__ == '__main__':
app.run(debug=True, port=5000)

Step 2: Executive Summary Endpoint

Add to app.py:

@app.route('/api/summary', methods=['GET'])
@lru_cache(maxsize=1)
def get_executive_summary():
"""Get executive summary with key metrics"""

# Get analytics summary
summary = ctwise_api_call('/483/analytics/summary')

# Get watchlist
watchlist = ctwise_api_call('/483/watchlist')

# Calculate risk distribution from watchlist
risk_distribution = {"low": 0, "medium": 0, "high": 0, "critical": 0}
for facility in watchlist.get('results', []):
score = facility.get('current_risk_score', 0)
if score < 25:
risk_distribution["low"] += 1
elif score < 50:
risk_distribution["medium"] += 1
elif score < 75:
risk_distribution["high"] += 1
else:
risk_distribution["critical"] += 1

return jsonify({
"total_citations": summary['total_citations'],
"total_facilities": summary['total_facilities'],
"watchlist_count": watchlist['total'],
"risk_distribution": risk_distribution,
"top_cited_cfr": summary['top_cited_cfr'][:5],
"last_updated": datetime.now().isoformat()
})

Step 3: Watchlist Endpoints

@app.route('/api/watchlist', methods=['GET'])
def get_watchlist():
"""Get all facilities on watchlist"""
watchlist = ctwise_api_call('/483/watchlist')

# Enrich with risk scores
enriched = []
for facility in watchlist.get('results', []):
# Risk score is already included in watchlist response
enriched.append({
"id": facility['watchlist_id'],
"fei_number": facility['fei_number'],
"facility_name": facility['facility_name'],
"risk_score": facility['current_risk_score'],
"risk_level": facility['current_risk_level'],
"last_inspection": facility['last_inspection_date'],
"notes": facility.get('notes', '')
})

return jsonify({"facilities": enriched, "total": len(enriched)})

@app.route('/api/watchlist', methods=['POST'])
def add_to_watchlist():
"""Add facility to watchlist"""
data = request.json

result = ctwise_api_call(
'/483/watchlist',
method='POST',
json_data={
"fei_number": data['fei_number'],
"notes": data.get('notes', ''),
"alert_on_new_inspection": data.get('alert_on_new_inspection', True)
}
)

return jsonify(result), 201
@app.route('/api/trending', methods=['GET'])
def get_trending_citations():
"""Get trending citations with chart data"""
days = request.args.get('days', 365, type=int)
limit = request.args.get('limit', 10, type=int)

trending = ctwise_api_call(
'/483/citations/trending',
params={"days": days, "limit": limit}
)

# Format for Chart.js
chart_data = {
"labels": [item['act_cfr_number'] for item in trending['results']],
"datasets": [
{
"label": "Recent Count",
"data": [item['recent_count'] for item in trending['results']],
"backgroundColor": "rgba(59, 130, 246, 0.5)"
},
{
"label": "Previous Count",
"data": [item['previous_count'] for item in trending['results']],
"backgroundColor": "rgba(156, 163, 175, 0.5)"
}
]
}

return jsonify({
"chart_data": chart_data,
"trending_items": trending['results'],
"period": f"Last {days} days"
})

Step 5: Facility Detail Endpoint

@app.route('/api/facility/<fei_number>', methods=['GET'])
def get_facility_detail(fei_number):
"""Get detailed facility information"""

# Get facility profile
facility = ctwise_api_call(f'/483/facilities/{fei_number}')

# Get risk score
risk_score = ctwise_api_call(f'/483/risk-scores/{fei_number}')

# Get recent citations
citations = ctwise_api_call(
f'/483/facilities/{fei_number}/citations',
params={"limit": 20, "sort_order": "desc"}
)

return jsonify({
"facility": facility,
"risk_score": risk_score,
"recent_citations": citations['results'][:10],
"total_citations": citations['total']
})

Step 6: Search Endpoint

@app.route('/api/search', methods=['POST'])
def search_observations():
"""Semantic search across 483 observations"""
data = request.json

results = ctwise_api_call(
'/483/observations/search',
method='POST',
json_data={
"query": data['query'],
"top_k": data.get('top_k', 20),
"filters": data.get('filters', {})
}
)

return jsonify(results)

Part 2: Frontend Dashboard (React)

Step 1: Executive Summary Component

Create src/components/ExecutiveSummary.jsx:

import React, { useState, useEffect } from 'react';
import { Pie } from 'react-chartjs-2';
import axios from 'axios';

const ExecutiveSummary = () => {
const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
axios.get('http://localhost:5000/api/summary')
.then(response => {
setSummary(response.data);
setLoading(false);
})
.catch(error => console.error('Error fetching summary:', error));
}, []);

if (loading) return <div>Loading...</div>;

const riskChartData = {
labels: ['Low Risk', 'Medium Risk', 'High Risk', 'Critical Risk'],
datasets: [{
data: [
summary.risk_distribution.low,
summary.risk_distribution.medium,
summary.risk_distribution.high,
summary.risk_distribution.critical
],
backgroundColor: [
'rgba(34, 197, 94, 0.8)', // green
'rgba(234, 179, 8, 0.8)', // yellow
'rgba(249, 115, 22, 0.8)', // orange
'rgba(239, 68, 68, 0.8)' // red
]
}]
};

return (
<div className="p-6 bg-white rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-6">Executive Summary</h2>

{/* Key Metrics */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-blue-50 p-4 rounded">
<p className="text-sm text-gray-600">Total Citations</p>
<p className="text-3xl font-bold text-blue-600">
{summary.total_citations.toLocaleString()}
</p>
</div>
<div className="bg-green-50 p-4 rounded">
<p className="text-sm text-gray-600">Total Facilities</p>
<p className="text-3xl font-bold text-green-600">
{summary.total_facilities.toLocaleString()}
</p>
</div>
<div className="bg-purple-50 p-4 rounded">
<p className="text-sm text-gray-600">Watchlist</p>
<p className="text-3xl font-bold text-purple-600">
{summary.watchlist_count}
</p>
</div>
</div>

{/* Risk Distribution Chart */}
<div className="mb-8">
<h3 className="text-lg font-semibold mb-4">Watchlist Risk Distribution</h3>
<div className="w-64 mx-auto">
<Pie data={riskChartData} />
</div>
</div>

{/* Top Cited CFRs */}
<div>
<h3 className="text-lg font-semibold mb-4">Top 5 Most Cited CFRs</h3>
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2">CFR Section</th>
<th className="text-right py-2">Citations</th>
</tr>
</thead>
<tbody>
{summary.top_cited_cfr.map((cfr, index) => (
<tr key={index} className="border-b">
<td className="py-2">{cfr.act_cfr_number}</td>
<td className="text-right py-2">{cfr.occurrence_count}</td>
</tr>
))}
</tbody>
</table>
</div>

<p className="text-sm text-gray-500 mt-4">
Last updated: {new Date(summary.last_updated).toLocaleString()}
</p>
</div>
);
};

export default ExecutiveSummary;

Step 2: Watchlist Monitor Component

Create src/components/WatchlistMonitor.jsx:

import React, { useState, useEffect } from 'react';
import axios from 'axios';

const WatchlistMonitor = () => {
const [watchlist, setWatchlist] = useState([]);
const [loading, setLoading] = useState(true);

const fetchWatchlist = () => {
axios.get('http://localhost:5000/api/watchlist')
.then(response => {
setWatchlist(response.data.facilities);
setLoading(false);
})
.catch(error => console.error('Error fetching watchlist:', error));
};

useEffect(() => {
fetchWatchlist();
// Refresh every 5 minutes
const interval = setInterval(fetchWatchlist, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);

const getRiskColor = (score) => {
if (score < 25) return 'bg-green-100 text-green-800';
if (score < 50) return 'bg-yellow-100 text-yellow-800';
if (score < 75) return 'bg-orange-100 text-orange-800';
return 'bg-red-100 text-red-800';
};

if (loading) return <div>Loading watchlist...</div>;

return (
<div className="p-6 bg-white rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-6">Facility Watchlist</h2>

<table className="w-full">
<thead>
<tr className="border-b-2">
<th className="text-left py-3">Facility</th>
<th className="text-left py-3">FEI</th>
<th className="text-center py-3">Risk Score</th>
<th className="text-left py-3">Last Inspection</th>
<th className="text-left py-3">Notes</th>
</tr>
</thead>
<tbody>
{watchlist.map((facility, index) => (
<tr key={index} className="border-b hover:bg-gray-50">
<td className="py-3">{facility.facility_name}</td>
<td className="py-3 font-mono text-sm">{facility.fei_number}</td>
<td className="py-3 text-center">
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${getRiskColor(facility.risk_score)}`}>
{facility.risk_score.toFixed(1)}
</span>
</td>
<td className="py-3 text-sm">{facility.last_inspection || 'N/A'}</td>
<td className="py-3 text-sm text-gray-600">{facility.notes}</td>
</tr>
))}
</tbody>
</table>

{watchlist.length === 0 && (
<p className="text-center py-8 text-gray-500">
No facilities on watchlist. Add facilities to start monitoring.
</p>
)}
</div>
);
};

export default WatchlistMonitor;

Create src/components/TrendingCitations.jsx:

import React, { useState, useEffect } from 'react';
import { Bar } from 'react-chartjs-2';
import axios from 'axios';

const TrendingCitations = () => {
const [trending, setTrending] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
axios.get('http://localhost:5000/api/trending?days=365&limit=10')
.then(response => {
setTrending(response.data);
setLoading(false);
})
.catch(error => console.error('Error fetching trending:', error));
}, []);

if (loading) return <div>Loading trending data...</div>;

return (
<div className="p-6 bg-white rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-6">Trending Citations</h2>
<p className="text-sm text-gray-600 mb-4">{trending.period}</p>

<div className="mb-8">
<Bar
data={trending.chart_data}
options={{
responsive: true,
plugins: {
legend: { position: 'top' },
title: { display: true, text: 'Citation Frequency Comparison' }
}
}}
/>
</div>

<table className="w-full">
<thead>
<tr className="border-b-2">
<th className="text-left py-3">CFR Section</th>
<th className="text-right py-3">Recent</th>
<th className="text-right py-3">Previous</th>
<th className="text-right py-3">Trend</th>
</tr>
</thead>
<tbody>
{trending.trending_items.map((item, index) => (
<tr key={index} className="border-b">
<td className="py-3">{item.act_cfr_number}</td>
<td className="text-right py-3">{item.recent_count}</td>
<td className="text-right py-3">{item.previous_count}</td>
<td className="text-right py-3">
<span className={item.trend_direction === 'increasing' ? 'text-red-600' : 'text-green-600'}>
{item.trend_percentage > 0 ? '+' : ''}{item.trend_percentage.toFixed(1)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

export default TrendingCitations;

Step 4: Main App Component

Create src/App.jsx:

import React, { useState } from 'react';
import ExecutiveSummary from './components/ExecutiveSummary';
import WatchlistMonitor from './components/WatchlistMonitor';
import TrendingCitations from './components/TrendingCitations';

function App() {
const [activeTab, setActiveTab] = useState('summary');

return (
<div className="min-h-screen bg-gray-100">
{/* Header */}
<header className="bg-blue-600 text-white p-4 shadow-md">
<h1 className="text-3xl font-bold">FDA 483 Compliance Dashboard</h1>
<p className="text-sm">Real-time inspection intelligence powered by CTWise</p>
</header>

{/* Navigation */}
<nav className="bg-white shadow-sm">
<div className="container mx-auto px-4">
<div className="flex space-x-8">
{['summary', 'watchlist', 'trending'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
</div>
</nav>

{/* Content */}
<main className="container mx-auto px-4 py-8">
{activeTab === 'summary' && <ExecutiveSummary />}
{activeTab === 'watchlist' && <WatchlistMonitor />}
{activeTab === 'trending' && <TrendingCitations />}
</main>

{/* Footer */}
<footer className="bg-white mt-12 py-6 text-center text-sm text-gray-600">
<p>Powered by CTWise 483 Intelligence API</p>
</footer>
</div>
);
}

export default App;

Part 3: Running the Dashboard

Start Backend

python app.py
# Server runs on http://localhost:5000

Start Frontend

npm start
# React app runs on http://localhost:3000

Test the Dashboard

  1. Navigate to http://localhost:3000
  2. View executive summary with key metrics
  3. Check watchlist for high-risk facilities
  4. Analyze trending citations over the past year

Enhancements

const FacilitySearch = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);

const handleSearch = async () => {
const response = await axios.post('http://localhost:5000/api/search', {
query,
top_k: 20
});
setResults(response.data.results);
};

return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search 483 observations..."
className="w-full p-2 border rounded"
/>
<button onClick={handleSearch} className="mt-2 px-4 py-2 bg-blue-600 text-white rounded">
Search
</button>

<div className="mt-4">
{results.map((result, index) => (
<div key={index} className="p-4 border-b">
<p className="font-semibold">{result.facility_name}</p>
<p className="text-sm text-gray-600">{result.short_description}</p>
<span className="text-xs bg-gray-200 px-2 py-1 rounded">{result.act_cfr_number}</span>
</div>
))}
</div>
</div>
);
};

2. Add Email Alerts

from flask_mail import Mail, Message

app.config['MAIL_SERVER'] = 'smtp.gmail.com'
app.config['MAIL_PORT'] = 587
mail = Mail(app)

def send_high_risk_alert(facility):
msg = Message(
subject=f'⚠️ High Risk Alert: {facility["facility_name"]}',
recipients=['quality.team@company.com'],
body=f'''
Facility: {facility["facility_name"]} (FEI: {facility["fei_number"]})
Risk Score: {facility["risk_score"]}/100 (HIGH)
Last Inspection: {facility["last_inspection"]}

Recommended Actions:
1. Review recent citations
2. Schedule supplier audit
3. Update risk assessment

View in dashboard: http://dashboard.company.com/facility/{facility["fei_number"]}
'''
)
mail.send(msg)

Next Steps