feat: implement 52-week annual curriculum system with admin management and automated topic progression
This commit is contained in:
321
src/components/admin/CurriculumManager.jsx
Normal file
321
src/components/admin/CurriculumManager.jsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Calendar, Wand2, ChevronDown, ChevronRight, RotateCcw, CheckCircle2, BookOpen, Loader, AlertTriangle } 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,
|
||||
hasCurriculum,
|
||||
} 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 [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 currentWeek = useMemo(() => {
|
||||
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 load = async () => {
|
||||
setIsLoading(true);
|
||||
const [currData, topicData] = await Promise.all([
|
||||
getFullCurriculum(year),
|
||||
db.getTopics(),
|
||||
]);
|
||||
setCurriculum(currData);
|
||||
setTopics(topicData.filter(t => t.type !== 'fact'));
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [year]);
|
||||
|
||||
const handleAutoGenerate = async () => {
|
||||
if (curriculum.length > 0 && !confirm('This will replace the existing curriculum for ' + year + '. Continue?')) return;
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
await autoGenerateCurriculum(year);
|
||||
await load();
|
||||
setSaveStatus('Curriculum generated!');
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
} catch (e) {
|
||||
console.error('Failed to generate curriculum:', e);
|
||||
setSaveStatus('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 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 toggleQuarter = (q) => {
|
||||
setExpandedQuarters(prev => ({ ...prev, [q]: !prev[q] }));
|
||||
};
|
||||
|
||||
// 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,
|
||||
}));
|
||||
|
||||
// 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;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader size={32} className="text-teal animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with year selector and stats */}
|
||||
<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>
|
||||
|
||||
<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`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 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>
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurriculumManager;
|
||||
253
src/lib/curriculumService.js
Normal file
253
src/lib/curriculumService.js
Normal file
@@ -0,0 +1,253 @@
|
||||
import * as db from './db';
|
||||
|
||||
/**
|
||||
* Default quarterly theme structure for auto-generating a curriculum.
|
||||
* Each quarter has a name and a list of thematic blocks.
|
||||
*/
|
||||
const DEFAULT_QUARTERS = [
|
||||
{
|
||||
quarter: 1,
|
||||
name: 'Foundation & Governance',
|
||||
themes: [
|
||||
'Company Purpose & Values',
|
||||
'Governance',
|
||||
'People & Culture',
|
||||
'Recruitment',
|
||||
],
|
||||
},
|
||||
{
|
||||
quarter: 2,
|
||||
name: 'Compliance, Legal & Finance',
|
||||
themes: [
|
||||
'Privacy',
|
||||
'Compliance',
|
||||
'Quality',
|
||||
'Finance',
|
||||
],
|
||||
},
|
||||
{
|
||||
quarter: 3,
|
||||
name: 'Technology & Operations',
|
||||
themes: [
|
||||
'Strategy',
|
||||
'Infrastructure',
|
||||
'Workplace',
|
||||
'Service Management',
|
||||
'Software Delivery',
|
||||
],
|
||||
},
|
||||
{
|
||||
quarter: 4,
|
||||
name: 'Business, Marketing & Sustainability',
|
||||
themes: [
|
||||
'Marketing',
|
||||
'Networking',
|
||||
'Events',
|
||||
'Sustainability',
|
||||
'Year Wrap-up',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the current curriculum year based on a date.
|
||||
* Uses the calendar year.
|
||||
*/
|
||||
export function getCurriculumYear(date = new Date()) {
|
||||
return date.getFullYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the quarter number (1-4) for a given ISO week number.
|
||||
*/
|
||||
export function getQuarterForWeek(weekNumber) {
|
||||
if (weekNumber <= 13) return 1;
|
||||
if (weekNumber <= 26) return 2;
|
||||
if (weekNumber <= 39) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the quarter name for a given week number.
|
||||
*/
|
||||
export function getQuarterName(weekNumber) {
|
||||
const q = getQuarterForWeek(weekNumber);
|
||||
return DEFAULT_QUARTERS[q - 1]?.name || `Quarter ${q}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the assigned topic for a given week from the curriculum.
|
||||
* Returns { topic, curriculumEntry } or { topic: null } if no curriculum exists.
|
||||
*/
|
||||
export async function getCurriculumTopic(weekNumber, year) {
|
||||
const currYear = year ?? getCurriculumYear();
|
||||
const entry = await db.getCurriculumWeek(currYear, weekNumber);
|
||||
|
||||
if (!entry || !entry.topic_id) {
|
||||
return { topic: null, curriculumEntry: entry || null };
|
||||
}
|
||||
|
||||
// Resolve the topic from the topics collection
|
||||
const topics = await db.getTopics();
|
||||
const topic = topics.find(t => t.id === entry.topic_id) || null;
|
||||
|
||||
return { topic, curriculumEntry: entry };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full curriculum for a year, with resolved topic labels.
|
||||
*/
|
||||
export async function getFullCurriculum(year) {
|
||||
const currYear = year ?? getCurriculumYear();
|
||||
const entries = await db.getCurriculum(currYear);
|
||||
const topics = await db.getTopics();
|
||||
const topicMap = Object.fromEntries(topics.map(t => [t.id, t]));
|
||||
|
||||
return entries.map(entry => ({
|
||||
...entry,
|
||||
topic: topicMap[entry.topic_id] || null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress for a user in a given quarter.
|
||||
* Returns { completed, total, percentage }.
|
||||
*/
|
||||
export async function getQuarterProgress(userId, quarter, year) {
|
||||
const currYear = year ?? getCurriculumYear();
|
||||
const curriculum = await db.getCurriculum(currYear);
|
||||
const quarterWeeks = curriculum.filter(w => w.quarter === quarter);
|
||||
|
||||
let completed = 0;
|
||||
for (const week of quarterWeeks) {
|
||||
const done = await db.getLearnDone(userId, week.week_number);
|
||||
if (done) completed++;
|
||||
}
|
||||
|
||||
return {
|
||||
completed,
|
||||
total: quarterWeeks.length,
|
||||
percentage: quarterWeeks.length > 0
|
||||
? Math.round((completed / quarterWeeks.length) * 100)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall annual progress for a user.
|
||||
* Returns { completed, total, percentage }.
|
||||
*/
|
||||
export async function getYearProgress(userId, year) {
|
||||
const currYear = year ?? getCurriculumYear();
|
||||
const curriculum = await db.getCurriculum(currYear);
|
||||
|
||||
if (curriculum.length === 0) {
|
||||
return { completed: 0, total: 52, percentage: 0 };
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
for (const week of curriculum) {
|
||||
const done = await db.getLearnDone(userId, week.week_number);
|
||||
if (done) completed++;
|
||||
}
|
||||
|
||||
return {
|
||||
completed,
|
||||
total: curriculum.length,
|
||||
percentage: Math.round((completed / curriculum.length) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upcoming weeks from the curriculum (next N weeks after current).
|
||||
*/
|
||||
export async function getUpcomingWeeks(currentWeek, count = 4, year) {
|
||||
const currYear = year ?? getCurriculumYear();
|
||||
const curriculum = await getFullCurriculum(currYear);
|
||||
|
||||
return curriculum
|
||||
.filter(w => w.week_number > currentWeek && w.week_number <= currentWeek + count)
|
||||
.sort((a, b) => a.week_number - b.week_number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-generate a 52-week curriculum from available topics.
|
||||
* Distributes topics evenly across quarters, adds review weeks at 13, 26, 39, 52.
|
||||
*/
|
||||
export async function autoGenerateCurriculum(year) {
|
||||
const currYear = year ?? getCurriculumYear();
|
||||
const topics = await db.getTopics();
|
||||
|
||||
// Filter out 'fact' type topics — those are for the knowledge graph only
|
||||
const learningTopics = topics.filter(t => t.type !== 'fact');
|
||||
|
||||
const weeks = [];
|
||||
const reviewWeeks = [13, 26, 39, 52];
|
||||
|
||||
// Calculate available weeks (52 total minus review weeks)
|
||||
const availableWeeks = 52 - reviewWeeks.length; // 48
|
||||
|
||||
// Distribute topics across available weeks
|
||||
let topicIndex = 0;
|
||||
|
||||
for (let w = 1; w <= 52; w++) {
|
||||
const quarter = getQuarterForWeek(w);
|
||||
|
||||
if (reviewWeeks.includes(w)) {
|
||||
// Review / recap week
|
||||
weeks.push({
|
||||
week_number: w,
|
||||
topic_id: '',
|
||||
theme: `Q${quarter} Review`,
|
||||
quarter,
|
||||
is_review_week: true,
|
||||
sort_order: w,
|
||||
});
|
||||
} else if (topicIndex < learningTopics.length) {
|
||||
const topic = learningTopics[topicIndex];
|
||||
weeks.push({
|
||||
week_number: w,
|
||||
topic_id: topic.id,
|
||||
theme: topic.type || 'General',
|
||||
quarter,
|
||||
is_review_week: false,
|
||||
sort_order: w,
|
||||
});
|
||||
topicIndex++;
|
||||
} else if (learningTopics.length > 0) {
|
||||
// If we have more weeks than topics, cycle through topics again
|
||||
const topic = learningTopics[topicIndex % learningTopics.length];
|
||||
weeks.push({
|
||||
week_number: w,
|
||||
topic_id: topic.id,
|
||||
theme: `${topic.type || 'General'} (Deep Dive)`,
|
||||
quarter,
|
||||
is_review_week: false,
|
||||
sort_order: w,
|
||||
});
|
||||
topicIndex++;
|
||||
} else {
|
||||
// No topics at all
|
||||
weeks.push({
|
||||
week_number: w,
|
||||
topic_id: '',
|
||||
theme: 'Unassigned',
|
||||
quarter,
|
||||
is_review_week: false,
|
||||
sort_order: w,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db.bulkSetCurriculum(currYear, weeks);
|
||||
return weeks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a curriculum exists for the given year.
|
||||
*/
|
||||
export async function hasCurriculum(year) {
|
||||
const currYear = year ?? getCurriculumYear();
|
||||
const entries = await db.getCurriculum(currYear);
|
||||
return entries.length > 0;
|
||||
}
|
||||
@@ -244,3 +244,67 @@ export async function setSetting(key, value) {
|
||||
return pb.collection('settings').create({ key, value: String(value) });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Curriculum ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getCurriculum(year) {
|
||||
try {
|
||||
return await pb.collection('curriculum').getFullList({
|
||||
filter: `year=${year}`,
|
||||
sort: 'week_number',
|
||||
});
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function getCurriculumWeek(year, weekNumber) {
|
||||
try {
|
||||
return await pb.collection('curriculum').getFirstListItem(
|
||||
`year=${year} && week_number=${weekNumber}`
|
||||
);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function setCurriculumWeek(year, weekNumber, data) {
|
||||
try {
|
||||
const r = await pb.collection('curriculum').getFirstListItem(
|
||||
`year=${year} && week_number=${weekNumber}`
|
||||
);
|
||||
return pb.collection('curriculum').update(r.id, data);
|
||||
} catch {
|
||||
return pb.collection('curriculum').create({
|
||||
year,
|
||||
week_number: weekNumber,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCurriculumWeek(year, weekNumber) {
|
||||
try {
|
||||
const r = await pb.collection('curriculum').getFirstListItem(
|
||||
`year=${year} && week_number=${weekNumber}`
|
||||
);
|
||||
return pb.collection('curriculum').delete(r.id);
|
||||
} catch { /* nothing to delete */ }
|
||||
}
|
||||
|
||||
export async function bulkSetCurriculum(year, weeks) {
|
||||
// Delete all existing entries for this year first
|
||||
const existing = await getCurriculum(year);
|
||||
await Promise.all(
|
||||
existing.map(r => pb.collection('curriculum').delete(r.id, { requestKey: null }))
|
||||
);
|
||||
// Create all new entries
|
||||
return Promise.all(
|
||||
weeks.map(w => pb.collection('curriculum').create({
|
||||
year,
|
||||
week_number: w.week_number,
|
||||
topic_id: w.topic_id || '',
|
||||
theme: w.theme || '',
|
||||
quarter: w.quarter || Math.ceil(w.week_number / 13),
|
||||
is_review_week: w.is_review_week || false,
|
||||
sort_order: w.sort_order ?? w.week_number,
|
||||
}, { requestKey: null }))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { anthropicApi } from './api';
|
||||
import * as db from './db';
|
||||
import { getCurriculumTopic, getCurriculumYear } from './curriculumService';
|
||||
|
||||
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
|
||||
You write training material for employees based on knowledge topics.
|
||||
@@ -46,7 +47,21 @@ const CONTENT_SCHEMA_ALL = `{
|
||||
"infographic": ${CONTENT_SCHEMA_INFOGRAPHIC.replace(/^\{|\}$/g, '').trim()}
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Get the assigned topic for a given week.
|
||||
* Curriculum-first: checks the curriculum collection for the current year.
|
||||
* Falls back to hash-based assignment if no curriculum is configured.
|
||||
*/
|
||||
export async function getAssignedTopic(userId, weekNumber) {
|
||||
// Try curriculum first
|
||||
try {
|
||||
const { topic } = await getCurriculumTopic(weekNumber);
|
||||
if (topic) return topic;
|
||||
} catch (e) {
|
||||
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
|
||||
}
|
||||
|
||||
// Fallback: hash-based assignment (backwards compatible)
|
||||
const topics = await db.getTopics();
|
||||
if (!topics || topics.length === 0) return null;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { anthropicApi } from './api';
|
||||
import * as db from './db';
|
||||
import { getCurriculumTopic, getQuarterForWeek } from './curriculumService';
|
||||
|
||||
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
|
||||
You generate multiple-choice questions to test employee knowledge on specific topics.
|
||||
@@ -8,8 +9,39 @@ ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
|
||||
|
||||
async function selectTestTopics(userId, weekNumber) {
|
||||
const topics = await db.getTopics();
|
||||
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [] };
|
||||
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
|
||||
|
||||
// Try curriculum-based selection first
|
||||
try {
|
||||
const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
|
||||
|
||||
if (curriculumEntry?.is_review_week) {
|
||||
// Review week: pull topics from the whole quarter
|
||||
const quarter = getQuarterForWeek(weekNumber);
|
||||
const curriculum = await db.getCurriculum(new Date().getFullYear());
|
||||
const quarterTopicIds = curriculum
|
||||
.filter(w => w.quarter === quarter && w.topic_id && !w.is_review_week)
|
||||
.map(w => w.topic_id);
|
||||
const quarterTopics = topics.filter(t => quarterTopicIds.includes(t.id));
|
||||
// Use all quarter topics as review topics (no single primary)
|
||||
return {
|
||||
primaryTopic: quarterTopics[0] || topics[0],
|
||||
reviewTopics: quarterTopics.slice(1),
|
||||
isReviewWeek: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic) {
|
||||
const others = topics.filter(t => t.id !== topic.id);
|
||||
const shuffled = others.sort(() => 0.5 - Math.random());
|
||||
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
||||
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
|
||||
}
|
||||
|
||||
// Fallback: hash-based selection
|
||||
const str = `${userId}:${weekNumber}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
@@ -23,7 +55,7 @@ async function selectTestTopics(userId, weekNumber) {
|
||||
const shuffled = others.sort(() => 0.5 - Math.random());
|
||||
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
||||
|
||||
return { primaryTopic, reviewTopics };
|
||||
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
||||
}
|
||||
|
||||
export async function getCachedQuiz(userId, weekNumber) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare } from 'lucide-react';
|
||||
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare, CalendarDays } from 'lucide-react';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Tag from '../../components/ui/Tag';
|
||||
import Button from '../../components/ui/Button';
|
||||
@@ -11,6 +11,7 @@ import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
|
||||
import ContentManager from '../../components/admin/ContentManager';
|
||||
import TestManager from '../../components/admin/TestManager';
|
||||
import TeamManager from '../../components/admin/TeamManager';
|
||||
import CurriculumManager from '../../components/admin/CurriculumManager';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
const Admin = () => {
|
||||
@@ -54,6 +55,7 @@ const Admin = () => {
|
||||
{ key: 'sources', icon: Database, label: 'Sources' },
|
||||
{ key: 'content', icon: Layers, label: 'Content' },
|
||||
{ key: 'tests', icon: CheckSquare, label: 'Quizzes' },
|
||||
{ key: 'curriculum', icon: CalendarDays, label: 'Curriculum' },
|
||||
{ key: 'graph', icon: Network, label: 'Graph' },
|
||||
{ key: 'team', icon: Users, label: 'Team' },
|
||||
{ key: 'settings', icon: Settings, label: 'Settings', bottom: true },
|
||||
@@ -140,6 +142,14 @@ const Admin = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'curriculum' && (
|
||||
<div className="animate-in fade-in duration-300 max-w-5xl mx-auto">
|
||||
<h1 className="text-3xl text-teal mb-2">Annual Curriculum</h1>
|
||||
<p className="text-fg-muted mb-8">Plan and manage the 52-week learning schedule. All employees follow the same weekly topic.</p>
|
||||
<CurriculumManager />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'graph' && (
|
||||
<div className="animate-in fade-in duration-300 h-full flex flex-col">
|
||||
<h1 className="text-3xl text-teal mb-2">Knowledge Graph</h1>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import * as db from '../lib/db';
|
||||
import { getAssignedTopic } from '../lib/learningService';
|
||||
import { getYearProgress, getQuarterName, getQuarterForWeek, hasCurriculum as checkHasCurriculum } from '../lib/curriculumService';
|
||||
|
||||
const Dashboard = () => {
|
||||
const { state } = useApp();
|
||||
@@ -19,6 +20,8 @@ const Dashboard = () => {
|
||||
myRank: 0,
|
||||
myPoints: 0,
|
||||
activity: [],
|
||||
yearProgress: null,
|
||||
hasCurriculum: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,27 +53,103 @@ const Dashboard = () => {
|
||||
if (pastLearn) activity.push({ type: 'learn', week: w, topic: pastTopic?.label });
|
||||
}
|
||||
|
||||
setDashData({ topic, learnDone, testResult, top3, myRank, myPoints, activity });
|
||||
// Load curriculum progress
|
||||
let yearProgress = null;
|
||||
let curriculumExists = false;
|
||||
try {
|
||||
curriculumExists = await checkHasCurriculum();
|
||||
if (curriculumExists) {
|
||||
yearProgress = await getYearProgress(currentUser.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Dashboard] Could not load curriculum data:', e.message);
|
||||
}
|
||||
|
||||
setDashData({ topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumExists });
|
||||
};
|
||||
|
||||
load();
|
||||
}, [currentUser, weekNumber]);
|
||||
|
||||
const { topic, learnDone, testResult, top3, myRank, myPoints, activity } = dashData;
|
||||
const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive } = dashData;
|
||||
const currentQuarter = getQuarterForWeek(weekNumber);
|
||||
const quarterName = getQuarterName(weekNumber);
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<header>
|
||||
<h1 className="text-3xl md:text-5xl mb-2">Welcome, {currentUser?.name}</h1>
|
||||
<p className="text-fg-muted text-lg">Here is your overview for week {weekNumber}.</p>
|
||||
<p className="text-fg-muted text-lg">
|
||||
{curriculumActive
|
||||
? `Week ${weekNumber} · ${quarterName}`
|
||||
: `Here is your overview for week ${weekNumber}.`}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Annual Progress Bar (only when curriculum exists) */}
|
||||
{curriculumActive && yearProgress && (
|
||||
<Card className="border border-bg-warm">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-14 h-14 flex-shrink-0">
|
||||
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
|
||||
<circle cx="18" cy="18" r="15.5" fill="none" stroke="var(--color-bg-warm)" strokeWidth="3" />
|
||||
<circle
|
||||
cx="18" cy="18" r="15.5" fill="none"
|
||||
stroke="var(--color-teal)" strokeWidth="3"
|
||||
strokeDasharray={`${yearProgress.percentage} 100`}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-700"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-xs font-bold">
|
||||
{yearProgress.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">Annual Progress</h3>
|
||||
<p className="text-sm text-fg-muted">{yearProgress.completed} of {yearProgress.total} weeks completed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Tag variant="dark" className="text-xs">Q{currentQuarter}</Tag>
|
||||
<span className="text-sm text-fg-muted">{52 - weekNumber} weeks remaining</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Visual week progress bar */}
|
||||
<div className="mt-4 flex gap-[2px] h-2 rounded-full overflow-hidden">
|
||||
{Array.from({ length: 52 }, (_, i) => {
|
||||
const w = i + 1;
|
||||
const isCurrent = w === weekNumber;
|
||||
const isPast = w < weekNumber;
|
||||
return (
|
||||
<div
|
||||
key={w}
|
||||
className="flex-1 rounded-sm transition-all"
|
||||
style={{
|
||||
backgroundColor: isCurrent
|
||||
? 'var(--color-teal)'
|
||||
: isPast
|
||||
? 'var(--color-teal)'
|
||||
: 'var(--color-bg-warm)',
|
||||
opacity: isPast ? 0.4 : 1,
|
||||
}}
|
||||
title={`Week ${w}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card className="flex flex-col border border-bg-warm" hoverable>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl">Learning</h3>
|
||||
<p className="text-fg-muted text-sm mt-1">Your topic this week:</p>
|
||||
<p className="text-fg-muted text-sm mt-1">
|
||||
{curriculumActive ? `Week ${weekNumber} topic:` : 'Your topic this week:'}
|
||||
</p>
|
||||
</div>
|
||||
{learnDone ? <Tag variant="success">Completed</Tag> : <Tag variant="accent">To Do</Tag>}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BookOpen, CheckCircle, Loader, ArrowRight, Plus, Search, ChevronLeft, MessageSquare } from 'lucide-react';
|
||||
import { BookOpen, CheckCircle, Loader, ArrowRight, Plus, Search, ChevronLeft, MessageSquare, Calendar, TrendingUp } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Card from '../components/ui/Card';
|
||||
@@ -9,6 +9,7 @@ import Input from '../components/ui/Input';
|
||||
import LearningContentViewer from '../components/ui/LearningContentViewer';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import { getAssignedTopic, generateLearningContent, getCachedContent, generateCustomTopic } from '../lib/learningService';
|
||||
import { getUpcomingWeeks, getQuarterProgress, getYearProgress, getQuarterName, getQuarterForWeek, hasCurriculum as checkHasCurriculum } from '../lib/curriculumService';
|
||||
import * as db from '../lib/db';
|
||||
|
||||
const Leren = () => {
|
||||
@@ -37,6 +38,12 @@ const Leren = () => {
|
||||
const [feedbackText, setFeedbackText] = useState('');
|
||||
const [feedbackPrompted, setFeedbackPrompted] = useState(false);
|
||||
|
||||
// Curriculum state
|
||||
const [hasCurriculum, setHasCurriculum] = useState(false);
|
||||
const [upcoming, setUpcoming] = useState([]);
|
||||
const [quarterProgress, setQuarterProgress] = useState(null);
|
||||
const [yearProgress, setYearProgress] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.currentUser) {
|
||||
const load = async () => {
|
||||
@@ -48,6 +55,24 @@ const Leren = () => {
|
||||
setAssignedTopic(assigned);
|
||||
setAllTopics(topics);
|
||||
if (done) setWeeklyDone(true);
|
||||
|
||||
// Load curriculum data
|
||||
try {
|
||||
const currExists = await checkHasCurriculum();
|
||||
setHasCurriculum(currExists);
|
||||
if (currExists) {
|
||||
const [upcomingData, qProgress, yProgress] = await Promise.all([
|
||||
getUpcomingWeeks(state.weekNumber, 4),
|
||||
getQuarterProgress(state.currentUser.id, getQuarterForWeek(state.weekNumber)),
|
||||
getYearProgress(state.currentUser.id),
|
||||
]);
|
||||
setUpcoming(upcomingData);
|
||||
setQuarterProgress(qProgress);
|
||||
setYearProgress(yProgress);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Learn] Could not load curriculum data:', e.message);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}
|
||||
@@ -271,13 +296,17 @@ const Leren = () => {
|
||||
|
||||
// ── Overview ──────────────────────────────────────────────
|
||||
const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id && t.type !== 'fact');
|
||||
const currentQuarter = getQuarterForWeek(state.weekNumber);
|
||||
const currentQuarterName = getQuarterName(state.weekNumber);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Learning Station</h1>
|
||||
<p className="text-fg-muted text-lg">
|
||||
You must complete at least 1 topic per week. Feel free to explore more from the library!
|
||||
{hasCurriculum
|
||||
? `Week ${state.weekNumber} · ${currentQuarterName}`
|
||||
: 'Complete at least 1 topic per week. Explore more from the library!'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -287,11 +316,74 @@ const Leren = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Cards (only shown when curriculum exists) */}
|
||||
{hasCurriculum && yearProgress && quarterProgress && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
{/* Year Progress */}
|
||||
<Card className="border border-bg-warm text-center p-4">
|
||||
<div className="relative w-16 h-16 mx-auto mb-2">
|
||||
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
|
||||
<circle cx="18" cy="18" r="15.5" fill="none" stroke="var(--color-bg-warm)" strokeWidth="3" />
|
||||
<circle
|
||||
cx="18" cy="18" r="15.5" fill="none"
|
||||
stroke="var(--color-teal)" strokeWidth="3"
|
||||
strokeDasharray={`${yearProgress.percentage} 100`}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-700"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
|
||||
{yearProgress.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">Annual Progress</div>
|
||||
<div className="text-[10px] text-fg-muted">{yearProgress.completed}/{yearProgress.total} weeks</div>
|
||||
</Card>
|
||||
|
||||
{/* Quarter Progress */}
|
||||
<Card className="border border-bg-warm text-center p-4">
|
||||
<div className="relative w-16 h-16 mx-auto mb-2">
|
||||
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
|
||||
<circle cx="18" cy="18" r="15.5" fill="none" stroke="var(--color-bg-warm)" strokeWidth="3" />
|
||||
<circle
|
||||
cx="18" cy="18" r="15.5" fill="none"
|
||||
stroke="#7c3aed" strokeWidth="3"
|
||||
strokeDasharray={`${quarterProgress.percentage} 100`}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-700"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
|
||||
{quarterProgress.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">Q{currentQuarter} Progress</div>
|
||||
<div className="text-[10px] text-fg-muted">{quarterProgress.completed}/{quarterProgress.total} weeks</div>
|
||||
</Card>
|
||||
|
||||
{/* Current Week */}
|
||||
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
|
||||
<Calendar size={24} className="text-teal mb-1" />
|
||||
<div className="text-2xl font-bold text-teal">{state.weekNumber}</div>
|
||||
<div className="text-xs text-fg-muted">Current Week</div>
|
||||
</Card>
|
||||
|
||||
{/* Status */}
|
||||
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
|
||||
<TrendingUp size={24} className={`mb-1 ${weeklyDone ? 'text-teal' : 'text-fg-muted'}`} />
|
||||
<div className={`text-lg font-bold ${weeklyDone ? 'text-teal' : 'text-fg-muted'}`}>
|
||||
{weeklyDone ? 'Complete' : 'In Progress'}
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">This Week</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Required Topic */}
|
||||
{assignedTopic && (
|
||||
<div className="mb-12">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
Weekly Assignment {weeklyDone && <CheckCircle size={20} className="text-teal" />}
|
||||
This Week's Topic {weeklyDone && <CheckCircle size={20} className="text-teal" />}
|
||||
</h2>
|
||||
<Card
|
||||
hoverable
|
||||
@@ -300,9 +392,14 @@ const Leren = () => {
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<Tag variant={weeklyDone ? 'success' : 'accent'} className="mb-2">
|
||||
{weeklyDone ? 'Completed' : 'Required'}
|
||||
</Tag>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Tag variant={weeklyDone ? 'success' : 'accent'} className="text-xs">
|
||||
{weeklyDone ? 'Completed' : 'Required'}
|
||||
</Tag>
|
||||
{hasCurriculum && (
|
||||
<Tag variant="dark" className="text-[10px]">Week {state.weekNumber}</Tag>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-teal">{assignedTopic.label}</h3>
|
||||
<p className="text-fg-muted mt-1">{assignedTopic.description}</p>
|
||||
</div>
|
||||
@@ -314,7 +411,34 @@ const Leren = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Upcoming Schedule (only when curriculum exists) */}
|
||||
{hasCurriculum && upcoming.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<Calendar size={20} className="text-fg-muted" /> Coming Up
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{upcoming.map(week => (
|
||||
<Card key={week.week_number} className="border border-bg-warm p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Tag variant="dark" className="text-[10px] font-mono">Week {week.week_number}</Tag>
|
||||
{week.is_review_week && <Tag variant="accent" className="text-[10px]">Review</Tag>}
|
||||
</div>
|
||||
{week.topic ? (
|
||||
<>
|
||||
<h4 className="font-medium text-sm leading-tight">{week.topic.label}</h4>
|
||||
<p className="text-xs text-fg-muted mt-1">{week.theme}</p>
|
||||
</>
|
||||
) : week.is_review_week ? (
|
||||
<h4 className="font-medium text-sm text-purple-600">{week.theme}</h4>
|
||||
) : (
|
||||
<h4 className="text-sm text-fg-muted italic">Unassigned</h4>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Available Topics */}
|
||||
{otherTopics.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user