- 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
1423 lines
59 KiB
TypeScript
1423 lines
59 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|