glam/frontend/src/pages/EntityReviewPage.tsx
kempersc d3d5c5cdde feat: Update manifest and refactor EnvironmentalZone schema with new slot mappings and archived slots
- Updated generated timestamp in manifest.json
- Refactored EnvironmentalZone.yaml to replace zone_name and zone_description with has_or_had_label and has_or_had_description respectively
- Archived previous slots zone_name, zone_id, and zone_description with detailed migration notes
- Introduced new classes for ApprovalTimeType, ApprovalTimeTypes, ISO639-3Identifier, Investment, InvestmentArea, Language, Liability, NetAsset, ResourceType, ResponseFormat, ResponseFormatType, Token, TrackIdentifier, TraditionalProductType, TranscriptFormat, TypeStatus, UNESCODomain, UNESCODomainType, VenueTypes, and VideoFrames with appropriate attributes and slots
- Added subclasses for ApprovalTimeTypes, ResponseFormatTypes, TraditionalProductTypes, and UNESCODomainTypes
2026-01-14 20:40:08 +01:00

1423 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Entity Resolution Review Page
*
* Review and verify matches between WCMS and LinkedIn profiles.
* Side-by-side comparison with match/not-match/uncertain decisions.
*
* Features:
* - Paginated list of profiles with pending reviews
* - Side-by-side profile comparison
* - Review decision buttons with keyboard shortcuts
* - Progress statistics dashboard
* - Match signal filtering
* - Compact UI to minimize scrolling
*/
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { Tooltip } from '../components/common/Tooltip';
import {
CheckCircle,
XCircle,
HelpCircle,
ChevronLeft,
ChevronRight,
User,
Building2,
Mail,
Globe,
Link2,
AlertCircle,
AlertTriangle,
Loader2,
Star,
Info,
Search,
X
} from 'lucide-react';
// Name similarity calculation using Levenshtein distance
function calculateNameSimilarity(name1: string, name2: string): number {
if (!name1 || !name2) return 0;
// Normalize names: lowercase, remove extra spaces, remove common titles
const normalize = (name: string) => {
return name
.toLowerCase()
.replace(/\s+/g, ' ')
.trim()
.replace(/^(dr|mr|mrs|ms|prof|ir|ing|drs|mr)\.\s*/i, '')
.replace(/\s+(jr|sr|ii|iii|iv)$/i, '');
};
const s1 = normalize(name1);
const s2 = normalize(name2);
// Exact match
if (s1 === s2) return 1.0;
// Check if one name contains the other (partial match)
if (s1.includes(s2) || s2.includes(s1)) {
const longerLength = Math.max(s1.length, s2.length);
const shorterLength = Math.min(s1.length, s2.length);
return shorterLength / longerLength;
}
// Levenshtein distance calculation
const len1 = s1.length;
const len2 = s2.length;
if (len1 === 0) return 0;
if (len2 === 0) return 0;
const matrix: number[][] = [];
for (let i = 0; i <= len1; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= len2; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
const distance = matrix[len1][len2];
const maxLen = Math.max(len1, len2);
return 1 - (distance / maxLen);
}
// Check if names match well enough (threshold: 0.7 = 70% similar)
function namesMatch(name1: string, name2: string, threshold = 0.7): boolean {
return calculateNameSimilarity(name1, name2) >= threshold;
}
import './EntityReviewPage.css';
// API base URL
const API_BASE = import.meta.env.VITE_API_URL || '';
// Types
interface MatchCandidate {
linkedin_ppid: string;
linkedin_name: string;
linkedin_slug: string | null;
confidence_score: number;
confidence_original?: number;
match_signals: string[];
reviewed: boolean;
review_decision: string | null;
reviewed_by: string | null;
reviewed_at: string | null;
is_likely_wrong_person?: boolean;
wrong_person_reason?: string | null;
email_birth_year?: number | null;
}
interface ProfileSummary {
ppid: string;
name: string;
email: string | null;
email_domain: string | null;
potential_matches: number;
reviewed_count: number;
pending_count: number;
}
interface ProfileDetail {
ppid: string;
name: string;
email: string | null;
email_domain: string | null;
wcms_identifiers: Record<string, unknown> | null;
wcms_activity: Record<string, unknown> | null;
match_candidates: MatchCandidate[];
annotation_date: string | null;
}
interface LinkedInProfile {
ppid: string;
name: string;
linkedin_slug: string | null;
profile_data: Record<string, unknown>;
affiliations: Array<Record<string, unknown>>;
web_claims: Array<Record<string, unknown>>;
}
interface LinkupSearchResult {
url: string;
title: string;
content: string;
snippet?: string;
linkedin_slug: string | null;
extracted_name: string | null;
extracted_headline: string | null;
}
interface ReviewStats {
total_profiles: number;
profiles_with_candidates: number;
total_candidates: number;
reviewed_candidates: number;
pending_candidates: number;
review_progress_percent: number;
likely_wrong_person?: number;
confidence_scoring_version?: string;
decisions: {
match: number;
not_match: number;
uncertain: number;
};
}
// Text translations
const TEXT = {
pageTitle: { nl: 'Entiteitsresolutie Review', en: 'Entity Resolution Review' },
pageSubtitle: {
nl: 'Verifieer matches tussen WCMS en LinkedIn profielen',
en: 'Verify matches between WCMS and LinkedIn profiles'
},
loading: { nl: 'Laden...', en: 'Loading...' },
noProfiles: { nl: 'Geen profielen te reviewen', en: 'No profiles to review' },
wcmsProfile: { nl: 'WCMS Profiel', en: 'WCMS Profile' },
linkedinProfile: { nl: 'LinkedIn Profiel', en: 'LinkedIn Profile' },
matchCandidate: { nl: 'Match Kandidaat', en: 'Match Candidate' },
confidence: { nl: 'Vertrouwen', en: 'Confidence' },
matchSignals: { nl: 'Match Signalen', en: 'Match Signals' },
match: { nl: 'Match', en: 'Match' },
notMatch: { nl: 'Geen Match', en: 'Not Match' },
uncertain: { nl: 'Onzeker', en: 'Uncertain' },
skip: { nl: 'Overslaan', en: 'Skip' },
reviewed: { nl: 'Beoordeeld', en: 'Reviewed' },
pending: { nl: 'In afwachting', en: 'Pending' },
progress: { nl: 'Voortgang', en: 'Progress' },
totalProfiles: { nl: 'Totaal profielen', en: 'Total profiles' },
totalCandidates: { nl: 'Totaal kandidaten', en: 'Total candidates' },
reviewedCandidates: { nl: 'Beoordeeld', en: 'Reviewed' },
pendingCandidates: { nl: 'In afwachting', en: 'Pending' },
decisions: { nl: 'Beslissingen', en: 'Decisions' },
selectProfile: { nl: 'Selecteer een profiel om te beginnen', en: 'Select a profile to begin' },
selectCandidate: { nl: 'Selecteer een kandidaat om te vergelijken', en: 'Select a candidate to compare' },
email: { nl: 'E-mail', en: 'Email' },
domain: { nl: 'Domein', en: 'Domain' },
affiliations: { nl: 'Affiliaties', en: 'Affiliations' },
savingDecision: { nl: 'Beslissing opslaan...', en: 'Saving decision...' },
decisionSaved: { nl: 'Beslissing opgeslagen', en: 'Decision saved' },
error: { nl: 'Fout', en: 'Error' },
wrongPerson: { nl: 'Verkeerde Persoon', en: 'Wrong Person' },
wrongPersonWarning: { nl: 'Waarschijnlijk verkeerde persoon!', en: 'Likely wrong person!' },
likelyWrongPerson: { nl: 'Waarschijnlijk verkeerd', en: 'Likely Wrong' },
highConfidence: { nl: 'Hoog vertrouwen', en: 'High Confidence' },
showHighConfidence: { nl: 'Toon hoog vertrouwen (>80%)', en: 'Show high confidence (>80%)' },
hideWrongPerson: { nl: 'Verberg verkeerde persoon', en: 'Hide wrong person' },
birthYearMismatch: { nl: 'Geboortejaar komt niet overeen', en: 'Birth year mismatch' },
nameMismatch: { nl: 'Namen komen niet overeen', en: 'Names do not match' },
nameMismatchWarning: { nl: 'De namen komen niet overeen - controleer zorgvuldig of zoek opnieuw', en: 'Names do not match - verify carefully or search again' },
searchLinkup: { nl: 'Zoek opnieuw met Linkup', en: 'Search again with Linkup' },
namesSimilar: { nl: 'Namen vergelijkbaar', en: 'Names similar' },
};
export default function EntityReviewPage() {
const { language } = useLanguage();
const navigate = useNavigate();
const t = (key: keyof typeof TEXT) => TEXT[key][language as 'nl' | 'en'] || TEXT[key].en;
// Auth check
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const reviewToken = sessionStorage.getItem('review_token');
useEffect(() => {
if (!reviewToken) {
navigate('/review-login', { state: { from: '/review' } });
} else {
setIsAuthenticated(true);
}
}, [reviewToken, navigate]);
// State
const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
const [stats, setStats] = useState<ReviewStats | null>(null);
const [selectedProfile, setSelectedProfile] = useState<ProfileDetail | null>(null);
const [selectedCandidate, setSelectedCandidate] = useState<MatchCandidate | null>(null);
const [linkedinProfile, setLinkedinProfile] = useState<LinkedInProfile | null>(null);
const [loading, setLoading] = useState(true);
const [loadingProfile, setLoadingProfile] = useState(false);
const [loadingLinkedin, setLoadingLinkedin] = useState(false);
const [savingDecision, setSavingDecision] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [pageInput, setPageInput] = useState('1');
const pageSize = 20;
// Filtering state
const [showFilters, setShowFilters] = useState(false);
const [minSignals, setMinSignals] = useState(1);
const [requiredSignals, setRequiredSignals] = useState<string[]>([]);
const [showHighConfidenceOnly, setShowHighConfidenceOnly] = useState(false);
const [hideWrongPerson, setHideWrongPerson] = useState(false);
const [availableSignals, setAvailableSignals] = useState<{signal_types: string[], signal_counts: Record<string, number>}>({signal_types: [], signal_counts: {}});
// Stats filter: 'all' | 'reviewed' | 'pending'
type StatsFilter = 'all' | 'reviewed' | 'pending';
const [statsFilter, setStatsFilter] = useState<StatsFilter>('pending');
// Profile search
const [profileSearchQuery, setProfileSearchQuery] = useState('');
// Linkup search state
const [linkupSearching, setLinkupSearching] = useState(false);
const [linkupResults, setLinkupResults] = useState<LinkupSearchResult[]>([]);
const [showLinkupResults, setShowLinkupResults] = useState(false);
const [linkupError, setLinkupError] = useState<string | null>(null);
// Manual LinkedIn URL input
const [manualLinkedinUrl, setManualLinkedinUrl] = useState('');
const [addingManualCandidate, setAddingManualCandidate] = useState(false);
const [manualUrlError, setManualUrlError] = useState<string | null>(null);
// Filtered profiles based on search query
const filteredProfiles = profiles.filter(profile => {
if (!profileSearchQuery.trim()) return true;
const query = profileSearchQuery.toLowerCase();
return (
profile.name.toLowerCase().includes(query) ||
(profile.email_domain && profile.email_domain.toLowerCase().includes(query))
);
});
// DEBUG: Log icon visibility info
useEffect(() => {
const debugIcons = () => {
const buttons = document.querySelectorAll('.header-actions button');
console.log('=== ICON DEBUG INFO ===');
console.log('Buttons found:', buttons.length);
buttons.forEach((btn, i) => {
const svg = btn.querySelector('svg');
const computedStyle = window.getComputedStyle(btn);
const svgStyle = svg ? window.getComputedStyle(svg) : null;
console.log(`--- Button ${i} (${btn.className}) ---`);
console.log(` btn color: ${computedStyle.color}`);
console.log(` btn background: ${computedStyle.backgroundColor}`);
if (svg && svgStyle) {
console.log(` svg stroke attr: ${svg.getAttribute('stroke')}`);
console.log(` svg style.stroke: ${svg.style.stroke}`);
console.log(` svg computed stroke: ${svgStyle.stroke}`);
console.log(` svg computed fill: ${svgStyle.fill}`);
console.log(` svg computed color: ${svgStyle.color}`);
// Check first path element
const path = svg.querySelector('path, line, circle, polyline, polygon');
if (path) {
const pathStyle = window.getComputedStyle(path);
console.log(` path stroke: ${pathStyle.stroke}`);
console.log(` path fill: ${pathStyle.fill}`);
console.log(` path strokeWidth: ${pathStyle.strokeWidth}`);
}
}
});
};
const timer = setTimeout(debugIcons, 1000);
return () => clearTimeout(timer);
}, [isAuthenticated]);
// Fetch available signal types
const fetchSignalTypes = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/api/review/signal-types`);
if (response.ok) {
const data = await response.json();
setAvailableSignals(data);
}
} catch (err) {
console.error('Failed to fetch signal types:', err);
}
}, []);
// Fetch profiles list with filters
const fetchProfiles = useCallback(async () => {
setLoading(true);
setError(null);
try {
// Use statsFilter to determine filter_status parameter
const filterStatus = statsFilter === 'all' ? '' : statsFilter;
let url = `${API_BASE}/api/review/candidates?page=${page}&page_size=${pageSize}&sort_by=confidence&min_signals=${minSignals}`;
if (filterStatus) {
url += `&filter_status=${filterStatus}`;
}
if (requiredSignals.length > 0) {
url += `&signal_types=${requiredSignals.join(',')}`;
}
if (showHighConfidenceOnly) {
url += `&min_confidence=0.8`;
}
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch profiles');
const data = await response.json();
setProfiles(data.profiles);
setTotalPages(Math.ceil(data.total / pageSize));
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [page, minSignals, requiredSignals, showHighConfidenceOnly, statsFilter]);
// Fetch stats
const fetchStats = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/api/review/stats`);
if (!response.ok) throw new Error('Failed to fetch stats');
const data = await response.json();
setStats(data);
} catch (err) {
console.error('Failed to fetch stats:', err);
}
}, []);
// Fetch profile detail
const fetchProfileDetail = useCallback(async (ppid: string) => {
setLoadingProfile(true);
setSelectedCandidate(null);
setLinkedinProfile(null);
try {
const response = await fetch(`${API_BASE}/api/review/profile/${encodeURIComponent(ppid)}`);
if (!response.ok) throw new Error('Failed to fetch profile');
const data = await response.json();
setSelectedProfile(data);
// Auto-select first pending candidate
const pendingCandidate = data.match_candidates?.find((c: MatchCandidate) => !c.reviewed);
if (pendingCandidate) {
setSelectedCandidate(pendingCandidate);
fetchLinkedinProfile(pendingCandidate.linkedin_ppid);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoadingProfile(false);
}
}, []);
// Fetch LinkedIn profile for comparison
const fetchLinkedinProfile = useCallback(async (ppid: string) => {
setLoadingLinkedin(true);
try {
const response = await fetch(`${API_BASE}/api/review/linkedin/${encodeURIComponent(ppid)}`);
if (!response.ok) throw new Error('Failed to fetch LinkedIn profile');
const data = await response.json();
setLinkedinProfile(data);
} catch (err) {
console.error('Failed to fetch LinkedIn profile:', err);
} finally {
setLoadingLinkedin(false);
}
}, []);
// Perform Linkup search to find additional LinkedIn candidates
const performLinkupSearch = useCallback(async () => {
if (!selectedProfile) return;
setLinkupSearching(true);
setLinkupError(null);
setLinkupResults([]);
try {
const response = await fetch(`${API_BASE}/api/review/linkup-search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wcms_ppid: selectedProfile.ppid,
name: selectedProfile.name,
email: selectedProfile.email || undefined,
email_domain: selectedProfile.email_domain || undefined,
institution: selectedProfile.wcms_identifiers?.institution_name || undefined,
}),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.detail || 'Linkup search failed');
}
const data = await response.json();
setLinkupResults(data.results || []);
setShowLinkupResults(true);
} catch (err) {
setLinkupError(err instanceof Error ? err.message : 'Search failed');
} finally {
setLinkupSearching(false);
}
}, [selectedProfile]);
// Add manual LinkedIn candidate by URL
const addManualLinkedinCandidate = useCallback(async () => {
if (!selectedProfile || !manualLinkedinUrl.trim()) return;
setAddingManualCandidate(true);
setManualUrlError(null);
// Extract LinkedIn slug from URL
const urlMatch = manualLinkedinUrl.match(/linkedin\.com\/in\/([^/?]+)/i);
if (!urlMatch) {
setManualUrlError(language === 'nl'
? 'Ongeldige LinkedIn URL. Gebruik format: linkedin.com/in/username'
: 'Invalid LinkedIn URL. Use format: linkedin.com/in/username');
setAddingManualCandidate(false);
return;
}
const linkedinSlug = urlMatch[1];
try {
const response = await fetch(`${API_BASE}/api/review/add-candidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wcms_ppid: selectedProfile.ppid,
linkedin_slug: linkedinSlug,
}),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.detail || 'Failed to add candidate');
}
// Clear input and refresh profile
setManualLinkedinUrl('');
await fetchProfileDetail(selectedProfile.ppid);
} catch (err) {
setManualUrlError(err instanceof Error ? err.message : 'Failed to add candidate');
} finally {
setAddingManualCandidate(false);
}
}, [selectedProfile, manualLinkedinUrl, language, fetchProfileDetail]);
// Save review decision
const saveDecision = useCallback(async (decision: 'match' | 'not_match' | 'uncertain') => {
if (!selectedProfile || !selectedCandidate) return;
setSavingDecision(true);
try {
const response = await fetch(`${API_BASE}/api/review/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wcms_ppid: selectedProfile.ppid,
linkedin_ppid: selectedCandidate.linkedin_ppid,
decision: decision,
// Auto-reject other candidates when matching (not for uncertain)
auto_reject_others: decision === 'match',
}),
});
if (!response.ok) throw new Error('Failed to save decision');
// Refresh data
await fetchProfileDetail(selectedProfile.ppid);
await fetchStats();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setSavingDecision(false);
}
}, [selectedProfile, selectedCandidate, fetchProfileDetail, fetchStats]);
// Initial load
useEffect(() => {
if (isAuthenticated) {
fetchProfiles();
fetchStats();
fetchSignalTypes();
}
}, [fetchProfiles, fetchStats, fetchSignalTypes, isAuthenticated]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isAuthenticated) return;
// Don't handle shortcuts if user is typing in an input
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
// Decision shortcuts (only when candidate is selected)
if (selectedCandidate && !savingDecision) {
switch (e.key) {
case 'm':
case 'M':
e.preventDefault();
saveDecision('match');
return;
case 'n':
case 'N':
e.preventDefault();
saveDecision('not_match');
return;
case 'u':
case 'U':
e.preventDefault();
saveDecision('uncertain');
return;
}
}
// Arrow key navigation
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
if (selectedProfile && selectedCandidate) {
// Navigate up through candidates
const candidates = selectedProfile.match_candidates.filter(
c => !hideWrongPerson || !c.is_likely_wrong_person
);
const currentIndex = candidates.findIndex(
c => c.linkedin_ppid === selectedCandidate.linkedin_ppid
);
if (currentIndex > 0) {
const prevCandidate = candidates[currentIndex - 1];
setSelectedCandidate(prevCandidate);
fetchLinkedinProfile(prevCandidate.linkedin_ppid);
}
} else if (profiles.length > 0 && !selectedProfile) {
// No profile selected, select the first one
fetchProfileDetail(profiles[0].ppid);
}
break;
case 'ArrowDown':
e.preventDefault();
if (selectedProfile && selectedCandidate) {
// Navigate down through candidates
const candidates = selectedProfile.match_candidates.filter(
c => !hideWrongPerson || !c.is_likely_wrong_person
);
const currentIndex = candidates.findIndex(
c => c.linkedin_ppid === selectedCandidate.linkedin_ppid
);
if (currentIndex < candidates.length - 1) {
const nextCandidate = candidates[currentIndex + 1];
setSelectedCandidate(nextCandidate);
fetchLinkedinProfile(nextCandidate.linkedin_ppid);
}
} else if (!selectedProfile && profiles.length > 0) {
// Navigate down through profiles when no profile selected
fetchProfileDetail(profiles[0].ppid);
}
break;
case 'ArrowLeft':
e.preventDefault();
// Navigate to previous profile
if (selectedProfile) {
const currentIndex = profiles.findIndex(p => p.ppid === selectedProfile.ppid);
if (currentIndex > 0) {
fetchProfileDetail(profiles[currentIndex - 1].ppid);
}
}
break;
case 'ArrowRight':
e.preventDefault();
// Navigate to next profile
if (selectedProfile) {
const currentIndex = profiles.findIndex(p => p.ppid === selectedProfile.ppid);
if (currentIndex < profiles.length - 1) {
fetchProfileDetail(profiles[currentIndex + 1].ppid);
}
} else if (profiles.length > 0) {
// No profile selected, select first one
fetchProfileDetail(profiles[0].ppid);
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [saveDecision, savingDecision, selectedCandidate, selectedProfile, profiles, hideWrongPerson, isAuthenticated, fetchProfileDetail, fetchLinkedinProfile]);
// Sync page input with page state
useEffect(() => {
setPageInput(page.toString());
}, [page]);
// Handle page input submission
const handlePageInputSubmit = (e: React.FormEvent | React.KeyboardEvent) => {
e.preventDefault();
const newPage = parseInt(pageInput, 10);
if (!isNaN(newPage) && newPage >= 1 && newPage <= totalPages) {
setPage(newPage);
} else {
setPageInput(page.toString()); // Reset to current page if invalid
}
};
// Handle logout
const handleLogout = () => {
sessionStorage.removeItem('review_token');
sessionStorage.removeItem('review_username');
navigate('/review-login');
};
// If not authenticated yet, show loading
if (isAuthenticated === null) {
return (
<div className="entity-review-page">
<div className="loading-state">
<Loader2 className="animate-spin" size={32} />
<span>Authenticating...</span>
</div>
</div>
);
}
// Format confidence score as percentage with color
const formatConfidence = (score: number) => {
const percent = Math.round(score * 100);
const color = percent >= 80 ? 'text-green-600' : percent >= 60 ? 'text-yellow-600' : 'text-red-600';
return <span className={color}>{percent}%</span>;
};
return (
<div className="entity-review-page">
{/* Header */}
<header className="review-header compact">
<div className="header-content">
<h1>{t('pageTitle')}</h1>
</div>
<div className="header-actions">
<button
className={`filter-btn ${showFilters ? 'active' : ''}`}
onClick={() => setShowFilters(!showFilters)}
title="Toggle filters"
>
{/* Raw inline SVG for Filter icon */}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
</button>
<button
className="refresh-btn"
onClick={() => { fetchProfiles(); fetchStats(); }}
disabled={loading}
>
{/* Raw inline SVG for RefreshCw icon */}
<svg className={loading ? 'animate-spin' : ''} width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path>
<path d="M3 21v-5h5"></path>
</svg>
</button>
<button
className="logout-btn"
onClick={handleLogout}
title="Uitloggen"
>
{/* Raw inline SVG for Key icon */}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"></path>
<path d="m21 2-9.6 9.6"></path>
<circle cx="7.5" cy="15.5" r="5.5"></circle>
</svg>
</button>
</div>
</header>
{/* Compact Stats Dashboard */}
{stats && (
<div className="stats-dashboard compact">
<Tooltip content={language === 'nl' ? 'Toon alle profielen' : 'Show all profiles'} position="bottom">
<div
className={`stat-card clickable ${statsFilter === 'all' ? 'active' : ''}`}
onClick={() => { setStatsFilter('all'); setPage(1); }}
>
<span className="stat-value">{stats.profiles_with_candidates.toLocaleString()}</span>
<span className="stat-label">Profielen</span>
</div>
</Tooltip>
<Tooltip content={language === 'nl' ? 'Totaal aantal kandidaat-matches' : 'Total candidate matches'} position="bottom">
<div className="stat-card">
<span className="stat-value">{stats.total_candidates.toLocaleString()}</span>
<span className="stat-label">Kandidaten</span>
</div>
</Tooltip>
<Tooltip content={language === 'nl' ? 'Toon alleen beoordeelde profielen' : 'Show reviewed profiles only'} position="bottom">
<div
className={`stat-card success clickable ${statsFilter === 'reviewed' ? 'active' : ''}`}
onClick={() => { setStatsFilter('reviewed'); setPage(1); }}
>
<span className="stat-value">{stats.reviewed_candidates.toLocaleString()}</span>
<span className="stat-label">Beoordeeld</span>
</div>
</Tooltip>
<Tooltip content={language === 'nl' ? 'Toon alleen profielen met openstaande reviews' : 'Show profiles with pending reviews'} position="bottom">
<div
className={`stat-card warning clickable ${statsFilter === 'pending' ? 'active' : ''}`}
onClick={() => { setStatsFilter('pending'); setPage(1); }}
>
<span className="stat-value">{stats.pending_candidates.toLocaleString()}</span>
<span className="stat-label">In afwachting</span>
</div>
</Tooltip>
{stats.likely_wrong_person !== undefined && stats.likely_wrong_person > 0 && (
<Tooltip content={t('wrongPersonWarning')} position="bottom">
<div className="stat-card danger">
<AlertTriangle size={14} className="stat-icon" />
<span className="stat-value">{stats.likely_wrong_person.toLocaleString()}</span>
<span className="stat-label">{t('likelyWrongPerson')}</span>
</div>
</Tooltip>
)}
<div className="stat-card progress">
<span className="stat-value">{stats.review_progress_percent}%</span>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${stats.review_progress_percent}%` }} />
</div>
</div>
</div>
)}
{/* Filter Panel */}
{showFilters && (
<div className="filter-panel">
<div className="filter-group">
<label>Min signalen:</label>
<select value={minSignals} onChange={(e) => { setMinSignals(Number(e.target.value)); setPage(1); }}>
<option value="1">1+</option>
<option value="2">2+</option>
<option value="3">3+</option>
</select>
</div>
<div className="filter-group">
<label className="filter-checkbox">
<input
type="checkbox"
checked={showHighConfidenceOnly}
onChange={(e) => { setShowHighConfidenceOnly(e.target.checked); setPage(1); }}
/>
<Star size={14} className="filter-icon high-confidence" />
<span>{t('showHighConfidence')}</span>
</label>
</div>
<div className="filter-group">
<label className="filter-checkbox">
<input
type="checkbox"
checked={hideWrongPerson}
onChange={(e) => { setHideWrongPerson(e.target.checked); setPage(1); }}
/>
<AlertTriangle size={14} className="filter-icon wrong-person" />
<span>{t('hideWrongPerson')}</span>
</label>
</div>
<div className="filter-group">
<label>Vereiste signalen:</label>
<div className="signal-checkboxes">
{availableSignals.signal_types.map((signal) => (
<label key={signal} className="signal-checkbox">
<input
type="checkbox"
checked={requiredSignals.includes(signal)}
onChange={(e) => {
if (e.target.checked) {
setRequiredSignals([...requiredSignals, signal]);
} else {
setRequiredSignals(requiredSignals.filter(s => s !== signal));
}
setPage(1);
}}
/>
<span>{signal}</span>
<span className="signal-count">({availableSignals.signal_counts[signal] || 0})</span>
</label>
))}
</div>
</div>
</div>
)}
{/* Main Content */}
<div className="review-content">
{/* Profile List Sidebar */}
<aside className="profile-sidebar compact">
<h2>Profielen ({profileSearchQuery ? `${filteredProfiles.length} / ${profiles.length}` : profiles.length})</h2>
{/* Profile Search */}
<div className="profile-search">
<Search size={16} />
<input
type="text"
placeholder={language === 'nl' ? 'Zoek op naam of domein...' : 'Search by name or domain...'}
value={profileSearchQuery}
onChange={(e) => setProfileSearchQuery(e.target.value)}
/>
{profileSearchQuery && (
<button
className="clear-search"
onClick={() => setProfileSearchQuery('')}
title={language === 'nl' ? 'Wissen' : 'Clear'}
>
<X size={14} />
</button>
)}
</div>
{loading ? (
<div className="loading-state">
<Loader2 className="animate-spin" size={24} />
<span>{t('loading')}</span>
</div>
) : error ? (
<div className="error-state">
<AlertCircle size={24} />
<span>{error}</span>
</div>
) : profiles.length === 0 ? (
<div className="empty-state">
<CheckCircle size={32} />
<span>{t('noProfiles')}</span>
</div>
) : filteredProfiles.length === 0 ? (
<div className="empty-state">
<Search size={32} />
<span>{language === 'nl' ? 'Geen resultaten gevonden' : 'No results found'}</span>
</div>
) : (
<>
<ul className="profile-list">
{filteredProfiles.map((profile) => (
<li
key={profile.ppid}
className={`profile-item ${selectedProfile?.ppid === profile.ppid ? 'selected' : ''}`}
onClick={() => fetchProfileDetail(profile.ppid)}
>
<div className="profile-item-header">
<User size={16} />
<span className="profile-name">{profile.name}</span>
</div>
<div className="profile-item-meta">
{profile.email_domain && (
<span className="domain-badge">
<Globe size={12} />
{profile.email_domain}
</span>
)}
<span className="match-count">
{profile.pending_count} pending
</span>
</div>
</li>
))}
</ul>
{/* Pagination */}
<div className="pagination">
<button
onClick={() => setPage(1)}
disabled={page === 1}
title="First page"
className="pagination-btn"
>
<ChevronLeft size={14} />
<ChevronLeft size={14} style={{ marginLeft: -8 }} />
</button>
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
title="Previous page"
className="pagination-btn"
>
<ChevronLeft size={16} />
</button>
<form onSubmit={handlePageInputSubmit} className="pagination-input-form">
<input
type="text"
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onBlur={handlePageInputSubmit}
onKeyDown={(e) => e.key === 'Enter' && handlePageInputSubmit(e)}
className="pagination-input"
/>
<span className="pagination-total">/ {totalPages}</span>
</form>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
title="Next page"
className="pagination-btn"
>
<ChevronRight size={16} />
</button>
<button
onClick={() => setPage(totalPages)}
disabled={page === totalPages}
title="Last page"
className="pagination-btn"
>
<ChevronRight size={14} />
<ChevronRight size={14} style={{ marginLeft: -8 }} />
</button>
</div>
</>
)}
</aside>
{/* Comparison Panel */}
<main className="comparison-panel">
{!selectedProfile ? (
<div className="empty-comparison">
<User size={48} />
<p>{t('selectProfile')}</p>
</div>
) : loadingProfile ? (
<div className="loading-state">
<Loader2 className="animate-spin" size={32} />
<span>{t('loading')}</span>
</div>
) : (
<div className="comparison-grid">
{/* WCMS Profile Card */}
<div className="profile-card wcms">
<h3>
<Building2 size={18} />
{t('wcmsProfile')}
</h3>
<div className="profile-details">
<div className="detail-row">
<span className="label">Name</span>
<span className="value">{selectedProfile.name}</span>
</div>
{selectedProfile.email && (
<div className="detail-row">
<span className="label">
<Mail size={14} />
{t('email')}
</span>
<span className="value">{selectedProfile.email}</span>
</div>
)}
{selectedProfile.email_domain && (
<div className="detail-row">
<span className="label">
<Globe size={14} />
{t('domain')}
</span>
<span className="value">{selectedProfile.email_domain}</span>
</div>
)}
{selectedProfile.wcms_identifiers && (
<div className="detail-row">
<span className="label">WCMS IDs</span>
<span className="value code">
{JSON.stringify(selectedProfile.wcms_identifiers, null, 2)}
</span>
</div>
)}
</div>
{/* Candidate selector */}
<div className="candidate-selector">
<h4>
{t('matchCandidate')}s ({selectedProfile.match_candidates.length})
<Tooltip
content={language === 'nl'
? "Deze LinkedIn profielen kunnen bij dit WCMS contact horen. Bekijk elke kandidaat om de match te bevestigen of af te wijzen. Gebruik ↑↓ om te navigeren."
: "These LinkedIn profiles may belong to this WCMS contact. Review each candidate to confirm or reject the match. Use ↑↓ to navigate."
}
position="right"
maxWidth={280}
>
<span className="info-tooltip">
<Info size={14} />
</span>
</Tooltip>
</h4>
<ul className="candidate-list">
{selectedProfile.match_candidates
.filter(c => !hideWrongPerson || !c.is_likely_wrong_person)
// Deduplicate by linkedin_slug (preferred) or linkedin_ppid - keep highest confidence score
// This handles cases where the same LinkedIn profile has multiple PPID entries
.reduce((unique, candidate) => {
const existing = unique.find(c =>
(c.linkedin_slug && c.linkedin_slug === candidate.linkedin_slug) ||
(!c.linkedin_slug && !candidate.linkedin_slug && c.linkedin_ppid === candidate.linkedin_ppid)
);
if (!existing) {
unique.push(candidate);
} else if (candidate.confidence_score > existing.confidence_score) {
// Replace with higher confidence version, but merge match_signals
const index = unique.indexOf(existing);
const mergedSignals = [...new Set([...existing.match_signals, ...candidate.match_signals])];
unique[index] = { ...candidate, match_signals: mergedSignals };
} else {
// Keep existing but merge match_signals from lower confidence candidate
const index = unique.indexOf(existing);
const mergedSignals = [...new Set([...existing.match_signals, ...candidate.match_signals])];
unique[index] = { ...existing, match_signals: mergedSignals };
}
return unique;
}, [] as typeof selectedProfile.match_candidates)
.map((candidate) => {
const nameMatches = namesMatch(selectedProfile.name, candidate.linkedin_name);
const showHighConfidence = candidate.confidence_score >= 0.8 && nameMatches;
return (
<li
key={candidate.linkedin_ppid}
className={`candidate-item ${selectedCandidate?.linkedin_ppid === candidate.linkedin_ppid ? 'selected' : ''} ${candidate.reviewed ? 'reviewed' : ''} ${candidate.is_likely_wrong_person ? 'wrong-person' : ''} ${showHighConfidence ? 'high-confidence' : ''} ${!nameMatches ? 'name-mismatch' : ''}`}
onClick={() => {
setSelectedCandidate(candidate);
fetchLinkedinProfile(candidate.linkedin_ppid);
}}
>
<div className="candidate-header">
{candidate.is_likely_wrong_person ? (
<AlertTriangle size={14} className="wrong-person-icon" />
) : !nameMatches ? (
<AlertCircle size={14} className="name-mismatch-icon" />
) : showHighConfidence ? (
<Star size={14} className="high-confidence-icon" />
) : (
<Link2 size={14} />
)}
<span>{candidate.linkedin_name}</span>
{formatConfidence(candidate.confidence_score)}
</div>
{!nameMatches && !candidate.is_likely_wrong_person && (
<div className="name-mismatch-indicator">
<AlertCircle size={12} />
<span>{t('nameMismatch')}</span>
</div>
)}
{candidate.is_likely_wrong_person && (
<div className="wrong-person-warning">
<AlertTriangle size={12} />
<span>{t('wrongPersonWarning')}</span>
</div>
)}
{candidate.reviewed && (
<span className={`decision-badge ${candidate.review_decision}`}>
{candidate.review_decision}
</span>
)}
</li>
);
})}
</ul>
{/* Linkup Search Section */}
<div className="linkup-search-section">
<button
className="linkup-search-btn"
onClick={performLinkupSearch}
disabled={linkupSearching}
title={language === 'nl' ? 'Zoek naar meer LinkedIn profielen' : 'Search for more LinkedIn profiles'}
>
{linkupSearching ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Search size={16} />
)}
{linkupSearching
? (language === 'nl' ? 'Zoeken...' : 'Searching...')
: (language === 'nl' ? 'Zoek meer kandidaten' : 'Find more candidates')}
</button>
{linkupError && (
<div className="linkup-error">
<AlertCircle size={14} />
<span>{linkupError}</span>
</div>
)}
{showLinkupResults && linkupResults.length > 0 && (
<div className="linkup-results">
<h5>
{language === 'nl' ? 'Gevonden LinkedIn profielen' : 'Found LinkedIn Profiles'} ({linkupResults.length})
<button
className="close-results-btn"
onClick={() => setShowLinkupResults(false)}
title={language === 'nl' ? 'Sluiten' : 'Close'}
>
×
</button>
</h5>
<ul className="linkup-results-list">
{linkupResults.map((result, idx) => (
<li key={idx} className="linkup-result-item">
<div className="result-name">
{result.extracted_name || result.title}
</div>
{result.linkedin_slug && (
<a
href={`https://linkedin.com/in/${result.linkedin_slug}`}
target="_blank"
rel="noopener noreferrer"
className="linkedin-link"
>
<Link2 size={12} />
{result.linkedin_slug}
</a>
)}
{result.snippet && (
<div className="result-snippet">{result.snippet.slice(0, 100)}...</div>
)}
</li>
))}
</ul>
</div>
)}
{showLinkupResults && linkupResults.length === 0 && !linkupSearching && !linkupError && (
<div className="linkup-no-results">
{language === 'nl' ? 'Geen extra profielen gevonden' : 'No additional profiles found'}
</div>
)}
{/* Manual LinkedIn URL Input */}
<div className="manual-linkedin-section">
<div className="manual-linkedin-header">
<span className="divider-text">
{language === 'nl' ? 'of voeg handmatig toe' : 'or add manually'}
</span>
<Tooltip
position="bottom"
maxWidth={350}
content={
<div style={{ textAlign: 'left', fontSize: '12px', lineHeight: '1.5' }}>
<strong style={{ display: 'block', marginBottom: '6px' }}>
{language === 'nl' ? 'Profiel Ophalen Providers:' : 'Profile Fetching Providers:'}
</strong>
<div style={{ marginBottom: '8px' }}>
<strong>LinkedIn:</strong>{' '}
{language === 'nl'
? 'Exa API (werkt) Linkup API kan LinkedIn niet benaderen vanwege anti-scraping beveiliging'
: 'Exa API (works) Linkup API cannot access LinkedIn due to anti-scraping protections'}
</div>
<div style={{ marginBottom: '8px' }}>
<strong>Facebook:</strong>{' '}
{language === 'nl'
? 'Noch Exa noch Linkup kan Facebook profielen ophalen gespecialiseerde diensten (Apify, Bright Data) vereist'
: 'Neither Exa nor Linkup can access Facebook profiles specialized services (Apify, Bright Data) required'}
</div>
<div style={{ fontSize: '11px', opacity: 0.85, borderTop: '1px solid rgba(255,255,255,0.2)', paddingTop: '6px', marginTop: '4px' }}>
{language === 'nl'
? 'Wanneer u een LinkedIn URL toevoegt, haalt het systeem automatisch profielgegevens op via Exa.'
: 'When you add a LinkedIn URL, the system automatically fetches profile data using Exa.'}
</div>
</div>
}
>
<Info size={14} className="info-icon" style={{ marginLeft: '6px', opacity: 0.6, cursor: 'help' }} />
</Tooltip>
</div>
<div className="manual-linkedin-input-group">
<input
type="text"
className="manual-linkedin-input"
placeholder={language === 'nl' ? 'LinkedIn URL (bijv. linkedin.com/in/username)' : 'LinkedIn URL (e.g. linkedin.com/in/username)'}
value={manualLinkedinUrl}
onChange={(e) => {
setManualLinkedinUrl(e.target.value);
setManualUrlError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && manualLinkedinUrl.trim()) {
addManualLinkedinCandidate();
}
}}
disabled={addingManualCandidate}
/>
<button
className="add-manual-btn"
onClick={addManualLinkedinCandidate}
disabled={!manualLinkedinUrl.trim() || addingManualCandidate}
title={language === 'nl' ? 'Kandidaat toevoegen' : 'Add candidate'}
>
{addingManualCandidate ? (
<Loader2 className="animate-spin" size={16} />
) : (
<span>+</span>
)}
</button>
</div>
{manualUrlError && (
<div className="manual-url-error">
<AlertCircle size={14} />
<span>{manualUrlError}</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* LinkedIn Profile Card */}
<div className="profile-card linkedin">
<h3>
<Link2 size={18} />
{t('linkedinProfile')}
</h3>
{!selectedCandidate ? (
<div className="empty-linkedin">
<p>{t('selectCandidate')}</p>
</div>
) : loadingLinkedin ? (
<div className="loading-state">
<Loader2 className="animate-spin" size={24} />
</div>
) : linkedinProfile ? (
<div className="profile-details">
{/* Wrong Person Alert Banner */}
{selectedCandidate.is_likely_wrong_person && (
<div className="wrong-person-alert">
<AlertTriangle size={18} />
<div className="alert-content">
<strong>{t('wrongPersonWarning')}</strong>
{selectedCandidate.wrong_person_reason && (
<p>{selectedCandidate.wrong_person_reason}</p>
)}
</div>
</div>
)}
{/* High Confidence Banner - Only show if names match */}
{selectedCandidate.confidence_score >= 0.8 &&
!selectedCandidate.is_likely_wrong_person &&
namesMatch(selectedProfile.name, linkedinProfile.name) && (
<div className="high-confidence-alert">
<Star size={18} />
<div className="alert-content">
<strong>{t('highConfidence')}</strong>
<p>Score: {Math.round(selectedCandidate.confidence_score * 100)}%</p>
</div>
</div>
)}
{/* Name Mismatch Warning */}
{!namesMatch(selectedProfile.name, linkedinProfile.name) && (
<div className="name-mismatch-alert">
<AlertTriangle size={18} />
<div className="alert-content">
<strong>{t('nameMismatch')}</strong>
<p>{t('nameMismatchWarning')}</p>
<div className="name-comparison">
<span className="name-label">WCMS:</span> <strong>{selectedProfile.name}</strong><br/>
<span className="name-label">LinkedIn:</span> <strong>{linkedinProfile.name}</strong>
</div>
<div className="similarity-score">
{t('namesSimilar')}: {Math.round(calculateNameSimilarity(selectedProfile.name, linkedinProfile.name) * 100)}%
</div>
</div>
</div>
)}
<div className="detail-row">
<span className="label">Name</span>
<span className="value">{linkedinProfile.name}</span>
</div>
{linkedinProfile.linkedin_slug && (
<div className="detail-row">
<span className="label">LinkedIn</span>
<a
href={`https://linkedin.com/in/${linkedinProfile.linkedin_slug}`}
target="_blank"
rel="noopener noreferrer"
className="value link"
>
{linkedinProfile.linkedin_slug}
</a>
</div>
)}
{/* Match Signals */}
{selectedCandidate.match_signals.length > 0 && (
<div className="detail-row">
<span className="label">{t('matchSignals')}</span>
<div className="signals">
{selectedCandidate.match_signals.map((signal, i) => (
<span key={i} className={`signal-badge ${signal === 'birth_year_mismatch' || signal === 'likely_wrong_person' ? 'danger' : signal === 'birth_year_validated' ? 'success' : ''}`}>{signal}</span>
))}
</div>
</div>
)}
{/* Affiliations */}
{linkedinProfile.affiliations.length > 0 && (
<div className="detail-row">
<span className="label">{t('affiliations')}</span>
<ul className="affiliations-list">
{linkedinProfile.affiliations.slice(0, 5).map((aff, i) => (
<li key={i}>
{(aff as Record<string, string>).organization_name ||
(aff as Record<string, string>).company ||
JSON.stringify(aff)}
</li>
))}
</ul>
</div>
)}
</div>
) : null}
{/* Decision Buttons - Always show when candidate selected, allow changing decisions */}
{selectedCandidate && (
<div className="decision-buttons">
<button
className={`decision-btn match ${selectedCandidate.review_decision === 'match' ? 'current-decision' : ''}`}
onClick={() => saveDecision('match')}
disabled={savingDecision}
>
<CheckCircle size={18} />
{t('match')} (M)
</button>
<button
className={`decision-btn not-match ${selectedCandidate.review_decision === 'not_match' ? 'current-decision' : ''}`}
onClick={() => saveDecision('not_match')}
disabled={savingDecision}
>
<XCircle size={18} />
{t('notMatch')} (N)
</button>
<button
className={`decision-btn uncertain ${selectedCandidate.review_decision === 'uncertain' ? 'current-decision' : ''}`}
onClick={() => saveDecision('uncertain')}
disabled={savingDecision}
>
<HelpCircle size={18} />
{t('uncertain')} (U)
</button>
</div>
)}
{savingDecision && (
<div className="saving-indicator">
<Loader2 className="animate-spin" size={16} />
{t('savingDecision')}
</div>
)}
</div>
</div>
)}
</main>
</div>
</div>
);
}