feat: add confidence-based RMP matching with manual review workflow

Replace simple auto-matching with scored candidate generation that
considers department overlap, name uniqueness, and rating volume.
Candidates above 0.85 auto-accept; others require admin approval.
This commit is contained in:
2026-01-30 01:31:11 -06:00
parent 39ba131322
commit 203c337cf0
19 changed files with 2428 additions and 175 deletions
+118 -7
View File
@@ -1,12 +1,21 @@
import type {
CandidateResponse,
CodeDescription,
CourseResponse,
DbMeetingTime,
InstructorDetail,
InstructorDetailResponse,
InstructorListItem,
InstructorResponse,
InstructorStats,
LinkedRmpProfile,
ListInstructorsResponse,
RescoreResponse,
SearchResponse as SearchResponseGenerated,
ServiceInfo,
ServiceStatus,
StatusResponse,
TopCandidateResponse,
User,
} from "$lib/bindings";
@@ -14,13 +23,22 @@ const API_BASE_URL = "/api";
// Re-export generated types under their canonical names
export type {
CandidateResponse,
CodeDescription,
CourseResponse,
DbMeetingTime,
InstructorDetail,
InstructorDetailResponse,
InstructorListItem,
InstructorResponse,
InstructorStats,
LinkedRmpProfile,
ListInstructorsResponse,
RescoreResponse,
ServiceInfo,
ServiceStatus,
StatusResponse,
TopCandidateResponse,
};
// Semantic aliases — these all share the CodeDescription shape
@@ -112,6 +130,15 @@ export interface SearchParams {
sort_dir?: SortDirection;
}
// Admin instructor query params (client-only, not generated)
export interface AdminInstructorListParams {
status?: string;
search?: string;
page?: number;
per_page?: number;
sort?: string;
}
export class BannerApiClient {
private baseUrl: string;
private fetchFn: typeof fetch;
@@ -121,8 +148,30 @@ export class BannerApiClient {
this.fetchFn = fetchFn;
}
private async request<T>(endpoint: string): Promise<T> {
const response = await this.fetchFn(`${this.baseUrl}${endpoint}`);
private buildInit(options?: { method?: string; body?: unknown }): RequestInit | undefined {
if (!options) return undefined;
const init: RequestInit = {};
if (options.method) {
init.method = options.method;
}
if (options.body !== undefined) {
init.headers = { "Content-Type": "application/json" };
init.body = JSON.stringify(options.body);
} else if (options.method) {
init.headers = { "Content-Type": "application/json" };
}
return Object.keys(init).length > 0 ? init : undefined;
}
private async request<T>(
endpoint: string,
options?: { method?: string; body?: unknown }
): Promise<T> {
const init = this.buildInit(options);
const args: [string, RequestInit?] = [`${this.baseUrl}${endpoint}`];
if (init) args.push(init);
const response = await this.fetchFn(...args);
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
@@ -131,6 +180,21 @@ export class BannerApiClient {
return (await response.json()) as T;
}
private async requestVoid(
endpoint: string,
options?: { method?: string; body?: unknown }
): Promise<void> {
const init = this.buildInit(options);
const args: [string, RequestInit?] = [`${this.baseUrl}${endpoint}`];
if (init) args.push(init);
const response = await this.fetchFn(...args);
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
}
async getStatus(): Promise<StatusResponse> {
return this.request<StatusResponse>("/status");
}
@@ -174,13 +238,10 @@ export class BannerApiClient {
}
async setUserAdmin(discordId: string, isAdmin: boolean): Promise<User> {
const response = await this.fetchFn(`${this.baseUrl}/admin/users/${discordId}/admin`, {
return this.request<User>(`/admin/users/${discordId}/admin`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_admin: isAdmin }),
body: { is_admin: isAdmin },
});
if (!response.ok) throw new Error(`API request failed: ${response.status}`);
return (await response.json()) as User;
}
async getAdminScrapeJobs(): Promise<ScrapeJobsResponse> {
@@ -230,6 +291,56 @@ export class BannerApiClient {
const qs = query.toString();
return this.request<MetricsResponse>(`/metrics${qs ? `?${qs}` : ""}`);
}
// Admin instructor endpoints
async getAdminInstructors(params?: AdminInstructorListParams): Promise<ListInstructorsResponse> {
const query = new URLSearchParams();
if (params?.status) query.set("status", params.status);
if (params?.search) query.set("search", params.search);
if (params?.page !== undefined) query.set("page", String(params.page));
if (params?.per_page !== undefined) query.set("per_page", String(params.per_page));
if (params?.sort) query.set("sort", params.sort);
const qs = query.toString();
return this.request<ListInstructorsResponse>(`/admin/instructors${qs ? `?${qs}` : ""}`);
}
async getAdminInstructor(id: number): Promise<InstructorDetailResponse> {
return this.request<InstructorDetailResponse>(`/admin/instructors/${id}`);
}
async matchInstructor(id: number, rmpLegacyId: number): Promise<InstructorDetailResponse> {
return this.request<InstructorDetailResponse>(`/admin/instructors/${id}/match`, {
method: "POST",
body: { rmpLegacyId },
});
}
async rejectCandidate(id: number, rmpLegacyId: number): Promise<void> {
return this.requestVoid(`/admin/instructors/${id}/reject-candidate`, {
method: "POST",
body: { rmpLegacyId },
});
}
async rejectAllCandidates(id: number): Promise<void> {
return this.requestVoid(`/admin/instructors/${id}/reject-all`, {
method: "POST",
});
}
async unmatchInstructor(id: number, rmpLegacyId?: number): Promise<void> {
return this.requestVoid(`/admin/instructors/${id}/unmatch`, {
method: "POST",
...(rmpLegacyId !== undefined ? { body: { rmpLegacyId } } : {}),
});
}
async rescoreInstructors(): Promise<RescoreResponse> {
return this.request<RescoreResponse>("/admin/rmp/rescore", {
method: "POST",
});
}
}
export const client = new BannerApiClient();
+10
View File
@@ -1,9 +1,19 @@
export type { CandidateResponse } from "./CandidateResponse";
export type { CodeDescription } from "./CodeDescription";
export type { CourseResponse } from "./CourseResponse";
export type { DbMeetingTime } from "./DbMeetingTime";
export type { InstructorDetail } from "./InstructorDetail";
export type { InstructorDetailResponse } from "./InstructorDetailResponse";
export type { InstructorListItem } from "./InstructorListItem";
export type { InstructorResponse } from "./InstructorResponse";
export type { InstructorStats } from "./InstructorStats";
export type { LinkedRmpProfile } from "./LinkedRmpProfile";
export type { ListInstructorsResponse } from "./ListInstructorsResponse";
export type { OkResponse } from "./OkResponse";
export type { RescoreResponse } from "./RescoreResponse";
export type { SearchResponse } from "./SearchResponse";
export type { ServiceInfo } from "./ServiceInfo";
export type { ServiceStatus } from "./ServiceStatus";
export type { StatusResponse } from "./StatusResponse";
export type { TopCandidateResponse } from "./TopCandidateResponse";
export type { User } from "./User";
+6 -3
View File
@@ -187,18 +187,20 @@ describe("getPrimaryInstructor", () => {
it("returns primary instructor", () => {
const instructors: InstructorResponse[] = [
{
instructorId: 1,
bannerId: "1",
displayName: "A",
email: null,
email: "a@utsa.edu",
isPrimary: false,
rmpRating: null,
rmpNumRatings: null,
rmpLegacyId: null,
},
{
instructorId: 2,
bannerId: "2",
displayName: "B",
email: null,
email: "b@utsa.edu",
isPrimary: true,
rmpRating: null,
rmpNumRatings: null,
@@ -210,9 +212,10 @@ describe("getPrimaryInstructor", () => {
it("returns first instructor when no primary", () => {
const instructors: InstructorResponse[] = [
{
instructorId: 3,
bannerId: "1",
displayName: "A",
email: null,
email: "a@utsa.edu",
isPrimary: false,
rmpRating: null,
rmpNumRatings: null,
+21
View File
@@ -415,3 +415,24 @@ export function formatCreditHours(course: CourseResponse): string {
}
return "—";
}
/**
* Convert Banner "Last, First Middle" → "First Middle Last".
* Handles: no comma (returned as-is), trailing/leading spaces,
* middle names/initials preserved.
*/
export function formatInstructorName(displayName: string): string {
const commaIdx = displayName.indexOf(",");
if (commaIdx === -1) return displayName.trim();
const last = displayName.slice(0, commaIdx).trim();
const rest = displayName.slice(commaIdx + 1).trim();
if (!rest) return last;
return `${rest} ${last}`;
}
/** Check if a rating value represents real data (not the 0.0 placeholder for unrated professors). */
export function isRatingValid(avgRating: number | null, numRatings: number): boolean {
return avgRating !== null && !(avgRating === 0 && numRatings === 0);
}
+2
View File
@@ -7,6 +7,7 @@ import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte"
import {
ClipboardList,
FileText,
GraduationCap,
LayoutDashboard,
LogOut,
Settings,
@@ -61,6 +62,7 @@ const adminItems = [
{ href: "/admin/jobs", label: "Scrape Jobs", icon: ClipboardList },
{ href: "/admin/audit", label: "Audit Log", icon: FileText },
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/instructors", label: "Instructors", icon: GraduationCap },
];
function isActive(href: string): boolean {
@@ -0,0 +1,651 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import {
client,
type InstructorListItem,
type InstructorStats,
type InstructorDetailResponse,
type CandidateResponse,
} from "$lib/api";
import { formatInstructorName, isRatingValid, ratingStyle, rmpUrl } from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import {
Check,
ChevronLeft,
ChevronRight,
ExternalLink,
RefreshCw,
X,
XCircle,
} from "@lucide/svelte";
// --- State ---
let instructors = $state<InstructorListItem[]>([]);
let stats = $state<InstructorStats>({
total: 0,
unmatched: 0,
auto: 0,
confirmed: 0,
rejected: 0,
withCandidates: 0,
});
let totalCount = $state(0);
let currentPage = $state(1);
let perPage = $state(25);
let activeFilter = $state<string | undefined>(undefined);
let searchQuery = $state("");
let searchInput = $state("");
let error = $state<string | null>(null);
let loading = $state(true);
// Expanded row detail
let expandedId = $state<number | null>(null);
let detail = $state<InstructorDetailResponse | null>(null);
let detailLoading = $state(false);
let detailError = $state<string | null>(null);
// Action states
let actionLoading = $state<string | null>(null);
let rescoreLoading = $state(false);
let rescoreResult = $state<string | null>(null);
// Search debounce
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
const filterOptions = [
{ label: "All", value: undefined as string | undefined },
{ label: "Needs Review", value: "unmatched" },
{ label: "Auto-matched", value: "auto" },
{ label: "Confirmed", value: "confirmed" },
{ label: "Rejected", value: "rejected" },
];
let matchedLegacyIds = $derived(
new Set(detail?.currentMatches.map((m: { legacyId: number }) => m.legacyId) ?? [])
);
let progressDenom = $derived(stats.total || 1);
let autoPct = $derived((stats.auto / progressDenom) * 100);
let confirmedPct = $derived((stats.confirmed / progressDenom) * 100);
let unmatchedPct = $derived((stats.unmatched / progressDenom) * 100);
let totalPages = $derived(Math.max(1, Math.ceil(totalCount / perPage)));
// --- Data fetching ---
async function fetchInstructors() {
loading = true;
error = null;
try {
const res = await client.getAdminInstructors({
status: activeFilter,
search: searchQuery || undefined,
page: currentPage,
per_page: perPage,
});
instructors = res.instructors;
totalCount = res.total;
stats = res.stats;
} catch (e) {
error = e instanceof Error ? e.message : "Failed to load instructors";
} finally {
loading = false;
}
}
async function fetchDetail(id: number) {
detailLoading = true;
detailError = null;
detail = null;
try {
detail = await client.getAdminInstructor(id);
} catch (e) {
detailError = e instanceof Error ? e.message : "Failed to load details";
} finally {
detailLoading = false;
}
}
onMount(() => {
fetchInstructors();
});
onDestroy(() => clearTimeout(searchTimeout));
function setFilter(value: string | undefined) {
activeFilter = value;
currentPage = 1;
expandedId = null;
fetchInstructors();
}
function handleSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchQuery = searchInput;
currentPage = 1;
expandedId = null;
fetchInstructors();
}, 300);
}
function goToPage(page: number) {
if (page < 1 || page > totalPages) return;
currentPage = page;
expandedId = null;
fetchInstructors();
}
async function toggleExpand(id: number) {
if (expandedId === id) {
expandedId = null;
detail = null;
return;
}
expandedId = id;
await fetchDetail(id);
}
// --- Actions ---
async function handleMatch(instructorId: number, rmpLegacyId: number) {
actionLoading = `match-${rmpLegacyId}`;
try {
detail = await client.matchInstructor(instructorId, rmpLegacyId);
await fetchInstructors();
} catch (e) {
detailError = e instanceof Error ? e.message : "Match failed";
} finally {
actionLoading = null;
}
}
async function handleReject(instructorId: number, rmpLegacyId: number) {
actionLoading = `reject-${rmpLegacyId}`;
try {
await client.rejectCandidate(instructorId, rmpLegacyId);
await Promise.all([fetchDetail(instructorId), fetchInstructors()]);
} catch (e) {
detailError = e instanceof Error ? e.message : "Reject failed";
} finally {
actionLoading = null;
}
}
async function handleRejectAll(instructorId: number) {
actionLoading = "reject-all";
try {
await client.rejectAllCandidates(instructorId);
await Promise.all([fetchDetail(instructorId), fetchInstructors()]);
} catch (e) {
detailError = e instanceof Error ? e.message : "Reject all failed";
} finally {
actionLoading = null;
}
}
async function handleUnmatch(instructorId: number, rmpLegacyId: number) {
actionLoading = `unmatch-${rmpLegacyId}`;
try {
await client.unmatchInstructor(instructorId, rmpLegacyId);
await Promise.all([fetchDetail(instructorId), fetchInstructors()]);
} catch (e) {
detailError = e instanceof Error ? e.message : "Unmatch failed";
} finally {
actionLoading = null;
}
}
async function handleRescore() {
rescoreLoading = true;
rescoreResult = null;
try {
const res = await client.rescoreInstructors();
rescoreResult = `Rescored: ${res.totalUnmatched} unmatched, ${res.candidatesCreated} candidates created, ${res.autoMatched} auto-matched`;
await fetchInstructors();
} catch (e) {
rescoreResult = e instanceof Error ? e.message : "Rescore failed";
} finally {
rescoreLoading = false;
}
}
// --- Helpers ---
function statusBadge(status: string): { label: string; classes: string } {
switch (status) {
case "unmatched":
return {
label: "Unmatched",
classes: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
};
case "auto":
return {
label: "Auto",
classes: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
};
case "confirmed":
return {
label: "Confirmed",
classes: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
};
case "rejected":
return {
label: "Rejected",
classes: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
default:
return { label: status, classes: "bg-muted text-muted-foreground" };
}
}
function formatScore(score: number): string {
return (score * 100).toFixed(0);
}
const scoreWeights: Record<string, number> = {
name: 0.5,
department: 0.25,
uniqueness: 0.15,
volume: 0.1,
};
const scoreColors: Record<string, string> = {
name: "bg-blue-500",
department: "bg-purple-500",
uniqueness: "bg-amber-500",
volume: "bg-emerald-500",
};
const scoreLabels: Record<string, string> = {
name: "Name",
department: "Dept",
uniqueness: "Unique",
volume: "Volume",
};
</script>
<div class="flex items-center justify-between mb-4">
<h1 class="text-lg font-semibold text-foreground">Instructors</h1>
<button
onclick={handleRescore}
disabled={rescoreLoading}
class="inline-flex items-center gap-1.5 rounded-md bg-muted px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer"
>
<RefreshCw size={14} class={rescoreLoading ? "animate-spin" : ""} />
Rescore
</button>
</div>
{#if rescoreResult}
<div class="mb-4 rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
{rescoreResult}
</div>
{/if}
{#if error}
<p class="text-destructive mb-4">{error}</p>
{/if}
{#if loading && instructors.length === 0}
<!-- Skeleton stats cards -->
<div class="mb-4 grid grid-cols-5 gap-3">
{#each Array(5) as _}
<div class="bg-card border-border rounded-lg border p-3">
<div class="h-3 w-16 animate-pulse rounded bg-muted mb-2"></div>
<div class="h-6 w-12 animate-pulse rounded bg-muted"></div>
</div>
{/each}
</div>
<div class="bg-muted mb-6 h-2 rounded-full overflow-hidden"></div>
<!-- Skeleton table rows -->
<div class="bg-card border-border overflow-hidden rounded-lg border">
<div class="divide-y divide-border">
{#each Array(8) as _}
<div class="flex items-center gap-4 px-4 py-3">
<div class="space-y-1.5 flex-1">
<div class="h-4 w-40 animate-pulse rounded bg-muted"></div>
<div class="h-3 w-28 animate-pulse rounded bg-muted"></div>
</div>
<div class="h-5 w-20 animate-pulse rounded-full bg-muted"></div>
<div class="h-4 w-32 animate-pulse rounded bg-muted"></div>
<div class="h-4 w-8 animate-pulse rounded bg-muted"></div>
<div class="h-6 w-16 animate-pulse rounded bg-muted"></div>
</div>
{/each}
</div>
</div>
{:else}
<!-- Stats Bar -->
<div class="mb-4 grid grid-cols-5 gap-3">
<div class="bg-card border-border rounded-lg border p-3">
<div class="text-muted-foreground text-xs">Total</div>
<div class="text-lg font-semibold">{stats.total}</div>
</div>
<div class="bg-card border-border rounded-lg border p-3">
<div class="text-xs text-amber-600 dark:text-amber-400">Unmatched</div>
<div class="text-lg font-semibold">{stats.unmatched}</div>
</div>
<div class="bg-card border-border rounded-lg border p-3">
<div class="text-xs text-blue-600 dark:text-blue-400">Auto</div>
<div class="text-lg font-semibold">{stats.auto}</div>
</div>
<div class="bg-card border-border rounded-lg border p-3">
<div class="text-xs text-green-600 dark:text-green-400">Confirmed</div>
<div class="text-lg font-semibold">{stats.confirmed}</div>
</div>
<div class="bg-card border-border rounded-lg border p-3">
<div class="text-xs text-red-600 dark:text-red-400">Rejected</div>
<div class="text-lg font-semibold">{stats.rejected}</div>
</div>
</div>
<!-- Progress Bar -->
<div class="bg-muted mb-6 h-2 rounded-full overflow-hidden flex">
<div class="bg-blue-500 h-full transition-all duration-300"
style="width: {autoPct}%" title="Auto: {stats.auto}"></div>
<div class="bg-green-500 h-full transition-all duration-300"
style="width: {confirmedPct}%" title="Confirmed: {stats.confirmed}"></div>
<div class="bg-amber-500 h-full transition-all duration-300"
style="width: {unmatchedPct}%" title="Unmatched: {stats.unmatched}"></div>
</div>
<!-- Filters -->
<div class="mb-4 flex items-center gap-4">
<div class="flex gap-2">
{#each filterOptions as opt}
<button
onclick={() => setFilter(opt.value)}
class="rounded-md px-3 py-1.5 text-sm transition-colors cursor-pointer
{activeFilter === opt.value
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent'}"
>
{opt.label}
</button>
{/each}
</div>
<input
type="text"
placeholder="Search by name or email..."
bind:value={searchInput}
oninput={handleSearch}
class="bg-card border-border rounded-md border px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring w-64"
/>
</div>
{#if instructors.length === 0}
<p class="text-muted-foreground py-8 text-center text-sm">No instructors found.</p>
{:else}
<div class="bg-card border-border overflow-hidden rounded-lg border">
<table class="w-full text-sm">
<thead>
<tr class="border-border border-b text-left text-muted-foreground">
<th class="px-4 py-2.5 font-medium">Name</th>
<th class="px-4 py-2.5 font-medium">Status</th>
<th class="px-4 py-2.5 font-medium">Top Candidate</th>
<th class="px-4 py-2.5 font-medium text-center">Candidates</th>
<th class="px-4 py-2.5 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{#each instructors as instructor (instructor.id)}
{@const badge = statusBadge(instructor.rmpMatchStatus)}
{@const isExpanded = expandedId === instructor.id}
<tr
class="border-border border-b cursor-pointer hover:bg-muted/50 transition-colors {isExpanded ? 'bg-muted/30' : ''}"
onclick={() => toggleExpand(instructor.id)}
>
<td class="px-4 py-2.5">
<div class="font-medium text-foreground">{formatInstructorName(instructor.displayName)}</div>
<div class="text-xs text-muted-foreground">{instructor.email}</div>
</td>
<td class="px-4 py-2.5">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {badge.classes}">
{badge.label}
</span>
</td>
<td class="px-4 py-2.5">
{#if instructor.topCandidate}
{@const tc = instructor.topCandidate}
<div class="flex items-center gap-2">
<span class="text-foreground">{tc.firstName} {tc.lastName}</span>
{#if isRatingValid(tc.avgRating, tc.numRatings ?? 0)}
<span class="font-semibold tabular-nums" style={ratingStyle(tc.avgRating!, themeStore.isDark)}>
{tc.avgRating!.toFixed(1)}
</span>
{:else}
<span class="text-xs text-muted-foreground">N/A</span>
{/if}
<span class="text-xs text-muted-foreground tabular-nums">
({formatScore(tc.score ?? 0)}%)
</span>
</div>
{:else}
<span class="text-muted-foreground text-xs">No candidates</span>
{/if}
</td>
<td class="px-4 py-2.5 text-center tabular-nums text-muted-foreground">
{instructor.candidateCount}
</td>
<td class="px-4 py-2.5 text-right">
<div class="inline-flex items-center gap-1">
{#if instructor.topCandidate && instructor.rmpMatchStatus === "unmatched"}
<button
onclick={(e) => { e.stopPropagation(); handleMatch(instructor.id, instructor.topCandidate!.rmpLegacyId); }}
disabled={actionLoading !== null}
class="rounded p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Accept top candidate"
>
<Check size={16} />
</button>
{/if}
<button
onclick={(e) => { e.stopPropagation(); toggleExpand(instructor.id); }}
class="rounded p-1 text-muted-foreground hover:bg-muted transition-colors cursor-pointer"
title={isExpanded ? "Collapse" : "Expand"}
>
<ChevronRight size={16} class="transition-transform duration-200 {isExpanded ? 'rotate-90' : ''}" />
</button>
</div>
</td>
</tr>
<!-- Expanded detail panel -->
{#if isExpanded}
<tr class="border-border border-b bg-muted/20">
<td colspan="5" class="p-0">
<div class="p-4">
{#if detailLoading}
<p class="text-muted-foreground text-sm py-4 text-center">Loading details...</p>
{:else if detailError}
<p class="text-destructive text-sm py-2">{detailError}</p>
{:else if detail}
<div class="grid grid-cols-3 gap-6">
<!-- Left: Instructor info -->
<div class="space-y-3">
<h3 class="font-medium text-foreground">Instructor</h3>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
<dt class="text-muted-foreground">Name</dt>
<dd class="text-foreground">{formatInstructorName(detail.instructor.displayName)}</dd>
<dt class="text-muted-foreground">Email</dt>
<dd class="text-foreground">{detail.instructor.email}</dd>
<dt class="text-muted-foreground">Courses</dt>
<dd class="text-foreground tabular-nums">{detail.instructor.courseCount}</dd>
{#if detail.instructor.subjectsTaught.length > 0}
<dt class="text-muted-foreground">Subjects</dt>
<dd class="flex flex-wrap gap-1">
{#each detail.instructor.subjectsTaught as subj}
<span class="rounded bg-muted px-1.5 py-0.5 text-xs">{subj}</span>
{/each}
</dd>
{/if}
</dl>
</div>
<!-- Right: Candidates (spans 2 cols) -->
<div class="col-span-2 space-y-3">
<div class="flex items-center justify-between">
<h3 class="font-medium text-foreground">
Candidates ({detail.candidates.length})
</h3>
{#if detail.candidates.some((c: CandidateResponse) => c.status !== "rejected" && !matchedLegacyIds.has(c.rmpLegacyId))}
<button
onclick={(e) => { e.stopPropagation(); handleRejectAll(detail!.instructor.id); }}
disabled={actionLoading !== null}
class="inline-flex items-center gap-1 rounded-md bg-red-100 px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50 transition-colors disabled:opacity-50 cursor-pointer"
>
<X size={12} /> Reject All
</button>
{/if}
</div>
{#if detail.candidates.length === 0}
<p class="text-muted-foreground text-sm">No candidates available.</p>
{:else}
<div class="max-h-80 overflow-y-auto space-y-2 pr-1">
{#each detail.candidates as candidate (candidate.id)}
{@const isMatched = candidate.status === "matched" || matchedLegacyIds.has(candidate.rmpLegacyId)}
{@const isRejected = candidate.status === "rejected"}
{@const isPending = !isMatched && !isRejected}
<div class="rounded-md border p-3 {isMatched ? 'border-l-4 border-l-green-500 bg-green-500/5 border-border' : isRejected ? 'border-border bg-card opacity-50' : 'border-border bg-card'}">
<div class="flex items-start justify-between gap-2">
<div>
<div class="flex items-center gap-2">
<span class="font-medium text-foreground text-sm">
{candidate.firstName} {candidate.lastName}
</span>
{#if isMatched}
<span class="text-[10px] rounded px-1 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
Matched
</span>
{:else if isRejected}
<span class="text-[10px] rounded px-1 py-0.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
Rejected
</span>
{/if}
</div>
{#if candidate.department}
<div class="text-xs text-muted-foreground">{candidate.department}</div>
{/if}
</div>
<div class="flex items-center gap-1 shrink-0">
{#if isMatched}
<button
onclick={(e) => { e.stopPropagation(); handleUnmatch(detail!.instructor.id, candidate.rmpLegacyId); }}
disabled={actionLoading !== null}
class="inline-flex items-center gap-1 rounded p-1 text-xs text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Unmatch"
>
<XCircle size={14} /> Unmatch
</button>
{:else if isPending}
<button
onclick={(e) => { e.stopPropagation(); handleMatch(detail!.instructor.id, candidate.rmpLegacyId); }}
disabled={actionLoading !== null}
class="rounded p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Accept"
>
<Check size={14} />
</button>
<button
onclick={(e) => { e.stopPropagation(); handleReject(detail!.instructor.id, candidate.rmpLegacyId); }}
disabled={actionLoading !== null}
class="rounded p-1 text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
title="Reject"
>
<X size={14} />
</button>
{/if}
<a
href={rmpUrl(candidate.rmpLegacyId)}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="rounded p-1 text-muted-foreground hover:bg-muted transition-colors cursor-pointer"
title="View on RMP"
>
<ExternalLink size={14} />
</a>
</div>
</div>
<!-- Stats row -->
<div class="mt-2 flex items-center gap-3 text-xs">
{#if isRatingValid(candidate.avgRating, candidate.numRatings ?? 0)}
<span class="font-semibold tabular-nums" style={ratingStyle(candidate.avgRating!, themeStore.isDark)}>
{candidate.avgRating!.toFixed(1)}
</span>
{:else}
<span class="text-xs text-muted-foreground">N/A</span>
{/if}
{#if candidate.avgDifficulty !== null}
<span class="text-muted-foreground tabular-nums">{candidate.avgDifficulty.toFixed(1)} diff</span>
{/if}
<span class="text-muted-foreground">{candidate.numRatings} ratings</span>
{#if candidate.wouldTakeAgainPct !== null}
<span class="text-muted-foreground">{candidate.wouldTakeAgainPct.toFixed(0)}% again</span>
{/if}
</div>
<!-- Score bar (weighted segments) -->
<div class="mt-2">
<div class="flex items-center gap-2 text-xs">
<span class="text-muted-foreground shrink-0">Score:</span>
<div class="bg-muted h-2 flex-1 rounded-full overflow-hidden flex">
{#each Object.entries(candidate.scoreBreakdown ?? {}) as [key, value]}
{@const w = scoreWeights[key] ?? 0}
{@const widthPct = (value as number) * w * 100}
<div
class="{scoreColors[key] ?? 'bg-primary'} h-full transition-all"
style="width: {widthPct}%"
title="{scoreLabels[key] ?? key}: {((value as number) * 100).toFixed(0)}% (×{w})"
></div>
{/each}
</div>
<span class="tabular-nums font-medium text-foreground shrink-0">{formatScore(candidate.score ?? 0)}%</span>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-4 flex items-center justify-between text-sm">
<span class="text-muted-foreground">
Showing {(currentPage - 1) * perPage + 1}{Math.min(currentPage * perPage, totalCount)} of {totalCount}
</span>
<div class="flex items-center gap-2">
<button
onclick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-sm text-foreground hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer"
>
<ChevronLeft size={14} /> Prev
</button>
<span class="text-muted-foreground tabular-nums">
{currentPage} / {totalPages}
</span>
<button
onclick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-sm text-foreground hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer"
>
Next <ChevronRight size={14} />
</button>
</div>
</div>
{/if}
{/if}