Implement EMBA Specialization Solver web app

Full React+TypeScript app with LP-based optimization engine,
drag-and-drop specialization ranking (with touch/arrow support),
course selection UI, results dashboard with decision tree, and
two optimization modes (maximize-count, priority-order).
This commit is contained in:
2026-02-28 20:43:00 -05:00
parent e62afa631b
commit 9e00901179
43 changed files with 10098 additions and 0 deletions

View File

@@ -0,0 +1,152 @@
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { SPECIALIZATIONS } from '../data/specializations';
import type { SpecStatus } from '../data/types';
interface SortableItemProps {
id: string;
rank: number;
total: number;
name: string;
status?: SpecStatus;
onMoveUp: () => void;
onMoveDown: () => void;
}
function SortableItem({ id, rank, total, name, status, onMoveUp, onMoveDown }: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 10px',
marginBottom: '4px',
borderRadius: '6px',
background: isDragging ? '#e8e8e8' : '#fff',
border: '1px solid #ddd',
fontSize: '14px',
};
const statusColors: Record<SpecStatus, string> = {
achieved: '#22c55e',
achievable: '#3b82f6',
missing_required: '#f59e0b',
unreachable: '#9ca3af',
};
const arrowBtn: React.CSSProperties = {
border: 'none',
background: 'none',
cursor: 'pointer',
padding: '0 2px',
fontSize: '14px',
color: '#999',
lineHeight: 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0px' }}>
<button style={{ ...arrowBtn, visibility: rank === 1 ? 'hidden' : 'visible' }} onClick={onMoveUp} aria-label="Move up"></button>
<button style={{ ...arrowBtn, visibility: rank === total ? 'hidden' : 'visible' }} onClick={onMoveDown} aria-label="Move down"></button>
</div>
<span
ref={setActivatorNodeRef}
{...listeners}
style={{ cursor: 'grab', color: '#ccc', fontSize: '14px', touchAction: 'none' }}
aria-label="Drag to reorder"
></span>
<span style={{ color: '#999', fontSize: '12px', width: '20px' }}>{rank}.</span>
<span style={{ flex: 1 }}>{name}</span>
{status && (
<span
style={{
fontSize: '11px',
padding: '2px 6px',
borderRadius: '10px',
background: statusColors[status] + '20',
color: statusColors[status],
fontWeight: 600,
}}
>
{status === 'missing_required' ? 'missing req.' : status}
</span>
)}
</div>
);
}
interface SpecializationRankingProps {
ranking: string[];
statuses: Record<string, SpecStatus>;
onReorder: (ranking: string[]) => void;
}
export function SpecializationRanking({ ranking, statuses, onReorder }: SpecializationRankingProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = ranking.indexOf(active.id as string);
const newIndex = ranking.indexOf(over.id as string);
onReorder(arrayMove(ranking, oldIndex, newIndex));
}
}
const specMap = new Map(SPECIALIZATIONS.map((s) => [s.id, s]));
return (
<div>
<h2 style={{ fontSize: '16px', marginBottom: '12px' }}>Specialization Priority</h2>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={ranking} strategy={verticalListSortingStrategy}>
{ranking.map((id, i) => (
<SortableItem
key={id}
id={id}
rank={i + 1}
total={ranking.length}
name={specMap.get(id)?.name ?? id}
status={statuses[id]}
onMoveUp={() => { if (i > 0) onReorder(arrayMove([...ranking], i, i - 1)); }}
onMoveDown={() => { if (i < ranking.length - 1) onReorder(arrayMove([...ranking], i, i + 1)); }}
/>
))}
</SortableContext>
</DndContext>
</div>
);
}