- Add first page (<<) and last page (>>) navigation buttons - Add direct page number input field for jumping to specific pages - Update CSS styling for new pagination controls including input field - Use stacked ChevronLeft/ChevronRight icons for first/last (lucide-react compatibility)
840 lines
32 KiB
TypeScript
840 lines
32 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 {
|
|
CheckCircle,
|
|
XCircle,
|
|
HelpCircle,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
User,
|
|
Building2,
|
|
Mail,
|
|
Globe,
|
|
Link2,
|
|
AlertCircle,
|
|
AlertTriangle,
|
|
Loader2,
|
|
RefreshCw,
|
|
Filter,
|
|
Key,
|
|
Star
|
|
} from 'lucide-react';
|
|
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 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' },
|
|
};
|
|
|
|
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: {}});
|
|
|
|
// 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 {
|
|
let url = `${API_BASE}/api/review/candidates?page=${page}&page_size=${pageSize}&filter_status=pending&sort_by=confidence&min_signals=${minSignals}`;
|
|
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]);
|
|
|
|
// 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);
|
|
}
|
|
}, []);
|
|
|
|
// 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,
|
|
}),
|
|
});
|
|
|
|
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 (savingDecision || !selectedCandidate || !isAuthenticated) return;
|
|
|
|
switch (e.key) {
|
|
case 'm':
|
|
case 'M':
|
|
e.preventDefault();
|
|
saveDecision('match');
|
|
break;
|
|
case 'n':
|
|
case 'N':
|
|
e.preventDefault();
|
|
saveDecision('not_match');
|
|
break;
|
|
case 'u':
|
|
case 'U':
|
|
e.preventDefault();
|
|
saveDecision('uncertain');
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [saveDecision, savingDecision, selectedCandidate, isAuthenticated]);
|
|
|
|
// 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"
|
|
>
|
|
<Filter size={16} />
|
|
</button>
|
|
<button
|
|
className="refresh-btn"
|
|
onClick={() => { fetchProfiles(); fetchStats(); }}
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw className={loading ? 'animate-spin' : ''} size={16} />
|
|
</button>
|
|
<button
|
|
className="logout-btn"
|
|
onClick={handleLogout}
|
|
title="Uitloggen"
|
|
>
|
|
<Key size={16} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Compact Stats Dashboard */}
|
|
{stats && (
|
|
<div className="stats-dashboard compact">
|
|
<div className="stat-card">
|
|
<span className="stat-value">{stats.profiles_with_candidates.toLocaleString()}</span>
|
|
<span className="stat-label">Profielen</span>
|
|
</div>
|
|
<div className="stat-card">
|
|
<span className="stat-value">{stats.total_candidates.toLocaleString()}</span>
|
|
<span className="stat-label">Kandidaten</span>
|
|
</div>
|
|
<div className="stat-card success">
|
|
<span className="stat-value">{stats.reviewed_candidates.toLocaleString()}</span>
|
|
<span className="stat-label">Beoordeeld</span>
|
|
</div>
|
|
<div className="stat-card warning">
|
|
<span className="stat-value">{stats.pending_candidates.toLocaleString()}</span>
|
|
<span className="stat-label">In afwachting</span>
|
|
</div>
|
|
{stats.likely_wrong_person !== undefined && stats.likely_wrong_person > 0 && (
|
|
<div className="stat-card danger" title={t('wrongPersonWarning')}>
|
|
<AlertTriangle size={14} className="stat-icon" />
|
|
<span className="stat-value">{stats.likely_wrong_person.toLocaleString()}</span>
|
|
<span className="stat-label">{t('likelyWrongPerson')}</span>
|
|
</div>
|
|
)}
|
|
<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 ({profiles.length})</h2>
|
|
|
|
{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>
|
|
) : (
|
|
<>
|
|
<ul className="profile-list">
|
|
{profiles.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})</h4>
|
|
<ul className="candidate-list">
|
|
{selectedProfile.match_candidates
|
|
.filter(c => !hideWrongPerson || !c.is_likely_wrong_person)
|
|
.map((candidate) => (
|
|
<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' : ''} ${candidate.confidence_score >= 0.8 ? 'high-confidence' : ''}`}
|
|
onClick={() => {
|
|
setSelectedCandidate(candidate);
|
|
fetchLinkedinProfile(candidate.linkedin_ppid);
|
|
}}
|
|
>
|
|
<div className="candidate-header">
|
|
{candidate.is_likely_wrong_person ? (
|
|
<AlertTriangle size={14} className="wrong-person-icon" />
|
|
) : candidate.confidence_score >= 0.8 ? (
|
|
<Star size={14} className="high-confidence-icon" />
|
|
) : (
|
|
<Link2 size={14} />
|
|
)}
|
|
<span>{candidate.linkedin_name}</span>
|
|
{formatConfidence(candidate.confidence_score)}
|
|
</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>
|
|
</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 */}
|
|
{selectedCandidate.confidence_score >= 0.8 && !selectedCandidate.is_likely_wrong_person && (
|
|
<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>
|
|
)}
|
|
|
|
<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 */}
|
|
{selectedCandidate && !selectedCandidate.reviewed && (
|
|
<div className="decision-buttons">
|
|
<button
|
|
className="decision-btn match"
|
|
onClick={() => saveDecision('match')}
|
|
disabled={savingDecision}
|
|
>
|
|
<CheckCircle size={18} />
|
|
{t('match')} (M)
|
|
</button>
|
|
<button
|
|
className="decision-btn not-match"
|
|
onClick={() => saveDecision('not_match')}
|
|
disabled={savingDecision}
|
|
>
|
|
<XCircle size={18} />
|
|
{t('notMatch')} (N)
|
|
</button>
|
|
<button
|
|
className="decision-btn uncertain"
|
|
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>
|
|
);
|
|
}
|