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:
parent
2ffe3783d2
commit
e3575052ee
6 changed files with 161 additions and 5 deletions
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue