Initial commit - WhyRating Engine (Google Reviews Scraper)
This commit is contained in:
@@ -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 => {
|
||||
|
||||
63
web/app/api/categories/route.ts
Normal file
63
web/app/api/categories/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
42
web/app/api/pipelines/reviewiq/analytics/route.ts
Normal file
42
web/app/api/pipelines/reviewiq/analytics/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
web/app/api/pipelines/reviewiq/reviews/[reviewId]/route.ts
Normal file
43
web/app/api/pipelines/reviewiq/reviews/[reviewId]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
web/app/api/pipelines/reviewiq/trends/route.ts
Normal file
42
web/app/api/pipelines/reviewiq/trends/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
37
web/app/api/sessions/validate/route.ts
Normal file
37
web/app/api/sessions/validate/route.ts
Normal 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
435
web/app/categories/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
190
web/app/pipelines/[pipelineId]/analytics/page.tsx
Normal file
190
web/app/pipelines/[pipelineId]/analytics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
101
web/app/reports/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
web/app/taxonomy/urt/v5-1/page.tsx
Normal file
145
web/app/taxonomy/urt/v5-1/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user