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>
|
||||
);
|
||||
}
|
||||
@@ -293,10 +293,10 @@ ${JSON.stringify(execution.result_summary || {}, null, 2)}
|
||||
{/* Analytics button - only for completed with job_id */}
|
||||
{isCompleted && execution.job_id && (
|
||||
<Link
|
||||
href={`/analytics/${execution.job_id}`}
|
||||
href={`/pipelines/${pipelineId}/analytics?job_id=${execution.job_id}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 hover:bg-purple-200 text-purple-700 text-xs font-medium rounded transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="View Job Analytics"
|
||||
title="View Pipeline Analytics"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
|
||||
@@ -637,7 +637,7 @@ ${logsFormatted}
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
{isStuck ? 'Stuck' : status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
{isStuck ? 'Stuck' : status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
509
web/components/ReportsView.tsx
Normal file
509
web/components/ReportsView.tsx
Normal file
@@ -0,0 +1,509 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
SortingState,
|
||||
ColumnFiltersState,
|
||||
} from '@tanstack/react-table';
|
||||
import { ExecutionStatus } from '@/lib/pipeline-types';
|
||||
import { JobStatus } from './ScraperTest';
|
||||
|
||||
interface ReportsViewProps {
|
||||
executions: ExecutionStatus[];
|
||||
jobsMap: Map<string, JobStatus>;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
// Helper to format duration
|
||||
function formatDuration(ms: number | null | undefined): string {
|
||||
if (!ms) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = ms / 1000;
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
// Sort icon component
|
||||
function SortIcon({ sorted }: { sorted: false | 'asc' | 'desc' }) {
|
||||
if (!sorted) {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className={`w-4 h-4 ${sorted === 'asc' ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Extract business name from execution using jobs map
|
||||
function extractBusinessName(execution: ExecutionStatus, jobsMap: Map<string, JobStatus>): string {
|
||||
// First try to get from the linked job
|
||||
if (execution.job_id) {
|
||||
const job = jobsMap.get(execution.job_id);
|
||||
if (job?.business_name) return job.business_name;
|
||||
}
|
||||
// Try to get from result_summary
|
||||
if (execution.result_summary) {
|
||||
const summary = execution.result_summary as Record<string, unknown>;
|
||||
if (summary.business_name) return String(summary.business_name);
|
||||
}
|
||||
// Try to get from input_summary
|
||||
if (execution.input_summary) {
|
||||
const summary = execution.input_summary as Record<string, unknown>;
|
||||
if (summary.business_name) return String(summary.business_name);
|
||||
}
|
||||
// Fallback to business_id
|
||||
if (execution.business_id) return execution.business_id;
|
||||
return 'Unknown Business';
|
||||
}
|
||||
|
||||
// Extract review count from execution using jobs map
|
||||
function extractReviewCount(execution: ExecutionStatus, jobsMap: Map<string, JobStatus>): number | null {
|
||||
// First try to get from the linked job
|
||||
if (execution.job_id) {
|
||||
const job = jobsMap.get(execution.job_id);
|
||||
if (job?.reviews_count) return job.reviews_count;
|
||||
}
|
||||
if (execution.result_summary) {
|
||||
const summary = execution.result_summary as Record<string, unknown>;
|
||||
if (typeof summary.review_count === 'number') return summary.review_count;
|
||||
if (typeof summary.reviews_processed === 'number') return summary.reviews_processed;
|
||||
}
|
||||
if (execution.input_summary) {
|
||||
const summary = execution.input_summary as Record<string, unknown>;
|
||||
if (typeof summary.review_count === 'number') return summary.review_count;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ReportsView({ executions, jobsMap, onRefresh }: ReportsViewProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: 'created_at', desc: true }]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
|
||||
// Filter executions by status
|
||||
const filteredExecutions = useMemo(() => {
|
||||
if (statusFilter === 'all') return executions;
|
||||
return executions.filter(e => e.status === statusFilter);
|
||||
}, [executions, statusFilter]);
|
||||
|
||||
// Status counts for filter badges
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = { all: executions.length };
|
||||
executions.forEach(e => {
|
||||
counts[e.status] = (counts[e.status] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [executions]);
|
||||
|
||||
// Calculate summary stats
|
||||
const stats = useMemo(() => {
|
||||
const completed = executions.filter(e => e.status === 'completed');
|
||||
const failed = executions.filter(e => e.status === 'failed');
|
||||
const running = executions.filter(e => e.status === 'running');
|
||||
|
||||
const totalReviews = completed.reduce((sum, e) => {
|
||||
const count = extractReviewCount(e, jobsMap);
|
||||
return sum + (count || 0);
|
||||
}, 0);
|
||||
|
||||
const avgDuration = completed.length > 0
|
||||
? completed.reduce((sum, e) => sum + (e.total_duration_ms || 0), 0) / completed.length
|
||||
: 0;
|
||||
|
||||
const successRate = executions.length > 0
|
||||
? (completed.length / executions.length) * 100
|
||||
: 0;
|
||||
|
||||
// Reports today
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const reportsToday = executions.filter(e => new Date(e.created_at || '') >= today).length;
|
||||
|
||||
return {
|
||||
total: executions.length,
|
||||
completed: completed.length,
|
||||
failed: failed.length,
|
||||
running: running.length,
|
||||
totalReviews,
|
||||
avgDuration,
|
||||
successRate,
|
||||
reportsToday,
|
||||
};
|
||||
}, [executions, jobsMap]);
|
||||
|
||||
const columns = useMemo<ColumnDef<ExecutionStatus>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'business',
|
||||
header: ({ column }) => (
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||
>
|
||||
Business
|
||||
<SortIcon sorted={column.getIsSorted()} />
|
||||
</button>
|
||||
),
|
||||
accessorFn: (row) => extractBusinessName(row, jobsMap),
|
||||
cell: ({ row }) => {
|
||||
const name = extractBusinessName(row.original, jobsMap);
|
||||
const reviewCount = extractReviewCount(row.original, jobsMap);
|
||||
return (
|
||||
<div className="max-w-xs">
|
||||
<div className="font-semibold text-gray-900 truncate" title={name}>
|
||||
{name}
|
||||
</div>
|
||||
{reviewCount !== null && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{reviewCount.toLocaleString()} reviews analyzed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'job_id',
|
||||
header: 'Job',
|
||||
cell: ({ row }) => {
|
||||
const jobId = row.original.job_id;
|
||||
if (!jobId) return <span className="text-gray-400">-</span>;
|
||||
return (
|
||||
<Link
|
||||
href={`/jobs/${jobId}`}
|
||||
className="text-blue-600 hover:text-blue-800 text-xs font-mono bg-blue-50 px-2 py-1 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{jobId.slice(0, 8)}...
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||
>
|
||||
Status
|
||||
<SortIcon sorted={column.getIsSorted()} />
|
||||
</button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||
status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
status === 'running' ? 'bg-blue-100 text-blue-800' :
|
||||
status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
status === 'cancelled' ? 'bg-gray-100 text-gray-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{status === 'running' && (
|
||||
<div className="w-2 h-2 border border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'stages_completed',
|
||||
header: 'Stages',
|
||||
cell: ({ row }) => {
|
||||
const requested = row.original.stages_requested || [];
|
||||
const completed = row.original.stages_completed || [];
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{requested.map((stage) => (
|
||||
<span
|
||||
key={stage}
|
||||
className={`px-1.5 py-0.5 text-xs rounded ${
|
||||
completed.includes(stage)
|
||||
? 'bg-green-100 text-green-700'
|
||||
: row.original.current_stage === stage
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
title={stage}
|
||||
>
|
||||
{stage.slice(0, 3)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'total_duration_ms',
|
||||
header: ({ column }) => (
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||
>
|
||||
Duration
|
||||
<SortIcon sorted={column.getIsSorted()} />
|
||||
</button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
{formatDuration(row.original.total_duration_ms)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => (
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||
>
|
||||
Created
|
||||
<SortIcon sorted={column.getIsSorted()} />
|
||||
</button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const dateStr = row.original.created_at;
|
||||
if (!dateStr) return <span className="text-gray-400">-</span>;
|
||||
const date = new Date(dateStr);
|
||||
const isToday = new Date().toDateString() === date.toDateString();
|
||||
return (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{isToday ? 'Today' : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const dateA = rowA.original.created_at ? new Date(rowA.original.created_at).getTime() : 0;
|
||||
const dateB = rowB.original.created_at ? new Date(rowB.original.created_at).getTime() : 0;
|
||||
return dateA - dateB;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
const execution = row.original;
|
||||
const isCompleted = execution.status === 'completed';
|
||||
const hasJobId = !!execution.job_id;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View Report - only for completed with job_id */}
|
||||
{isCompleted && hasJobId && (
|
||||
<Link
|
||||
href={`/pipelines/reviewiq/analytics?job_id=${execution.job_id}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 hover:bg-purple-700 text-white text-xs font-semibold rounded-lg transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="View Report"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
View Report
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Metrics button */}
|
||||
<Link
|
||||
href={`/pipelines/reviewiq/executions/${execution.id}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs font-medium rounded transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="View Execution Details"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Details
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[jobsMap]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredExecutions,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Total Reports</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Success Rate</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.successRate.toFixed(0)}%</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Reviews Analyzed</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.totalReviews.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Today</div>
|
||||
<div className="text-2xl font-bold text-purple-600">{stats.reportsToday}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{['all', 'completed', 'running', 'failed', 'pending'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
statusFilter === status
|
||||
? status === 'completed' ? 'bg-green-600 text-white' :
|
||||
status === 'running' ? 'bg-blue-600 text-white' :
|
||||
status === 'failed' ? 'bg-red-600 text-white' :
|
||||
'bg-gray-800 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
{statusCounts[status] !== undefined && (
|
||||
<span className="ml-1.5 text-xs opacity-80">({statusCounts[status]})</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center text-gray-500">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<svg className="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span>No reports found</span>
|
||||
<span className="text-sm text-gray-400">Run a pipeline to generate reports</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-4 py-3">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Showing {table.getRowModel().rows.length} of {filteredExecutions.length} reports
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ReviewAnalytics from './ReviewAnalytics';
|
||||
|
||||
interface Review {
|
||||
@@ -63,6 +64,9 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
||||
const [businessRating, setBusinessRating] = useState<number | null>(null);
|
||||
const [businessImage, setBusinessImage] = useState<string | null>(null);
|
||||
const [businessCategory, setBusinessCategory] = useState<string | null>(null);
|
||||
// Session handoff - store session_id from validation for browser reuse
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [sessionExpiresIn, setSessionExpiresIn] = useState<number | null>(null);
|
||||
|
||||
// Scraper version selection - v1.1.0 is default (multi-sort enabled)
|
||||
const AVAILABLE_VERSIONS = [
|
||||
@@ -309,6 +313,7 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
||||
}, [jobs, onJobsChange]);
|
||||
|
||||
// Check for reviews function (called manually when user clicks Validate)
|
||||
// Uses session handoff - keeps browser alive for reuse during scraping
|
||||
const checkReviews = async (query: string) => {
|
||||
// Abort any previous validation request
|
||||
if (abortControllerRef.current) {
|
||||
@@ -323,6 +328,8 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
||||
setBusinessRating(null);
|
||||
setBusinessImage(null);
|
||||
setBusinessCategory(null);
|
||||
setSessionId(null);
|
||||
setSessionExpiresIn(null);
|
||||
setError('');
|
||||
|
||||
// Create new abort controller with 60 second timeout (validation can be slow)
|
||||
@@ -334,13 +341,15 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
||||
// Force English with hl=en parameter
|
||||
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}&hl=en`;
|
||||
|
||||
const response = await fetch('/api/check-reviews', {
|
||||
// Use session validation endpoint - keeps browser alive for reuse
|
||||
const response = await fetch('/api/sessions/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
geolocation: userFingerprint.geolocation,
|
||||
browser_fingerprint: userFingerprint // Pass full fingerprint
|
||||
browser_fingerprint: userFingerprint, // Pass full fingerprint
|
||||
session_ttl: 300 // 5 minute session TTL
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -350,13 +359,19 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setHasReviews(data.has_reviews);
|
||||
const businessInfo = data.business_info || {};
|
||||
setHasReviews(data.total_reviews > 0);
|
||||
setAvailableReviewCount(data.total_reviews || 0);
|
||||
setBusinessName(data.name);
|
||||
setBusinessAddress(data.address);
|
||||
setBusinessRating(data.rating);
|
||||
setBusinessImage(data.image_url);
|
||||
setBusinessCategory(data.category);
|
||||
setBusinessName(businessInfo.name);
|
||||
setBusinessAddress(businessInfo.address);
|
||||
setBusinessRating(businessInfo.rating);
|
||||
setBusinessCategory(businessInfo.category);
|
||||
// Store session_id for browser reuse during scraping
|
||||
if (data.session_id) {
|
||||
setSessionId(data.session_id);
|
||||
setSessionExpiresIn(data.expires_in);
|
||||
console.log(`Session created: ${data.session_id} (expires in ${data.expires_in}s)`);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to get business info:', data.error);
|
||||
// Business not found
|
||||
@@ -465,21 +480,31 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
||||
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(searchedQuery)}&hl=en`;
|
||||
|
||||
try {
|
||||
// Build request body - include session_id if available for browser reuse
|
||||
const requestBody: Record<string, unknown> = {
|
||||
url,
|
||||
business_name: businessName,
|
||||
business_address: businessAddress,
|
||||
rating_snapshot: businessRating,
|
||||
total_reviews_snapshot: availableReviewCount,
|
||||
geolocation: userFingerprint.geolocation,
|
||||
browser_fingerprint: userFingerprint, // Pass full fingerprint
|
||||
// Google Reviews scraper (this component is specific to Google Reviews)
|
||||
job_type: 'google-reviews',
|
||||
scraper_version: scraperVersion, // Selected scraper version
|
||||
};
|
||||
|
||||
// If we have a session_id from validation, use it for browser reuse
|
||||
// This saves 4-16 seconds by skipping navigation
|
||||
if (sessionId) {
|
||||
requestBody.session_id = sessionId;
|
||||
console.log(`Using session handoff: ${sessionId}`);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/scrape', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
business_name: businessName,
|
||||
business_address: businessAddress,
|
||||
rating_snapshot: businessRating,
|
||||
total_reviews_snapshot: availableReviewCount,
|
||||
geolocation: userFingerprint.geolocation,
|
||||
browser_fingerprint: userFingerprint, // Pass full fingerprint
|
||||
// Google Reviews scraper (this component is specific to Google Reviews)
|
||||
job_type: 'google-reviews',
|
||||
scraper_version: scraperVersion, // Selected scraper version
|
||||
}),
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -972,6 +997,7 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
||||
</div>
|
||||
|
||||
{Array.from(jobs.values())
|
||||
.filter(job => job && job.status)
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.map(job => (
|
||||
<div
|
||||
@@ -1278,15 +1304,14 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{showConfirmModal && (
|
||||
{/* Confirmation Modal - rendered via portal to modal-root for proper centering */}
|
||||
{showConfirmModal && typeof document !== 'undefined' && document.getElementById('modal-root') && createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
>
|
||||
<div
|
||||
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-2xl shadow-2xl border-2 border-green-500 animate-fade-in"
|
||||
style={{ width: '400px', maxWidth: 'calc(100vw - 32px)' }}
|
||||
className="bg-white rounded-2xl shadow-2xl border-2 border-green-500 animate-fade-in w-full max-w-[400px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -1370,7 +1395,8 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.getElementById('modal-root')!
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,16 @@ export default function Sidebar() {
|
||||
matchPaths: ['/jobs'],
|
||||
badge: jobs.length > 0 ? jobs.length : undefined,
|
||||
},
|
||||
{
|
||||
href: '/reports',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Reports',
|
||||
matchPaths: ['/reports'],
|
||||
},
|
||||
{
|
||||
href: '/analytics',
|
||||
icon: (
|
||||
@@ -70,6 +80,26 @@ export default function Sidebar() {
|
||||
label: 'Scrapers',
|
||||
matchPaths: ['/dashboard/scrapers'],
|
||||
},
|
||||
{
|
||||
href: '/taxonomy/urt/v5-1',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Taxonomy',
|
||||
matchPaths: ['/taxonomy'],
|
||||
},
|
||||
{
|
||||
href: '/categories',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
label: 'GBP Cats',
|
||||
matchPaths: ['/categories'],
|
||||
},
|
||||
];
|
||||
|
||||
const isActive = (item: typeof navItems[0]) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ interface DashboardSectionProps {
|
||||
section: DashboardSectionType;
|
||||
pipelineId: string;
|
||||
businessId?: string;
|
||||
jobId?: string;
|
||||
timeRange?: string;
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ export function DashboardSection({
|
||||
section,
|
||||
pipelineId,
|
||||
businessId,
|
||||
jobId,
|
||||
timeRange = '30d',
|
||||
}: DashboardSectionProps) {
|
||||
const [collapsed, setCollapsed] = useState(section.collapsed ?? false);
|
||||
@@ -37,6 +39,7 @@ export function DashboardSection({
|
||||
try {
|
||||
const data = await getWidgetData(pipelineId, widgetId, {
|
||||
business_id: businessId,
|
||||
job_id: jobId,
|
||||
time_range: timeRange,
|
||||
page: page || tablePagination[widgetId] || 1,
|
||||
});
|
||||
@@ -50,7 +53,7 @@ export function DashboardSection({
|
||||
setWidgetLoading((prev) => ({ ...prev, [widgetId]: false }));
|
||||
}
|
||||
},
|
||||
[pipelineId, businessId, timeRange, tablePagination]
|
||||
[pipelineId, businessId, jobId, timeRange, tablePagination]
|
||||
);
|
||||
|
||||
// Fetch all widget data on mount and when params change
|
||||
@@ -60,7 +63,7 @@ export function DashboardSection({
|
||||
fetchWidgetData(widget.id);
|
||||
});
|
||||
}
|
||||
}, [section.widgets, collapsed, pipelineId, businessId, timeRange]);
|
||||
}, [section.widgets, collapsed, pipelineId, businessId, jobId, timeRange]);
|
||||
|
||||
// Handle page change for tables
|
||||
const handlePageChange = (widgetId: string, page: number) => {
|
||||
@@ -109,7 +112,7 @@ export function DashboardSection({
|
||||
<ChevronDown className="w-5 h-5 text-gray-500 mr-2" />
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 group-hover:text-blue-600">
|
||||
<h2 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600">
|
||||
{section.title}
|
||||
</h2>
|
||||
{section.description && (
|
||||
|
||||
@@ -9,6 +9,7 @@ interface DynamicDashboardProps {
|
||||
pipelineId: string;
|
||||
config: DashboardConfig;
|
||||
businessId?: string;
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
// Time range options
|
||||
@@ -31,6 +32,7 @@ export function DynamicDashboard({
|
||||
pipelineId,
|
||||
config,
|
||||
businessId: initialBusinessId,
|
||||
jobId,
|
||||
}: DynamicDashboardProps) {
|
||||
const [timeRange, setTimeRange] = useState(config.default_time_range || '30d');
|
||||
const [businessId, setBusinessId] = useState(initialBusinessId);
|
||||
@@ -46,11 +48,11 @@ export function DynamicDashboard({
|
||||
{/* Dashboard Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{config.title}
|
||||
</h1>
|
||||
{config.description && (
|
||||
<p className="text-gray-500 mt-1">{config.description}</p>
|
||||
<p className="text-gray-600 mt-1">{config.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -58,7 +60,7 @@ export function DynamicDashboard({
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Business Filter (placeholder) */}
|
||||
{businessId && (
|
||||
<div className="flex items-center text-sm text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded-md">
|
||||
<div className="flex items-center text-sm text-gray-700 bg-gray-100 px-3 py-2 rounded-md">
|
||||
<Building2 className="w-4 h-4 mr-2" />
|
||||
<span className="truncate max-w-[150px]">{businessId}</span>
|
||||
</div>
|
||||
@@ -69,7 +71,7 @@ export function DynamicDashboard({
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
className="appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md pl-9 pr-8 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="appearance-none bg-white border border-gray-300 rounded-md pl-9 pr-8 py-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{TIME_RANGES.map((range) => (
|
||||
<option key={range.value} value={range.value}>
|
||||
@@ -83,7 +85,7 @@ export function DynamicDashboard({
|
||||
{/* Refresh Button */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
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"
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded-md"
|
||||
title="Refresh all widgets"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
@@ -98,6 +100,7 @@ export function DynamicDashboard({
|
||||
section={section}
|
||||
pipelineId={pipelineId}
|
||||
businessId={businessId}
|
||||
jobId={jobId}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function BarChartWidget({
|
||||
error,
|
||||
onRefresh,
|
||||
}: BarChartWidgetProps) {
|
||||
const chartConfig = config.config as ChartWidgetConfig;
|
||||
const chartConfig = config.config as unknown as ChartWidgetConfig;
|
||||
const chartData = data?.data || [];
|
||||
|
||||
return (
|
||||
@@ -58,25 +58,25 @@ export function BarChartWidget({
|
||||
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
||||
>
|
||||
{chartConfig.show_grid !== false && (
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#d1d5db" />
|
||||
)}
|
||||
<XAxis
|
||||
dataKey={chartConfig.x_axis?.key || 'x'}
|
||||
tick={{ fontSize: 12 }}
|
||||
tick={{ fontSize: 12, fill: '#374151' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
axisLine={{ stroke: '#d1d5db' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tick={{ fontSize: 12, fill: '#374151' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
axisLine={{ stroke: '#d1d5db' }}
|
||||
label={
|
||||
chartConfig.y_axis?.label
|
||||
? {
|
||||
value: chartConfig.y_axis.label,
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { fontSize: 12 },
|
||||
style: { fontSize: 12, fill: '#374151' },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function DataTableWidget({
|
||||
onPageChange,
|
||||
currentPage = 1,
|
||||
}: DataTableWidgetProps) {
|
||||
const tableConfig = config.config as TableWidgetConfig;
|
||||
const tableConfig = config.config as unknown as TableWidgetConfig;
|
||||
const rows = data?.data || [];
|
||||
const total = data?.total || 0;
|
||||
const pageSize = tableConfig.page_size || 10;
|
||||
@@ -42,13 +42,13 @@ export function DataTableWidget({
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800 sticky top-0">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
{tableConfig.columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${
|
||||
className={`px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider ${
|
||||
col.align === 'right'
|
||||
? 'text-right'
|
||||
: col.align === 'center'
|
||||
@@ -62,16 +62,16 @@ export function DataTableWidget({
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={row[tableConfig.row_key] as string || rowIndex}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
className="hover:bg-gray-50"
|
||||
>
|
||||
{tableConfig.columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-4 py-3 text-sm text-gray-900 dark:text-gray-100 whitespace-nowrap ${
|
||||
className={`px-4 py-3 text-sm text-gray-900 whitespace-nowrap ${
|
||||
col.align === 'right'
|
||||
? 'text-right'
|
||||
: col.align === 'center'
|
||||
@@ -90,8 +90,8 @@ export function DataTableWidget({
|
||||
|
||||
{/* Pagination */}
|
||||
{tableConfig.show_pagination !== false && totalPages > 1 && onPageChange && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<div className="text-sm text-gray-600">
|
||||
Showing {(currentPage - 1) * pageSize + 1} to{' '}
|
||||
{Math.min(currentPage * pageSize, total)} of {total}
|
||||
</div>
|
||||
@@ -99,17 +99,17 @@ export function DataTableWidget({
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="p-1 rounded text-gray-600 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<span className="text-sm text-gray-700">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="p-1 rounded text-gray-600 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function HeatmapWidget({
|
||||
error,
|
||||
onRefresh,
|
||||
}: HeatmapWidgetProps) {
|
||||
const heatmapConfig = config.config as HeatmapConfig;
|
||||
const heatmapConfig = config.config as unknown as HeatmapConfig;
|
||||
const rawData = data?.data || [];
|
||||
|
||||
// Extract unique x and y values
|
||||
@@ -77,7 +77,7 @@ export function HeatmapWidget({
|
||||
<tbody>
|
||||
{yValues.map((y) => (
|
||||
<tr key={y}>
|
||||
<td className="px-2 py-2 text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
<td className="px-2 py-2 text-xs font-medium text-gray-700">
|
||||
{y}
|
||||
</td>
|
||||
{xValues.map((x) => {
|
||||
|
||||
@@ -42,7 +42,7 @@ export function LineChartWidget({
|
||||
error,
|
||||
onRefresh,
|
||||
}: LineChartWidgetProps) {
|
||||
const chartConfig = config.config as ChartWidgetConfig;
|
||||
const chartConfig = config.config as unknown as ChartWidgetConfig;
|
||||
const chartData = data?.data || [];
|
||||
|
||||
return (
|
||||
@@ -58,25 +58,25 @@ export function LineChartWidget({
|
||||
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
||||
>
|
||||
{chartConfig.show_grid !== false && (
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#d1d5db" />
|
||||
)}
|
||||
<XAxis
|
||||
dataKey={chartConfig.x_axis?.key || 'x'}
|
||||
tick={{ fontSize: 12 }}
|
||||
tick={{ fontSize: 12, fill: '#374151' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
axisLine={{ stroke: '#d1d5db' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tick={{ fontSize: 12, fill: '#374151' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
axisLine={{ stroke: '#d1d5db' }}
|
||||
label={
|
||||
chartConfig.y_axis?.label
|
||||
? {
|
||||
value: chartConfig.y_axis.label,
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { fontSize: 12 },
|
||||
style: { fontSize: 12, fill: '#374151' },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function PieChartWidget({
|
||||
error,
|
||||
onRefresh,
|
||||
}: PieChartWidgetProps) {
|
||||
const chartConfig = config.config as PieChartConfig;
|
||||
const chartConfig = config.config as unknown as PieChartConfig;
|
||||
const chartData = data?.data || [];
|
||||
const colors = chartConfig.colors || DEFAULT_COLORS;
|
||||
const innerRadius = chartConfig.inner_radius || 0; // 0 = pie, > 0 = donut
|
||||
@@ -71,7 +71,7 @@ export function PieChartWidget({
|
||||
nameKey="name"
|
||||
label={
|
||||
chartConfig.show_labels !== false
|
||||
? ({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`
|
||||
? ({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`
|
||||
: undefined
|
||||
}
|
||||
labelLine={chartConfig.show_labels !== false}
|
||||
@@ -89,7 +89,7 @@ export function PieChartWidget({
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '0.375rem',
|
||||
}}
|
||||
formatter={(value: number) => [value.toLocaleString(), 'Count']}
|
||||
formatter={(value) => [(value ?? 0).toLocaleString(), 'Count']}
|
||||
/>
|
||||
{chartConfig.show_legend !== false && (
|
||||
<Legend
|
||||
|
||||
@@ -29,14 +29,14 @@ const ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
activity: Activity,
|
||||
};
|
||||
|
||||
// Color mapping
|
||||
// Color mapping (light mode optimized for bg-gray-50 background)
|
||||
const COLORS: Record<string, string> = {
|
||||
blue: 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-900/30',
|
||||
green: 'text-green-600 bg-green-100 dark:text-green-400 dark:bg-green-900/30',
|
||||
red: 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/30',
|
||||
yellow: 'text-yellow-600 bg-yellow-100 dark:text-yellow-400 dark:bg-yellow-900/30',
|
||||
purple: 'text-purple-600 bg-purple-100 dark:text-purple-400 dark:bg-purple-900/30',
|
||||
gray: 'text-gray-600 bg-gray-100 dark:text-gray-400 dark:bg-gray-700',
|
||||
blue: 'text-blue-700 bg-blue-100',
|
||||
green: 'text-green-700 bg-green-100',
|
||||
red: 'text-red-700 bg-red-100',
|
||||
yellow: 'text-yellow-700 bg-yellow-100',
|
||||
purple: 'text-purple-700 bg-purple-100',
|
||||
gray: 'text-gray-700 bg-gray-100',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -70,7 +70,7 @@ function formatValue(value: number | string, format?: string): string {
|
||||
* Stat card widget for displaying KPIs.
|
||||
*/
|
||||
export function StatCard({ config, data, loading, error, onRefresh }: StatCardProps) {
|
||||
const widgetConfig = config.config as StatCardConfig;
|
||||
const widgetConfig = config.config as unknown as StatCardConfig;
|
||||
const Icon = widgetConfig.icon ? ICONS[widgetConfig.icon] : Activity;
|
||||
const colorClass = widgetConfig.color ? COLORS[widgetConfig.color] : COLORS.gray;
|
||||
|
||||
@@ -82,7 +82,7 @@ export function StatCard({ config, data, loading, error, onRefresh }: StatCardPr
|
||||
<WidgetWrapper config={config} loading={loading} error={error} onRefresh={onRefresh}>
|
||||
<div className="flex items-center justify-between h-full">
|
||||
<div className="flex-1">
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
{formatValue(value, widgetConfig.format)}
|
||||
</p>
|
||||
{trend !== undefined && (
|
||||
|
||||
@@ -23,17 +23,17 @@ export function WidgetWrapper({
|
||||
children,
|
||||
}: WidgetWrapperProps) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm h-full flex flex-col">
|
||||
<div className="bg-white rounded-lg border-2 border-gray-200 shadow-sm h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100 text-sm">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900 text-sm">
|
||||
{config.title}
|
||||
</h3>
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-50"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
@@ -47,14 +47,14 @@ export function WidgetWrapper({
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-8 h-8 text-red-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-pulse flex flex-col items-center">
|
||||
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
|
||||
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-4 w-24 bg-gray-200 rounded mb-2" />
|
||||
<div className="h-3 w-16 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
511
web/components/reviewiq/ExplorerView.tsx
Normal file
511
web/components/reviewiq/ExplorerView.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { TrendingDown, AlertCircle, Loader2, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
DOMAIN_FRIENDLY,
|
||||
DOMAIN_COLORS,
|
||||
TimeRange,
|
||||
Granularity,
|
||||
URTDomain,
|
||||
} from './types';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
interface TrendDataPoint {
|
||||
date: string;
|
||||
count: number;
|
||||
positive: number;
|
||||
negative: number;
|
||||
review_count: number;
|
||||
sentiment_score: number; // -100 to +100
|
||||
// Rating impact - THE BUSINESS VALUE
|
||||
avg_rating_negative: number | null; // Avg stars when complaints mention this category
|
||||
avg_rating_positive: number | null; // Avg stars when praise mentions this category
|
||||
}
|
||||
|
||||
interface TrendItem {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
data: TrendDataPoint[];
|
||||
}
|
||||
|
||||
interface ExplorerViewProps {
|
||||
jobId?: string;
|
||||
businessId?: string;
|
||||
}
|
||||
|
||||
// ==================== Constants ====================
|
||||
|
||||
const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; description: string }[] = [
|
||||
{ value: '7d', label: '7D', description: 'Last 7 days' },
|
||||
{ value: '14d', label: '2W', description: 'Last 2 weeks' },
|
||||
{ value: '30d', label: '1M', description: 'Last month' },
|
||||
{ value: '90d', label: '3M', description: 'Last 3 months' },
|
||||
{ value: '1y', label: '1Y', description: 'Last year' },
|
||||
{ value: 'all', label: 'All', description: 'All time' },
|
||||
];
|
||||
|
||||
const DOMAIN_OPTIONS: { value: URTDomain; label: string; emoji: string; color: string }[] = [
|
||||
{ value: 'P', label: DOMAIN_FRIENDLY['P'].label, emoji: DOMAIN_FRIENDLY['P'].emoji, color: DOMAIN_COLORS['P'] },
|
||||
{ value: 'V', label: DOMAIN_FRIENDLY['V'].label, emoji: DOMAIN_FRIENDLY['V'].emoji, color: DOMAIN_COLORS['V'] },
|
||||
{ value: 'J', label: DOMAIN_FRIENDLY['J'].label, emoji: DOMAIN_FRIENDLY['J'].emoji, color: DOMAIN_COLORS['J'] },
|
||||
{ value: 'O', label: DOMAIN_FRIENDLY['O'].label, emoji: DOMAIN_FRIENDLY['O'].emoji, color: DOMAIN_COLORS['O'] },
|
||||
{ value: 'A', label: DOMAIN_FRIENDLY['A'].label, emoji: DOMAIN_FRIENDLY['A'].emoji, color: DOMAIN_COLORS['A'] },
|
||||
{ value: 'E', label: DOMAIN_FRIENDLY['E'].label, emoji: DOMAIN_FRIENDLY['E'].emoji, color: DOMAIN_COLORS['E'] },
|
||||
{ value: 'R', label: DOMAIN_FRIENDLY['R'].label, emoji: DOMAIN_FRIENDLY['R'].emoji, color: DOMAIN_COLORS['R'] },
|
||||
];
|
||||
|
||||
// Metric options for dropdown
|
||||
type MetricType = 'damage' | 'sentiment';
|
||||
const METRIC_OPTIONS: { value: MetricType; label: string; description: string }[] = [
|
||||
{ value: 'damage', label: '📉 Reputation Damage', description: 'Cumulative star loss from complaints' },
|
||||
{ value: 'sentiment', label: '📊 Net Mentions', description: 'Positive minus negative (cumulative)' },
|
||||
];
|
||||
|
||||
// Map time range to granularity
|
||||
const getGranularity = (timeRange: TimeRange): Granularity => {
|
||||
switch (timeRange) {
|
||||
case '7d':
|
||||
case '14d':
|
||||
return 'day';
|
||||
case '30d':
|
||||
return 'week';
|
||||
case '90d':
|
||||
return 'week';
|
||||
case '1y':
|
||||
return 'month';
|
||||
case 'all':
|
||||
return 'month';
|
||||
default:
|
||||
return 'week';
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Component ====================
|
||||
|
||||
const MAX_CATEGORIES = 4;
|
||||
|
||||
export function ExplorerView({ jobId, businessId }: ExplorerViewProps) {
|
||||
// State
|
||||
const [selectedCategories, setSelectedCategories] = useState<URTDomain[]>(['P', 'V']);
|
||||
const [selectedMetric, setSelectedMetric] = useState<MetricType>('damage');
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('1y');
|
||||
const [trendData, setTrendData] = useState<TrendItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isMetricOpen, setIsMetricOpen] = useState(false);
|
||||
|
||||
// Fetch trend data when selection or time range changes
|
||||
useEffect(() => {
|
||||
if (selectedCategories.length === 0) {
|
||||
setTrendData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchTrendData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const granularity = getGranularity(timeRange);
|
||||
const itemsParam = selectedCategories.join(',');
|
||||
const url = `/api/pipelines/reviewiq/trends?job_id=${jobId}&items=${itemsParam}&time_range=${timeRange}&granularity=${granularity}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch trend data: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: TrendItem[] = await response.json();
|
||||
setTrendData(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching trend data:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load trend data');
|
||||
setTrendData([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTrendData();
|
||||
}, [jobId, selectedCategories, timeRange]);
|
||||
|
||||
// Transform data for Recharts - calculate cumulative damage AND normalized sentiment
|
||||
const chartData = useMemo(() => {
|
||||
if (trendData.length === 0) return [];
|
||||
|
||||
// Get all unique dates
|
||||
const dateSet = new Set<string>();
|
||||
trendData.forEach((item) => {
|
||||
item.data.forEach((d) => dateSet.add(d.date));
|
||||
});
|
||||
const dates = Array.from(dateSet).sort(
|
||||
(a, b) => new Date(a).getTime() - new Date(b).getTime()
|
||||
);
|
||||
|
||||
// Track cumulative values per category
|
||||
const cumulatives: Record<string, {
|
||||
damage: number;
|
||||
totalPositive: number;
|
||||
totalNegative: number;
|
||||
totalCount: number;
|
||||
}> = {};
|
||||
trendData.forEach((item) => {
|
||||
cumulatives[item.id] = { damage: 0, totalPositive: 0, totalNegative: 0, totalCount: 0 };
|
||||
});
|
||||
|
||||
// Build chart data with cumulative values for each category
|
||||
return dates.map((date) => {
|
||||
const point: Record<string, string | number | null> = { date };
|
||||
|
||||
trendData.forEach((item) => {
|
||||
const dataPoint = item.data.find((d) => d.date === date);
|
||||
|
||||
if (dataPoint) {
|
||||
// Damage: complaints * (5 - avg_rating)
|
||||
const avgRating = dataPoint.avg_rating_negative ?? 3;
|
||||
const periodDamage = dataPoint.negative * (5 - avgRating);
|
||||
cumulatives[item.id].damage += periodDamage;
|
||||
|
||||
// Track totals for normalized sentiment
|
||||
cumulatives[item.id].totalPositive += dataPoint.positive;
|
||||
cumulatives[item.id].totalNegative += dataPoint.negative;
|
||||
cumulatives[item.id].totalCount += dataPoint.count;
|
||||
}
|
||||
|
||||
// Damage as negative (going down like losses)
|
||||
point[`${item.id}_damage`] = -Math.round(cumulatives[item.id].damage * 10) / 10;
|
||||
|
||||
// Net Mentions: cumulative (positive - negative) - preserves volume!
|
||||
const { totalPositive, totalNegative } = cumulatives[item.id];
|
||||
point[`${item.id}_sentiment`] = totalPositive - totalNegative;
|
||||
|
||||
// Store period data for tooltips
|
||||
const dp = item.data.find((d) => d.date === date);
|
||||
point[`${item.id}_periodDamage`] = dp ? Math.round((dp.negative * (5 - (dp.avg_rating_negative ?? 3))) * 10) / 10 : 0;
|
||||
point[`${item.id}_complaints`] = dp?.negative ?? 0;
|
||||
point[`${item.id}_avgRating`] = dp?.avg_rating_negative ?? null;
|
||||
point[`${item.id}_periodSentiment`] = dp?.sentiment_score ?? 0;
|
||||
});
|
||||
|
||||
return point;
|
||||
});
|
||||
}, [trendData]);
|
||||
|
||||
// Toggle category selection
|
||||
const toggleCategory = (category: URTDomain) => {
|
||||
setSelectedCategories((prev) => {
|
||||
if (prev.includes(category)) {
|
||||
return prev.filter((c) => c !== category);
|
||||
}
|
||||
if (prev.length >= MAX_CATEGORIES) {
|
||||
return [...prev.slice(1), category];
|
||||
}
|
||||
return [...prev, category];
|
||||
});
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const granularity = getGranularity(timeRange);
|
||||
|
||||
switch (granularity) {
|
||||
case 'day':
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
case 'week':
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
case 'month':
|
||||
return date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||
case 'year':
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric' });
|
||||
default:
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
const currentMetric = METRIC_OPTIONS.find(o => o.value === selectedMetric);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-md border-2 border-gray-200 hover:border-red-300 transition-all">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-red-100 rounded-lg">
|
||||
<TrendingDown className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Reputation Tracker</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Compare cumulative damage across categories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Range Selector */}
|
||||
<div className="flex items-center bg-gray-100 rounded-lg p-1">
|
||||
{TIME_RANGE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setTimeRange(opt.value)}
|
||||
className={`px-2.5 py-1.5 text-xs font-semibold rounded-md transition-all ${
|
||||
timeRange === opt.value
|
||||
? 'bg-red-600 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
title={opt.description}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Chips + Metric Dropdown */}
|
||||
<div className="mb-6 space-y-3">
|
||||
{/* Category Toggle Chips */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DOMAIN_OPTIONS.map((opt) => {
|
||||
const isSelected = selectedCategories.includes(opt.value);
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => toggleCategory(opt.value)}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
transition-all border-2
|
||||
${isSelected
|
||||
? 'shadow-md'
|
||||
: 'border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
style={isSelected ? {
|
||||
backgroundColor: `${opt.color}15`,
|
||||
borderColor: opt.color,
|
||||
color: opt.color,
|
||||
} : undefined}
|
||||
>
|
||||
<span className="text-base">{opt.emoji}</span>
|
||||
<span>{opt.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Metric Dropdown */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">Show:</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsMetricOpen(!isMetricOpen)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-lg hover:border-gray-300 transition-all text-sm"
|
||||
>
|
||||
<span className="font-medium text-gray-700">{currentMetric?.label}</span>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${isMetricOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isMetricOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsMetricOpen(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-20 bg-white rounded-xl shadow-xl border border-gray-200 py-1 min-w-[250px]">
|
||||
{METRIC_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { setSelectedMetric(opt.value); setIsMetricOpen(false); }}
|
||||
className={`w-full flex flex-col items-start px-4 py-2.5 hover:bg-gray-50 transition-colors ${
|
||||
selectedMetric === opt.value ? 'bg-red-50' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-gray-700">{opt.label}</span>
|
||||
<span className="text-xs text-gray-500">{opt.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-gray-400 ml-auto">
|
||||
Select up to {MAX_CATEGORIES} categories to compare
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart Area */}
|
||||
<div className="relative">
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white/80 z-10 flex items-center justify-center rounded-lg">
|
||||
<div className="flex items-center gap-3 text-red-600">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
<span className="font-medium">Loading data...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-80 text-red-500">
|
||||
<AlertCircle className="w-12 h-12 mb-3" />
|
||||
<p className="font-medium mb-1">Failed to load data</p>
|
||||
<p className="text-sm text-gray-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State - No categories selected */}
|
||||
{!isLoading && !error && selectedCategories.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-80 text-gray-400">
|
||||
<TrendingDown className="w-12 h-12 mb-3" />
|
||||
<p className="font-medium mb-1">No categories selected</p>
|
||||
<p className="text-sm">Click categories above to compare their reputation impact</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Data State */}
|
||||
{!isLoading && !error && selectedCategories.length > 0 && chartData.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-80 text-gray-400">
|
||||
<TrendingDown className="w-12 h-12 mb-3" />
|
||||
<p className="font-medium mb-1">No data available</p>
|
||||
<p className="text-sm">Try selecting a different time range</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
{!isLoading && !error && chartData.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 30, left: 0, bottom: 10 }}
|
||||
>
|
||||
<defs>
|
||||
{/* Generate gradients for each selected category */}
|
||||
{trendData.map((item) => (
|
||||
<linearGradient key={`gradient-${item.id}`} id={`gradient-${item.id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={item.color} stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor={item.color} stopOpacity={0.05}/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
|
||||
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: '#6b7280', fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
tickFormatter={formatDate}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
tick={{ fill: '#6b7280', fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value.toFixed(0)}
|
||||
/>
|
||||
|
||||
{/* Center reference line at 0 */}
|
||||
<ReferenceLine y={0} stroke="#9ca3af" strokeDasharray="5 5" strokeWidth={1.5} />
|
||||
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
|
||||
padding: '16px',
|
||||
}}
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="min-w-[240px]">
|
||||
<p className="font-bold text-gray-900 mb-3 pb-2 border-b border-gray-100">
|
||||
{formatDate(String(label))}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{trendData.map((item) => {
|
||||
const friendly = DOMAIN_FRIENDLY[item.id];
|
||||
const value = data[`${item.id}_${selectedMetric}`];
|
||||
const periodDamage = data[`${item.id}_periodDamage`];
|
||||
const complaints = data[`${item.id}_complaints`];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg px-3 py-2 -mx-1"
|
||||
style={{ backgroundColor: `${item.color}10` }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{friendly?.emoji}</span>
|
||||
<span className="text-sm font-semibold" style={{ color: item.color }}>
|
||||
{friendly?.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bold" style={{ color: item.color }}>
|
||||
{typeof value === 'number' ? value.toFixed(0) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex gap-3">
|
||||
<span>Period: -{periodDamage} pts</span>
|
||||
<span>Complaints: {complaints}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Render area for each selected category */}
|
||||
{trendData.map((item) => (
|
||||
<Area
|
||||
key={item.id}
|
||||
type="monotone"
|
||||
dataKey={`${item.id}_${selectedMetric}`}
|
||||
name={item.label}
|
||||
stroke={item.color}
|
||||
strokeWidth={2.5}
|
||||
fill={`url(#gradient-${item.id})`}
|
||||
fillOpacity={0.6}
|
||||
dot={false}
|
||||
activeDot={{ r: 5, strokeWidth: 2, stroke: '#fff', fill: item.color }}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
{selectedMetric === 'damage'
|
||||
? 'Each complaint costs points based on how low the rating is. Steeper drops = worse periods.'
|
||||
: 'Cumulative positive minus negative mentions. Above 0 = net positive. Categories with more volume show bigger swings.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,16 +15,16 @@ import {
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { X, TrendingUp, TrendingDown, Minus, Calendar, Filter } from 'lucide-react';
|
||||
import type { TimelinePoint, TimeRange, TimelineAnnotation } from '../types';
|
||||
import type { TimelinePoint, TimeRange, Granularity } from '../types';
|
||||
import { DOMAIN_LABELS } from '../types';
|
||||
import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
||||
|
||||
interface TimelineChartProps {
|
||||
data: TimelinePoint[];
|
||||
// AI-generated insight (optional - shows when available)
|
||||
// AI-generated insight headline (optional - shows when available)
|
||||
insight?: string | null;
|
||||
// Timeline annotations from AI (optional - marks key events)
|
||||
annotations?: TimelineAnnotation[] | null;
|
||||
// Timeline granularity from API (day, week, month, year)
|
||||
granularity?: Granularity;
|
||||
}
|
||||
|
||||
type ViewMode = 'sentiment' | 'volume' | 'rating';
|
||||
@@ -49,7 +49,7 @@ const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; description: string
|
||||
* User-friendly design with view toggles and interactive brush.
|
||||
* Responds to domain/sentiment filters.
|
||||
*/
|
||||
export function TimelineChart({ data, insight, annotations }: TimelineChartProps) {
|
||||
export function TimelineChart({ data, insight, granularity = 'week' }: TimelineChartProps) {
|
||||
const { filters, setTimeRange, setBrushRange } = useReviewIQFilters();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('sentiment');
|
||||
const [localBrushRange, setLocalBrushRange] = useState<{
|
||||
@@ -137,10 +137,21 @@ export function TimelineChart({ data, insight, annotations }: TimelineChartProps
|
||||
const hasSentimentFilter = filters.sentiment.length > 0;
|
||||
const hasAnyFilter = hasBrushFilter || hasDomainFilter || hasSentimentFilter;
|
||||
|
||||
// Format date for display
|
||||
// Format date for display based on granularity
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
switch (granularity) {
|
||||
case 'day':
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
case 'week':
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
case 'month':
|
||||
return date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||
case 'year':
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric' });
|
||||
default:
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -281,31 +292,6 @@ export function TimelineChart({ data, insight, annotations }: TimelineChartProps
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key Events (when annotations available) */}
|
||||
{annotations && annotations.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{annotations.slice(0, 3).map((annotation, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium flex items-center gap-1 ${
|
||||
annotation.type === 'positive' ? 'bg-green-100 text-green-700' :
|
||||
annotation.type === 'negative' ? 'bg-red-100 text-red-700' :
|
||||
annotation.type === 'event' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
title={annotation.description}
|
||||
>
|
||||
<span>{
|
||||
annotation.type === 'positive' ? '📈' :
|
||||
annotation.type === 'negative' ? '📉' :
|
||||
annotation.type === 'event' ? '📍' : '•'
|
||||
}</span>
|
||||
<span>{annotation.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-80 text-gray-500">
|
||||
<Calendar className="w-12 h-12 text-gray-300 mb-2" />
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Award,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain, Synthesis } from '../types';
|
||||
import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain } from '../types';
|
||||
import { getSubcodeDefinition } from '@/lib/taxonomy/data';
|
||||
|
||||
interface ExecutiveSummaryProps {
|
||||
@@ -25,8 +25,6 @@ interface ExecutiveSummaryProps {
|
||||
domainScores?: DomainScore[];
|
||||
onDriverClick?: (subcode: string) => void;
|
||||
onDomainClick?: (domain: URTDomain) => void;
|
||||
// AI-generated narrative (optional - enhances when available)
|
||||
synthesis?: Synthesis | null;
|
||||
}
|
||||
|
||||
// User-friendly domain config
|
||||
@@ -204,13 +202,12 @@ export function ExecutiveSummary({
|
||||
domainScores,
|
||||
onDriverClick,
|
||||
onDomainClick,
|
||||
synthesis,
|
||||
}: ExecutiveSummaryProps) {
|
||||
const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights;
|
||||
const [showFullSummary, setShowFullSummary] = useState(false);
|
||||
|
||||
// Use AI narrative if available, otherwise fall back to generated summary
|
||||
const narrativeText = synthesis?.executive_narrative || executive_summary;
|
||||
// Use the generated summary from insights
|
||||
const narrativeText = executive_summary;
|
||||
|
||||
const topStrength = strengths[0];
|
||||
const topWeakness = weaknesses[0];
|
||||
@@ -294,20 +291,13 @@ export function ExecutiveSummary({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Summary */}
|
||||
{/* Summary */}
|
||||
{narrativeText && (
|
||||
<div className="px-6 pb-4">
|
||||
<div className={`p-4 rounded-xl border ${
|
||||
synthesis?.executive_narrative
|
||||
? 'bg-gradient-to-r from-purple-50 to-blue-50 border-purple-200'
|
||||
: 'bg-white/70 border-blue-100'
|
||||
}`}>
|
||||
<div className="p-4 rounded-xl border bg-white/70 border-blue-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">{synthesis?.executive_narrative ? '✨' : '💡'}</span>
|
||||
<span className="text-lg">💡</span>
|
||||
<div className="flex-1">
|
||||
{synthesis?.executive_narrative && (
|
||||
<div className="text-xs font-medium text-purple-600 mb-1">AI-Generated Insight</div>
|
||||
)}
|
||||
<p className={`text-gray-700 leading-relaxed ${!showFullSummary && 'line-clamp-3'}`}>
|
||||
{narrativeText}
|
||||
</p>
|
||||
|
||||
127
web/components/taxonomy/CausalCodesSection.tsx
Normal file
127
web/components/taxonomy/CausalCodesSection.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { taxonomy } from '@/lib/taxonomy/data';
|
||||
|
||||
const LAYER_COLORS = {
|
||||
conditions: {
|
||||
bg: 'bg-yellow-500/10',
|
||||
border: 'border-yellow-500/30',
|
||||
text: 'text-yellow-400',
|
||||
badge: 'bg-yellow-500/20 text-yellow-300',
|
||||
},
|
||||
management: {
|
||||
bg: 'bg-blue-500/10',
|
||||
border: 'border-blue-500/30',
|
||||
text: 'text-blue-400',
|
||||
badge: 'bg-blue-500/20 text-blue-300',
|
||||
},
|
||||
systemic: {
|
||||
bg: 'bg-purple-500/10',
|
||||
border: 'border-purple-500/30',
|
||||
text: 'text-purple-400',
|
||||
badge: 'bg-purple-500/20 text-purple-300',
|
||||
},
|
||||
};
|
||||
|
||||
const LAYER_TITLES = {
|
||||
conditions: 'Conditions',
|
||||
management: 'Management',
|
||||
systemic: 'Systemic',
|
||||
};
|
||||
|
||||
const LAYER_DESCRIPTIONS = {
|
||||
conditions: 'What allowed the experience to happen?',
|
||||
management: 'What decisions allowed enabling conditions?',
|
||||
systemic: 'Why does the organization create these conditions?',
|
||||
};
|
||||
|
||||
export default function CausalCodesSection() {
|
||||
const layers = ['conditions', 'management', 'systemic'] as const;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-100">Causal Codes</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Three-layer root cause analysis framework with 16 codes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Three layers visualization */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{layers.map((layerKey) => {
|
||||
const layer = taxonomy.causal_codes[layerKey];
|
||||
const colors = LAYER_COLORS[layerKey];
|
||||
const codeCount = Object.keys(layer.codes).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={layerKey}
|
||||
className={`rounded-lg border ${colors.border} ${colors.bg} p-4`}
|
||||
>
|
||||
{/* Layer Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold ${colors.text}`}>
|
||||
{LAYER_TITLES[layerKey]}
|
||||
</h3>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${colors.badge}`}>
|
||||
{layer.prefix}* ({codeCount})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
{LAYER_DESCRIPTIONS[layerKey]}
|
||||
</p>
|
||||
|
||||
{/* Codes list */}
|
||||
<div className="space-y-2">
|
||||
{Object.entries(layer.codes).map(([codeKey, code]) => (
|
||||
<div
|
||||
key={codeKey}
|
||||
className="p-2 bg-gray-800/50 rounded border border-gray-700/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono text-sm ${colors.text}`}>
|
||||
{codeKey}
|
||||
</span>
|
||||
<span className="text-sm text-gray-300">{code.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{code.definition}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Flow Indicator */}
|
||||
<div className="flex items-center justify-center gap-4 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<span className="px-3 py-1 bg-yellow-500/20 text-yellow-400 rounded">
|
||||
Conditions
|
||||
</span>
|
||||
<span className="text-gray-600">→</span>
|
||||
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded">
|
||||
Management
|
||||
</span>
|
||||
<span className="text-gray-600">→</span>
|
||||
<span className="px-3 py-1 bg-purple-500/20 text-purple-400 rounded">
|
||||
Systemic
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage note */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-gray-200 mb-2">Usage</h4>
|
||||
<p className="text-sm text-gray-400">
|
||||
Causal codes are used for root cause analysis in URT-Full profile. Start with
|
||||
Conditions (immediate factors), trace to Management (decisions that enabled conditions),
|
||||
and finally to Systemic (organizational factors that created the management decisions).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
web/components/taxonomy/MetadataSection.tsx
Normal file
111
web/components/taxonomy/MetadataSection.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { taxonomy } from '@/lib/taxonomy/data';
|
||||
|
||||
const DIMENSION_COLORS: Record<string, { bg: string; border: string; text: string }> = {
|
||||
valence: { bg: 'bg-green-500/10', border: 'border-green-500/30', text: 'text-green-400' },
|
||||
intensity: { bg: 'bg-orange-500/10', border: 'border-orange-500/30', text: 'text-orange-400' },
|
||||
specificity: { bg: 'bg-blue-500/10', border: 'border-blue-500/30', text: 'text-blue-400' },
|
||||
actionability: { bg: 'bg-purple-500/10', border: 'border-purple-500/30', text: 'text-purple-400' },
|
||||
temporal: { bg: 'bg-cyan-500/10', border: 'border-cyan-500/30', text: 'text-cyan-400' },
|
||||
evidence: { bg: 'bg-pink-500/10', border: 'border-pink-500/30', text: 'text-pink-400' },
|
||||
comparative: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
|
||||
};
|
||||
|
||||
export default function MetadataSection() {
|
||||
const dimensions = Object.entries(taxonomy.metadata_dimensions);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-100">Metadata Dimensions</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
7 dimensions with 24 values for enriching classifications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dimension Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{dimensions.map(([dimKey, dimension]) => {
|
||||
const colors = DIMENSION_COLORS[dimKey] || {
|
||||
bg: 'bg-gray-500/10',
|
||||
border: 'border-gray-500/30',
|
||||
text: 'text-gray-400',
|
||||
};
|
||||
const valueCount = Object.keys(dimension.values).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dimKey}
|
||||
className={`rounded-lg border ${colors.border} ${colors.bg} p-4`}
|
||||
>
|
||||
{/* Dimension Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className={`font-semibold ${colors.text}`}>{dimension.name}</h3>
|
||||
<span className="font-mono text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">
|
||||
{dimension.code}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-4">{dimension.description}</p>
|
||||
|
||||
{/* Values */}
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dimension.values).map(([valueKey, value]) => (
|
||||
<div
|
||||
key={valueKey}
|
||||
className="flex items-start gap-2 text-sm"
|
||||
>
|
||||
<span className={`font-mono ${colors.text} flex-shrink-0`}>
|
||||
{valueKey}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<span className="text-gray-300">{value.label}</span>
|
||||
{value.markers && value.markers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{value.markers.slice(0, 3).map((marker, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-xs text-gray-500 bg-gray-800/50 px-1.5 py-0.5 rounded"
|
||||
>
|
||||
"{marker}"
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{value.example && (
|
||||
<p className="text-xs text-gray-500 mt-1 italic">
|
||||
e.g., {value.example}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Default indicator */}
|
||||
{dimension.default && (
|
||||
<div className="mt-3 pt-2 border-t border-gray-700/50">
|
||||
<span className="text-xs text-gray-500">
|
||||
Default: <span className="font-mono">{dimension.default}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Usage note */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-gray-200 mb-2">Usage</h4>
|
||||
<p className="text-sm text-gray-400">
|
||||
Metadata dimensions enrich each classification span. Required dimensions vary by profile:
|
||||
URT-Lite requires only Valence, while URT-Full requires all 7 dimensions.
|
||||
Dimensions like Comparative (CR) and Evidence (E) have defaults when not applicable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
web/components/taxonomy/ProfilesSection.tsx
Normal file
213
web/components/taxonomy/ProfilesSection.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import { Check, X, Minus } from 'lucide-react';
|
||||
import { taxonomy } from '@/lib/taxonomy/data';
|
||||
|
||||
const PROFILE_COLORS: Record<string, { bg: string; border: string; text: string; badge: string }> = {
|
||||
lite: {
|
||||
bg: 'bg-green-500/10',
|
||||
border: 'border-green-500/30',
|
||||
text: 'text-green-400',
|
||||
badge: 'bg-green-500',
|
||||
},
|
||||
core: {
|
||||
bg: 'bg-blue-500/10',
|
||||
border: 'border-blue-500/30',
|
||||
text: 'text-blue-400',
|
||||
badge: 'bg-blue-500',
|
||||
},
|
||||
standard: {
|
||||
bg: 'bg-purple-500/10',
|
||||
border: 'border-purple-500/30',
|
||||
text: 'text-purple-400',
|
||||
badge: 'bg-purple-500',
|
||||
},
|
||||
full: {
|
||||
bg: 'bg-amber-500/10',
|
||||
border: 'border-amber-500/30',
|
||||
text: 'text-amber-400',
|
||||
badge: 'bg-amber-500',
|
||||
},
|
||||
};
|
||||
|
||||
const COMPLEXITY_COLORS: Record<string, string> = {
|
||||
Minimal: 'text-green-400',
|
||||
Low: 'text-blue-400',
|
||||
Medium: 'text-purple-400',
|
||||
High: 'text-amber-400',
|
||||
};
|
||||
|
||||
const ALL_FIELDS = [
|
||||
'primary_code',
|
||||
'secondary_codes',
|
||||
'valence',
|
||||
'intensity',
|
||||
'specificity',
|
||||
'actionability',
|
||||
'temporal',
|
||||
'evidence',
|
||||
'comparative',
|
||||
'causal_chain',
|
||||
'linked_spans',
|
||||
'confidence',
|
||||
'annotator_notes',
|
||||
];
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
primary_code: 'Primary Code',
|
||||
secondary_codes: 'Secondary Codes',
|
||||
valence: 'Valence',
|
||||
intensity: 'Intensity',
|
||||
specificity: 'Specificity',
|
||||
actionability: 'Actionability',
|
||||
temporal: 'Temporal',
|
||||
evidence: 'Evidence',
|
||||
comparative: 'Comparative',
|
||||
causal_chain: 'Causal Chain',
|
||||
linked_spans: 'Linked Spans',
|
||||
confidence: 'Confidence',
|
||||
annotator_notes: 'Annotator Notes',
|
||||
};
|
||||
|
||||
export default function ProfilesSection() {
|
||||
const profiles = Object.entries(taxonomy.profiles);
|
||||
|
||||
const getFieldStatus = (profile: typeof taxonomy.profiles.lite, field: string) => {
|
||||
if (profile.required_fields.includes(field)) return 'required';
|
||||
if (profile.optional_fields.includes(field)) return 'optional';
|
||||
if (profile.forbidden_fields.includes(field)) return 'forbidden';
|
||||
return 'forbidden';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-100">Implementation Profiles</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
4 profiles for different implementation complexity levels
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Cards */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{profiles.map(([profileKey, profile]) => {
|
||||
const colors = PROFILE_COLORS[profileKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={profileKey}
|
||||
className={`rounded-lg border ${colors.border} ${colors.bg} p-4`}
|
||||
>
|
||||
{/* Profile Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className={`font-semibold ${colors.text}`}>{profile.name}</h3>
|
||||
<span className={`w-3 h-3 rounded-full ${colors.badge}`} />
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-3 mb-3 text-sm">
|
||||
<span className="text-gray-300">{profile.code_count} codes</span>
|
||||
<span className="text-gray-600">|</span>
|
||||
<span className={COMPLEXITY_COLORS[profile.complexity]}>
|
||||
{profile.complexity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-4">{profile.use_case}</p>
|
||||
|
||||
{/* Code pattern */}
|
||||
<div className="mb-4">
|
||||
<span className="text-xs text-gray-500">Code Level:</span>
|
||||
<span className="ml-2 font-mono text-sm text-gray-300">
|
||||
{profile.code_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pattern */}
|
||||
<div className="bg-gray-800/50 rounded p-2 mb-3">
|
||||
<span className="text-xs text-gray-500">Pattern:</span>
|
||||
<code className="block font-mono text-xs text-gray-300 mt-1">
|
||||
{profile.primary_code_pattern}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Secondary codes */}
|
||||
<div className="text-xs text-gray-400">
|
||||
{profile.secondary_codes_allowed ? (
|
||||
<span>
|
||||
Up to {profile.secondary_codes_max} secondary codes (tier{' '}
|
||||
{profile.secondary_codes_tier})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">No secondary codes</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Field Comparison Table */}
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-semibold text-gray-200 mb-4">Field Requirements by Profile</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th className="text-left py-2 px-3 text-gray-400 font-medium">Field</th>
|
||||
{profiles.map(([profileKey, profile]) => (
|
||||
<th
|
||||
key={profileKey}
|
||||
className={`text-center py-2 px-3 ${PROFILE_COLORS[profileKey].text} font-medium`}
|
||||
>
|
||||
{profile.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ALL_FIELDS.map((field) => (
|
||||
<tr key={field} className="border-b border-gray-800 hover:bg-gray-800/30">
|
||||
<td className="py-2 px-3 text-gray-300">{FIELD_LABELS[field]}</td>
|
||||
{profiles.map(([profileKey, profile]) => {
|
||||
const status = getFieldStatus(profile, field);
|
||||
return (
|
||||
<td key={profileKey} className="py-2 px-3 text-center">
|
||||
{status === 'required' && (
|
||||
<Check className="w-4 h-4 text-green-400 mx-auto" />
|
||||
)}
|
||||
{status === 'optional' && (
|
||||
<Minus className="w-4 h-4 text-yellow-400 mx-auto" />
|
||||
)}
|
||||
{status === 'forbidden' && (
|
||||
<X className="w-4 h-4 text-gray-600 mx-auto" />
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-6 mt-4 text-xs text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
<span>Required</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Minus className="w-4 h-4 text-yellow-400" />
|
||||
<span>Optional</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<X className="w-4 h-4 text-gray-600" />
|
||||
<span>Not used</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
web/components/taxonomy/SubcodeDetail.tsx
Normal file
98
web/components/taxonomy/SubcodeDetail.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
|
||||
import type { SelectedSubcode } from '@/lib/taxonomy/types';
|
||||
import { DOMAIN_TEXT_COLORS, DOMAIN_BG_COLORS, DOMAIN_BORDER_COLORS } from '@/lib/taxonomy/types';
|
||||
|
||||
interface SubcodeDetailProps {
|
||||
selectedSubcode: SelectedSubcode | null;
|
||||
}
|
||||
|
||||
export default function SubcodeDetail({ selectedSubcode }: SubcodeDetailProps) {
|
||||
if (!selectedSubcode) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
<div className="text-center">
|
||||
<p className="text-lg">Select a subcode</p>
|
||||
<p className="text-sm mt-1">Click on a subcode to view its details</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { code, domainKey, domainName, categoryName, subcode } = selectedSubcode;
|
||||
const textColor = DOMAIN_TEXT_COLORS[domainKey];
|
||||
const bgColor = DOMAIN_BG_COLORS[domainKey];
|
||||
const borderColor = DOMAIN_BORDER_COLORS[domainKey];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className={`p-4 border-b border-gray-700 ${bgColor}`}>
|
||||
<h2 className={`text-xl font-bold ${textColor}`}>
|
||||
{code} {subcode.name}
|
||||
</h2>
|
||||
<div className="flex gap-2 mt-2 text-sm text-gray-400">
|
||||
<span>Domain: {domainName}</span>
|
||||
<span className="text-gray-600">|</span>
|
||||
<span>Category: {categoryName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-4 space-y-6">
|
||||
{/* Definition */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-2">
|
||||
Definition
|
||||
</h3>
|
||||
<p className="text-gray-200">{subcode.definition}</p>
|
||||
</section>
|
||||
|
||||
{/* Examples */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">
|
||||
Examples
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{/* Positive Example */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="text-xs text-green-400 uppercase font-medium">Positive</span>
|
||||
<p className="text-gray-200 mt-0.5">{subcode.positive_example}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Negative Example */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<XCircle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="text-xs text-red-400 uppercase font-medium">Negative</span>
|
||||
<p className="text-gray-200 mt-0.5">{subcode.negative_example}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Don't Confuse With */}
|
||||
{subcode.dont_confuse_with && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">
|
||||
Don't Confuse With
|
||||
</h3>
|
||||
<div className={`flex items-start gap-3 p-3 rounded-lg border ${bgColor} ${borderColor}`}>
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className={`font-mono font-medium ${textColor}`}>
|
||||
{subcode.dont_confuse_with}
|
||||
</span>
|
||||
<p className="text-gray-300 mt-1 text-sm">{subcode.dont_confuse_reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
web/components/taxonomy/TaxonomySearch.tsx
Normal file
61
web/components/taxonomy/TaxonomySearch.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
|
||||
interface TaxonomySearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
resultCount?: number;
|
||||
}
|
||||
|
||||
export default function TaxonomySearch({ value, onChange, resultCount }: TaxonomySearchProps) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
// Debounce the search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onChange(localValue);
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [localValue, onChange]);
|
||||
|
||||
// Sync external changes
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setLocalValue('');
|
||||
onChange('');
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
placeholder="Search codes..."
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg pl-9 pr-9 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
|
||||
/>
|
||||
{localValue && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{value && resultCount !== undefined && (
|
||||
<div className="absolute right-0 top-full mt-1 text-xs text-gray-500">
|
||||
{resultCount} {resultCount === 1 ? 'match' : 'matches'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
web/components/taxonomy/TaxonomyTree.tsx
Normal file
199
web/components/taxonomy/TaxonomyTree.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import TreeNode from './TreeNode';
|
||||
import { taxonomy, getDomainSubcodeCount, getSubcodeCount } from '@/lib/taxonomy/data';
|
||||
import type { SelectedSubcode } from '@/lib/taxonomy/types';
|
||||
|
||||
interface TaxonomyTreeProps {
|
||||
searchQuery: string;
|
||||
searchResults: {
|
||||
domains: string[];
|
||||
categories: string[];
|
||||
subcodes: string[];
|
||||
};
|
||||
selectedSubcode: SelectedSubcode | null;
|
||||
onSelectSubcode: (subcode: SelectedSubcode | null) => void;
|
||||
}
|
||||
|
||||
export default function TaxonomyTree({
|
||||
searchQuery,
|
||||
searchResults,
|
||||
selectedSubcode,
|
||||
onSelectSubcode,
|
||||
}: TaxonomyTreeProps) {
|
||||
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
|
||||
// Auto-expand nodes when search results change
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
const domainsToExpand = new Set<string>();
|
||||
const categoriesToExpand = new Set<string>();
|
||||
|
||||
// Expand domains that have matches
|
||||
searchResults.domains.forEach((d) => domainsToExpand.add(d));
|
||||
|
||||
// Expand parent domains for matched categories
|
||||
searchResults.categories.forEach((c) => {
|
||||
domainsToExpand.add(c[0]);
|
||||
categoriesToExpand.add(c);
|
||||
});
|
||||
|
||||
// Expand parent domains and categories for matched subcodes
|
||||
searchResults.subcodes.forEach((s) => {
|
||||
const domainKey = s[0];
|
||||
const categoryKey = s.slice(0, 2);
|
||||
domainsToExpand.add(domainKey);
|
||||
categoriesToExpand.add(categoryKey);
|
||||
});
|
||||
|
||||
setExpandedDomains(domainsToExpand);
|
||||
setExpandedCategories(categoriesToExpand);
|
||||
}
|
||||
}, [searchQuery, searchResults]);
|
||||
|
||||
const toggleDomain = useCallback((domainKey: string) => {
|
||||
setExpandedDomains((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(domainKey)) {
|
||||
next.delete(domainKey);
|
||||
} else {
|
||||
next.add(domainKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleCategory = useCallback((categoryKey: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(categoryKey)) {
|
||||
next.delete(categoryKey);
|
||||
} else {
|
||||
next.add(categoryKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectSubcode = (
|
||||
subcodeKey: string,
|
||||
domainKey: string,
|
||||
domainName: string,
|
||||
categoryKey: string,
|
||||
categoryName: string,
|
||||
subcode: SelectedSubcode['subcode']
|
||||
) => {
|
||||
onSelectSubcode({
|
||||
code: subcodeKey,
|
||||
domainKey,
|
||||
domainName,
|
||||
categoryKey,
|
||||
categoryName,
|
||||
subcode,
|
||||
});
|
||||
};
|
||||
|
||||
const isSubcodeSelected = (subcodeKey: string) => {
|
||||
return selectedSubcode?.code === subcodeKey;
|
||||
};
|
||||
|
||||
const isSearchMatch = (key: string) => {
|
||||
if (!searchQuery) return false;
|
||||
return (
|
||||
searchResults.domains.includes(key) ||
|
||||
searchResults.categories.includes(key) ||
|
||||
searchResults.subcodes.includes(key)
|
||||
);
|
||||
};
|
||||
|
||||
// Filter to show only matches when searching
|
||||
const shouldShowDomain = (domainKey: string) => {
|
||||
if (!searchQuery) return true;
|
||||
// Show domain if it matches or any of its children match
|
||||
if (searchResults.domains.includes(domainKey)) return true;
|
||||
if (searchResults.categories.some((c) => c.startsWith(domainKey))) return true;
|
||||
if (searchResults.subcodes.some((s) => s.startsWith(domainKey))) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const shouldShowCategory = (categoryKey: string) => {
|
||||
if (!searchQuery) return true;
|
||||
if (searchResults.categories.includes(categoryKey)) return true;
|
||||
if (searchResults.subcodes.some((s) => s.startsWith(categoryKey))) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const shouldShowSubcode = (subcodeKey: string) => {
|
||||
if (!searchQuery) return true;
|
||||
return searchResults.subcodes.includes(subcodeKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{Object.entries(taxonomy.domains).map(([domainKey, domain]) => {
|
||||
if (!shouldShowDomain(domainKey)) return null;
|
||||
|
||||
return (
|
||||
<TreeNode
|
||||
key={domainKey}
|
||||
code={domainKey}
|
||||
name={domain.name}
|
||||
count={getDomainSubcodeCount(domainKey)}
|
||||
isExpanded={expandedDomains.has(domainKey)}
|
||||
level="domain"
|
||||
domainKey={domainKey}
|
||||
onToggle={() => toggleDomain(domainKey)}
|
||||
searchMatch={isSearchMatch(domainKey)}
|
||||
>
|
||||
{Object.entries(domain.categories).map(([categoryKey, category]) => {
|
||||
if (!shouldShowCategory(categoryKey)) return null;
|
||||
|
||||
return (
|
||||
<TreeNode
|
||||
key={categoryKey}
|
||||
code={categoryKey}
|
||||
name={category.name}
|
||||
count={getSubcodeCount(categoryKey)}
|
||||
isExpanded={expandedCategories.has(categoryKey)}
|
||||
level="category"
|
||||
domainKey={domainKey}
|
||||
onToggle={() => toggleCategory(categoryKey)}
|
||||
searchMatch={isSearchMatch(categoryKey)}
|
||||
>
|
||||
{Object.entries(category.subcodes).map(([subcodeKey, subcode]) => {
|
||||
if (!shouldShowSubcode(subcodeKey)) return null;
|
||||
|
||||
return (
|
||||
<TreeNode
|
||||
key={subcodeKey}
|
||||
code={subcodeKey}
|
||||
name={subcode.name}
|
||||
isLeaf
|
||||
isSelected={isSubcodeSelected(subcodeKey)}
|
||||
level="subcode"
|
||||
domainKey={domainKey}
|
||||
onClick={() =>
|
||||
handleSelectSubcode(
|
||||
subcodeKey,
|
||||
domainKey,
|
||||
domain.name,
|
||||
categoryKey,
|
||||
category.name,
|
||||
subcode
|
||||
)
|
||||
}
|
||||
searchMatch={isSearchMatch(subcodeKey)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TreeNode>
|
||||
);
|
||||
})}
|
||||
</TreeNode>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
web/components/taxonomy/TreeNode.tsx
Normal file
111
web/components/taxonomy/TreeNode.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { DOMAIN_TEXT_COLORS } from '@/lib/taxonomy/types';
|
||||
|
||||
interface TreeNodeProps {
|
||||
code: string;
|
||||
name: string;
|
||||
count?: number;
|
||||
isExpanded?: boolean;
|
||||
isSelected?: boolean;
|
||||
isLeaf?: boolean;
|
||||
level: 'domain' | 'category' | 'subcode';
|
||||
domainKey: string;
|
||||
onToggle?: () => void;
|
||||
onClick?: () => void;
|
||||
searchMatch?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function TreeNode({
|
||||
code,
|
||||
name,
|
||||
count,
|
||||
isExpanded = false,
|
||||
isSelected = false,
|
||||
isLeaf = false,
|
||||
level,
|
||||
domainKey,
|
||||
onToggle,
|
||||
onClick,
|
||||
searchMatch = false,
|
||||
children,
|
||||
}: TreeNodeProps) {
|
||||
const textColor = DOMAIN_TEXT_COLORS[domainKey] || 'text-gray-400';
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isLeaf) {
|
||||
onClick?.();
|
||||
} else {
|
||||
onToggle?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChevronClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onToggle?.();
|
||||
};
|
||||
|
||||
const getPadding = () => {
|
||||
switch (level) {
|
||||
case 'domain':
|
||||
return 'pl-2';
|
||||
case 'category':
|
||||
return 'pl-6';
|
||||
case 'subcode':
|
||||
return 'pl-10';
|
||||
default:
|
||||
return 'pl-2';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={`
|
||||
flex items-center gap-2 py-1.5 px-2 rounded cursor-pointer transition-colors
|
||||
${getPadding()}
|
||||
${isSelected ? 'bg-gray-700' : 'hover:bg-gray-800/50'}
|
||||
${searchMatch ? 'ring-1 ring-yellow-500/50' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Expand/Collapse Icon */}
|
||||
{!isLeaf ? (
|
||||
<button
|
||||
onClick={handleChevronClick}
|
||||
className="w-4 h-4 flex items-center justify-center text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4 h-4 flex items-center justify-center text-gray-600">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Code */}
|
||||
<span className={`font-mono text-sm ${textColor}`}>{code}</span>
|
||||
|
||||
{/* Name */}
|
||||
<span className="text-sm text-gray-300 truncate flex-1">{name}</span>
|
||||
|
||||
{/* Count Badge */}
|
||||
{count !== undefined && (
|
||||
<span className="text-xs text-gray-500 bg-gray-800 px-1.5 py-0.5 rounded">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Children (expanded content) */}
|
||||
{isExpanded && children && <div>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
web/contexts/ReviewIQFilterContext.tsx
Normal file
117
web/contexts/ReviewIQFilterContext.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useMemo, useCallback } from 'react';
|
||||
import type {
|
||||
ReviewIQFilters,
|
||||
ReviewIQFilterContextValue,
|
||||
TimeRange,
|
||||
Sentiment,
|
||||
URTDomain,
|
||||
Intensity,
|
||||
} from '@/components/reviewiq/types';
|
||||
|
||||
const defaultFilters: ReviewIQFilters = {
|
||||
timeRange: 'all',
|
||||
sentiment: [],
|
||||
urtDomain: null,
|
||||
intensity: [],
|
||||
brushRange: null,
|
||||
};
|
||||
|
||||
const ReviewIQFilterContext = createContext<ReviewIQFilterContextValue | null>(null);
|
||||
|
||||
export function ReviewIQFilterProvider({ children }: { children: React.ReactNode }) {
|
||||
const [filters, setFilters] = useState<ReviewIQFilters>(defaultFilters);
|
||||
|
||||
const toggleSentiment = useCallback((s: Sentiment) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sentiment: prev.sentiment.includes(s)
|
||||
? prev.sentiment.filter((x) => x !== s)
|
||||
: [...prev.sentiment, s],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setURTDomain = useCallback((domain: URTDomain | null) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
urtDomain: prev.urtDomain === domain ? null : domain,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const toggleIntensity = useCallback((i: Intensity) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
intensity: prev.intensity.includes(i)
|
||||
? prev.intensity.filter((x) => x !== i)
|
||||
: [...prev.intensity, i],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setTimeRange = useCallback((range: TimeRange) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
timeRange: range,
|
||||
brushRange: null, // Clear brush when time range changes
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setBrushRange = useCallback((range: { start: string; end: string } | null) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
brushRange: range,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters(defaultFilters);
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return (
|
||||
filters.timeRange !== 'all' ||
|
||||
filters.sentiment.length > 0 ||
|
||||
filters.urtDomain !== null ||
|
||||
filters.intensity.length > 0 ||
|
||||
filters.brushRange !== null
|
||||
);
|
||||
}, [filters]);
|
||||
|
||||
const value = useMemo<ReviewIQFilterContextValue>(
|
||||
() => ({
|
||||
filters,
|
||||
setFilters,
|
||||
toggleSentiment,
|
||||
setURTDomain,
|
||||
toggleIntensity,
|
||||
setTimeRange,
|
||||
setBrushRange,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
}),
|
||||
[
|
||||
filters,
|
||||
toggleSentiment,
|
||||
setURTDomain,
|
||||
toggleIntensity,
|
||||
setTimeRange,
|
||||
setBrushRange,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<ReviewIQFilterContext.Provider value={value}>
|
||||
{children}
|
||||
</ReviewIQFilterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useReviewIQFilters(): ReviewIQFilterContextValue {
|
||||
const context = useContext(ReviewIQFilterContext);
|
||||
if (!context) {
|
||||
throw new Error('useReviewIQFilters must be used within ReviewIQFilterProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
158
web/hooks/useReviewIQAnalytics.ts
Normal file
158
web/hooks/useReviewIQAnalytics.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type {
|
||||
ReviewIQAnalyticsResponse,
|
||||
ReviewIQFilters,
|
||||
} from '@/components/reviewiq/types';
|
||||
|
||||
interface UseReviewIQAnalyticsOptions {
|
||||
jobId?: string | null;
|
||||
businessId?: string | null;
|
||||
filters: ReviewIQFilters;
|
||||
issuesPage?: number;
|
||||
issuesPageSize?: number;
|
||||
spansPage?: number;
|
||||
spansPageSize?: number;
|
||||
}
|
||||
|
||||
interface UseReviewIQAnalyticsResult {
|
||||
data: ReviewIQAnalyticsResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for fetching ReviewIQ analytics data.
|
||||
* Uses a single API call to fetch all dashboard data.
|
||||
*/
|
||||
export function useReviewIQAnalytics({
|
||||
jobId,
|
||||
businessId,
|
||||
filters,
|
||||
issuesPage = 1,
|
||||
issuesPageSize = 10,
|
||||
spansPage = 1,
|
||||
spansPageSize = 10,
|
||||
}: UseReviewIQAnalyticsOptions): UseReviewIQAnalyticsResult {
|
||||
const [data, setData] = useState<ReviewIQAnalyticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refetchTrigger, setRefetchTrigger] = useState(0);
|
||||
|
||||
// Build query params from filters
|
||||
const queryParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (jobId) {
|
||||
params.set('job_id', jobId);
|
||||
}
|
||||
if (businessId) {
|
||||
params.set('business_id', businessId);
|
||||
}
|
||||
|
||||
params.set('time_range', filters.timeRange);
|
||||
|
||||
if (filters.sentiment.length > 0) {
|
||||
params.set('sentiment', filters.sentiment.join(','));
|
||||
}
|
||||
|
||||
if (filters.urtDomain) {
|
||||
params.set('urt_domain', filters.urtDomain);
|
||||
}
|
||||
|
||||
if (filters.intensity.length > 0) {
|
||||
params.set('intensity', filters.intensity.join(','));
|
||||
}
|
||||
|
||||
params.set('issues_page', issuesPage.toString());
|
||||
params.set('issues_page_size', issuesPageSize.toString());
|
||||
params.set('spans_page', spansPage.toString());
|
||||
params.set('spans_page_size', spansPageSize.toString());
|
||||
|
||||
return params.toString();
|
||||
}, [jobId, businessId, filters, issuesPage, issuesPageSize, spansPage, spansPageSize]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!jobId && !businessId) {
|
||||
setLoading(false);
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/pipelines/reviewiq/analytics?${queryParams}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
const responseData: ReviewIQAnalyticsResponse = await response.json();
|
||||
setData(responseData);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch ReviewIQ analytics:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch analytics');
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams, jobId, businessId]);
|
||||
|
||||
// Fetch data when params change
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData, refetchTrigger]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setRefetchTrigger((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
return { data, loading, error, refetch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching spans related to a specific issue.
|
||||
*/
|
||||
export function useIssueSpans(issueId: string | null) {
|
||||
const [data, setData] = useState<ReviewIQAnalyticsResponse['spans']['items']>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issueId) {
|
||||
setData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchSpans = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/pipelines/reviewiq/issues/${issueId}/spans`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
const spans = await response.json();
|
||||
setData(spans);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch issue spans:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch spans');
|
||||
setData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSpans();
|
||||
}, [issueId]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
111
web/hooks/useTranslation.ts
Normal file
111
web/hooks/useTranslation.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
interface TranslationState {
|
||||
translated: string;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
isTranslated: boolean;
|
||||
}
|
||||
|
||||
interface TranslationsMap {
|
||||
[key: string]: TranslationState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for translating text on demand using MyMemory free API
|
||||
* - 1000 translations/day free (anonymous)
|
||||
* - 10000/day with free email registration
|
||||
* - No API key required for basic usage
|
||||
*/
|
||||
export function useTranslation(targetLang: string = 'en') {
|
||||
const [translations, setTranslations] = useState<TranslationsMap>({});
|
||||
const translationsRef = useRef<TranslationsMap>({});
|
||||
|
||||
// Keep ref in sync with state
|
||||
translationsRef.current = translations;
|
||||
|
||||
const translate = useCallback(async (text: string, id: string, sourceLang: string = 'auto') => {
|
||||
const current = translationsRef.current[id];
|
||||
|
||||
// If already translated, toggle back to original
|
||||
if (current?.isTranslated) {
|
||||
setTranslations(prev => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], isTranslated: false }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have a cached translation, just show it
|
||||
if (current?.translated) {
|
||||
setTranslations(prev => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], isTranslated: true }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Start loading
|
||||
setTranslations(prev => ({
|
||||
...prev,
|
||||
[id]: { translated: '', isLoading: true, error: null, isTranslated: false }
|
||||
}));
|
||||
|
||||
try {
|
||||
// Use MyMemory free translation API
|
||||
// Docs: https://mymemory.translated.net/doc/spec.php
|
||||
// Note: 500 character limit per query
|
||||
const truncatedText = text.length > 500 ? text.substring(0, 497) + '...' : text;
|
||||
const langPair = sourceLang === 'auto' ? `autodetect|${targetLang}` : `${sourceLang}|${targetLang}`;
|
||||
const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(truncatedText)}&langpair=${langPair}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Translation failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Handle "same language" error - text is already in target language
|
||||
if (data.responseStatus !== 200) {
|
||||
const errorMsg = data.responseDetails || '';
|
||||
if (errorMsg.includes('DISTINCT LANGUAGES') || errorMsg.includes('same language')) {
|
||||
// Text is already in target language, just show original
|
||||
setTranslations(prev => ({
|
||||
...prev,
|
||||
[id]: { translated: text, isLoading: false, error: null, isTranslated: true }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
throw new Error(errorMsg || 'Translation failed');
|
||||
}
|
||||
|
||||
const translatedText = data.responseData.translatedText;
|
||||
|
||||
setTranslations(prev => ({
|
||||
...prev,
|
||||
[id]: { translated: translatedText, isLoading: false, error: null, isTranslated: true }
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[Translation] Error:', error);
|
||||
setTranslations(prev => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
translated: '',
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Translation failed',
|
||||
isTranslated: false
|
||||
}
|
||||
}));
|
||||
}
|
||||
}, [targetLang]);
|
||||
|
||||
const getState = useCallback((id: string): TranslationState => {
|
||||
return translations[id] || { translated: '', isLoading: false, error: null, isTranslated: false };
|
||||
}, [translations]);
|
||||
|
||||
return { translate, getState };
|
||||
}
|
||||
150
web/lib/categories.ts
Normal file
150
web/lib/categories.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// Category types and utilities
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
path: string;
|
||||
level: number;
|
||||
parent_id: number | null;
|
||||
category_count: number;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
export interface CategoryTreeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: CategoryTreeNode[];
|
||||
data?: Category;
|
||||
}
|
||||
|
||||
export interface CategoryStats {
|
||||
total: number;
|
||||
sectors: number;
|
||||
business_types: number;
|
||||
sub_categories: number;
|
||||
leaf_categories: number;
|
||||
}
|
||||
|
||||
// Convert flat categories to tree structure
|
||||
export function buildCategoryTree(categories: Category[]): CategoryTreeNode[] {
|
||||
const map = new Map<string, CategoryTreeNode>();
|
||||
const roots: CategoryTreeNode[] = [];
|
||||
|
||||
// First pass: create all nodes
|
||||
for (const cat of categories) {
|
||||
map.set(cat.path, {
|
||||
id: cat.path,
|
||||
name: cat.name,
|
||||
children: [],
|
||||
data: cat,
|
||||
});
|
||||
}
|
||||
|
||||
// Second pass: link children to parents
|
||||
for (const cat of categories) {
|
||||
const node = map.get(cat.path)!;
|
||||
const pathParts = cat.path.split('.');
|
||||
|
||||
if (pathParts.length === 1) {
|
||||
// Root node
|
||||
roots.push(node);
|
||||
} else {
|
||||
// Find parent
|
||||
const parentPath = pathParts.slice(0, -1).join('.');
|
||||
const parent = map.get(parentPath);
|
||||
if (parent) {
|
||||
parent.children!.push(node);
|
||||
} else {
|
||||
// Parent not found, add as root
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children alphabetically
|
||||
const sortChildren = (nodes: CategoryTreeNode[]) => {
|
||||
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const node of nodes) {
|
||||
if (node.children && node.children.length > 0) {
|
||||
sortChildren(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sortChildren(roots);
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
// Convert to react-d3-tree format
|
||||
export function toD3Tree(nodes: CategoryTreeNode[]): any {
|
||||
return nodes.map((node) => ({
|
||||
name: node.name,
|
||||
attributes: {
|
||||
level: node.data?.level,
|
||||
count: node.data?.category_count,
|
||||
},
|
||||
children: node.children && node.children.length > 0
|
||||
? toD3Tree(node.children)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// Get breadcrumb path for a category
|
||||
export function getCategoryBreadcrumb(path: string, categories: Category[]): Category[] {
|
||||
const parts = path.split('.');
|
||||
const breadcrumb: Category[] = [];
|
||||
|
||||
for (let i = 1; i <= parts.length; i++) {
|
||||
const partialPath = parts.slice(0, i).join('.');
|
||||
const cat = categories.find((c) => c.path === partialPath);
|
||||
if (cat) {
|
||||
breadcrumb.push(cat);
|
||||
}
|
||||
}
|
||||
|
||||
return breadcrumb;
|
||||
}
|
||||
|
||||
// Search categories
|
||||
export function searchCategories(categories: Category[], query: string): Category[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return categories.filter(
|
||||
(cat) =>
|
||||
cat.name.toLowerCase().includes(lowerQuery) ||
|
||||
cat.path.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
|
||||
// Get level name
|
||||
export function getLevelName(level: number): string {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return 'Sector';
|
||||
case 2:
|
||||
return 'Business Type';
|
||||
case 3:
|
||||
return 'Sub-category';
|
||||
case 4:
|
||||
return 'Category';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// Get level color
|
||||
export function getLevelColor(level: number): string {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return 'bg-blue-500';
|
||||
case 2:
|
||||
return 'bg-green-500';
|
||||
case 3:
|
||||
return 'bg-yellow-500';
|
||||
case 4:
|
||||
return 'bg-purple-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export async function getWidgetData(
|
||||
widgetId: string,
|
||||
params: {
|
||||
business_id?: string;
|
||||
job_id?: string;
|
||||
time_range?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
@@ -78,6 +79,9 @@ export async function getWidgetData(
|
||||
if (params.business_id) {
|
||||
searchParams.set('business_id', params.business_id);
|
||||
}
|
||||
if (params.job_id) {
|
||||
searchParams.set('job_id', params.job_id);
|
||||
}
|
||||
if (params.time_range) {
|
||||
searchParams.set('time_range', params.time_range);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,15 @@ export interface PipelineDetail extends PipelineInfo {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// Per-stage execution metrics
|
||||
export interface StageMetrics {
|
||||
duration_ms: number;
|
||||
success: boolean;
|
||||
records_in: number;
|
||||
records_out: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Execution status
|
||||
export interface ExecutionStatus {
|
||||
id: string;
|
||||
@@ -87,6 +96,10 @@ export interface ExecutionStatus {
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
created_at?: string;
|
||||
total_duration_ms?: number;
|
||||
input_summary?: Record<string, unknown>;
|
||||
result_summary?: Record<string, unknown>;
|
||||
stage_metrics?: Record<string, StageMetrics>;
|
||||
}
|
||||
|
||||
// Widget-specific data types
|
||||
|
||||
139
web/lib/taxonomy/data.ts
Normal file
139
web/lib/taxonomy/data.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Static import of URT Taxonomy data
|
||||
*/
|
||||
|
||||
import urtData from './urt-codes.json';
|
||||
import type { URTTaxonomy } from './types';
|
||||
|
||||
export const taxonomy = urtData as URTTaxonomy;
|
||||
|
||||
/**
|
||||
* Get subcode count for a category
|
||||
*/
|
||||
export function getSubcodeCount(categoryKey: string): number {
|
||||
const domainKey = categoryKey[0];
|
||||
const domain = taxonomy.domains[domainKey];
|
||||
if (!domain) return 0;
|
||||
|
||||
const category = domain.categories[categoryKey];
|
||||
if (!category) return 0;
|
||||
|
||||
return Object.keys(category.subcodes).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subcode count for a domain
|
||||
*/
|
||||
export function getDomainSubcodeCount(domainKey: string): number {
|
||||
return taxonomy.indices.subcodes_by_domain[domainKey] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category count for a domain
|
||||
*/
|
||||
export function getDomainCategoryCount(domainKey: string): number {
|
||||
const domain = taxonomy.domains[domainKey];
|
||||
if (!domain) return 0;
|
||||
return Object.keys(domain.categories).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search taxonomy codes by text
|
||||
*/
|
||||
export function searchTaxonomy(query: string): {
|
||||
domains: string[];
|
||||
categories: string[];
|
||||
subcodes: string[];
|
||||
} {
|
||||
const normalizedQuery = query.toLowerCase().trim();
|
||||
if (!normalizedQuery) {
|
||||
return { domains: [], categories: [], subcodes: [] };
|
||||
}
|
||||
|
||||
const matchedDomains: string[] = [];
|
||||
const matchedCategories: string[] = [];
|
||||
const matchedSubcodes: string[] = [];
|
||||
|
||||
for (const [domainKey, domain] of Object.entries(taxonomy.domains)) {
|
||||
const domainMatches =
|
||||
domainKey.toLowerCase().includes(normalizedQuery) ||
|
||||
domain.name.toLowerCase().includes(normalizedQuery) ||
|
||||
domain.description.toLowerCase().includes(normalizedQuery);
|
||||
|
||||
if (domainMatches) {
|
||||
matchedDomains.push(domainKey);
|
||||
}
|
||||
|
||||
for (const [categoryKey, category] of Object.entries(domain.categories)) {
|
||||
const categoryMatches =
|
||||
categoryKey.toLowerCase().includes(normalizedQuery) ||
|
||||
category.name.toLowerCase().includes(normalizedQuery) ||
|
||||
category.definition.toLowerCase().includes(normalizedQuery);
|
||||
|
||||
if (categoryMatches) {
|
||||
matchedCategories.push(categoryKey);
|
||||
}
|
||||
|
||||
for (const [subcodeKey, subcode] of Object.entries(category.subcodes)) {
|
||||
const subcodeMatches =
|
||||
subcodeKey.toLowerCase().includes(normalizedQuery) ||
|
||||
subcode.name.toLowerCase().includes(normalizedQuery) ||
|
||||
subcode.definition.toLowerCase().includes(normalizedQuery) ||
|
||||
subcode.positive_example.toLowerCase().includes(normalizedQuery) ||
|
||||
subcode.negative_example.toLowerCase().includes(normalizedQuery);
|
||||
|
||||
if (subcodeMatches) {
|
||||
matchedSubcodes.push(subcodeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
domains: matchedDomains,
|
||||
categories: matchedCategories,
|
||||
subcodes: matchedSubcodes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parent domain and category for a subcode
|
||||
*/
|
||||
export function getSubcodeContext(subcodeKey: string): {
|
||||
domainKey: string;
|
||||
categoryKey: string;
|
||||
} | null {
|
||||
// Parse subcode key like "O1.01" -> domain "O", category "O1"
|
||||
const match = subcodeKey.match(/^([OPJEAVR])(\d)/);
|
||||
if (!match) return null;
|
||||
|
||||
const domainKey = match[1];
|
||||
const categoryKey = `${match[1]}${match[2]}`;
|
||||
|
||||
return { domainKey, categoryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parent domain for a category
|
||||
*/
|
||||
export function getCategoryDomain(categoryKey: string): string | null {
|
||||
const match = categoryKey.match(/^([OPJEAVR])/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subcode definition by subcode key (e.g., "J1.04" -> "Meeting scheduled times")
|
||||
*/
|
||||
export function getSubcodeDefinition(subcodeKey: string): string | null {
|
||||
const context = getSubcodeContext(subcodeKey);
|
||||
if (!context) return null;
|
||||
|
||||
const domain = taxonomy.domains[context.domainKey];
|
||||
if (!domain) return null;
|
||||
|
||||
const category = domain.categories[context.categoryKey];
|
||||
if (!category) return null;
|
||||
|
||||
const subcode = category.subcodes[subcodeKey];
|
||||
return subcode?.definition || null;
|
||||
}
|
||||
177
web/lib/taxonomy/types.ts
Normal file
177
web/lib/taxonomy/types.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* TypeScript types for URT Taxonomy Explorer
|
||||
*/
|
||||
|
||||
// ==================== Subcode Types ====================
|
||||
|
||||
export interface Subcode {
|
||||
name: string;
|
||||
definition: string;
|
||||
positive_example: string;
|
||||
negative_example: string;
|
||||
dont_confuse_with: string;
|
||||
dont_confuse_reason: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
name: string;
|
||||
definition: string;
|
||||
subcodes: Record<string, Subcode>;
|
||||
}
|
||||
|
||||
export interface Domain {
|
||||
name: string;
|
||||
description: string;
|
||||
core_question: string;
|
||||
default_owner: string;
|
||||
categories: Record<string, Category>;
|
||||
}
|
||||
|
||||
// ==================== Causal Code Types ====================
|
||||
|
||||
export interface CausalCode {
|
||||
name: string;
|
||||
definition: string;
|
||||
}
|
||||
|
||||
export interface CausalLayer {
|
||||
layer: string;
|
||||
prefix: string;
|
||||
description: string;
|
||||
codes: Record<string, CausalCode>;
|
||||
}
|
||||
|
||||
// ==================== Metadata Types ====================
|
||||
|
||||
export interface MetadataValue {
|
||||
label: string;
|
||||
definition: string;
|
||||
example?: string;
|
||||
constraint?: string;
|
||||
markers?: string[];
|
||||
trigger_words?: string[];
|
||||
}
|
||||
|
||||
export interface MetadataDimension {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
default?: string;
|
||||
values: Record<string, MetadataValue>;
|
||||
}
|
||||
|
||||
// ==================== Profile Types ====================
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
use_case: string;
|
||||
code_tier: number;
|
||||
code_count: number;
|
||||
code_type: string;
|
||||
complexity: string;
|
||||
required_fields: string[];
|
||||
optional_fields: string[];
|
||||
forbidden_fields: string[];
|
||||
primary_code_pattern: string;
|
||||
secondary_codes_allowed: boolean;
|
||||
secondary_codes_max?: number;
|
||||
secondary_codes_tier?: number;
|
||||
}
|
||||
|
||||
// ==================== Statistics ====================
|
||||
|
||||
export interface TaxonomyStatistics {
|
||||
domains: number;
|
||||
categories: number;
|
||||
subcodes_spec_claims: number;
|
||||
subcodes_actual: number;
|
||||
causal_codes: number;
|
||||
metadata_dimensions: number;
|
||||
metadata_values: number;
|
||||
total_classification_codes_spec_claims: number;
|
||||
total_classification_codes_actual: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// ==================== Indices ====================
|
||||
|
||||
export interface TaxonomyIndices {
|
||||
all_domains: string[];
|
||||
all_categories: string[];
|
||||
all_subcodes: string[];
|
||||
all_causal_codes: string[];
|
||||
subcodes_by_domain: Record<string, number>;
|
||||
}
|
||||
|
||||
// ==================== Main Taxonomy ====================
|
||||
|
||||
export interface URTTaxonomy {
|
||||
version: string;
|
||||
status: string;
|
||||
release_date: string;
|
||||
statistics: TaxonomyStatistics;
|
||||
domains: Record<string, Domain>;
|
||||
causal_codes: Record<string, CausalLayer>;
|
||||
metadata_dimensions: Record<string, MetadataDimension>;
|
||||
profiles: Record<string, Profile>;
|
||||
indices: TaxonomyIndices;
|
||||
}
|
||||
|
||||
// ==================== UI State Types ====================
|
||||
|
||||
export type TaxonomyTab = 'codes' | 'causal' | 'metadata' | 'profiles';
|
||||
|
||||
export interface TreeNodeState {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export interface SelectedSubcode {
|
||||
code: string;
|
||||
domainKey: string;
|
||||
domainName: string;
|
||||
categoryKey: string;
|
||||
categoryName: string;
|
||||
subcode: Subcode;
|
||||
}
|
||||
|
||||
// ==================== Domain Colors ====================
|
||||
|
||||
export const DOMAIN_COLORS: Record<string, string> = {
|
||||
O: '#f97316', // Offering - orange
|
||||
P: '#3b82f6', // People - blue
|
||||
J: '#8b5cf6', // Journey - purple
|
||||
E: '#06b6d4', // Environment - cyan
|
||||
A: '#10b981', // Access - green
|
||||
V: '#ec4899', // Value - pink
|
||||
R: '#f59e0b', // Relationship - amber
|
||||
};
|
||||
|
||||
export const DOMAIN_BG_COLORS: Record<string, string> = {
|
||||
O: 'bg-orange-500/10',
|
||||
P: 'bg-blue-500/10',
|
||||
J: 'bg-purple-500/10',
|
||||
E: 'bg-cyan-500/10',
|
||||
A: 'bg-emerald-500/10',
|
||||
V: 'bg-pink-500/10',
|
||||
R: 'bg-amber-500/10',
|
||||
};
|
||||
|
||||
export const DOMAIN_BORDER_COLORS: Record<string, string> = {
|
||||
O: 'border-orange-500/30',
|
||||
P: 'border-blue-500/30',
|
||||
J: 'border-purple-500/30',
|
||||
E: 'border-cyan-500/30',
|
||||
A: 'border-emerald-500/30',
|
||||
V: 'border-pink-500/30',
|
||||
R: 'border-amber-500/30',
|
||||
};
|
||||
|
||||
export const DOMAIN_TEXT_COLORS: Record<string, string> = {
|
||||
O: 'text-orange-400',
|
||||
P: 'text-blue-400',
|
||||
J: 'text-purple-400',
|
||||
E: 'text-cyan-400',
|
||||
A: 'text-emerald-400',
|
||||
V: 'text-pink-400',
|
||||
R: 'text-amber-400',
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
// API routes are handled by Next.js in app/api/
|
||||
// They internally call the Python backend at localhost:8000
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Reference in New Issue
Block a user