UI improvements: responsive layout, unified panels, credit legend
- Add responsive 2-panel layout (mobile single-col, tablet/desktop grid) - Unify specialization ranking with credit bars, status badges, and expandable allocation breakdowns (remove standalone ResultsDashboard) - Inline decision tree ceiling data on course buttons with spec counts - Add Clear All button to reset all course selections - Add collapsible CreditLegend explaining bars, badges, and limits - Extract ModeComparison and MutualExclusionWarnings to Notifications - Add useMediaQuery hook with matchMedia-based breakpoint detection
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -17,19 +18,87 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import type { SpecStatus } from '../data/types';
|
||||
import { courseById } from '../data/lookups';
|
||||
import type { SpecStatus, AllocationResult } from '../data/types';
|
||||
|
||||
const STATUS_STYLES: Record<SpecStatus, { bg: string; color: string; label: string }> = {
|
||||
achieved: { bg: '#dcfce7', color: '#16a34a', label: 'Achieved' },
|
||||
achievable: { bg: '#dbeafe', color: '#2563eb', label: 'Achievable' },
|
||||
missing_required: { bg: '#fef3c7', color: '#d97706', label: 'Missing Req.' },
|
||||
unreachable: { bg: '#f3f4f6', color: '#9ca3af', label: 'Unreachable' },
|
||||
};
|
||||
|
||||
function CreditBar({ allocated, potential, threshold }: { allocated: number; potential: number; threshold: number }) {
|
||||
const maxWidth = Math.max(potential, threshold);
|
||||
const allocPct = Math.min((allocated / maxWidth) * 100, 100);
|
||||
const potentialPct = Math.min((potential / maxWidth) * 100, 100);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: '6px', background: '#e5e7eb', borderRadius: '3px', marginTop: '4px' }}>
|
||||
{potential > allocated && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', left: 0, top: 0, height: '100%',
|
||||
width: `${potentialPct}%`, background: '#bfdbfe', borderRadius: '3px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', left: 0, top: 0, height: '100%',
|
||||
width: `${allocPct}%`, background: allocated >= threshold ? '#22c55e' : '#3b82f6',
|
||||
borderRadius: '3px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', left: `${(threshold / maxWidth) * 100}%`, top: '-2px',
|
||||
width: '2px', height: '10px', background: '#666',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AllocationBreakdown({ specId, allocations }: { specId: string; allocations: Record<string, Record<string, number>> }) {
|
||||
const contributions: { courseName: string; credits: number }[] = [];
|
||||
for (const [courseId, specAlloc] of Object.entries(allocations)) {
|
||||
const credits = specAlloc[specId];
|
||||
if (credits && credits > 0) {
|
||||
const course = courseById[courseId];
|
||||
contributions.push({ courseName: course?.name ?? courseId, credits });
|
||||
}
|
||||
}
|
||||
if (contributions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '6px', paddingLeft: '28px', fontSize: '12px', color: '#555' }}>
|
||||
{contributions.map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
|
||||
<span>{c.courseName}</span>
|
||||
<span style={{ fontWeight: 600 }}>{c.credits.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableItemProps {
|
||||
id: string;
|
||||
rank: number;
|
||||
total: number;
|
||||
name: string;
|
||||
status?: SpecStatus;
|
||||
status: SpecStatus;
|
||||
allocated: number;
|
||||
potential: number;
|
||||
isExpanded: boolean;
|
||||
allocations: Record<string, Record<string, number>>;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onToggleExpand: () => void;
|
||||
}
|
||||
|
||||
function SortableItem({ id, rank, total, name, status, onMoveUp, onMoveDown }: SortableItemProps) {
|
||||
function SortableItem({ id, rank, total, name, status, allocated, potential, isExpanded, allocations, onMoveUp, onMoveDown, onToggleExpand }: SortableItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -40,26 +109,18 @@ function SortableItem({ id, rank, total, name, status, onMoveUp, onMoveDown }: S
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable;
|
||||
const isAchieved = status === 'achieved';
|
||||
|
||||
const rowStyle: 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',
|
||||
background: isDragging ? '#e8e8e8' : style.bg,
|
||||
border: '1px solid #ddd',
|
||||
fontSize: '14px',
|
||||
};
|
||||
|
||||
const statusColors: Record<SpecStatus, string> = {
|
||||
achieved: '#22c55e',
|
||||
achievable: '#3b82f6',
|
||||
missing_required: '#f59e0b',
|
||||
unreachable: '#9ca3af',
|
||||
padding: '6px 10px',
|
||||
};
|
||||
|
||||
const arrowBtn: React.CSSProperties = {
|
||||
@@ -73,32 +134,40 @@ function SortableItem({ id, rank, total, name, status, onMoveUp, onMoveDown }: S
|
||||
};
|
||||
|
||||
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 && (
|
||||
<div ref={setNodeRef} style={rowStyle} {...attributes}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: isAchieved ? 'pointer' : 'default' }}
|
||||
onClick={isAchieved ? onToggleExpand : undefined}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }} onClick={(e) => e.stopPropagation()}>
|
||||
<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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>⠿</span>
|
||||
<span style={{ color: '#999', fontSize: '12px', minWidth: '20px' }}>{rank}.</span>
|
||||
<span style={{ flex: 1, fontSize: '13px', color: '#333' }}>{name}</span>
|
||||
<span style={{ fontSize: '11px', color: '#888', whiteSpace: 'nowrap' }}>
|
||||
{allocated > 0 ? allocated.toFixed(1) : '0'} / 9.0
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px',
|
||||
background: statusColors[status] + '20',
|
||||
color: statusColors[status],
|
||||
fontWeight: 600,
|
||||
fontSize: '11px', padding: '2px 6px', borderRadius: '10px',
|
||||
background: style.color + '20', color: style.color, fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{status === 'missing_required' ? 'missing req.' : status}
|
||||
{style.label}
|
||||
</span>
|
||||
</div>
|
||||
<CreditBar allocated={allocated} potential={potential} threshold={9} />
|
||||
{isAchieved && isExpanded && (
|
||||
<AllocationBreakdown specId={id} allocations={allocations} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -106,11 +175,12 @@ function SortableItem({ id, rank, total, name, status, onMoveUp, onMoveDown }: S
|
||||
|
||||
interface SpecializationRankingProps {
|
||||
ranking: string[];
|
||||
statuses: Record<string, SpecStatus>;
|
||||
result: AllocationResult;
|
||||
onReorder: (ranking: string[]) => void;
|
||||
}
|
||||
|
||||
export function SpecializationRanking({ ranking, statuses, onReorder }: SpecializationRankingProps) {
|
||||
export function SpecializationRanking({ ranking, result, onReorder }: SpecializationRankingProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
|
||||
@@ -126,11 +196,33 @@ export function SpecializationRanking({ ranking, statuses, onReorder }: Speciali
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand(specId: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(specId)) next.delete(specId);
|
||||
else next.add(specId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function getAllocatedCredits(specId: string): number {
|
||||
let total = 0;
|
||||
for (const specAlloc of Object.values(result.allocations)) {
|
||||
total += specAlloc[specId] || 0;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
const specMap = new Map(SPECIALIZATIONS.map((s) => [s.id, s]));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ fontSize: '16px', marginBottom: '12px' }}>Specialization Priority</h2>
|
||||
<h2 style={{ fontSize: '16px', marginBottom: '8px' }}>Specializations</h2>
|
||||
<div style={{ marginBottom: '8px', fontSize: '13px', color: '#666' }}>
|
||||
{result.achieved.length > 0
|
||||
? `${result.achieved.length} specialization${result.achieved.length > 1 ? 's' : ''} achieved`
|
||||
: 'No specializations achieved yet'}
|
||||
</div>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={ranking} strategy={verticalListSortingStrategy}>
|
||||
{ranking.map((id, i) => (
|
||||
@@ -140,9 +232,14 @@ export function SpecializationRanking({ ranking, statuses, onReorder }: Speciali
|
||||
rank={i + 1}
|
||||
total={ranking.length}
|
||||
name={specMap.get(id)?.name ?? id}
|
||||
status={statuses[id]}
|
||||
status={result.statuses[id]}
|
||||
allocated={getAllocatedCredits(id)}
|
||||
potential={result.upperBounds[id] || 0}
|
||||
isExpanded={expanded.has(id)}
|
||||
allocations={result.allocations}
|
||||
onMoveUp={() => { if (i > 0) onReorder(arrayMove([...ranking], i, i - 1)); }}
|
||||
onMoveDown={() => { if (i < ranking.length - 1) onReorder(arrayMove([...ranking], i, i + 1)); }}
|
||||
onToggleExpand={() => toggleExpand(id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
Reference in New Issue
Block a user