Connect frontend to PostgreSQL database via API
- Replace all localStorage-based state management with API calls - Load campaigns, proofs, and audit items from database - Persist proof analysis results to database via WebSocket - Add dropdown options CRUD API endpoints (channels, sub-channels, proof types) - Create DropdownRepository for managing dropdown options - Update Analytics component to fetch data from API - Remove demo data and localStorage persistence code Frontend changes: - App.tsx: Initialize apiService with MSAL, use API for all CRUD operations - apiService.ts: Add dropdown options API methods - Analytics.tsx: Fetch stats from /api/analytics Backend changes: - New dropdown_repository.py for dropdown CRUD - routes.py: Add 7 dropdown endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6ff69cc308
commit
c07c66a583
6 changed files with 778 additions and 472 deletions
|
|
@ -28,6 +28,7 @@ from app.repositories import (
|
|||
ProofRepository,
|
||||
UserRepository,
|
||||
AuditRepository,
|
||||
DropdownRepository,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -486,3 +487,106 @@ async def list_users(
|
|||
)
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
# Dropdown options endpoints
|
||||
@router.get("/dropdown-options", response_model=DropdownOptionsResponse)
|
||||
async def get_dropdown_options(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get all dropdown options as hierarchical structure."""
|
||||
repo = DropdownRepository(db)
|
||||
options = await repo.get_all_hierarchical()
|
||||
return DropdownOptionsResponse(**options)
|
||||
|
||||
|
||||
@router.post("/dropdown-options/channels", status_code=201)
|
||||
async def add_channel(
|
||||
name: str = Query(..., description="Channel name"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Add a new channel."""
|
||||
repo = DropdownRepository(db)
|
||||
await repo.add_channel(name)
|
||||
await db.commit()
|
||||
return {"message": f"Channel '{name}' added successfully"}
|
||||
|
||||
|
||||
@router.post("/dropdown-options/channels/{channel}/sub-channels", status_code=201)
|
||||
async def add_sub_channel(
|
||||
channel: str,
|
||||
name: str = Query(..., description="Sub-channel name"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Add a sub-channel under a channel."""
|
||||
repo = DropdownRepository(db)
|
||||
result = await repo.add_sub_channel(channel, name)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail=f"Channel '{channel}' not found")
|
||||
await db.commit()
|
||||
return {"message": f"Sub-channel '{name}' added to '{channel}'"}
|
||||
|
||||
|
||||
@router.post("/dropdown-options/channels/{channel}/sub-channels/{sub_channel}/proof-types", status_code=201)
|
||||
async def add_proof_type(
|
||||
channel: str,
|
||||
sub_channel: str,
|
||||
name: str = Query(..., description="Proof type name"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Add a proof type under a sub-channel."""
|
||||
repo = DropdownRepository(db)
|
||||
result = await repo.add_proof_type(channel, sub_channel, name)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail=f"Channel '{channel}' or sub-channel '{sub_channel}' not found")
|
||||
await db.commit()
|
||||
return {"message": f"Proof type '{name}' added to '{channel}/{sub_channel}'"}
|
||||
|
||||
|
||||
@router.delete("/dropdown-options/channels/{channel}", status_code=204)
|
||||
async def delete_channel(
|
||||
channel: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a channel and all its sub-channels and proof types."""
|
||||
repo = DropdownRepository(db)
|
||||
success = await repo.remove_channel(channel)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Channel '{channel}' not found")
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.delete("/dropdown-options/channels/{channel}/sub-channels/{sub_channel}", status_code=204)
|
||||
async def delete_sub_channel(
|
||||
channel: str,
|
||||
sub_channel: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a sub-channel and all its proof types."""
|
||||
repo = DropdownRepository(db)
|
||||
success = await repo.remove_sub_channel(channel, sub_channel)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Sub-channel '{sub_channel}' not found in channel '{channel}'")
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.delete("/dropdown-options/channels/{channel}/sub-channels/{sub_channel}/proof-types/{proof_type}", status_code=204)
|
||||
async def delete_proof_type(
|
||||
channel: str,
|
||||
sub_channel: str,
|
||||
proof_type: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a proof type."""
|
||||
repo = DropdownRepository(db)
|
||||
success = await repo.remove_proof_type(channel, sub_channel, proof_type)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Proof type '{proof_type}' not found")
|
||||
await db.commit()
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ from app.repositories.campaign_repository import CampaignRepository
|
|||
from app.repositories.proof_repository import ProofRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.repositories.audit_repository import AuditRepository
|
||||
from app.repositories.dropdown_repository import DropdownRepository
|
||||
|
||||
__all__ = [
|
||||
"CampaignRepository",
|
||||
"ProofRepository",
|
||||
"UserRepository",
|
||||
"AuditRepository",
|
||||
"DropdownRepository",
|
||||
]
|
||||
|
|
|
|||
202
backend/app/repositories/dropdown_repository.py
Normal file
202
backend/app/repositories/dropdown_repository.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"""Repository for dropdown options (channels, sub-channels, proof types)."""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.models import DropdownOption
|
||||
|
||||
|
||||
class DropdownRepository:
|
||||
"""Repository for managing dropdown options."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_all_hierarchical(self) -> dict:
|
||||
"""
|
||||
Get all dropdown options as a hierarchical structure.
|
||||
Returns: { channels: { channel_name: { sub_channel_name: [proof_types] } } }
|
||||
"""
|
||||
# Get all channels (top-level, no parent)
|
||||
stmt = select(DropdownOption).where(
|
||||
DropdownOption.option_type == "channel",
|
||||
DropdownOption.parent_id.is_(None)
|
||||
).options(selectinload(DropdownOption.children).selectinload(DropdownOption.children))
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
channels = result.scalars().all()
|
||||
|
||||
# Build hierarchical structure
|
||||
hierarchy: dict[str, dict[str, list[str]]] = {}
|
||||
|
||||
for channel in channels:
|
||||
channel_name = channel.value
|
||||
hierarchy[channel_name] = {}
|
||||
|
||||
for sub_channel in channel.children:
|
||||
sub_channel_name = sub_channel.value
|
||||
hierarchy[channel_name][sub_channel_name] = [
|
||||
pt.value for pt in sub_channel.children
|
||||
]
|
||||
|
||||
return {"channels": hierarchy, "campaigns": []} # campaigns not used from dropdown options
|
||||
|
||||
async def add_channel(self, name: str) -> DropdownOption:
|
||||
"""Add a new channel (top-level option)."""
|
||||
option = DropdownOption(
|
||||
option_type="channel",
|
||||
value=name,
|
||||
parent_id=None
|
||||
)
|
||||
self.session.add(option)
|
||||
await self.session.flush()
|
||||
return option
|
||||
|
||||
async def add_sub_channel(self, channel_name: str, sub_channel_name: str) -> Optional[DropdownOption]:
|
||||
"""Add a sub-channel under a channel."""
|
||||
# Find the parent channel
|
||||
stmt = select(DropdownOption).where(
|
||||
DropdownOption.option_type == "channel",
|
||||
DropdownOption.value == channel_name
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
channel = result.scalar_one_or_none()
|
||||
|
||||
if not channel:
|
||||
return None
|
||||
|
||||
option = DropdownOption(
|
||||
option_type="sub_channel",
|
||||
value=sub_channel_name,
|
||||
parent_id=channel.id
|
||||
)
|
||||
self.session.add(option)
|
||||
await self.session.flush()
|
||||
return option
|
||||
|
||||
async def add_proof_type(self, channel_name: str, sub_channel_name: str, proof_type_name: str) -> Optional[DropdownOption]:
|
||||
"""Add a proof type under a sub-channel."""
|
||||
# Find the parent sub-channel by traversing from channel
|
||||
stmt = select(DropdownOption).where(
|
||||
DropdownOption.option_type == "channel",
|
||||
DropdownOption.value == channel_name
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
channel = result.scalar_one_or_none()
|
||||
|
||||
if not channel:
|
||||
return None
|
||||
|
||||
# Find sub-channel
|
||||
stmt = select(DropdownOption).where(
|
||||
DropdownOption.option_type == "sub_channel",
|
||||
DropdownOption.value == sub_channel_name,
|
||||
DropdownOption.parent_id == channel.id
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
sub_channel = result.scalar_one_or_none()
|
||||
|
||||
if not sub_channel:
|
||||
return None
|
||||
|
||||
option = DropdownOption(
|
||||
option_type="proof_type",
|
||||
value=proof_type_name,
|
||||
parent_id=sub_channel.id
|
||||
)
|
||||
self.session.add(option)
|
||||
await self.session.flush()
|
||||
return option
|
||||
|
||||
async def remove_channel(self, channel_name: str) -> bool:
|
||||
"""Remove a channel and all its children (cascades)."""
|
||||
stmt = select(DropdownOption).where(
|
||||
DropdownOption.option_type == "channel",
|
||||
DropdownOption.value == channel_name
|
||||
).options(selectinload(DropdownOption.children).selectinload(DropdownOption.children))
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
channel = result.scalar_one_or_none()
|
||||
|
||||
if not channel:
|
||||
return False
|
||||
|
||||
# Delete proof types, then sub-channels, then channel
|
||||
for sub_channel in channel.children:
|
||||
for proof_type in sub_channel.children:
|
||||
await self.session.delete(proof_type)
|
||||
await self.session.delete(sub_channel)
|
||||
await self.session.delete(channel)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
async def remove_sub_channel(self, channel_name: str, sub_channel_name: str) -> bool:
|
||||
"""Remove a sub-channel and its proof types."""
|
||||
stmt = select(DropdownOption).where(
|
||||
DropdownOption.option_type == "channel",
|
||||
DropdownOption.value == channel_name
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
channel = result.scalar_one_or_none()
|
||||
|
||||
if not channel:
|
||||
return False
|
||||
|
||||
stmt = select(DropdownOption).where(
|
||||
DropdownOption.option_type == "sub_channel",
|
||||
DropdownOption.value == sub_channel_name,
|
||||
DropdownOption.parent_id == channel.id
|
||||
).options(selectinload(DropdownOption.children))
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
sub_channel = result.scalar_one_or_none()
|
||||
|
||||
if not sub_channel:
|
||||
return False
|
||||
|
||||
# Delete proof types, then sub-channel
|
||||
for proof_type in sub_channel.children:
|
||||
await self.session.delete(proof_type)
|
||||
await self.session.delete(sub_channel)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
async def remove_proof_type(self, channel_name: str, sub_channel_name: str, proof_type_name: str) -> bool:
|
||||
"""Remove a proof type."""
|
||||
stmt = select(DropdownOption).where(
|
||||
DropdownOption.option_type == "channel",
|
||||
DropdownOption.value == channel_name
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
channel = result.scalar_one_or_none()
|
||||
|
||||
if not channel:
|
||||
return False
|
||||
|
||||
stmt = select(DropdownOption).where(
|
||||
DropdownOption.option_type == "sub_channel",
|
||||
DropdownOption.value == sub_channel_name,
|
||||
DropdownOption.parent_id == channel.id
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
sub_channel = result.scalar_one_or_none()
|
||||
|
||||
if not sub_channel:
|
||||
return False
|
||||
|
||||
stmt = select(DropdownOption).where(
|
||||
DropdownOption.option_type == "proof_type",
|
||||
DropdownOption.value == proof_type_name,
|
||||
DropdownOption.parent_id == sub_channel.id
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
proof_type = result.scalar_one_or_none()
|
||||
|
||||
if not proof_type:
|
||||
return False
|
||||
|
||||
await self.session.delete(proof_type)
|
||||
await self.session.flush()
|
||||
return True
|
||||
856
frontend/App.tsx
856
frontend/App.tsx
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,12 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { UploadIcon } from './icons/UploadIcon';
|
||||
import { TrendingUpIcon } from './icons/TrendingUpIcon';
|
||||
import { BugIcon } from './icons/BugIcon';
|
||||
import { ClockIcon } from './icons/ClockIcon';
|
||||
import { LightbulbIcon } from './icons/LightbulbIcon';
|
||||
import apiService, { AnalyticsResponse } from '../services/apiService';
|
||||
|
||||
const stats = [
|
||||
{ name: 'Proofs Uploaded', value: '57', icon: UploadIcon },
|
||||
{ name: 'Pass Rate', value: '76%', icon: TrendingUpIcon },
|
||||
{ name: 'Issues Found', value: '34', icon: BugIcon },
|
||||
{ name: 'Time Saved', value: '93 hours', icon: ClockIcon },
|
||||
];
|
||||
|
||||
// Agent performance is still static for now - would need separate API
|
||||
const agentPerformance = [
|
||||
{ name: 'Legal Agent', passRate: 85, avgIssues: 1.2, trend: 'up' },
|
||||
{ name: 'Brand Agent', passRate: 68, avgIssues: 2.5, trend: 'down' },
|
||||
|
|
@ -34,6 +29,35 @@ const TrendIndicator: React.FC<{ trend: 'up' | 'down' | 'stable' }> = ({ trend }
|
|||
};
|
||||
|
||||
export const Analytics: React.FC = () => {
|
||||
const [analytics, setAnalytics] = useState<AnalyticsResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAnalytics = async () => {
|
||||
try {
|
||||
const data = await apiService.getAnalytics();
|
||||
setAnalytics(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load analytics:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadAnalytics();
|
||||
}, []);
|
||||
|
||||
// Calculate stats from API data
|
||||
const passRate = analytics && analytics.total_reviews > 0
|
||||
? Math.round((analytics.passed / analytics.total_reviews) * 100)
|
||||
: 0;
|
||||
|
||||
const stats = [
|
||||
{ name: 'Proofs Reviewed', value: analytics?.total_reviews?.toString() || '0', icon: UploadIcon },
|
||||
{ name: 'Pass Rate', value: `${passRate}%`, icon: TrendingUpIcon },
|
||||
{ name: 'Failed Reviews', value: analytics?.failed?.toString() || '0', icon: BugIcon },
|
||||
{ name: 'Legal Review Required', value: analytics?.legal_review?.toString() || '0', icon: ClockIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray">
|
||||
<header className="mb-8">
|
||||
|
|
|
|||
|
|
@ -311,6 +311,52 @@ class ApiService {
|
|||
timestamp: item.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
// Dropdown options endpoints
|
||||
async getDropdownOptions(): Promise<DropdownOptionsResponse> {
|
||||
return this.fetch<DropdownOptionsResponse>('/dropdown-options');
|
||||
}
|
||||
|
||||
async addChannel(name: string): Promise<void> {
|
||||
await this.fetch<void>(`/dropdown-options/channels?name=${encodeURIComponent(name)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async addSubChannel(channel: string, name: string): Promise<void> {
|
||||
await this.fetch<void>(`/dropdown-options/channels/${encodeURIComponent(channel)}/sub-channels?name=${encodeURIComponent(name)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async addProofType(channel: string, subChannel: string, name: string): Promise<void> {
|
||||
await this.fetch<void>(`/dropdown-options/channels/${encodeURIComponent(channel)}/sub-channels/${encodeURIComponent(subChannel)}/proof-types?name=${encodeURIComponent(name)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteChannel(channel: string): Promise<void> {
|
||||
await this.fetch<void>(`/dropdown-options/channels/${encodeURIComponent(channel)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSubChannel(channel: string, subChannel: string): Promise<void> {
|
||||
await this.fetch<void>(`/dropdown-options/channels/${encodeURIComponent(channel)}/sub-channels/${encodeURIComponent(subChannel)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProofType(channel: string, subChannel: string, proofType: string): Promise<void> {
|
||||
await this.fetch<void>(`/dropdown-options/channels/${encodeURIComponent(channel)}/sub-channels/${encodeURIComponent(subChannel)}/proof-types/${encodeURIComponent(proofType)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface DropdownOptionsResponse {
|
||||
campaigns: string[];
|
||||
channels: Record<string, Record<string, string[]>>;
|
||||
}
|
||||
|
||||
export const apiService = new ApiService();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue