Add per-agency analytics breakdown table for admin users

New GET /analytics/by-agency endpoint groups review metrics by agency.
The Analytics page now shows a sortable agency performance table with
pass rates, failures, errors, and legal review counts for each agency.
Only visible to super_admin and oversight_admin users. Selected agency
row is highlighted when the AgencyFilterBar is active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael 2026-02-22 14:33:48 -06:00
parent 2ffe3783d2
commit e3575052ee
6 changed files with 161 additions and 5 deletions

View file

@ -20,6 +20,8 @@ from app.api.schemas import (
ResolvedItemResponse,
ErrorItemResponse,
AnalyticsResponse,
AgencyAnalyticsItem,
AgencyAnalyticsResponse,
DropdownOptionsResponse,
AgencyResponse,
AgencyCreate,
@ -587,6 +589,21 @@ async def get_analytics(
return AnalyticsResponse(**analytics)
@router.get("/analytics/by-agency", response_model=AgencyAnalyticsResponse)
async def get_analytics_by_agency(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_db_user),
):
"""Get per-agency analytics breakdown (admin only)."""
if current_user.role not in ("super_admin", "oversight_admin"):
return AgencyAnalyticsResponse(agencies=[])
repo = CampaignRepository(db)
rows = await repo.get_analytics_by_agency()
return AgencyAnalyticsResponse(
agencies=[AgencyAnalyticsItem(**row) for row in rows]
)
# ---------------------------------------------------------------------------
# User Management endpoints
# ---------------------------------------------------------------------------

View file

@ -148,6 +148,20 @@ class AnalyticsResponse(BaseModel):
legal_review: int
class AgencyAnalyticsItem(BaseModel):
agency_id: uuid.UUID
agency_name: str
total_reviews: int
passed: int
failed: int
errors: int
legal_review: int
class AgencyAnalyticsResponse(BaseModel):
agencies: list[AgencyAnalyticsItem]
# Dropdown options schemas
class DropdownOptionsResponse(BaseModel):
campaigns: list[str]

View file

@ -5,7 +5,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.models import Campaign, Proof, ProofVersion
from app.models.models import Agency, Campaign, Proof, ProofVersion
class CampaignRepository:
@ -164,3 +164,39 @@ class CampaignRepository:
"errors": row.errors,
"legal_review": row.legal_review,
}
async def get_analytics_by_agency(self) -> list[dict]:
"""Get analytics data grouped by agency."""
query = (
select(
Agency.id.label("agency_id"),
Agency.name.label("agency_name"),
func.count(ProofVersion.id).label("total"),
func.count(ProofVersion.id).filter(ProofVersion.overall_status == "Passed").label("passed"),
func.count(ProofVersion.id).filter(ProofVersion.overall_status == "Failed").label("failed"),
func.count(ProofVersion.id).filter(ProofVersion.overall_status == "Analysis Error").label("errors"),
func.count(ProofVersion.id).filter(ProofVersion.overall_status == "Requires Manual Legal Review").label("legal_review"),
)
.select_from(ProofVersion)
.join(Proof)
.join(Campaign)
.join(Agency, Campaign.agency_id == Agency.id)
.group_by(Agency.id, Agency.name)
.order_by(func.count(ProofVersion.id).desc())
)
result = await self.session.execute(query)
rows = result.all()
return [
{
"agency_id": row.agency_id,
"agency_name": row.agency_name,
"total_reviews": row.total,
"passed": row.passed,
"failed": row.failed,
"errors": row.errors,
"legal_review": row.legal_review,
}
for row in rows
]

View file

