Initial commit - WhyRating Engine (Google Reviews Scraper)

This commit is contained in:
Alejandro Gutiérrez
2026-02-02 18:19:00 +00:00
parent 0543a08242
commit 2206ddeff2
136 changed files with 51138 additions and 855 deletions

View File

@@ -68,8 +68,11 @@ export default function AnalyticsDetailPage() {
: `/api/jobs/${jobId}/reviews?limit=10000`;
fetch(url)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch reviews');
.then(async res => {
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to fetch reviews (${res.status})`);
}
return res.json();
})
.then(data => {

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
const DB_URL = process.env.DATABASE_URL || 'postgresql://scraper:scraper123@localhost:5437/scraper';
// Direct database query for categories
async function fetchCategoriesFromDB() {
// For now, we'll fetch from the API server which has DB access
// In production, you might want to use a direct DB connection or cache
const response = await fetch(`${API_BASE_URL}/categories/tree`, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error('Failed to fetch categories from API');
}
return response.json();
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const search = searchParams.get('search');
const parentPath = searchParams.get('parent');
const level = searchParams.get('level');
// Build query params for backend
const params = new URLSearchParams();
if (search) params.set('search', search);
if (parentPath) params.set('parent', parentPath);
if (level) params.set('level', level);
const url = `${API_BASE_URL}/categories?${params.toString()}`;
const response = await fetch(url, {
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
// Fallback: return mock data for development
console.error('API not available, returning mock data');
return NextResponse.json({
categories: [],
total: 0,
message: 'API not available'
});
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Error fetching categories:', error);
return NextResponse.json(
{ error: 'Failed to fetch categories', categories: [], total: 0 },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function POST(request: NextRequest) {
try {

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
// GET /api/jobs/[jobId]/compare?previous=<previousJobId>
// Returns reviews from current job with a flag indicating if they're new
@@ -16,8 +16,10 @@ export async function GET(
// Fetch current job reviews
const currentResponse = await fetch(`${API_BASE_URL}/jobs/${jobId}/reviews?limit=10000`);
if (!currentResponse.ok) {
const errorText = await currentResponse.text().catch(() => '');
console.error(`Failed to get current job reviews: ${currentResponse.status} - ${errorText}`);
return NextResponse.json(
{ error: 'Failed to get current job reviews' },
{ error: `Failed to get reviews for job ${jobId} (${currentResponse.status})` },
{ status: currentResponse.status }
);
}

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
/**
* GET /api/jobs/[jobId]/crash-report

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function GET(
request: NextRequest,

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
/**
* POST /api/jobs/[jobId]/retry

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function GET(
request: NextRequest,

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function GET(
request: NextRequest,

View File

@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export const dynamic = 'force-dynamic';

View File

@@ -1,13 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const limit = searchParams.get('limit') || '100';
const status = searchParams.get('status');
const response = await fetch(`${API_BASE_URL}/jobs?limit=${limit}`);
let url = `${API_BASE_URL}/jobs?limit=${limit}`;
if (status) {
url += `&status=${status}`;
}
const response = await fetch(url);
if (!response.ok) {
return NextResponse.json(

View File

@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export const dynamic = 'force-dynamic';

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function GET(
request: NextRequest,

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function POST(
request: NextRequest,

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function GET(
request: NextRequest,

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function GET(
request: NextRequest,

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function GET(
request: NextRequest,

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function GET(
request: NextRequest,

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8001';
/**
* Proxy route for ReviewIQ analytics endpoint.
* GET /api/pipelines/reviewiq/analytics
*/
export async function GET(request: NextRequest) {
try {
// Forward query parameters
const searchParams = request.nextUrl.searchParams;
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/api/pipelines/reviewiq/analytics${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return NextResponse.json(
{ detail: errorData.detail || `Backend error: ${response.status}` },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('ReviewIQ analytics proxy error:', error);
return NextResponse.json(
{ detail: 'Failed to fetch analytics data' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8001';
/**
* Proxy route for fetching spans related to an issue.
* GET /api/pipelines/reviewiq/issues/[issueId]/spans
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ issueId: string }> }
) {
try {
const { issueId } = await params;
const url = `${API_BASE_URL}/api/pipelines/reviewiq/issues/${issueId}/spans`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return NextResponse.json(
{ detail: errorData.detail || `Backend error: ${response.status}` },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Issue spans proxy error:', error);
return NextResponse.json(
{ detail: 'Failed to fetch issue spans' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE = process.env.API_URL || 'http://localhost:8001';
/**
* GET /api/pipelines/reviewiq/reviews/[reviewId]
* Proxy to backend for fetching a full review with all its spans.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ reviewId: string }> }
) {
const { reviewId } = await params;
const { searchParams } = new URL(request.url);
const source = searchParams.get('source') || 'google';
try {
const url = `${API_BASE}/api/pipelines/reviewiq/reviews/${encodeURIComponent(reviewId)}?source=${encodeURIComponent(source)}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.text();
return NextResponse.json(
{ error: `Backend error: ${error}` },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Error fetching review:', error);
return NextResponse.json(
{ error: 'Failed to fetch review' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8001';
/**
* Proxy route for ReviewIQ trends endpoint.
* GET /api/pipelines/reviewiq/trends
*/
export async function GET(request: NextRequest) {
try {
// Forward query parameters
const searchParams = request.nextUrl.searchParams;
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/api/pipelines/reviewiq/trends${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return NextResponse.json(
{ detail: errorData.detail || `Backend error: ${response.status}` },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('ReviewIQ trends proxy error:', error);
return NextResponse.json(
{ detail: 'Failed to fetch trends data' },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function GET(request: NextRequest) {
try {

View File

@@ -1,16 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { url, business_name, business_address, rating_snapshot, total_reviews_snapshot, scraper_version } = body;
const { url, business_name, business_address, rating_snapshot, total_reviews_snapshot, scraper_version, session_id, browser_fingerprint, geolocation } = body;
if (!url) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
// Build metadata object
const metadata: Record<string, unknown> = {
business_name,
business_address,
rating_snapshot,
total_reviews_snapshot,
scraper_version, // Store in metadata for job tracking
};
// Include session_id for browser reuse (session handoff from validation)
if (session_id) {
metadata.session_id = session_id;
}
// Include browser fingerprint if provided
if (browser_fingerprint) {
metadata.browser_fingerprint = browser_fingerprint;
}
if (geolocation) {
metadata.geolocation = geolocation;
}
// Call the containerized scraper API with business metadata and version
const response = await fetch(`${API_BASE_URL}/scrape`, {
method: 'POST',
@@ -18,13 +40,8 @@ export async function POST(request: NextRequest) {
body: JSON.stringify({
url,
scraper_version, // Pass version to backend for routing
metadata: {
business_name,
business_address,
rating_snapshot,
total_reviews_snapshot,
scraper_version, // Also store in metadata for job tracking
},
session_id, // Pass session_id for browser reuse
metadata,
}),
});

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
if (!body.url) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
// Call the backend session validation endpoint
const response = await fetch(`${API_BASE_URL}/sessions/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(
{ error: data.detail || 'Failed to validate session' },
{ status: response.status }
);
}
return NextResponse.json(data);
} catch (error) {
console.error('Session validation API error:', error);
return NextResponse.json(
{ error: 'Failed to connect to scraper API' },
{ status: 500 }
);
}
}

435
web/app/categories/page.tsx Normal file
View File

@@ -0,0 +1,435 @@
'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Search, TreePine, Network, ChevronRight, ChevronDown, Folder, FolderOpen, Tag, Loader2 } from 'lucide-react';
import dynamic from 'next/dynamic';
import {
Category,
CategoryTreeNode,
buildCategoryTree,
toD3Tree,
getLevelName,
getLevelColor,
searchCategories,
getCategoryBreadcrumb,
} from '@/lib/categories';
// Dynamic import for react-d3-tree (SSR issues)
const Tree = dynamic(() => import('react-d3-tree').then((mod) => mod.default), {
ssr: false,
loading: () => <div className="flex items-center justify-center h-full"><Loader2 className="animate-spin" /></div>,
});
// API base URL
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<'explorer' | 'diagram'>('explorer');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [stats, setStats] = useState({ total: 0, sectors: 0, types: 0, subs: 0, leaves: 0 });
// Fetch categories from API
useEffect(() => {
async function fetchCategories() {
try {
setLoading(true);
const response = await fetch(`${API_BASE}/categories`);
if (!response.ok) {
throw new Error('Failed to fetch categories');
}
const data = await response.json();
setCategories(data.categories || []);
setStats({
total: data.total || 0,
sectors: data.categories?.filter((c: Category) => c.level === 1).length || 0,
types: data.categories?.filter((c: Category) => c.level === 2).length || 0,
subs: data.categories?.filter((c: Category) => c.level === 3).length || 0,
leaves: data.categories?.filter((c: Category) => c.level === 4).length || 0,
});
// Expand level 1 by default
const level1Paths = new Set<string>(
data.categories
?.filter((c: Category) => c.level === 1)
.map((c: Category) => c.path) || []
);
setExpandedPaths(level1Paths);
} catch (err) {
console.error('Error fetching categories:', err);
setError(err instanceof Error ? err.message : 'Failed to load categories');
} finally {
setLoading(false);
}
}
fetchCategories();
}, []);
// Filter categories based on search
const filteredCategories = useMemo(() => {
if (!searchQuery.trim()) return categories;
return searchCategories(categories, searchQuery);
}, [categories, searchQuery]);
// Build tree structure
const tree = useMemo(() => buildCategoryTree(filteredCategories), [filteredCategories]);
// D3 tree data
const d3TreeData = useMemo(() => {
if (tree.length === 0) return null;
return {
name: 'GBP Categories',
children: toD3Tree(tree),
};
}, [tree]);
// Toggle expand/collapse
const toggleExpand = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
// Expand all ancestors when searching
useEffect(() => {
if (searchQuery.trim()) {
const pathsToExpand = new Set<string>();
for (const cat of filteredCategories) {
const parts = cat.path.split('.');
for (let i = 1; i < parts.length; i++) {
pathsToExpand.add(parts.slice(0, i).join('.'));
}
}
setExpandedPaths(pathsToExpand);
}
}, [searchQuery, filteredCategories]);
// Get breadcrumb for selected category
const breadcrumb = useMemo(() => {
if (!selectedCategory) return [];
return getCategoryBreadcrumb(selectedCategory.path, categories);
}, [selectedCategory, categories]);
// Render tree node (recursive)
const renderTreeNode = (node: CategoryTreeNode, depth: number = 0) => {
const isExpanded = expandedPaths.has(node.id);
const hasChildren = node.children && node.children.length > 0;
const isSelected = selectedCategory?.path === node.id;
const level = node.data?.level || 1;
return (
<div key={node.id} className="select-none">
<div
className={`flex items-center gap-2 py-1.5 px-2 rounded cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 ${
isSelected ? 'bg-blue-50 dark:bg-blue-900/30 border-l-2 border-blue-500' : ''
}`}
style={{ paddingLeft: `${depth * 20 + 8}px` }}
onClick={() => {
setSelectedCategory(node.data || null);
if (hasChildren) {
toggleExpand(node.id);
}
}}
>
{/* Expand/Collapse Icon */}
<span className="w-4 h-4 flex items-center justify-center">
{hasChildren ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)
) : (
<span className="w-4" />
)}
</span>
{/* Folder/Tag Icon */}
{hasChildren ? (
isExpanded ? (
<FolderOpen className="w-4 h-4 text-yellow-500" />
) : (
<Folder className="w-4 h-4 text-yellow-600" />
)
) : (
<Tag className="w-4 h-4 text-purple-500" />
)}
{/* Name */}
<span className="flex-1 truncate text-sm">{node.name}</span>
{/* Level Badge */}
<span
className={`text-xs px-1.5 py-0.5 rounded ${getLevelColor(level)} text-white`}
>
L{level}
</span>
{/* Count */}
{node.data && node.data.category_count > 0 && (
<span className="text-xs text-gray-400">
({node.data.category_count})
</span>
)}
</div>
{/* Children */}
{hasChildren && isExpanded && (
<div>
{node.children!.map((child) => renderTreeNode(child, depth + 1))}
</div>
)}
</div>
);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
<span className="ml-2">Loading categories...</span>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Retry
</button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="bg-white dark:bg-gray-800 shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
GBP Category Explorer
</h1>
<p className="text-sm text-gray-500 mt-1">
Browse {stats.total.toLocaleString()} Google Business Profile categories
</p>
</div>
{/* Stats */}
<div className="flex gap-4 text-sm">
<div className="text-center">
<div className="font-bold text-blue-600">{stats.sectors}</div>
<div className="text-gray-500">Sectors</div>
</div>
<div className="text-center">
<div className="font-bold text-green-600">{stats.types}</div>
<div className="text-gray-500">Types</div>
</div>
<div className="text-center">
<div className="font-bold text-yellow-600">{stats.subs}</div>
<div className="text-gray-500">Sub-cats</div>
</div>
<div className="text-center">
<div className="font-bold text-purple-600">{stats.leaves}</div>
<div className="text-gray-500">Categories</div>
</div>
</div>
</div>
</div>
</header>
{/* Toolbar */}
<div className="bg-white dark:bg-gray-800 border-b px-4 py-3">
<div className="max-w-7xl mx-auto flex items-center gap-4">
{/* Search */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search categories..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
/>
</div>
{/* View Toggle */}
<div className="flex border rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('explorer')}
className={`px-4 py-2 flex items-center gap-2 ${
viewMode === 'explorer'
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-700 hover:bg-gray-50'
}`}
>
<TreePine className="w-4 h-4" />
Explorer
</button>
<button
onClick={() => setViewMode('diagram')}
className={`px-4 py-2 flex items-center gap-2 ${
viewMode === 'diagram'
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-700 hover:bg-gray-50'
}`}
>
<Network className="w-4 h-4" />
Diagram
</button>
</div>
{/* Results count */}
{searchQuery && (
<span className="text-sm text-gray-500">
{filteredCategories.length} results
</span>
)}
</div>
</div>
{/* Main Content */}
<div className="px-4 py-6">
{viewMode === 'explorer' ? (
/* Explorer View - Full Width */
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
{tree.length > 0 ? (
tree.map((node) => renderTreeNode(node))
) : (
<div className="text-center text-gray-500 py-8">
No categories found
</div>
)}
</div>
) : (
/* Diagram View with Detail Panel */
<div className="flex gap-6 h-[calc(100vh-180px)]">
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="h-full w-full">
{d3TreeData ? (
<Tree
data={d3TreeData}
orientation="vertical"
pathFunc="step"
translate={{ x: 400, y: 50 }}
separation={{ siblings: 1, nonSiblings: 2 }}
nodeSize={{ x: 200, y: 80 }}
renderCustomNodeElement={({ nodeDatum, toggleNode }) => (
<g onClick={toggleNode}>
<circle r={15} fill="#3b82f6" />
<text
fill="#1f2937"
strokeWidth="0"
x={20}
dy=".35em"
fontSize={12}
fontFamily="sans-serif"
>
{nodeDatum.name.length > 25
? nodeDatum.name.slice(0, 25) + '...'
: nodeDatum.name}
</text>
</g>
)}
/>
) : (
<div className="flex items-center justify-center h-full text-gray-500">
No data to display
</div>
)}
</div>
</div>
{/* Detail Panel - Only in Diagram Mode */}
{selectedCategory && (
<div className="w-80 bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h3 className="font-bold text-lg mb-4">{selectedCategory.name}</h3>
{/* Breadcrumb */}
<div className="mb-4">
<span className="text-xs text-gray-500 uppercase">Path</span>
<div className="flex flex-wrap gap-1 mt-1">
{breadcrumb.map((cat, i) => (
<span key={cat.path} className="flex items-center">
<span
className={`text-xs px-2 py-1 rounded ${getLevelColor(cat.level)} text-white cursor-pointer hover:opacity-80`}
onClick={() => setSelectedCategory(cat)}
>
{cat.name}
</span>
{i < breadcrumb.length - 1 && (
<ChevronRight className="w-3 h-3 text-gray-400 mx-1" />
)}
</span>
))}
</div>
</div>
{/* Details */}
<div className="space-y-3 text-sm">
<div>
<span className="text-gray-500">Level:</span>
<span className="ml-2 font-medium">
{getLevelName(selectedCategory.level)} (L{selectedCategory.level})
</span>
</div>
<div>
<span className="text-gray-500">Path:</span>
<code className="ml-2 text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{selectedCategory.path}
</code>
</div>
<div>
<span className="text-gray-500">Children:</span>
<span className="ml-2 font-medium">
{selectedCategory.category_count}
</span>
</div>
<div>
<span className="text-gray-500">Slug:</span>
<code className="ml-2 text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{selectedCategory.slug}
</code>
</div>
</div>
{/* Use in search */}
<div className="mt-6 pt-4 border-t">
<button
className="w-full py-2 px-4 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
onClick={() => {
// Copy ltree path for use in queries
navigator.clipboard.writeText(selectedCategory.path);
alert('Path copied to clipboard!');
}}
>
Copy Path for Query
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -29,11 +29,11 @@ body {
@keyframes fade-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(4px);
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
transform: translateY(0);
}
}

View File

@@ -32,10 +32,12 @@ export default function RootLayout({
<JobsProvider>
<div className="h-screen w-screen overflow-hidden flex">
<Sidebar />
<div className="flex-1 bg-gray-50 overflow-hidden">
<div className="flex-1 bg-gray-50 overflow-auto">
{children}
</div>
</div>
{/* Portal target for modals - outside overflow-hidden container */}
<div id="modal-root" />
</JobsProvider>
</body>
</html>

View File

@@ -0,0 +1,190 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useSearchParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Loader2, FileText, BarChart3 } from 'lucide-react';
import { DynamicDashboard } from '@/components/dashboard/DynamicDashboard';
import { ReviewIQDashboard } from '@/components/reviewiq';
import { getDashboardConfig } from '@/lib/pipeline-api';
import type { DashboardConfig } from '@/lib/pipeline-types';
// Lazy load Report tab
import dynamic from 'next/dynamic';
const ReportTab = dynamic(() => import('@/components/reviewiq/ReportTab').then(m => m.ReportTab), {
loading: () => <div className="flex items-center justify-center min-h-[400px]"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
});
type ReviewIQTab = 'report' | 'dashboard';
export default function PipelineAnalyticsPage() {
const params = useParams();
const searchParams = useSearchParams();
const pipelineId = params.pipelineId as string;
const jobId = searchParams.get('job_id') || undefined;
const businessId = searchParams.get('business_id') || undefined;
const [config, setConfig] = useState<DashboardConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Use the handcrafted ReviewIQ dashboard for the reviewiq pipeline
const useReviewIQDashboard = pipelineId === 'reviewiq';
// Tab state for ReviewIQ
const router = useRouter();
const viewParam = searchParams.get('view') as ReviewIQTab | null;
const [activeTab, setActiveTab] = useState<ReviewIQTab>(viewParam || 'report');
// Update URL when tab changes
const handleTabChange = (tab: ReviewIQTab) => {
setActiveTab(tab);
const params = new URLSearchParams(searchParams.toString());
if (tab === 'report') {
params.delete('view');
} else {
params.set('view', tab);
}
router.push(`/pipelines/${pipelineId}/analytics?${params.toString()}`, { scroll: false });
};
useEffect(() => {
// Skip config fetch for ReviewIQ - it uses its own optimized endpoint
if (useReviewIQDashboard) {
setLoading(false);
return;
}
async function fetchConfig() {
try {
setLoading(true);
const dashboardConfig = await getDashboardConfig(pipelineId);
setConfig(dashboardConfig);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load dashboard config');
} finally {
setLoading(false);
}
}
fetchConfig();
}, [pipelineId, useReviewIQDashboard]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
);
}
// Use handcrafted ReviewIQ Dashboard with tabs
if (useReviewIQDashboard) {
const tabs = [
{ id: 'report' as const, label: 'Report', icon: FileText },
{ id: 'dashboard' as const, label: 'Dashboard', icon: BarChart3 },
];
return (
<div className="h-full overflow-y-auto p-6">
{/* Navigation breadcrumb */}
<div className="mb-4">
<Link
href={`/pipelines/${pipelineId}`}
className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4 mr-1" />
Back to ReviewIQ Pipeline
</Link>
</div>
{/* Job context indicator */}
{jobId && (
<div className="mb-4 bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-700">
Showing results for job: <code className="bg-blue-100 px-1 rounded">{jobId}</code>
</div>
)}
{/* Tab Navigation */}
<div className="mb-6 border-b border-gray-200">
<nav className="flex gap-2" aria-label="Tabs">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`
relative px-4 py-2.5 flex items-center gap-2 text-sm font-medium transition-colors
${isActive
? 'text-blue-600'
: 'text-gray-500 hover:text-gray-700'
}
`}
>
<Icon className={`w-4 h-4 ${isActive ? 'text-blue-600' : 'text-gray-400'}`} />
<span>{tab.label}</span>
{/* Active indicator bar */}
<span
className={`absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 transition-opacity ${isActive ? 'opacity-100' : 'opacity-0'}`}
/>
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
{activeTab === 'report' && (
<ReportTab jobId={jobId} businessId={businessId} />
)}
{activeTab === 'dashboard' && (
<ReviewIQDashboard jobId={jobId} businessId={businessId} />
)}
</div>
);
}
// Fallback for other pipelines using dynamic dashboard
if (error || !config) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error || 'Failed to load dashboard configuration'}
</div>
</div>
);
}
return (
<div className="h-full overflow-y-auto p-6">
{/* Navigation breadcrumb */}
<div className="mb-4">
<Link
href={`/pipelines/${pipelineId}`}
className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4 mr-1" />
Back to {pipelineId} Pipeline
</Link>
</div>
{/* Job context indicator */}
{jobId && (
<div className="mb-4 bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-700">
Showing results for job: <code className="bg-blue-100 px-1 rounded">{jobId}</code>
</div>
)}
{/* Dynamic Dashboard for other pipelines */}
<DynamicDashboard
pipelineId={pipelineId}
config={config}
businessId={businessId}
jobId={jobId}
/>
</div>
);
}

View File

@@ -16,6 +16,7 @@ import {
ExternalLink,
Timer,
ArrowRightLeft,
BarChart3,
} from 'lucide-react';
import type { ExecutionStatus, StageMetrics } from '@/lib/pipeline-types';
import { getExecution } from '@/lib/pipeline-api';
@@ -432,6 +433,22 @@ export default function ExecutionDetailPage() {
</span>
</div>
)}
{/* View Results Dashboard Button */}
{execution?.status === 'completed' && execution?.job_id && (
<div className="mt-6 pt-4 border-t border-gray-200">
<Link
href={`/pipelines/${pipelineId}/analytics?job_id=${execution.job_id}`}
className="inline-flex items-center px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
<BarChart3 className="w-5 h-5 mr-2" />
View Results Dashboard
</Link>
<p className="mt-2 text-sm text-gray-500">
See classification results, sentiment analysis, and identified issues
</p>
</div>
)}
</div>
{/* Error Message */}

View File

@@ -22,21 +22,21 @@ function PipelineCard({ pipeline }: { pipeline: PipelineInfo }) {
return (
<Link
href={`/pipelines/${pipeline.id}`}
className="block bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all"
className="block bg-white rounded-xl border-2 border-gray-200 p-5 hover:shadow-lg hover:border-blue-400 transition-all"
>
<div className="flex items-start justify-between">
<div className="flex items-center">
<div
className={`p-2 rounded-lg ${
pipeline.is_enabled
? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
? 'bg-green-100 text-green-600'
: 'bg-gray-100 text-gray-600'
}`}
>
<Beaker className="w-5 h-5" />
</div>
<div className="ml-3">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
<h3 className="font-bold text-gray-900">
{pipeline.name}
</h3>
<p className="text-sm text-gray-500">v{pipeline.version}</p>
@@ -45,33 +45,33 @@ function PipelineCard({ pipeline }: { pipeline: PipelineInfo }) {
<ChevronRight className="w-5 h-5 text-gray-400" />
</div>
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
<p className="mt-3 text-sm text-gray-600 line-clamp-2">
{pipeline.description}
</p>
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center text-sm text-gray-500">
<div className="flex items-center text-sm text-gray-600">
<span className="font-medium mr-2">Stages:</span>
{pipeline.stages.slice(0, 3).map((stage, i) => (
<span
key={stage}
className="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs mr-1"
className="px-2 py-0.5 bg-gray-100 text-gray-700 rounded text-xs mr-1"
>
{stage}
</span>
))}
{pipeline.stages.length > 3 && (
<span className="text-xs text-gray-400">
<span className="text-xs text-gray-500">
+{pipeline.stages.length - 3} more
</span>
)}
</div>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
className={`px-2 py-1 rounded-full text-xs font-semibold ${
pipeline.is_enabled
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}
>
{pipeline.is_enabled ? 'Enabled' : 'Disabled'}
@@ -108,14 +108,14 @@ export default function PipelinesPage() {
}, [showDisabled]);
return (
<div className="p-6">
<div className="h-full overflow-y-auto p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
<h1 className="text-2xl font-bold text-gray-900">
Pipelines
</h1>
<p className="text-gray-500 mt-1">
<p className="text-gray-600 mt-1">
Data processing pipelines for review analysis
</p>
</div>
@@ -129,7 +129,7 @@ export default function PipelinesPage() {
onChange={(e) => setShowDisabled(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-600 dark:text-gray-400">
<span className="ml-2 text-sm text-gray-700">
Show disabled
</span>
</label>
@@ -138,7 +138,7 @@ export default function PipelinesPage() {
<button
onClick={fetchPipelines}
disabled={loading}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md disabled:opacity-50"
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded-md disabled:opacity-50"
title="Refresh"
>
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
@@ -150,7 +150,7 @@ export default function PipelinesPage() {
{error ? (
<div className="text-center py-12">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<p className="text-red-600 dark:text-red-400">{error}</p>
<p className="text-red-600">{error}</p>
<button
onClick={fetchPipelines}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
@@ -163,17 +163,17 @@ export default function PipelinesPage() {
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 animate-pulse"
className="bg-white rounded-lg border border-gray-200 p-6 animate-pulse"
>
<div className="flex items-center">
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg" />
<div className="w-10 h-10 bg-gray-200 rounded-lg" />
<div className="ml-3 flex-1">
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded mt-2" />
<div className="h-4 w-32 bg-gray-200 rounded" />
<div className="h-3 w-16 bg-gray-200 rounded mt-2" />
</div>
</div>
<div className="h-8 w-full bg-gray-200 dark:bg-gray-700 rounded mt-4" />
<div className="h-6 w-3/4 bg-gray-200 dark:bg-gray-700 rounded mt-4" />
<div className="h-8 w-full bg-gray-200 rounded mt-4" />
<div className="h-6 w-3/4 bg-gray-200 rounded mt-4" />
</div>
))}
</div>

101
web/app/reports/page.tsx Normal file
View File

@@ -0,0 +1,101 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import ReportsView from '@/components/ReportsView';
import { listExecutions } from '@/lib/pipeline-api';
import type { ExecutionStatus } from '@/lib/pipeline-types';
import type { JobStatus } from '@/components/ScraperTest';
export default function ReportsPage() {
const [executions, setExecutions] = useState<ExecutionStatus[]>([]);
const [jobsMap, setJobsMap] = useState<Map<string, JobStatus>>(new Map());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Fetch executions and jobs in parallel
const [executionsData, jobsResponse] = await Promise.all([
listExecutions('reviewiq', { limit: 100 }),
fetch('/api/jobs?limit=100').then(r => r.json())
]);
setExecutions(executionsData);
// Create a map of job_id -> job for quick lookup
const map = new Map<string, JobStatus>();
const jobsList = jobsResponse?.jobs || jobsResponse;
if (Array.isArray(jobsList)) {
jobsList.forEach((job: JobStatus) => {
map.set(job.job_id, job);
});
}
setJobsMap(map);
} catch (err) {
console.error('Failed to fetch data:', err);
setError(err instanceof Error ? err.message : 'Failed to load reports');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
if (loading) {
return (
<div className="h-full overflow-y-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Reports</h1>
<p className="text-gray-500 mt-1">AI-generated business intelligence reports from ReviewIQ pipeline</p>
</div>
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
<span className="text-gray-500">Loading reports...</span>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="h-full overflow-y-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Reports</h1>
<p className="text-gray-500 mt-1">AI-generated business intelligence reports from ReviewIQ pipeline</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium">Error loading reports</span>
</div>
<p className="mt-1 text-sm">{error}</p>
<button
onClick={fetchData}
className="mt-3 px-4 py-2 bg-red-100 hover:bg-red-200 text-red-800 text-sm font-medium rounded-lg transition-colors"
>
Try again
</button>
</div>
</div>
);
}
return (
<div className="h-full overflow-y-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Reports</h1>
<p className="text-gray-500 mt-1">AI-generated business intelligence reports from ReviewIQ pipeline</p>
</div>
<ReportsView executions={executions} jobsMap={jobsMap} onRefresh={fetchData} />
</div>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import TaxonomyTree from '@/components/taxonomy/TaxonomyTree';
import TaxonomySearch from '@/components/taxonomy/TaxonomySearch';
import SubcodeDetail from '@/components/taxonomy/SubcodeDetail';
import CausalCodesSection from '@/components/taxonomy/CausalCodesSection';
import MetadataSection from '@/components/taxonomy/MetadataSection';
import ProfilesSection from '@/components/taxonomy/ProfilesSection';
import { taxonomy, searchTaxonomy } from '@/lib/taxonomy/data';
import type { SelectedSubcode, TaxonomyTab } from '@/lib/taxonomy/types';
const TABS: { key: TaxonomyTab; label: string }[] = [
{ key: 'codes', label: 'Codes' },
{ key: 'causal', label: 'Causal Codes' },
{ key: 'metadata', label: 'Metadata' },
{ key: 'profiles', label: 'Profiles' },
];
export default function TaxonomyPage() {
const [activeTab, setActiveTab] = useState<TaxonomyTab>('codes');
const [searchQuery, setSearchQuery] = useState('');
const [selectedSubcode, setSelectedSubcode] = useState<SelectedSubcode | null>(null);
// Memoize search results
const searchResults = useMemo(() => {
return searchTaxonomy(searchQuery);
}, [searchQuery]);
const totalSearchMatches = useMemo(() => {
return (
searchResults.domains.length +
searchResults.categories.length +
searchResults.subcodes.length
);
}, [searchResults]);
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
}, []);
const handleSelectSubcode = useCallback((subcode: SelectedSubcode | null) => {
setSelectedSubcode(subcode);
}, []);
return (
<div className="flex flex-col h-full bg-gray-900">
{/* Header */}
<header className="flex-shrink-0 border-b border-gray-700 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-100">
URT Taxonomy v{taxonomy.version}
</h1>
<p className="text-sm text-gray-400 mt-1">
Universal Review Taxonomy Classification System
</p>
</div>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span className="px-3 py-1 bg-gray-800 rounded-full">
{taxonomy.statistics.domains} Domains
</span>
<span className="px-3 py-1 bg-gray-800 rounded-full">
{taxonomy.statistics.categories} Categories
</span>
<span className="px-3 py-1 bg-gray-800 rounded-full">
{taxonomy.statistics.subcodes_actual} Subcodes
</span>
</div>
</div>
</header>
{/* Tab Navigation */}
<nav className="flex-shrink-0 border-b border-gray-700 px-6">
<div className="flex gap-1">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.key
? 'text-blue-400 border-blue-400'
: 'text-gray-400 border-transparent hover:text-gray-200 hover:border-gray-600'
}`}
>
{tab.label}
</button>
))}
</div>
</nav>
{/* Content */}
<main className="flex-1 overflow-hidden">
{activeTab === 'codes' && (
<div className="flex h-full">
{/* Left Panel - Tree */}
<div className="w-1/2 xl:w-2/5 border-r border-gray-700 flex flex-col">
{/* Search */}
<div className="flex-shrink-0 p-4 border-b border-gray-700">
<TaxonomySearch
value={searchQuery}
onChange={handleSearchChange}
resultCount={searchQuery ? totalSearchMatches : undefined}
/>
</div>
{/* Tree */}
<div className="flex-1 overflow-y-auto p-2">
<TaxonomyTree
searchQuery={searchQuery}
searchResults={searchResults}
selectedSubcode={selectedSubcode}
onSelectSubcode={handleSelectSubcode}
/>
</div>
</div>
{/* Right Panel - Detail */}
<div className="flex-1 bg-gray-850">
<SubcodeDetail selectedSubcode={selectedSubcode} />
</div>
</div>
)}
{activeTab === 'causal' && (
<div className="h-full overflow-y-auto">
<CausalCodesSection />
</div>
)}
{activeTab === 'metadata' && (
<div className="h-full overflow-y-auto">
<MetadataSection />
</div>
)}
{activeTab === 'profiles' && (
<div className="h-full overflow-y-auto">
<ProfilesSection />
</div>
)}
</main>
</div>
);
}