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:
152
app/src/components/SpecializationRanking.tsx
Normal file
152
app/src/components/SpecializationRanking.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user