@ -852,7 +852,7 @@ const AppContent: React.FC<{ msalInstance: any }> = ({ msalInstance }) => {
const renderContent = () => {
switch (currentView) {
case 'Analytics':
return <Analytics agencyId={selectedAgencyId || undefined} />;
return <Analytics agencyId={selectedAgencyId || undefined} isAdmin={isSuperAdmin || isOversightAdmin} />;
case 'Profile':
return <Profile onLogout={handleLogout} msalInstance={msalInstance} />;
case 'CopyGenAI':

View file

@ -5,7 +5,7 @@ import { BugIcon } from './icons/BugIcon';
import { ClockIcon } from './icons/ClockIcon';
import { LightbulbIcon } from './icons/LightbulbIcon';
import { ExclamationTriangleIcon } from './icons/StatusIcons';
import apiService, { AnalyticsResponse } from '../services/apiService';
import apiService, { AnalyticsResponse, AgencyAnalyticsItem } from '../services/apiService';
// Agent performance is still static for now - would need separate API
const agentPerformance = [
@ -29,8 +29,9 @@ const TrendIndicator: React.FC<{ trend: 'up' | 'down' | 'stable' }> = ({ trend }
return <div className="flex items-center gap-1.5 text-grey-700"><StableLine/> Stable</div>;
};
export const Analytics: React.FC<{ agencyId?: string }> = ({ agencyId }) => {
export const Analytics: React.FC<{ agencyId?: string; isAdmin?: boolean }> = ({ agencyId, isAdmin }) => {
const [analytics, setAnalytics] = useState<AnalyticsResponse | null>(null);
const [agencyAnalytics, setAgencyAnalytics] = useState<AgencyAnalyticsItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
@ -48,6 +49,19 @@ export const Analytics: React.FC<{ agencyId?: string }> = ({ agencyId }) => {
loadAnalytics();
}, [agencyId]);
useEffect(() => {
if (!isAdmin) return;
const loadAgencyAnalytics = async () => {
try {
const data = await apiService.getAnalyticsByAgency();
setAgencyAnalytics(data.agencies);
} catch (error) {
console.error('Failed to load agency analytics:', error);
}
};
loadAgencyAnalytics();
}, [isAdmin]);
// Calculate stats from API data
// Exclude errors from denominator since they weren't successfully reviewed
const reviewedCount = analytics
@ -92,6 +106,63 @@ export const Analytics: React.FC<{ agencyId?: string }> = ({ agencyId }) => {
</div>
</section>
{/* Per-Agency Breakdown (admin only) */}
{isAdmin && agencyAnalytics.length > 0 && (
<section className="mt-10">
<h2 className="text-2xl font-semibold text-primary-blue mb-4">Agency performance</h2>
<div className="bg-white rounded-[10px] shadow-md overflow-hidden border border-grey-300">
<div className="overflow-x-auto">
<table className="min-w-full">
<thead className="bg-lime">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Agency</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Proofs Reviewed</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Pass Rate</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Failed</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Errors</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Legal Review</th>
</tr>
</thead>
<tbody className="divide-y divide-grey-300">
{agencyAnalytics.map((agency, index) => {
const reviewed = agency.passed + agency.failed + agency.legal_review;
const rate = reviewed > 0 ? Math.round((agency.passed / reviewed) * 100) : 0;
const isSelected = agencyId === agency.agency_id;
return (
<tr
key={agency.agency_id}
className={
isSelected
? 'bg-blue-50'
: index % 2 === 0
? 'bg-white'
: 'bg-grey-100'
}
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-black-title">
{agency.agency_name}
{isSelected && <span className="ml-2 text-xs text-primary-blue font-normal">(selected)</span>}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-black-title">{agency.total_reviews}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-black-title">
<div className="flex items-center">
<span className={`h-2.5 w-2.5 rounded-full mr-3 ${rate >= 80 ? 'bg-success' : rate < 70 ? 'bg-error' : 'bg-warning'}`}></span>
{rate}%
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-black-title">{agency.failed}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-black-title">{agency.errors}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-black-title">{agency.legal_review}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</section>
)}
{/* AI Performance Summary */}
<section className="mt-10">
<h2 className="text-2xl font-semibold text-primary-blue mb-4">AI performance summary</h2>

View file

@ -55,6 +55,20 @@ export interface AnalyticsResponse {
legal_review: number;
}
export interface AgencyAnalyticsItem {
agency_id: string;
agency_name: string;
total_reviews: number;
passed: number;
failed: number;
errors: number;
legal_review: number;
}
export interface AgencyAnalyticsResponse {
agencies: AgencyAnalyticsItem[];
}
export interface FlaggedItemResponse {
id: string;
proof_version_id: string;
@ -263,12 +277,16 @@ class ApiService {
return this.fetch<ErrorItemResponse[]>(`/audit/errors${params}`);
}
// Analytics endpoint
// Analytics endpoints
async getAnalytics(agencyId?: string): Promise<AnalyticsResponse> {
const params = agencyId ? `?agency_id=${agencyId}` : '';
return this.fetch<AnalyticsResponse>(`/analytics${params}`);
}
async getAnalyticsByAgency(): Promise<AgencyAnalyticsResponse> {
return this.fetch<AgencyAnalyticsResponse>('/analytics/by-agency');
}
// Helper to convert API response to frontend format
convertCampaignToFrontend(campaign: CampaignResponse) {
return {