mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 00:23:31 -06:00
feat: refactor admin instructor UI with component extraction and optimistic updates
This commit is contained in:
+154
-16
@@ -78,8 +78,9 @@ fn department_similarity(subjects: &[String], rmp_department: Option<&str>) -> f
|
||||
/// Expand common subject abbreviations used at UTSA and check for overlap.
|
||||
fn matches_known_abbreviation(subject: &str, department: &str) -> bool {
|
||||
const MAPPINGS: &[(&str, &[&str])] = &[
|
||||
// Core subjects (original mappings, corrected)
|
||||
("cs", &["computer science"]),
|
||||
("ece", &["electrical", "computer engineering"]),
|
||||
("ece", &["early childhood education", "early childhood"]),
|
||||
("ee", &["electrical engineering", "electrical"]),
|
||||
("me", &["mechanical engineering", "mechanical"]),
|
||||
("ce", &["civil engineering", "civil"]),
|
||||
@@ -105,6 +106,85 @@ fn matches_known_abbreviation(subject: &str, department: &str) -> bool {
|
||||
("ms", &["management science"]),
|
||||
("kin", &["kinesiology"]),
|
||||
("com", &["communication"]),
|
||||
// Architecture & Design
|
||||
("arc", &["architecture"]),
|
||||
("ide", &["interior design", "design"]),
|
||||
// Anthropology & Ethnic Studies
|
||||
("ant", &["anthropology"]),
|
||||
("aas", &["african american studies", "ethnic studies"]),
|
||||
("mas", &["mexican american studies", "ethnic studies"]),
|
||||
("regs", &["ethnic studies", "gender"]),
|
||||
// Languages
|
||||
("lng", &["linguistics", "applied linguistics"]),
|
||||
("spn", &["spanish"]),
|
||||
("frn", &["french"]),
|
||||
("ger", &["german"]),
|
||||
("chn", &["chinese"]),
|
||||
("jpn", &["japanese"]),
|
||||
("kor", &["korean"]),
|
||||
("itl", &["italian"]),
|
||||
("rus", &["russian"]),
|
||||
("lat", &["latin"]),
|
||||
("grk", &["greek"]),
|
||||
("asl", &["american sign language", "sign language"]),
|
||||
(
|
||||
"fl",
|
||||
&["foreign languages", "languages", "modern languages"],
|
||||
),
|
||||
// Education
|
||||
("edu", &["education"]),
|
||||
("ci", &["curriculum", "education"]),
|
||||
("edl", &["educational leadership", "education"]),
|
||||
("edp", &["educational psychology", "education"]),
|
||||
("bbl", &["bilingual education"]),
|
||||
("spe", &["special education", "education"]),
|
||||
// Business
|
||||
("ent", &["entrepreneurship"]),
|
||||
("gba", &["general business", "business"]),
|
||||
("blw", &["business law", "law"]),
|
||||
("rfd", &["real estate"]),
|
||||
("mot", &["management of technology", "management"]),
|
||||
// Engineering
|
||||
("egr", &["engineering"]),
|
||||
("bme", &["biomedical engineering", "engineering"]),
|
||||
("cme", &["chemical engineering", "engineering"]),
|
||||
("cpe", &["computer engineering", "engineering"]),
|
||||
("ise", &["industrial", "systems engineering", "engineering"]),
|
||||
("mate", &["materials engineering", "engineering"]),
|
||||
// Sciences
|
||||
("che", &["chemistry"]),
|
||||
("bch", &["biochemistry", "chemistry"]),
|
||||
("geo", &["geology"]),
|
||||
("phy", &["physics"]),
|
||||
("ast", &["astronomy"]),
|
||||
("es", &["environmental science"]),
|
||||
// Social Sciences
|
||||
("crj", &["criminal justice"]),
|
||||
("swk", &["social work"]),
|
||||
("pad", &["public administration"]),
|
||||
("grg", &["geography"]),
|
||||
("ges", &["geography"]),
|
||||
// Humanities
|
||||
("cla", &["classics"]),
|
||||
("hum", &["humanities"]),
|
||||
("wgss", &["women's studies"]),
|
||||
// Health
|
||||
("hth", &["health"]),
|
||||
("hcp", &["health science", "health"]),
|
||||
("ntr", &["nutrition"]),
|
||||
// Military
|
||||
("msc", &["military science"]),
|
||||
("asc", &["aerospace"]),
|
||||
// Arts
|
||||
("dan", &["dance"]),
|
||||
("thr", &["theater"]),
|
||||
("ahc", &["art history"]),
|
||||
// Other
|
||||
("cou", &["counseling"]),
|
||||
("hon", &["honors"]),
|
||||
("csm", &["construction"]),
|
||||
("wrc", &["writing"]),
|
||||
("set", &["tourism management", "tourism"]),
|
||||
];
|
||||
|
||||
for &(abbr, expansions) in MAPPINGS {
|
||||
@@ -164,6 +244,7 @@ pub fn compute_match_score(
|
||||
pub struct MatchingStats {
|
||||
pub total_unmatched: usize,
|
||||
pub candidates_created: usize,
|
||||
pub candidates_rescored: usize,
|
||||
pub auto_matched: usize,
|
||||
pub skipped_unparseable: usize,
|
||||
pub skipped_no_candidates: usize,
|
||||
@@ -200,6 +281,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
||||
return Ok(MatchingStats {
|
||||
total_unmatched: 0,
|
||||
candidates_created: 0,
|
||||
candidates_rescored: 0,
|
||||
auto_matched: 0,
|
||||
skipped_unparseable: 0,
|
||||
skipped_no_candidates: 0,
|
||||
@@ -245,24 +327,34 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Load existing candidate pairs (and rejected subset) in a single query
|
||||
// 4. Load existing candidate pairs — only skip resolved (accepted/rejected) pairs.
|
||||
// Pending candidates are rescored so updated mappings take effect.
|
||||
let candidate_rows: Vec<(i32, i32, String)> =
|
||||
sqlx::query_as("SELECT instructor_id, rmp_legacy_id, status FROM rmp_match_candidates")
|
||||
.fetch_all(db_pool)
|
||||
.await?;
|
||||
|
||||
let mut existing_pairs: HashSet<(i32, i32)> = HashSet::with_capacity(candidate_rows.len());
|
||||
let mut resolved_pairs: HashSet<(i32, i32)> = HashSet::new();
|
||||
let mut pending_pairs: HashSet<(i32, i32)> = HashSet::new();
|
||||
let mut rejected_pairs: HashSet<(i32, i32)> = HashSet::new();
|
||||
for (iid, lid, status) in candidate_rows {
|
||||
existing_pairs.insert((iid, lid));
|
||||
match status.as_str() {
|
||||
"accepted" | "rejected" => {
|
||||
resolved_pairs.insert((iid, lid));
|
||||
if status == "rejected" {
|
||||
rejected_pairs.insert((iid, lid));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
pending_pairs.insert((iid, lid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Score and collect candidates
|
||||
// 5. Score and collect candidates (new + rescored pending)
|
||||
let empty_subjects: Vec<String> = Vec::new();
|
||||
let mut candidates: Vec<(i32, i32, f32, serde_json::Value)> = Vec::new();
|
||||
let mut new_candidates: Vec<(i32, i32, f32, serde_json::Value)> = Vec::new();
|
||||
let mut rescored_candidates: Vec<(i32, i32, f32, serde_json::Value)> = Vec::new();
|
||||
let mut auto_accept: Vec<(i32, i32)> = Vec::new(); // (instructor_id, legacy_id)
|
||||
let mut skipped_unparseable = 0usize;
|
||||
let mut skipped_no_candidates = 0usize;
|
||||
@@ -290,7 +382,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
||||
|
||||
for prof in rmp_candidates {
|
||||
let pair = (*instructor_id, prof.legacy_id);
|
||||
if existing_pairs.contains(&pair) {
|
||||
if resolved_pairs.contains(&pair) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -308,7 +400,16 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
||||
let breakdown_json =
|
||||
serde_json::to_value(&ms.breakdown).unwrap_or_else(|_| serde_json::json!({}));
|
||||
|
||||
candidates.push((*instructor_id, prof.legacy_id, ms.score, breakdown_json));
|
||||
if pending_pairs.contains(&pair) {
|
||||
rescored_candidates.push((
|
||||
*instructor_id,
|
||||
prof.legacy_id,
|
||||
ms.score,
|
||||
breakdown_json,
|
||||
));
|
||||
} else {
|
||||
new_candidates.push((*instructor_id, prof.legacy_id, ms.score, breakdown_json));
|
||||
}
|
||||
|
||||
match best {
|
||||
Some((s, _)) if ms.score > s => best = Some((ms.score, prof.legacy_id)),
|
||||
@@ -327,19 +428,20 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
||||
}
|
||||
}
|
||||
|
||||
// 6–7. Write candidates and auto-accept within a single transaction
|
||||
let candidates_created = candidates.len();
|
||||
// 6–7. Write candidates, rescore, and auto-accept within a single transaction
|
||||
let candidates_created = new_candidates.len();
|
||||
let candidates_rescored = rescored_candidates.len();
|
||||
let auto_matched = auto_accept.len();
|
||||
|
||||
let mut tx = db_pool.begin().await?;
|
||||
|
||||
// 6. Batch-insert candidates
|
||||
if !candidates.is_empty() {
|
||||
let c_instructor_ids: Vec<i32> = candidates.iter().map(|(iid, _, _, _)| *iid).collect();
|
||||
let c_legacy_ids: Vec<i32> = candidates.iter().map(|(_, lid, _, _)| *lid).collect();
|
||||
let c_scores: Vec<f32> = candidates.iter().map(|(_, _, s, _)| *s).collect();
|
||||
// 6a. Batch-insert new candidates
|
||||
if !new_candidates.is_empty() {
|
||||
let c_instructor_ids: Vec<i32> = new_candidates.iter().map(|(iid, _, _, _)| *iid).collect();
|
||||
let c_legacy_ids: Vec<i32> = new_candidates.iter().map(|(_, lid, _, _)| *lid).collect();
|
||||
let c_scores: Vec<f32> = new_candidates.iter().map(|(_, _, s, _)| *s).collect();
|
||||
let c_breakdowns: Vec<serde_json::Value> =
|
||||
candidates.into_iter().map(|(_, _, _, b)| b).collect();
|
||||
new_candidates.into_iter().map(|(_, _, _, b)| b).collect();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -358,6 +460,40 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 6b. Batch-update rescored pending candidates
|
||||
if !rescored_candidates.is_empty() {
|
||||
let r_instructor_ids: Vec<i32> = rescored_candidates
|
||||
.iter()
|
||||
.map(|(iid, _, _, _)| *iid)
|
||||
.collect();
|
||||
let r_legacy_ids: Vec<i32> = rescored_candidates
|
||||
.iter()
|
||||
.map(|(_, lid, _, _)| *lid)
|
||||
.collect();
|
||||
let r_scores: Vec<f32> = rescored_candidates.iter().map(|(_, _, s, _)| *s).collect();
|
||||
let r_breakdowns: Vec<serde_json::Value> = rescored_candidates
|
||||
.into_iter()
|
||||
.map(|(_, _, _, b)| b)
|
||||
.collect();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE rmp_match_candidates mc
|
||||
SET score = v.score, score_breakdown = v.score_breakdown
|
||||
FROM UNNEST($1::int4[], $2::int4[], $3::real[], $4::jsonb[])
|
||||
AS v(instructor_id, rmp_legacy_id, score, score_breakdown)
|
||||
WHERE mc.instructor_id = v.instructor_id
|
||||
AND mc.rmp_legacy_id = v.rmp_legacy_id
|
||||
"#,
|
||||
)
|
||||
.bind(&r_instructor_ids)
|
||||
.bind(&r_legacy_ids)
|
||||
.bind(&r_scores)
|
||||
.bind(&r_breakdowns)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 7. Auto-accept top candidates
|
||||
if !auto_accept.is_empty() {
|
||||
let aa_instructor_ids: Vec<i32> = auto_accept.iter().map(|(iid, _)| *iid).collect();
|
||||
@@ -411,6 +547,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
||||
let stats = MatchingStats {
|
||||
total_unmatched,
|
||||
candidates_created,
|
||||
candidates_rescored,
|
||||
auto_matched,
|
||||
skipped_unparseable,
|
||||
skipped_no_candidates,
|
||||
@@ -419,6 +556,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
||||
info!(
|
||||
total_unmatched = stats.total_unmatched,
|
||||
candidates_created = stats.candidates_created,
|
||||
candidates_rescored = stats.candidates_rescored,
|
||||
auto_matched = stats.auto_matched,
|
||||
skipped_unparseable = stats.skipped_unparseable,
|
||||
skipped_no_candidates = stats.skipped_no_candidates,
|
||||
|
||||
@@ -310,6 +310,7 @@ impl Scheduler {
|
||||
total,
|
||||
stats.total_unmatched,
|
||||
stats.candidates_created,
|
||||
stats.candidates_rescored,
|
||||
stats.auto_matched,
|
||||
stats.skipped_unparseable,
|
||||
stats.skipped_no_candidates,
|
||||
|
||||
@@ -180,6 +180,7 @@ pub struct InstructorDetailResponse {
|
||||
pub struct RescoreResponse {
|
||||
pub total_unmatched: usize,
|
||||
pub candidates_created: usize,
|
||||
pub candidates_rescored: usize,
|
||||
pub auto_matched: usize,
|
||||
pub skipped_unparseable: usize,
|
||||
pub skipped_no_candidates: usize,
|
||||
@@ -858,6 +859,7 @@ pub async fn rescore(
|
||||
Ok(Json(RescoreResponse {
|
||||
total_unmatched: stats.total_unmatched,
|
||||
candidates_created: stats.candidates_created,
|
||||
candidates_rescored: stats.candidates_rescored,
|
||||
auto_matched: stats.auto_matched,
|
||||
skipped_unparseable: stats.skipped_unparseable,
|
||||
skipped_no_candidates: stats.skipped_no_candidates,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { slide, fade } from "svelte/transition";
|
||||
import {
|
||||
client,
|
||||
type InstructorListItem,
|
||||
@@ -7,19 +8,22 @@ import {
|
||||
type InstructorDetailResponse,
|
||||
type CandidateResponse,
|
||||
} from "$lib/api";
|
||||
import { formatInstructorName, isRatingValid, ratingStyle, rmpUrl } from "$lib/course";
|
||||
import { formatInstructorName, isRatingValid, ratingStyle } from "$lib/course";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
X,
|
||||
XCircle,
|
||||
} from "@lucide/svelte";
|
||||
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
|
||||
import CandidateCard from "./CandidateCard.svelte";
|
||||
|
||||
// --- State ---
|
||||
let subjectMap = $state(new Map<string, string>());
|
||||
let instructors = $state<InstructorListItem[]>([]);
|
||||
let stats = $state<InstructorStats>({
|
||||
total: 0,
|
||||
@@ -47,34 +51,84 @@ let detailError = $state<string | null>(null);
|
||||
// Action states
|
||||
let actionLoading = $state<string | null>(null);
|
||||
let rescoreLoading = $state(false);
|
||||
let rescoreResult = $state<string | null>(null);
|
||||
let rescoreResult = $state<{ message: string; isError: boolean } | null>(null);
|
||||
|
||||
// Stale row tracking: rows that changed status but haven't been refetched yet
|
||||
let recentlyChanged = $state(new Set<number>());
|
||||
let highlightTimeouts = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Reject-all inline confirmation
|
||||
let showRejectConfirm = $state<number | 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" },
|
||||
// --- Constants ---
|
||||
const filterCards: Array<{
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
stat: keyof InstructorStats;
|
||||
textColor: string;
|
||||
ringColor: string;
|
||||
}> = [
|
||||
{
|
||||
label: "Total",
|
||||
value: undefined,
|
||||
stat: "total",
|
||||
textColor: "text-muted-foreground",
|
||||
ringColor: "ring-primary",
|
||||
},
|
||||
{
|
||||
label: "Unmatched",
|
||||
value: "unmatched",
|
||||
stat: "unmatched",
|
||||
textColor: "text-amber-600 dark:text-amber-400",
|
||||
ringColor: "ring-amber-500",
|
||||
},
|
||||
{
|
||||
label: "Auto",
|
||||
value: "auto",
|
||||
stat: "auto",
|
||||
textColor: "text-blue-600 dark:text-blue-400",
|
||||
ringColor: "ring-blue-500",
|
||||
},
|
||||
{
|
||||
label: "Confirmed",
|
||||
value: "confirmed",
|
||||
stat: "confirmed",
|
||||
textColor: "text-green-600 dark:text-green-400",
|
||||
ringColor: "ring-green-500",
|
||||
},
|
||||
{
|
||||
label: "Rejected",
|
||||
value: "rejected",
|
||||
stat: "rejected",
|
||||
textColor: "text-red-600 dark:text-red-400",
|
||||
ringColor: "ring-red-500",
|
||||
},
|
||||
];
|
||||
|
||||
const progressSegments = [
|
||||
{ stat: "auto" as keyof InstructorStats, color: "bg-blue-500", label: "Auto" },
|
||||
{ stat: "confirmed" as keyof InstructorStats, color: "bg-green-500", label: "Confirmed" },
|
||||
{ stat: "unmatched" as keyof InstructorStats, color: "bg-amber-500", label: "Unmatched" },
|
||||
{ stat: "rejected" as keyof InstructorStats, color: "bg-red-500", label: "Rejected" },
|
||||
];
|
||||
|
||||
// --- Derived ---
|
||||
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;
|
||||
recentlyChanged = new Set();
|
||||
clearHighlightTimeouts();
|
||||
try {
|
||||
const res = await client.getAdminInstructors({
|
||||
status: activeFilter,
|
||||
@@ -107,14 +161,31 @@ async function fetchDetail(id: number) {
|
||||
|
||||
onMount(() => {
|
||||
fetchInstructors();
|
||||
client
|
||||
.getReference("subject")
|
||||
.then((entries) => {
|
||||
const map = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
map.set(entry.code, entry.description);
|
||||
}
|
||||
subjectMap = map;
|
||||
})
|
||||
.catch(() => {
|
||||
// Subject lookup is best-effort
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => clearTimeout(searchTimeout));
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchTimeout);
|
||||
clearHighlightTimeouts();
|
||||
});
|
||||
|
||||
// --- Navigation & filters ---
|
||||
function setFilter(value: string | undefined) {
|
||||
activeFilter = value;
|
||||
currentPage = 1;
|
||||
expandedId = null;
|
||||
showRejectConfirm = null;
|
||||
fetchInstructors();
|
||||
}
|
||||
|
||||
@@ -128,10 +199,28 @@ function handleSearch() {
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchInput = "";
|
||||
searchQuery = "";
|
||||
currentPage = 1;
|
||||
expandedId = null;
|
||||
fetchInstructors();
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
searchInput = "";
|
||||
searchQuery = "";
|
||||
activeFilter = undefined;
|
||||
currentPage = 1;
|
||||
expandedId = null;
|
||||
fetchInstructors();
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page < 1 || page > totalPages) return;
|
||||
currentPage = page;
|
||||
expandedId = null;
|
||||
showRejectConfirm = null;
|
||||
fetchInstructors();
|
||||
}
|
||||
|
||||
@@ -139,18 +228,64 @@ async function toggleExpand(id: number) {
|
||||
if (expandedId === id) {
|
||||
expandedId = null;
|
||||
detail = null;
|
||||
showRejectConfirm = null;
|
||||
return;
|
||||
}
|
||||
expandedId = id;
|
||||
showRejectConfirm = null;
|
||||
await fetchDetail(id);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && expandedId !== null) {
|
||||
expandedId = null;
|
||||
detail = null;
|
||||
showRejectConfirm = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Local state updates (no refetch) ---
|
||||
function updateLocalStatus(instructorId: number, newStatus: string) {
|
||||
instructors = instructors.map((i) =>
|
||||
i.id === instructorId ? { ...i, rmpMatchStatus: newStatus } : i
|
||||
);
|
||||
markChanged(instructorId);
|
||||
}
|
||||
|
||||
function markChanged(id: number) {
|
||||
// Clear any existing timeout for this ID
|
||||
const existing = highlightTimeouts.get(id);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
recentlyChanged = new Set([...recentlyChanged, id]);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const next = new Set(recentlyChanged);
|
||||
next.delete(id);
|
||||
recentlyChanged = next;
|
||||
highlightTimeouts.delete(id);
|
||||
}, 2000);
|
||||
highlightTimeouts.set(id, timeout);
|
||||
}
|
||||
|
||||
function clearHighlightTimeouts() {
|
||||
for (const timeout of highlightTimeouts.values()) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
highlightTimeouts.clear();
|
||||
}
|
||||
|
||||
function matchesFilter(status: string): boolean {
|
||||
if (!activeFilter) return true;
|
||||
return status === activeFilter;
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
async function handleMatch(instructorId: number, rmpLegacyId: number) {
|
||||
actionLoading = `match-${rmpLegacyId}`;
|
||||
try {
|
||||
detail = await client.matchInstructor(instructorId, rmpLegacyId);
|
||||
await fetchInstructors();
|
||||
updateLocalStatus(instructorId, "confirmed");
|
||||
} catch (e) {
|
||||
detailError = e instanceof Error ? e.message : "Match failed";
|
||||
} finally {
|
||||
@@ -162,7 +297,7 @@ async function handleReject(instructorId: number, rmpLegacyId: number) {
|
||||
actionLoading = `reject-${rmpLegacyId}`;
|
||||
try {
|
||||
await client.rejectCandidate(instructorId, rmpLegacyId);
|
||||
await Promise.all([fetchDetail(instructorId), fetchInstructors()]);
|
||||
await fetchDetail(instructorId);
|
||||
} catch (e) {
|
||||
detailError = e instanceof Error ? e.message : "Reject failed";
|
||||
} finally {
|
||||
@@ -170,11 +305,17 @@ async function handleReject(instructorId: number, rmpLegacyId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectAll(instructorId: number) {
|
||||
function requestRejectAll(instructorId: number) {
|
||||
showRejectConfirm = instructorId;
|
||||
}
|
||||
|
||||
async function confirmRejectAll(instructorId: number) {
|
||||
showRejectConfirm = null;
|
||||
actionLoading = "reject-all";
|
||||
try {
|
||||
await client.rejectAllCandidates(instructorId);
|
||||
await Promise.all([fetchDetail(instructorId), fetchInstructors()]);
|
||||
await fetchDetail(instructorId);
|
||||
updateLocalStatus(instructorId, "rejected");
|
||||
} catch (e) {
|
||||
detailError = e instanceof Error ? e.message : "Reject all failed";
|
||||
} finally {
|
||||
@@ -182,11 +323,16 @@ async function handleRejectAll(instructorId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRejectAll() {
|
||||
showRejectConfirm = null;
|
||||
}
|
||||
|
||||
async function handleUnmatch(instructorId: number, rmpLegacyId: number) {
|
||||
actionLoading = `unmatch-${rmpLegacyId}`;
|
||||
try {
|
||||
await client.unmatchInstructor(instructorId, rmpLegacyId);
|
||||
await Promise.all([fetchDetail(instructorId), fetchInstructors()]);
|
||||
await fetchDetail(instructorId);
|
||||
updateLocalStatus(instructorId, "unmatched");
|
||||
} catch (e) {
|
||||
detailError = e instanceof Error ? e.message : "Unmatch failed";
|
||||
} finally {
|
||||
@@ -199,10 +345,16 @@ async function handleRescore() {
|
||||
rescoreResult = null;
|
||||
try {
|
||||
const res = await client.rescoreInstructors();
|
||||
rescoreResult = `Rescored: ${res.totalUnmatched} unmatched, ${res.candidatesCreated} candidates created, ${res.autoMatched} auto-matched`;
|
||||
rescoreResult = {
|
||||
message: `Rescored: ${res.totalUnmatched} unmatched, ${res.candidatesCreated} candidates created, ${res.autoMatched} auto-matched`,
|
||||
isError: false,
|
||||
};
|
||||
await fetchInstructors();
|
||||
} catch (e) {
|
||||
rescoreResult = e instanceof Error ? e.message : "Rescore failed";
|
||||
rescoreResult = {
|
||||
message: e instanceof Error ? e.message : "Rescore failed",
|
||||
isError: true,
|
||||
};
|
||||
} finally {
|
||||
rescoreLoading = false;
|
||||
}
|
||||
@@ -239,52 +391,85 @@ function statusBadge(status: string): { label: string; classes: string } {
|
||||
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">
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<h1 class="text-lg font-semibold text-foreground">Instructors</h1>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<Search
|
||||
size={14}
|
||||
class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name or email..."
|
||||
bind:value={searchInput}
|
||||
oninput={handleSearch}
|
||||
class="bg-card border-border rounded-md border pl-8 pr-8 py-1.5 text-sm text-foreground
|
||||
placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring w-64 transition-shadow"
|
||||
/>
|
||||
{#if searchInput}
|
||||
<button
|
||||
onclick={clearSearch}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Rescore -->
|
||||
<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"
|
||||
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>
|
||||
|
||||
<!-- Rescore result (dismissable) -->
|
||||
{#if rescoreResult}
|
||||
<div class="mb-4 rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||
{rescoreResult}
|
||||
<div
|
||||
class="mb-4 rounded-md px-3 py-2 text-sm flex items-center justify-between gap-2
|
||||
{rescoreResult.isError
|
||||
? 'bg-destructive/10 text-destructive'
|
||||
: 'bg-muted text-muted-foreground'}"
|
||||
transition:fade={{ duration: 150 }}
|
||||
>
|
||||
<span>{rescoreResult.message}</span>
|
||||
<button
|
||||
onclick={() => (rescoreResult = null)}
|
||||
class="text-muted-foreground hover:text-foreground transition-colors cursor-pointer shrink-0"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if error}
|
||||
<p class="text-destructive mb-4">{error}</p>
|
||||
<div
|
||||
class="mb-4 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
transition:fade={{ duration: 150 }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading && instructors.length === 0}
|
||||
<!-- Skeleton stats cards -->
|
||||
<div class="mb-4 grid grid-cols-5 gap-3">
|
||||
<div class="mb-4 grid grid-cols-2 sm:grid-cols-3 lg: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>
|
||||
@@ -311,68 +496,61 @@ const scoreLabels: Record<string, string> = {
|
||||
</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'}"
|
||||
<div class="relative">
|
||||
<!-- Loading overlay for refetching -->
|
||||
{#if loading}
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex items-center justify-center bg-background/60 rounded-lg"
|
||||
in:fade={{ duration: 100, delay: 150 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
>
|
||||
{opt.label}
|
||||
<LoaderCircle size={24} class="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats / Filter Cards -->
|
||||
<div class="mb-4 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{#each filterCards as card}
|
||||
<button
|
||||
onclick={() => setFilter(card.value)}
|
||||
class="bg-card border-border rounded-lg border p-3 text-left transition-all duration-200
|
||||
cursor-pointer hover:shadow-sm hover:border-border/80
|
||||
{activeFilter === card.value ? `ring-2 ${card.ringColor} shadow-sm` : ''}"
|
||||
>
|
||||
<div class="text-xs {card.textColor}">{card.label}</div>
|
||||
<div class="text-lg font-semibold tabular-nums">{stats[card.stat]}</div>
|
||||
</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"
|
||||
/>
|
||||
<!-- Progress Bar -->
|
||||
<div class="mb-6">
|
||||
<div class="bg-muted h-2 rounded-full overflow-hidden flex">
|
||||
{#each progressSegments as seg}
|
||||
{@const pct = (stats[seg.stat] / progressDenom) * 100}
|
||||
<div
|
||||
class="{seg.color} h-full transition-all duration-500"
|
||||
style="width: {pct}%"
|
||||
title="{seg.label}: {stats[seg.stat]}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if instructors.length === 0}
|
||||
<p class="text-muted-foreground py-8 text-center text-sm">No instructors found.</p>
|
||||
<div class="py-12 text-center">
|
||||
{#if searchQuery || activeFilter}
|
||||
<p class="text-muted-foreground text-sm">No instructors match your filters.</p>
|
||||
<button
|
||||
onclick={clearAllFilters}
|
||||
class="mt-2 text-sm text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-sm">No instructors found.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-card border-border overflow-hidden rounded-lg border">
|
||||
<table class="w-full text-sm">
|
||||
@@ -389,16 +567,25 @@ const scoreLabels: Record<string, string> = {
|
||||
{#each instructors as instructor (instructor.id)}
|
||||
{@const badge = statusBadge(instructor.rmpMatchStatus)}
|
||||
{@const isExpanded = expandedId === instructor.id}
|
||||
{@const isStale = !matchesFilter(instructor.rmpMatchStatus)}
|
||||
{@const isHighlighted = recentlyChanged.has(instructor.id)}
|
||||
<tr
|
||||
class="border-border border-b cursor-pointer hover:bg-muted/50 transition-colors {isExpanded ? 'bg-muted/30' : ''}"
|
||||
class="border-border border-b cursor-pointer transition-colors duration-700
|
||||
{isExpanded ? 'bg-muted/30' : 'hover:bg-muted/50'}
|
||||
{isHighlighted ? 'bg-primary/10' : ''}
|
||||
{isStale && !isHighlighted ? 'opacity-60' : ''}"
|
||||
onclick={() => toggleExpand(instructor.id)}
|
||||
>
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="font-medium text-foreground">{formatInstructorName(instructor.displayName)}</div>
|
||||
<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}">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors duration-300 {badge.classes}"
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
</td>
|
||||
@@ -408,7 +595,10 @@ const scoreLabels: Record<string, string> = {
|
||||
<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)}>
|
||||
<span
|
||||
class="font-semibold tabular-nums"
|
||||
style={ratingStyle(tc.avgRating!, themeStore.isDark)}
|
||||
>
|
||||
{tc.avgRating!.toFixed(1)}
|
||||
</span>
|
||||
{:else}
|
||||
@@ -429,20 +619,35 @@ const scoreLabels: Record<string, string> = {
|
||||
<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); }}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
{#if actionLoading === `match-${instructor.topCandidate.rmpLegacyId}`}
|
||||
<LoaderCircle size={16} class="animate-spin" />
|
||||
{:else}
|
||||
<Check size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); toggleExpand(instructor.id); }}
|
||||
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"}
|
||||
title={isExpanded ? "Collapse" : "Expand details"}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<ChevronRight size={16} class="transition-transform duration-200 {isExpanded ? 'rotate-90' : ''}" />
|
||||
<ChevronRight
|
||||
size={16}
|
||||
class="transition-transform duration-200 {isExpanded ? 'rotate-90' : ''}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -451,162 +656,161 @@ const scoreLabels: Record<string, string> = {
|
||||
<!-- Expanded detail panel -->
|
||||
{#if isExpanded}
|
||||
<tr class="border-border border-b bg-muted/20">
|
||||
<td colspan="5" class="p-0">
|
||||
<div class="p-4">
|
||||
<td colspan="5" class="p-0 overflow-hidden">
|
||||
<div transition:slide={{ duration: 200 }} class="p-4">
|
||||
{#if detailLoading}
|
||||
<p class="text-muted-foreground text-sm py-4 text-center">Loading details...</p>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="space-y-3 animate-pulse">
|
||||
<div class="h-4 w-20 rounded bg-muted"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-3 w-36 rounded bg-muted"></div>
|
||||
<div class="h-3 w-44 rounded bg-muted"></div>
|
||||
<div class="h-3 w-28 rounded bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-2 space-y-3 animate-pulse">
|
||||
<div class="h-4 w-32 rounded bg-muted"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-20 rounded bg-muted"></div>
|
||||
<div class="h-20 rounded bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if detailError}
|
||||
<p class="text-destructive text-sm py-2">{detailError}</p>
|
||||
<div class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{detailError}
|
||||
</div>
|
||||
{:else if detail}
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
<!-- Left: Instructor info -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- 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">
|
||||
<h3 class="font-medium text-foreground text-sm">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>
|
||||
<dd class="text-foreground">
|
||||
{formatInstructorName(detail.instructor.displayName)}
|
||||
</dd>
|
||||
|
||||
<dt class="text-muted-foreground">Email</dt>
|
||||
<dd class="text-foreground">{detail.instructor.email}</dd>
|
||||
<dd class="text-foreground break-all">{detail.instructor.email}</dd>
|
||||
|
||||
<dt class="text-muted-foreground">Courses</dt>
|
||||
<dd class="text-foreground tabular-nums">{detail.instructor.courseCount}</dd>
|
||||
<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>
|
||||
{#if subjectMap.has(subj)}
|
||||
<SimpleTooltip text={subjectMap.get(subj)!} delay={75}>
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 text-xs font-medium"
|
||||
>{subj}</span
|
||||
>
|
||||
</SimpleTooltip>
|
||||
{:else}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 text-xs font-medium"
|
||||
>{subj}</span
|
||||
>
|
||||
{/if}
|
||||
{/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})
|
||||
<!-- Candidates -->
|
||||
<div class="lg:col-span-2 space-y-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="font-medium text-foreground text-sm">
|
||||
Candidates
|
||||
<span class="text-muted-foreground font-normal"
|
||||
>({detail.candidates.length})</span
|
||||
>
|
||||
</h3>
|
||||
{#if detail.candidates.some((c: CandidateResponse) => c.status !== "rejected" && !matchedLegacyIds.has(c.rmpLegacyId))}
|
||||
{#if showRejectConfirm === detail.instructor.id}
|
||||
<div
|
||||
class="inline-flex items-center gap-2 text-xs"
|
||||
in:fade={{ duration: 100 }}
|
||||
>
|
||||
<span class="text-muted-foreground"
|
||||
>Reject all candidates?</span
|
||||
>
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); handleRejectAll(detail!.instructor.id); }}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
confirmRejectAll(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"
|
||||
class="font-medium text-red-600 hover:text-red-700
|
||||
dark:text-red-400 dark:hover:text-red-300
|
||||
cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelRejectAll();
|
||||
}}
|
||||
class="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
requestRejectAll(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}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if detail.candidates.length === 0}
|
||||
<p class="text-muted-foreground text-sm">No candidates available.</p>
|
||||
<p class="text-muted-foreground text-sm py-2">
|
||||
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); }}
|
||||
<CandidateCard
|
||||
{candidate}
|
||||
isMatched={candidate.status === "matched" ||
|
||||
matchedLegacyIds.has(candidate.rmpLegacyId)}
|
||||
isRejected={candidate.status === "rejected"}
|
||||
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>
|
||||
{actionLoading}
|
||||
isDark={themeStore.isDark}
|
||||
onmatch={() =>
|
||||
handleMatch(
|
||||
detail!.instructor.id,
|
||||
candidate.rmpLegacyId
|
||||
)}
|
||||
onreject={() =>
|
||||
handleReject(
|
||||
detail!.instructor.id,
|
||||
candidate.rmpLegacyId
|
||||
)}
|
||||
onunmatch={() =>
|
||||
handleUnmatch(
|
||||
detail!.instructor.id,
|
||||
candidate.rmpLegacyId
|
||||
)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -625,27 +829,35 @@ const scoreLabels: Record<string, string> = {
|
||||
<!-- 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}
|
||||
Showing {(currentPage - 1) * perPage + 1}–{Math.min(
|
||||
currentPage * perPage,
|
||||
totalCount
|
||||
)} of {totalCount}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<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"
|
||||
class="inline-flex items-center justify-center size-8 rounded-md bg-muted text-foreground
|
||||
hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer disabled:cursor-default"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft size={14} /> Prev
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span class="text-muted-foreground tabular-nums">
|
||||
<span class="text-muted-foreground tabular-nums px-2">
|
||||
{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"
|
||||
class="inline-flex items-center justify-center size-8 rounded-md bg-muted text-foreground
|
||||
hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer disabled:cursor-default"
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next <ChevronRight size={14} />
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import type { CandidateResponse } from "$lib/api";
|
||||
import { isRatingValid, ratingStyle, rmpUrl } from "$lib/course";
|
||||
import { Check, ExternalLink, LoaderCircle, X, XCircle } from "@lucide/svelte";
|
||||
import ScoreBreakdown from "./ScoreBreakdown.svelte";
|
||||
|
||||
let {
|
||||
candidate,
|
||||
isMatched = false,
|
||||
isRejected = false,
|
||||
disabled = false,
|
||||
actionLoading = null,
|
||||
isDark = false,
|
||||
onmatch,
|
||||
onreject,
|
||||
onunmatch,
|
||||
}: {
|
||||
candidate: CandidateResponse;
|
||||
isMatched?: boolean;
|
||||
isRejected?: boolean;
|
||||
disabled?: boolean;
|
||||
actionLoading?: string | null;
|
||||
isDark?: boolean;
|
||||
onmatch?: () => void;
|
||||
onreject?: () => void;
|
||||
onunmatch?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isPending = $derived(!isMatched && !isRejected);
|
||||
const isMatchLoading = $derived(actionLoading === `match-${candidate.rmpLegacyId}`);
|
||||
const isRejectLoading = $derived(actionLoading === `reject-${candidate.rmpLegacyId}`);
|
||||
const isUnmatchLoading = $derived(actionLoading === `unmatch-${candidate.rmpLegacyId}`);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-md border p-3 transition-all duration-200
|
||||
{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 hover:shadow-sm'}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium text-foreground text-sm">
|
||||
{candidate.firstName} {candidate.lastName}
|
||||
</span>
|
||||
{#if isMatched}
|
||||
<span
|
||||
class="text-[10px] rounded px-1.5 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 font-medium"
|
||||
>
|
||||
Matched
|
||||
</span>
|
||||
{:else if isRejected}
|
||||
<span
|
||||
class="text-[10px] rounded px-1.5 py-0.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 font-medium"
|
||||
>
|
||||
Rejected
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if candidate.department}
|
||||
<div class="text-xs text-muted-foreground mt-0.5">{candidate.department}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-0.5 shrink-0">
|
||||
{#if isMatched}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onunmatch?.();
|
||||
}}
|
||||
{disabled}
|
||||
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-xs text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
title="Remove match"
|
||||
>
|
||||
{#if isUnmatchLoading}
|
||||
<LoaderCircle size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<XCircle size={14} />
|
||||
{/if}
|
||||
Unmatch
|
||||
</button>
|
||||
{:else if isPending}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onmatch?.();
|
||||
}}
|
||||
{disabled}
|
||||
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 match"
|
||||
>
|
||||
{#if isMatchLoading}
|
||||
<LoaderCircle size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<Check size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onreject?.();
|
||||
}}
|
||||
{disabled}
|
||||
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 candidate"
|
||||
>
|
||||
{#if isRejectLoading}
|
||||
<LoaderCircle size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<X size={14} />
|
||||
{/if}
|
||||
</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 hover:text-foreground transition-colors cursor-pointer"
|
||||
title="View on RateMyProfessors"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rating stats -->
|
||||
<div class="mt-2 flex items-center gap-3 text-xs flex-wrap">
|
||||
{#if isRatingValid(candidate.avgRating, candidate.numRatings ?? 0)}
|
||||
<span
|
||||
class="font-semibold tabular-nums"
|
||||
style={ratingStyle(candidate.avgRating!, isDark)}
|
||||
>
|
||||
{candidate.avgRating!.toFixed(1)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">No rating</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 tabular-nums">{candidate.numRatings} ratings</span>
|
||||
{#if candidate.wouldTakeAgainPct !== null}
|
||||
<span class="text-muted-foreground tabular-nums"
|
||||
>{candidate.wouldTakeAgainPct.toFixed(0)}% again</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Score breakdown -->
|
||||
<div class="mt-2">
|
||||
<ScoreBreakdown breakdown={candidate.scoreBreakdown} score={candidate.score ?? 0} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
|
||||
|
||||
let {
|
||||
breakdown = null,
|
||||
score = 0,
|
||||
}: {
|
||||
breakdown?: { [key in string]?: number } | null;
|
||||
score?: number;
|
||||
} = $props();
|
||||
|
||||
const weights: Record<string, number> = {
|
||||
name: 0.5,
|
||||
department: 0.25,
|
||||
uniqueness: 0.15,
|
||||
volume: 0.1,
|
||||
};
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
name: "bg-blue-500",
|
||||
department: "bg-purple-500",
|
||||
uniqueness: "bg-amber-500",
|
||||
volume: "bg-emerald-500",
|
||||
};
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
name: "Name",
|
||||
department: "Dept",
|
||||
uniqueness: "Unique",
|
||||
volume: "Volume",
|
||||
};
|
||||
|
||||
function fmt(v: number): string {
|
||||
return (v * 100).toFixed(0);
|
||||
}
|
||||
|
||||
const segments = $derived(
|
||||
Object.entries(breakdown ?? {})
|
||||
.filter(([_, value]) => value != null)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: labels[key] ?? key,
|
||||
color: colors[key] ?? "bg-primary",
|
||||
weight: weights[key] ?? 0,
|
||||
raw: value!,
|
||||
pct: value! * (weights[key] ?? 0) * 100,
|
||||
}))
|
||||
);
|
||||
|
||||
const tooltipText = $derived(
|
||||
segments.map((s) => `${s.label}: ${fmt(s.raw)}% \u00d7 ${fmt(s.weight)}%`).join("\n") +
|
||||
`\nTotal: ${fmt(score)}%`
|
||||
);
|
||||
</script>
|
||||
|
||||
<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 segments as seg (seg.key)}
|
||||
<div
|
||||
class="{seg.color} h-full transition-all duration-300"
|
||||
style="width: {seg.pct}%"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<SimpleTooltip text={tooltipText} side="top">
|
||||
<span
|
||||
class="tabular-nums font-medium text-foreground cursor-help border-b border-dotted border-muted-foreground/40"
|
||||
>
|
||||
{fmt(score)}%
|
||||
</span>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
Reference in New Issue
Block a user