feat: implement curriculum management system including automated generation, enrichment, and versioning workflows
This commit is contained in:
@@ -1,114 +1,184 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Calendar, Wand2, ChevronDown, ChevronRight, RotateCcw, CheckCircle2, Loader, AlertTriangle } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Calendar, Wand2, CheckCircle2, Loader, AlertTriangle, Play, Sparkles, X, Clock, Target } from 'lucide-react';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Tag from '../ui/Tag';
|
||||
import * as db from '../../lib/db';
|
||||
import {
|
||||
autoGenerateCurriculum,
|
||||
getCurriculumYear,
|
||||
getQuarterForWeek,
|
||||
getQuarterName,
|
||||
getFullCurriculum,
|
||||
getCurriculumWeek,
|
||||
generateCurriculumDraft,
|
||||
confirmVersion,
|
||||
rejectVersion,
|
||||
enrichTopicsForCurriculum,
|
||||
getActiveVersion,
|
||||
getDraftVersion
|
||||
} from '../../lib/curriculumService';
|
||||
|
||||
const QUARTER_COLORS = {
|
||||
1: { bg: 'bg-teal-50', border: 'border-teal-200', text: 'text-teal-700', accent: 'var(--color-teal)' },
|
||||
2: { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700', accent: '#7c3aed' },
|
||||
3: { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', accent: '#2563eb' },
|
||||
4: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-700', accent: '#d97706' },
|
||||
};
|
||||
|
||||
const CurriculumManager = () => {
|
||||
const [year, setYear] = useState(getCurriculumYear());
|
||||
const [curriculum, setCurriculum] = useState([]);
|
||||
const [activeVersion, setActiveVersion] = useState(null);
|
||||
const [draftVersion, setDraftVersion] = useState(null);
|
||||
const [topics, setTopics] = useState([]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [expandedQuarters, setExpandedQuarters] = useState({ 1: true, 2: true, 3: true, 4: true });
|
||||
const [editingWeek, setEditingWeek] = useState(null);
|
||||
const [saveStatus, setSaveStatus] = useState(null);
|
||||
const [isEnriching, setIsEnriching] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
|
||||
const [generationReason, setGenerationReason] = useState('');
|
||||
|
||||
const currentWeek = useMemo(() => {
|
||||
const currentIsoWeek = (() => {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
|
||||
}, []);
|
||||
})();
|
||||
|
||||
const currentWeek = getCurriculumWeek(currentIsoWeek);
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
const [currData, topicData] = await Promise.all([
|
||||
getFullCurriculum(year),
|
||||
db.getTopics(),
|
||||
]);
|
||||
setCurriculum(currData);
|
||||
setTopics(topicData.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude'));
|
||||
setIsLoading(false);
|
||||
try {
|
||||
const [active, draft, allTopics] = await Promise.all([
|
||||
getActiveVersion(),
|
||||
getDraftVersion(),
|
||||
db.getTopics(),
|
||||
]);
|
||||
setActiveVersion(active);
|
||||
setDraftVersion(draft);
|
||||
setTopics(allTopics);
|
||||
} catch (e) {
|
||||
console.error('Failed to load curriculum state:', e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [year]);
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleAutoGenerate = async () => {
|
||||
if (curriculum.length > 0 && !confirm('This will replace the existing curriculum for ' + year + '. Continue?')) return;
|
||||
const showStatus = (msg) => {
|
||||
setStatusMessage(msg);
|
||||
setTimeout(() => setStatusMessage(''), 4000);
|
||||
};
|
||||
|
||||
const handleEnrichTopics = async () => {
|
||||
setIsEnriching(true);
|
||||
try {
|
||||
const res = await enrichTopicsForCurriculum();
|
||||
showStatus(`Enrichment complete! Enriched: ${res.enriched}, Skipped: ${res.skipped}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
showStatus(`Enrichment failed: ${e.message}`);
|
||||
} finally {
|
||||
setIsEnriching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
await autoGenerateCurriculum(year);
|
||||
await generateCurriculumDraft(generationReason || 'Admin request');
|
||||
setGenerationReason('');
|
||||
showStatus('Draft generated successfully!');
|
||||
await load();
|
||||
setSaveStatus('Curriculum generated!');
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
} catch (e) {
|
||||
console.error('Failed to generate curriculum:', e);
|
||||
setSaveStatus('Generation failed: ' + e.message);
|
||||
showStatus(`Generation failed: ${e.message}`);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWeekTopicChange = async (weekNumber, topicId) => {
|
||||
const topic = topics.find(t => t.id === topicId);
|
||||
await db.setCurriculumWeek(year, weekNumber, {
|
||||
topic_id: topicId,
|
||||
theme: topic?.type || 'General',
|
||||
quarter: getQuarterForWeek(weekNumber),
|
||||
is_review_week: false,
|
||||
sort_order: weekNumber,
|
||||
});
|
||||
setEditingWeek(null);
|
||||
await load();
|
||||
setSaveStatus('Week ' + weekNumber + ' updated');
|
||||
setTimeout(() => setSaveStatus(null), 2000);
|
||||
const handleConfirmDraft = async () => {
|
||||
if (!draftVersion) return;
|
||||
if (!confirm('This will replace the currently active curriculum for all employees. Proceed?')) return;
|
||||
|
||||
try {
|
||||
// Typically we'd pass the actual admin user ID here, hardcoded to 'admin' for MVP
|
||||
await confirmVersion(draftVersion.id, 'admin');
|
||||
showStatus('Curriculum activated!');
|
||||
await load();
|
||||
} catch (e) {
|
||||
showStatus(`Confirmation failed: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleReview = async (weekNumber, currentEntry) => {
|
||||
await db.setCurriculumWeek(year, weekNumber, {
|
||||
topic_id: currentEntry?.topic_id || '',
|
||||
theme: !currentEntry?.is_review_week ? `Q${getQuarterForWeek(weekNumber)} Review` : currentEntry?.theme || '',
|
||||
quarter: getQuarterForWeek(weekNumber),
|
||||
is_review_week: !currentEntry?.is_review_week,
|
||||
sort_order: weekNumber,
|
||||
});
|
||||
await load();
|
||||
const handleRejectDraft = async () => {
|
||||
if (!draftVersion) return;
|
||||
if (!confirm('Are you sure you want to discard this draft?')) return;
|
||||
|
||||
try {
|
||||
await rejectVersion(draftVersion.id);
|
||||
showStatus('Draft discarded.');
|
||||
await load();
|
||||
} catch (e) {
|
||||
showStatus(`Rejection failed: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleQuarter = (q) => {
|
||||
setExpandedQuarters(prev => ({ ...prev, [q]: !prev[q] }));
|
||||
// --- Rendering Helpers ---
|
||||
|
||||
const renderCoverageStats = (stats) => {
|
||||
if (!stats) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||
<div className="text-center p-3 bg-bg-warm rounded-[var(--r-sm)]">
|
||||
<div className="text-xl font-bold text-teal">{stats.themes_scheduled} / {stats.themes_kb}</div>
|
||||
<div className="text-xs text-fg-muted">Themes Scheduled</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-bg-warm rounded-[var(--r-sm)]">
|
||||
<div className="text-xl font-bold text-purple-600">{stats.topics_scheduled} / {stats.topics_kb}</div>
|
||||
<div className="text-xs text-fg-muted">Topics Covered</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-bg-warm rounded-[var(--r-sm)]">
|
||||
<div className="text-xl font-bold text-amber-600">26</div>
|
||||
<div className="text-xs text-fg-muted">Total Weeks</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-bg-warm rounded-[var(--r-sm)]">
|
||||
<div className="text-xl font-bold text-blue-600">100%</div>
|
||||
<div className="text-xs text-fg-muted">Perpetual Cycle</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Group by quarter
|
||||
const quarters = [1, 2, 3, 4].map(q => ({
|
||||
quarter: q,
|
||||
name: getQuarterName(q * 13 - 12),
|
||||
weeks: curriculum.filter(w => w.quarter === q),
|
||||
colors: QUARTER_COLORS[q],
|
||||
startWeek: (q - 1) * 13 + 1,
|
||||
endWeek: q * 13,
|
||||
}));
|
||||
const renderScheduleList = (schedule, isActiveVersion = false) => {
|
||||
return (
|
||||
<div className="divide-y divide-bg-warm mt-6 border border-bg-warm rounded-[var(--r-lg)] overflow-hidden">
|
||||
{schedule.map((week) => {
|
||||
const isCurrent = isActiveVersion && week.week_number === currentWeek;
|
||||
|
||||
return (
|
||||
<div key={week.week_number} className={`p-4 ${isCurrent ? 'bg-teal/5 border-l-4 border-l-teal' : 'bg-bg'}`}>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-[var(--r-org)] flex items-center justify-center font-mono text-lg font-bold flex-shrink-0 ${isCurrent ? 'bg-teal text-white' : 'bg-bg-warm text-fg'}`}>
|
||||
{week.week_number}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tag variant="dark" className="text-xs">{week.theme}</Tag>
|
||||
{isCurrent && <Tag variant="accent" className="text-[10px]">Current Week</Tag>}
|
||||
<span className="text-xs text-fg-muted flex items-center gap-1 ml-2">
|
||||
<Clock size={12} /> {week.estimated_duration}m
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="font-medium text-sm mb-1">
|
||||
Topics: {week.topic_ids.join(', ')}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-fg-muted italic">
|
||||
Rationale: {week.week_rationale}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Stats
|
||||
const assignedCount = curriculum.filter(w => w.topic_id).length;
|
||||
const reviewCount = curriculum.filter(w => w.is_review_week).length;
|
||||
const unassignedCount = curriculum.length > 0 ? 52 - assignedCount - reviewCount : 52;
|
||||
// --- Main Render ---
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -118,201 +188,128 @@ const CurriculumManager = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||
const unenrichedCount = learningTopics.filter(t => !t.theme).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with year selector and stats */}
|
||||
<div className="space-y-6 animate-in fade-in duration-300 pb-20">
|
||||
|
||||
{/* Header & Global Status */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar size={20} className="text-teal" />
|
||||
<select
|
||||
value={year}
|
||||
onChange={e => setYear(Number(e.target.value))}
|
||||
className="text-lg font-bold bg-transparent border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 focus:outline-none focus:border-teal transition-colors"
|
||||
>
|
||||
{[year - 1, year, year + 1].map(y => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-teal flex items-center gap-2">
|
||||
<Calendar size={24} /> 26-Week Curriculum
|
||||
</h1>
|
||||
<p className="text-sm text-fg-muted">Manage the AI-generated perpetual learning cycle.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{saveStatus && <span className="text-teal text-sm font-medium">{saveStatus}</span>}
|
||||
<Button
|
||||
onClick={handleAutoGenerate}
|
||||
variant={curriculum.length > 0 ? 'outline' : 'primary'}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<><Loader size={16} className="mr-2 animate-spin" /> Generating...</>
|
||||
) : (
|
||||
<><Wand2 size={16} className="mr-2" /> {curriculum.length > 0 ? 'Regenerate' : 'Auto-Generate'} Curriculum</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<Card className="border border-bg-warm">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-teal">{curriculum.length}</div>
|
||||
<div className="text-xs text-fg-muted">Total Weeks</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold" style={{ color: '#22c55e' }}>{assignedCount}</div>
|
||||
<div className="text-xs text-fg-muted">Topics Assigned</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold" style={{ color: '#7c3aed' }}>{reviewCount}</div>
|
||||
<div className="text-xs text-fg-muted">Review Weeks</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold" style={{ color: unassignedCount > 0 ? '#ef4444' : '#22c55e' }}>{unassignedCount}</div>
|
||||
<div className="text-xs text-fg-muted">Unassigned</div>
|
||||
</div>
|
||||
</div>
|
||||
{curriculum.length > 0 && (
|
||||
<div className="mt-4 h-2 rounded-full bg-bg-warm overflow-hidden flex">
|
||||
{[1, 2, 3, 4].map(q => {
|
||||
const qWeeks = curriculum.filter(w => w.quarter === q).length;
|
||||
return (
|
||||
<div
|
||||
key={q}
|
||||
style={{ width: `${(qWeeks / 52) * 100}%`, backgroundColor: QUARTER_COLORS[q].accent }}
|
||||
className="h-full transition-all"
|
||||
title={`Q${q}: ${qWeeks} weeks`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{statusMessage && (
|
||||
<div className="px-4 py-2 bg-teal/10 text-teal rounded-[var(--r-sm)] text-sm font-medium animate-in slide-in-from-top-2">
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{curriculum.length === 0 && (
|
||||
<Card className="border border-bg-warm text-center py-12">
|
||||
<Calendar size={48} className="mx-auto text-fg-muted/30 mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">No curriculum for {year}</h3>
|
||||
<p className="text-fg-muted mb-6 max-w-md mx-auto">
|
||||
Click "Auto-Generate Curriculum" to distribute all knowledge base topics across 52 weeks
|
||||
with quarterly review periods.
|
||||
</p>
|
||||
{topics.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 text-amber-600 text-sm mb-4">
|
||||
<AlertTriangle size={16} />
|
||||
<span>No topics in the knowledge base yet. Import sources first.</span>
|
||||
{/* Draft Preview View */}
|
||||
{draftVersion && (
|
||||
<Card className="border-2 border-amber-400 bg-amber-50/30">
|
||||
<div className="flex items-center justify-between border-b border-amber-200 pb-4 mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-amber-700 flex items-center gap-2">
|
||||
<Sparkles size={20} /> Curriculum Draft Preview
|
||||
</h2>
|
||||
<p className="text-sm text-amber-600/80">Generated for: "{draftVersion.generation_reason}"</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleRejectDraft} className="border-amber-300 text-amber-700 hover:bg-amber-100">
|
||||
<X size={16} className="mr-2" /> Discard
|
||||
</Button>
|
||||
<Button onClick={handleConfirmDraft} className="bg-amber-600 text-white hover:bg-amber-700">
|
||||
<CheckCircle2 size={16} className="mr-2" /> Confirm & Activate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderCoverageStats(draftVersion.coverage_stats)}
|
||||
{renderScheduleList(draftVersion.schedule, false)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty State / Generation Tools */}
|
||||
{!draftVersion && (
|
||||
<Card className="border border-bg-warm">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-bold mb-2">Curriculum Generator</h2>
|
||||
<p className="text-sm text-fg-muted">Generate a new 26-week draft based on the current knowledge base.</p>
|
||||
</div>
|
||||
|
||||
{learningTopics.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 p-4 rounded-[var(--r-sm)] border border-amber-200">
|
||||
<AlertTriangle size={20} />
|
||||
<span>No learning topics in the knowledge base. Import sources first before generating a curriculum.</span>
|
||||
</div>
|
||||
) : unenrichedCount > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 p-4 rounded-[var(--r-sm)] border border-amber-200">
|
||||
<AlertTriangle size={20} />
|
||||
<span>There are {unenrichedCount} topics missing theme and complexity data. Enrich them before generating.</span>
|
||||
</div>
|
||||
<Button onClick={handleEnrichTopics} disabled={isEnriching}>
|
||||
{isEnriching ? <Loader size={16} className="animate-spin mr-2" /> : <Sparkles size={16} className="mr-2" />}
|
||||
Enrich {unenrichedCount} Topics
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col md:flex-row gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium mb-1 text-fg-muted">Reason for generation (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={generationReason}
|
||||
onChange={e => setGenerationReason(e.target.value)}
|
||||
placeholder="e.g., 'Include new privacy topics' or 'Q3 refresh'"
|
||||
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-2 bg-bg focus:outline-none focus:border-teal"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleGenerate} disabled={isGenerating}>
|
||||
{isGenerating ? (
|
||||
<><Loader size={16} className="animate-spin mr-2" /> Architecting...</>
|
||||
) : (
|
||||
<><Wand2 size={16} className="mr-2" /> Generate Draft</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quarter sections */}
|
||||
{curriculum.length > 0 && quarters.map(({ quarter, name, weeks, colors, startWeek, endWeek }) => (
|
||||
<div key={quarter} className="space-y-0">
|
||||
<button
|
||||
onClick={() => toggleQuarter(quarter)}
|
||||
className={`w-full flex items-center justify-between p-4 rounded-t-[var(--r-lg)] border ${colors.border} ${colors.bg} transition-colors hover:opacity-90`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{expandedQuarters[quarter] ? <ChevronDown size={20} className={colors.text} /> : <ChevronRight size={20} className={colors.text} />}
|
||||
<div className="text-left">
|
||||
<h3 className={`font-bold ${colors.text}`}>Q{quarter}: {name}</h3>
|
||||
<p className="text-xs text-fg-muted">Weeks {startWeek}–{endWeek} · {weeks.filter(w => w.topic_id).length} topics assigned</p>
|
||||
</div>
|
||||
{/* Active Version View */}
|
||||
{!draftVersion && activeVersion && (
|
||||
<Card className="border border-bg-warm">
|
||||
<div className="flex items-center justify-between border-b border-bg-warm pb-4 mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<Target size={20} className="text-teal" /> Active Curriculum
|
||||
</h2>
|
||||
<p className="text-sm text-fg-muted">Version {activeVersion.version_number} · Confirmed {new Date(activeVersion.confirmed_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<Tag variant="dark" className="text-xs">{weeks.length} weeks</Tag>
|
||||
</button>
|
||||
|
||||
{expandedQuarters[quarter] && (
|
||||
<Card className={`rounded-t-none border ${colors.border} border-t-0 p-0 overflow-hidden`}>
|
||||
<div className="divide-y divide-bg-warm">
|
||||
{/* Fill in all weeks for the quarter, even if not in curriculum */}
|
||||
{Array.from({ length: 13 }, (_, i) => startWeek + i).map(weekNum => {
|
||||
const entry = weeks.find(w => w.week_number === weekNum) || curriculum.find(w => w.week_number === weekNum);
|
||||
const isCurrent = weekNum === currentWeek && year === getCurriculumYear();
|
||||
const isPast = year < getCurriculumYear() || (year === getCurriculumYear() && weekNum < currentWeek);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={weekNum}
|
||||
className={`flex items-center gap-4 p-3 px-4 transition-colors ${
|
||||
isCurrent ? 'bg-teal/5 border-l-4 border-l-teal' : ''
|
||||
} ${isPast ? 'opacity-60' : ''} hover:bg-bg-warm/30`}
|
||||
>
|
||||
{/* Week number */}
|
||||
<div className={`w-10 h-10 rounded-[var(--r-org)] flex items-center justify-center font-mono text-sm font-bold flex-shrink-0 ${
|
||||
isCurrent ? 'bg-teal text-white' :
|
||||
entry?.is_review_week ? 'bg-purple-100 text-purple-700' :
|
||||
entry?.topic_id ? 'bg-bg-warm text-fg' :
|
||||
'bg-bg-warm/50 text-fg-muted'
|
||||
}`}>
|
||||
{weekNum}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{entry?.is_review_week ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<RotateCcw size={14} className="text-purple-600" />
|
||||
<span className="font-medium text-purple-700">{entry.theme || `Q${quarter} Review`}</span>
|
||||
</div>
|
||||
) : entry?.topic ? (
|
||||
<div>
|
||||
<span className="font-medium">{entry.topic.label}</span>
|
||||
<span className="text-xs text-fg-muted ml-2">{entry.theme}</span>
|
||||
</div>
|
||||
) : entry?.topic_id ? (
|
||||
<span className="text-fg-muted italic">Topic: {entry.topic_id} (not found)</span>
|
||||
) : (
|
||||
<span className="text-fg-muted">Unassigned</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{isCurrent && <Tag variant="accent" className="text-[10px]">Current</Tag>}
|
||||
{isPast && entry?.topic_id && <CheckCircle2 size={16} className="text-teal/50" />}
|
||||
|
||||
{editingWeek === weekNum ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
autoFocus
|
||||
defaultValue={entry?.topic_id || ''}
|
||||
onChange={e => {
|
||||
if (e.target.value === '__review__') {
|
||||
handleToggleReview(weekNum, entry);
|
||||
setEditingWeek(null);
|
||||
} else {
|
||||
handleWeekTopicChange(weekNum, e.target.value);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setEditingWeek(null)}
|
||||
className="text-sm border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 bg-bg focus:outline-none focus:border-teal max-w-[200px]"
|
||||
>
|
||||
<option value="">— Unassigned —</option>
|
||||
<option value="__review__">📋 Review Week</option>
|
||||
{topics.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditingWeek(weekNum)}
|
||||
className="text-xs text-fg-muted hover:text-teal transition-colors px-2 py-1 rounded hover:bg-bg-warm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<Tag variant="success">Active</Tag>
|
||||
</div>
|
||||
|
||||
{renderCoverageStats(activeVersion.coverage_stats)}
|
||||
{renderScheduleList(activeVersion.schedule, true)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!draftVersion && !activeVersion && learningTopics.length > 0 && unenrichedCount === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Calendar size={48} className="mx-auto text-fg-muted/30 mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">No active curriculum</h3>
|
||||
<p className="text-fg-muted max-w-md mx-auto">
|
||||
Generate your first curriculum draft above to get started.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user