glam/frontend/src/pages/EntityReviewPage.tsx
kempersc 17da3a81e9 feat(review): add enhanced pagination with first/last page buttons and page input
- 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)
2026-01-13 23:27:28 +01:00

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>
);
}