Round 14: Self-Assessment, Learning Goals, Portfolio, Homework, Announcements

This commit is contained in:
Mustafa Cetin committed 2026-07-29 10:53:22 +00:00
1 parent d325984786
commit 50c2d13d90
22 files changed
+5232 -2

No files matched your search

+111
View File
@@ -52,6 +52,9 @@ import {
UserCheck,
BarChart3,
Trash2,
BookCheck,
Megaphone,
Briefcase,
} from 'lucide-react';
import {
Sidebar,
@@ -101,6 +104,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { useAppStore, type ViewName } from '@/lib/store';
import { t } from '@/lib/i18n';
import { fetchSchoolYears, fetchStudents, fetchClasses, fetchDBNotifications, markAllNotificationsRead, markSingleNotificationRead, type SchoolYear, type Student, type ClassGroup, type DBNotification, type DBNotificationData } from '@/lib/api';
import { apiGet } from '@/lib/api';
import OnboardingTour, { isOnboardingCompleted } from '@/components/onboarding-tour';
import KeyboardShortcutsDialog from '@/components/keyboard-shortcuts-dialog';
import {
@@ -139,6 +143,8 @@ import RubricLibraryView from './rubric-library-view';
import CommentBankView from './comment-bank-view';
import NotebooksView from './notebooks-view';
import DrawingView from './drawing-view';
import HomeworkView from './homework-view';
import PortfolioView from './portfolio-view';
type NavItem = { key: ViewName; icon: React.ElementType; labelKey: string };
type NavSection = { id: string; labelKey: string; items: NavItem[] };
@@ -169,10 +175,12 @@ const navSections: NavSection[] = [
{ key: 'lesson-plans', icon: CalendarDays, labelKey: 'nav.lesson_plans' },
{ key: 'parents', icon: Mail, labelKey: 'nav.parents' },
{ key: 'behavior', icon: Shield, labelKey: 'nav.behavior' },
{ key: 'homework', icon: BookCheck, labelKey: 'nav.homework' },
{ key: 'rubrics', icon: Ruler, labelKey: 'nav.rubrics' },
{ key: 'comments', icon: MessageSquareText, labelKey: 'nav.comments' },
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.notebooks' },
{ key: 'drawing', icon: Palette, labelKey: 'nav.drawing' },
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
],
},
{
@@ -195,7 +203,9 @@ const studentNavSections: NavSection[] = [
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' },
{ key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' },
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
{ key: 'homework', icon: BookCheck, labelKey: 'nav.homework' },
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
],
},
@@ -215,6 +225,103 @@ const parentNavSections: NavSection[] = [
},
];
/* ── Announcement Banner ────────────────────────────────────────── */
interface AnnouncementItem {
id: string;
title: string;
content: string;
priority: string;
isPinned: boolean;
author: { id: string; firstName: string; lastName: string };
classGroup: { id: string; name: string } | null;
createdAt: string;
}
function getPriorityColor(priority: string): string {
switch (priority) {
case 'urgent': return 'bg-rose-50 border-rose-300 dark:bg-rose-950/50 dark:border-rose-800';
case 'high': return 'bg-amber-50 border-amber-300 dark:bg-amber-950/50 dark:border-amber-800';
case 'normal': return 'bg-emerald-50 border-emerald-300 dark:bg-emerald-950/50 dark:border-emerald-800';
case 'low': return 'bg-teal-50 border-teal-300 dark:bg-teal-950/50 dark:border-teal-800';
default: return 'bg-emerald-50 border-emerald-300 dark:bg-emerald-950/50 dark:border-emerald-800';
}
}
function getPriorityIcon(priority: string) {
switch (priority) {
case 'urgent': return AlertTriangle;
case 'high': return Info;
default: return Megaphone;
}
}
function AnnouncementBanner({ schoolId }: { schoolId: string | null }) {
const [announcements, setAnnouncements] = useState<AnnouncementItem[]>([]);
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
useEffect(() => {
try {
const stored = localStorage.getItem('ct_dismissed_announcements');
if (stored) setDismissed(new Set(JSON.parse(stored)));
} catch { /* ignore */ }
}, []);
useEffect(() => {
if (!schoolId) return;
apiGet<AnnouncementItem[]>(`/api/announcements?schoolId=${schoolId}&isPinned=true&limit=5`)
.then((data) => setAnnouncements(data))
.catch(() => {});
}, [schoolId]);
const visibleAnnouncements = announcements.filter((a) => !dismissed.has(a.id));
const handleDismiss = (id: string) => {
const newDismissed = new Set(dismissed);
newDismissed.add(id);
setDismissed(newDismissed);
try {
localStorage.setItem('ct_dismissed_announcements', JSON.stringify([...newDismissed]));
} catch { /* ignore */ }
};
if (visibleAnnouncements.length === 0) return null;
return (
<div className="space-y-0">
<AnimatePresence>
{visibleAnnouncements.map((announcement) => {
const PriorityIcon = getPriorityIcon(announcement.priority);
return (
<motion.div
key={announcement.id}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className={`announcement-banner border-b px-4 py-2 flex items-center gap-3 ${getPriorityColor(announcement.priority)}`}
>
<PriorityIcon className="h-4 w-4 shrink-0" />
<div className="flex-1 min-w-0">
<span className="font-semibold text-sm">{announcement.title}</span>
<span className="text-sm text-muted-foreground ml-2 hidden sm:inline">{announcement.content.slice(0, 100)}{announcement.content.length > 100 ? '...' : ''}</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 min-h-[44px] min-w-[44px]"
onClick={() => handleDismiss(announcement.id)}
>
<X className="h-4 w-4" />
</Button>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}
function renderView(view: ViewName) {
switch (view) {
case 'dashboard': return <DashboardView />;
@@ -239,6 +346,8 @@ function renderView(view: ViewName) {
case 'comments': return <CommentBankView />;
case 'notebooks': return <NotebooksView />;
case 'drawing': return <DrawingView />;
case 'homework': return <HomeworkView />;
case 'portfolio': return <PortfolioView />;
default: return <DashboardView />;
}
}
@@ -775,6 +884,8 @@ export default function AppLayout() {
</Sidebar>
<SidebarInset>
{/* Announcement Banner */}
<AnnouncementBanner schoolId={currentUser?.schoolId ?? null} />
<header className="flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-white dark:bg-gray-950 backdrop-blur-sm px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10">
<SidebarTrigger className="-ml-1 h-10 w-10 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 shrink-0" />
<Separator orientation="vertical" className="h-6 bg-emerald-200/50 dark:bg-emerald-900/30 shrink-0" />
+390
View File
@@ -30,6 +30,7 @@ import {
PartyPopper,
Lightbulb,
ChevronRight,
ChevronDown,
RefreshCw,
GraduationCap,
UserCheck,
@@ -47,6 +48,11 @@ import {
Calendar as CalendarIconNav,
BookMarked,
Heart,
BookCheck,
Megaphone,
Pin,
X,
Send,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -55,6 +61,25 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import {
Tooltip,
TooltipContent,
@@ -64,6 +89,7 @@ import {
import { useAppStore, type ViewName, type CurrentUser } from '@/lib/store';
import { t } from '@/lib/i18n';
import { fetchDashboard, type DashboardData, getStoredNotifications, type AppNotification, addNotification, markNotificationsRead, fetchParentLinks, type ParentStudentLinkData, fetchStudents } from '@/lib/api';
import { apiGet, apiPost } from '@/lib/api';
const containerVariants = {
hidden: { opacity: 0 },
@@ -1727,6 +1753,15 @@ export default function DashboardView() {
</Card>
</motion.div>
{/* ===== ANNOUNCEMENTS & HOMEWORK DUE ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Announcements Card */}
<DashboardAnnouncementsCard currentUser={currentUser} locale={locale} />
{/* Homework Due Soon Card */}
<DashboardHomeworkCard currentUser={currentUser} locale={locale} />
</div>
{/* ===== PAPER SAVED & ENVIRONMENTAL SECTION ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Paper Saved Counter */}
@@ -1812,3 +1847,358 @@ export default function DashboardView() {
</motion.div>
);
}
/* ── Dashboard Announcements Card ──────────────────────────────── */
interface DashboardAnnouncement {
id: string;
title: string;
content: string;
priority: string;
isPinned: boolean;
targetAudience: string;
author: { id: string; firstName: string; lastName: string };
classGroup: { id: string; name: string } | null;
createdAt: string;
}
function DashboardAnnouncementsCard({ currentUser, locale }: { currentUser: CurrentUser | null; locale: string }) {
const setCurrentView = useAppStore((s) => s.setCurrentView);
const [announcements, setAnnouncements] = useState<DashboardAnnouncement[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState({ title: '', content: '', priority: 'normal', targetAudience: 'all' });
const [creating, setCreating] = useState(false);
const isTeacherOrAdmin = currentUser?.role === 'TEACHER' || currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
useEffect(() => {
if (!currentUser?.schoolId) return;
apiGet<DashboardAnnouncement[]>(`/api/announcements?schoolId=${currentUser.schoolId}&limit=5`)
.then((data) => { setAnnouncements(data); setLoading(false); })
.catch(() => setLoading(false));
}, [currentUser?.schoolId]);
const handleCreate = async () => {
if (!currentUser?.schoolId || !createForm.title || !createForm.content) return;
setCreating(true);
try {
await apiPost('/api/announcements', {
schoolId: currentUser.schoolId,
title: createForm.title,
content: createForm.content,
priority: createForm.priority,
targetAudience: createForm.targetAudience,
});
toast.success(t('announcement.create_success'));
setCreateOpen(false);
setCreateForm({ title: '', content: '', priority: 'normal', targetAudience: 'all' });
// Reload
const data = await apiGet<DashboardAnnouncement[]>(`/api/announcements?schoolId=${currentUser.schoolId}&limit=5`);
setAnnouncements(data);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('announcement.create_error'));
} finally {
setCreating(false);
}
};
const getPriorityBorder = (priority: string) => {
switch (priority) {
case 'urgent': return 'border-l-rose-500';
case 'high': return 'border-l-amber-500';
case 'normal': return 'border-l-emerald-500';
case 'low': return 'border-l-teal-500';
default: return 'border-l-emerald-500';
}
};
const getPriorityBadge = (priority: string) => {
switch (priority) {
case 'urgent': return 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300';
case 'high': return 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300';
case 'normal': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300';
case 'low': return 'bg-teal-100 text-teal-700 dark:bg-teal-950 dark:text-teal-300';
default: return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300';
}
};
const relativeTime = (dateStr: string) => {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return locale === 'de' ? 'Gerade eben' : 'Just now';
if (diffMins < 60) return locale === 'de' ? `Vor ${diffMins} Min.` : `${diffMins}m ago`;
if (diffHours < 24) return locale === 'de' ? `Vor ${diffHours} Std.` : `${diffHours}h ago`;
if (diffDays === 1) return t('date.yesterday');
if (diffDays < 7) return t('date.days_ago', { count: diffDays });
return date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: '2-digit', month: 'short' });
};
return (
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-rose-500 overflow-hidden transition-shadow duration-200 hover:shadow-md ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-rose-50/50 to-transparent dark:from-rose-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
<Megaphone className="h-4 w-4" />
</div>
{t('announcement.title')}
</CardTitle>
{isTeacherOrAdmin && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="min-h-[44px] text-rose-600 dark:text-rose-400 border-rose-200/50 dark:border-rose-900/30 hover:bg-rose-50 dark:hover:bg-rose-900/20">
<Plus className="h-3 w-3 mr-1" /> {t('announcement.create')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('announcement.create_title')}</DialogTitle>
<DialogDescription>{t('announcement.create_description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>{t('announcement.field_title')}</Label>
<Input value={createForm.title} onChange={(e) => setCreateForm({ ...createForm, title: e.target.value })} placeholder={t('announcement.field_title_placeholder')} />
</div>
<div className="space-y-2">
<Label>{t('announcement.field_content')}</Label>
<Textarea value={createForm.content} onChange={(e) => setCreateForm({ ...createForm, content: e.target.value })} placeholder={t('announcement.field_content_placeholder')} rows={4} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('announcement.field_priority')}</Label>
<Select value={createForm.priority} onValueChange={(v) => setCreateForm({ ...createForm, priority: v })}>
<SelectTrigger className="min-h-[44px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('announcement.priority_low')}</SelectItem>
<SelectItem value="normal">{t('announcement.priority_normal')}</SelectItem>
<SelectItem value="high">{t('announcement.priority_high')}</SelectItem>
<SelectItem value="urgent">{t('announcement.priority_urgent')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('announcement.field_target')}</Label>
<Select value={createForm.targetAudience} onValueChange={(v) => setCreateForm({ ...createForm, targetAudience: v })}>
<SelectTrigger className="min-h-[44px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('announcement.target_all')}</SelectItem>
<SelectItem value="teachers">{t('announcement.target_teachers')}</SelectItem>
<SelectItem value="students">{t('announcement.target_students')}</SelectItem>
<SelectItem value="parents">{t('announcement.target_parents')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)} className="min-h-[44px]">{t('action.cancel')}</Button>
<Button onClick={handleCreate} disabled={creating} className="min-h-[44px]">{creating ? t('announcement.creating') : t('action.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : announcements.length === 0 ? (
<div className="text-center py-8">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-rose-100 dark:bg-rose-900/20 mx-auto mb-4">
<Megaphone className="h-8 w-8 text-rose-400 dark:text-rose-500" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{t('announcement.no_announcements')}</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education pr-1">
{announcements.map((ann) => (
<motion.div
key={ann.id}
className={`rounded-xl border-l-3 ${getPriorityBorder(ann.priority)} p-3 bg-gray-50/50 dark:bg-gray-800/30 hover:bg-gray-100/50 dark:hover:bg-gray-800/50 transition-colors cursor-pointer`}
onClick={() => setExpanded(expanded === ann.id ? null : ann.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
{ann.isPinned && <Pin className="h-3.5 w-3.5 text-rose-500 shrink-0" />}
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{ann.title}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Badge className={`text-[10px] ${getPriorityBadge(ann.priority)}`}>{t(`announcement.priority_${ann.priority}`)}</Badge>
{expanded === ann.id ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
</div>
</div>
<AnimatePresence>
{expanded === ann.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2 whitespace-pre-wrap">{ann.content}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>{ann.author.firstName} {ann.author.lastName}</span>
<span className="text-border">|</span>
<span>{relativeTime(ann.createdAt)}</span>
{ann.classGroup && (
<>
<span className="text-border">|</span>
<span>{ann.classGroup.name}</span>
</>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{expanded !== ann.id && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{ann.content}</p>
)}
</motion.div>
))}
</div>
)}
</CardContent>
</Card>
</motion.div>
);
}
/* ── Dashboard Homework Due Card ───────────────────────────────── */
interface DashboardHomework {
id: string;
title: string;
dueDate: string;
homeworkType: string;
maxPoints: number | null;
classGroup: { id: string; name: string; gradeLevel: number };
subject: { id: string; name: string } | null;
teacher: { id: string; firstName: string; lastName: string };
_count?: { submissions: number };
}
function DashboardHomeworkCard({ currentUser, locale }: { currentUser: CurrentUser | null; locale: string }) {
const setCurrentView = useAppStore((s) => s.setCurrentView);
const [homeworks, setHomeworks] = useState<DashboardHomework[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!currentUser?.schoolId) return;
const now = new Date();
const weekFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
apiGet<DashboardHomework[]>(`/api/homework?schoolId=${currentUser.schoolId}&dueDateFrom=${now.toISOString()}&dueDateTo=${weekFromNow.toISOString()}&isPublished=true`)
.then((data) => { setHomeworks(data); setLoading(false); })
.catch(() => setLoading(false));
}, [currentUser?.schoolId]);
const getDueDateStatus = (dueDate: string): 'overdue' | 'today' | 'upcoming' => {
const now = new Date();
const due = new Date(dueDate);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
if (dueDay < today) return 'overdue';
if (dueDay.getTime() === today.getTime()) return 'today';
return 'upcoming';
};
const getDueDateColor = (status: 'overdue' | 'today' | 'upcoming') => {
switch (status) {
case 'overdue': return 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300';
case 'today': return 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300';
case 'upcoming': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300';
}
};
const getDueDateLabel = (dueDate: string) => {
const status = getDueDateStatus(dueDate);
const due = new Date(dueDate);
const now = new Date();
const diffMs = due.getTime() - now.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (status === 'overdue') return t('homework.overdue_days', { days: Math.abs(diffDays) });
if (status === 'today') return t('homework.due_today');
if (diffDays === 1) return t('homework.due_tomorrow');
return t('homework.due_in_days', { days: diffDays });
};
return (
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden transition-shadow duration-200 hover:shadow-md ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<BookCheck className="h-4 w-4" />
</div>
{t('homework.due_soon')}
</CardTitle>
<Button
size="sm"
variant="outline"
onClick={() => setCurrentView('homework')}
className="min-h-[44px] text-amber-600 dark:text-amber-400 border-amber-200/50 dark:border-amber-900/30 hover:bg-amber-50 dark:hover:bg-amber-900/20"
>
{t('homework.view_all')} <ArrowUpRight className="h-3 w-3 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : homeworks.length === 0 ? (
<div className="text-center py-8">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-amber-100 dark:bg-amber-900/20 mx-auto mb-4">
<BookCheck className="h-8 w-8 text-amber-400 dark:text-amber-500" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{t('homework.no_due_soon')}</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education pr-1">
{homeworks.slice(0, 6).map((hw) => {
const dueStatus = getDueDateStatus(hw.dueDate);
return (
<motion.div
key={hw.id}
whileHover={{ scale: 1.01, x: 2 }}
transition={{ duration: 0.15 }}
className="flex items-center justify-between p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40 hover:border-amber-200/60 dark:hover:border-amber-800/30 transition-colors cursor-pointer"
onClick={() => setCurrentView('homework')}
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white text-xs font-bold shadow-sm shrink-0">
<BookCheck className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{hw.title}</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400 truncate">
{hw.classGroup.name}{hw.subject ? ` | ${hw.subject.name}` : ''}
</p>
</div>
</div>
<Badge className={`text-xs shrink-0 ${getDueDateColor(dueStatus)}`}>
<Clock className="h-3 w-3 mr-1" />
{getDueDateLabel(hw.dueDate)}
</Badge>
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
</motion.div>
);
}
File diff suppressed because it is too large. Load diff
+605
View File
@@ -0,0 +1,605 @@
'use client';
import React, { useEffect, useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Briefcase, Plus, Trash2, Pencil, Eye, EyeOff, Search,
Palette, BookOpen, FileText, Presentation, Award, Lightbulb,
Grid3X3, List, Tag, Calendar, Filter, X,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner';
// ─── Types ──────────────────────────────────────────────────────────────
interface PortfolioEntryData {
id: string;
schoolId: string;
studentId: string;
title: string;
description: string | null;
entryType: string;
competencyId: string | null;
content: string | null;
mediaUrls: string | null;
notebookPageId: string | null;
drawingId: string | null;
isPublic: boolean;
tags: string | null;
isDemo: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
student: { id: string; firstName: string; lastName: string };
competency: { id: string; code: string; title: string } | null;
}
const ENTRY_TYPES = [
{ value: 'artwork', labelKey: 'portfolio.artwork', icon: Palette, color: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300' },
{ value: 'writing', labelKey: 'portfolio.writing', icon: BookOpen, color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
{ value: 'project', labelKey: 'portfolio.project', icon: Grid3X3, color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' },
{ value: 'presentation', labelKey: 'portfolio.presentation', icon: Presentation, color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' },
{ value: 'achievement', labelKey: 'portfolio.achievement', icon: Award, color: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300' },
{ value: 'reflection', labelKey: 'portfolio.reflection', icon: Lightbulb, color: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300' },
];
function getEntryTypeConfig(type: string) {
return ENTRY_TYPES.find((et) => et.value === type) ?? ENTRY_TYPES[5];
}
export default function PortfolioView() {
const currentUser = useAppStore((s) => s.currentUser);
const currentStudentId = useAppStore((s) => s.currentStudentId);
const [entries, setEntries] = useState<PortfolioEntryData[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<string>('all');
const [filterTag, setFilterTag] = useState<string>('');
const [viewMode, setViewMode] = useState<'grid' | 'timeline'>('grid');
const [dialogOpen, setDialogOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState({
title: '',
description: '',
entryType: 'reflection',
competencyId: '',
content: '',
tags: '',
isPublic: false,
});
const schoolId = currentUser?.schoolId;
const studentId = currentStudentId ?? currentUser?.id ?? '';
// Load entries
useEffect(() => {
if (!schoolId || !studentId) { setLoading(false); return; }
let cancelled = false;
async function load() {
setLoading(true);
try {
const data = await apiGet<PortfolioEntryData[]>(`/api/portfolio?schoolId=${schoolId}&studentId=${studentId}`);
if (!cancelled) setEntries(data);
} catch { /* ignore */ } finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [schoolId, studentId]);
// Collect all unique tags
const allTags = useMemo(() => {
const tagSet = new Set<string>();
entries.forEach((e) => {
if (e.tags) {
try {
const parsed = JSON.parse(e.tags) as string[];
parsed.forEach((tag) => tagSet.add(tag));
} catch { /* ignore */ }
}
});
return Array.from(tagSet).sort();
}, [entries]);
// Filter entries
const filteredEntries = useMemo(() => {
return entries.filter((e) => {
if (filterType !== 'all' && e.entryType !== filterType) return false;
if (filterTag && e.tags) {
try {
const parsed = JSON.parse(e.tags) as string[];
if (!parsed.includes(filterTag)) return false;
} catch { return false; }
}
if (filterTag && !e.tags) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
return (
e.title.toLowerCase().includes(q) ||
(e.description ?? '').toLowerCase().includes(q) ||
(e.content ?? '').toLowerCase().includes(q)
);
}
return true;
});
}, [entries, filterType, filterTag, searchQuery]);
// Open dialog for create
const openCreate = () => {
setEditId(null);
setForm({ title: '', description: '', entryType: 'reflection', competencyId: '', content: '', tags: '', isPublic: false });
setDialogOpen(true);
};
// Open dialog for edit
const openEdit = (entry: PortfolioEntryData) => {
setEditId(entry.id);
let parsedTags = '';
if (entry.tags) {
try { parsedTags = (JSON.parse(entry.tags) as string[]).join(', '); } catch { /* ignore */ }
}
setForm({
title: entry.title,
description: entry.description ?? '',
entryType: entry.entryType,
competencyId: entry.competencyId ?? '',
content: entry.content ?? '',
tags: parsedTags,
isPublic: entry.isPublic,
});
setDialogOpen(true);
};
// Handle save
const handleSave = async () => {
if (!form.title) { toast.error(t('portfolio.entry_title')); return; }
if (!form.entryType) { toast.error(t('portfolio.entry_type')); return; }
try {
const tagsArray = form.tags ? form.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
const payload = {
schoolId,
studentId,
title: form.title,
description: form.description || null,
entryType: form.entryType,
competencyId: form.competencyId || null,
content: form.content || null,
tags: tagsArray.length > 0 ? tagsArray : null,
isPublic: form.isPublic,
};
if (editId) {
const updated = await apiPut<PortfolioEntryData>(`/api/portfolio/${editId}`, payload);
setEntries((prev) => prev.map((e) => e.id === editId ? updated : e));
} else {
const created = await apiPost<PortfolioEntryData>('/api/portfolio', payload);
setEntries((prev) => [created, ...prev]);
}
setDialogOpen(false);
toast.success(editId ? t('action.save') : t('action.create'));
} catch { toast.error(t('error.generic')); }
};
// Handle delete
const handleDelete = async (id: string) => {
try {
await apiDelete(`/api/portfolio/${id}`);
setEntries((prev) => prev.filter((e) => e.id !== id));
toast.success(t('action.delete'));
} catch { toast.error(t('error.generic')); }
};
// Toggle public
const togglePublic = async (entry: PortfolioEntryData) => {
try {
const updated = await apiPut<PortfolioEntryData>(`/api/portfolio/${entry.id}`, { isPublic: !entry.isPublic });
setEntries((prev) => prev.map((e) => e.id === entry.id ? updated : e));
toast.success(updated.isPublic ? t('portfolio.public') : t('portfolio.private'));
} catch { toast.error(t('error.generic')); }
};
if (loading) {
return (
<div className="space-y-4 p-6">
<Skeleton className="h-12 w-48 rounded-xl" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-48 rounded-xl" />
))}
</div>
</div>
);
}
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<h2 className="text-2xl font-bold gradient-text flex items-center gap-2">
<Briefcase className="h-6 w-6 text-amber-600" />
{t('portfolio.title')}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}
</p>
</div>
<Button
className="rounded-xl bg-amber-600 hover:bg-amber-700 text-white min-h-[44px]"
onClick={openCreate}
>
<Plus className="h-4 w-4 mr-1" />
{t('portfolio.add_new')}
</Button>
</div>
{/* Filters & Search */}
<Card className="border-0 shadow-sm rounded-xl">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
{/* Search */}
<div className="relative flex-1 w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('action.search')}
className="pl-9 rounded-lg h-10"
/>
</div>
{/* Type filter */}
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="h-10 w-40 rounded-lg">
<Filter className="h-4 w-4 mr-1" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('portfolio.all_types')}</SelectItem>
{ENTRY_TYPES.map((et) => (
<SelectItem key={et.value} value={et.value}>
{t(et.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Tag filter */}
{allTags.length > 0 && (
<Select value={filterTag} onValueChange={setFilterTag}>
<SelectTrigger className="h-10 w-40 rounded-lg">
<Tag className="h-4 w-4 mr-1" />
<SelectValue placeholder={t('portfolio.filter_by_tag')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{t('portfolio.all_types')}</SelectItem>
{allTags.map((tag) => (
<SelectItem key={tag} value={tag}>{tag}</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* View mode toggle */}
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-md transition-all min-h-[44px] min-w-[44px] flex items-center justify-center ${viewMode === 'grid' ? 'bg-white dark:bg-gray-700 shadow-sm' : 'text-gray-500'}`}
>
<Grid3X3 className="h-4 w-4" />
</button>
<button
onClick={() => setViewMode('timeline')}
className={`p-2 rounded-md transition-all min-h-[44px] min-w-[44px] flex items-center justify-center ${viewMode === 'timeline' ? 'bg-white dark:bg-gray-700 shadow-sm' : 'text-gray-500'}`}
>
<List className="h-4 w-4" />
</button>
</div>
</div>
{/* Active filter tags */}
{(filterType !== 'all' || filterTag) && (
<div className="flex items-center gap-2 mt-3 flex-wrap">
{filterType !== 'all' && (
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs cursor-pointer" onClick={() => setFilterType('all')}>
{t(getEntryTypeConfig(filterType).labelKey)} <X className="h-3 w-3 ml-1" />
</Badge>
)}
{filterTag && (
<Badge className="bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 text-xs cursor-pointer" onClick={() => setFilterTag('')}>
{filterTag} <X className="h-3 w-3 ml-1" />
</Badge>
)}
</div>
)}
</CardContent>
</Card>
{/* Empty state */}
{filteredEntries.length === 0 && (
<div className="text-center py-16">
<Briefcase className="h-12 w-12 text-amber-400 dark:text-amber-500 mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400 font-medium">{t('portfolio.no_entries')}</p>
<Button
variant="outline"
className="mt-4 rounded-xl border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 min-h-[44px]"
onClick={openCreate}
>
<Plus className="h-4 w-4 mr-1" />
{t('portfolio.add_new')}
</Button>
</div>
)}
{/* Grid View */}
{viewMode === 'grid' && filteredEntries.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<AnimatePresence mode="popLayout">
{filteredEntries.map((entry, i) => {
const typeConfig = getEntryTypeConfig(entry.entryType);
const TypeIcon = typeConfig.icon;
const entryTags: string[] = entry.tags ? (() => { try { return JSON.parse(entry.tags) as string[]; } catch { return []; } })() : [];
return (
<motion.div
key={entry.id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ delay: i * 0.03 }}
layout
>
<Card className="border-0 shadow-sm rounded-xl hover:shadow-md transition-shadow h-full flex flex-col overflow-hidden">
{/* Color bar at top */}
<div className={`h-1.5 ${typeConfig.color.replace('text-', 'bg-').split(' ')[0]}`} />
<CardHeader className="pb-2 pt-4 px-4">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${typeConfig.color} shrink-0`}>
<TypeIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
<CardTitle className="text-sm font-bold truncate">{entry.title}</CardTitle>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t(typeConfig.labelKey)}</p>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => togglePublic(entry)}
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
title={entry.isPublic ? t('portfolio.public') : t('portfolio.private')}
>
{entry.isPublic ? <Eye className="h-3.5 w-3.5 text-emerald-500" /> : <EyeOff className="h-3.5 w-3.5 text-gray-400" />}
</button>
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0 flex-1 flex flex-col">
{entry.description && (
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-2 mb-2">{entry.description}</p>
)}
{entry.content && (
<p className="text-xs text-gray-700 dark:text-gray-300 line-clamp-3 mb-2 italic">&ldquo;{entry.content}&rdquo;</p>
)}
{entry.competency && (
<Badge variant="outline" className="text-[10px] w-fit mb-2">{entry.competency.code}</Badge>
)}
{entryTags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{entryTags.slice(0, 4).map((tag) => (
<Badge key={tag} className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 text-[10px] px-1.5 py-0">
<Tag className="h-2.5 w-2.5 mr-0.5" />{tag}
</Badge>
))}
{entryTags.length > 4 && (
<Badge className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 text-[10px] px-1.5 py-0">+{entryTags.length - 4}</Badge>
)}
</div>
)}
<div className="mt-auto flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
<span className="text-[10px] text-gray-400 flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(entry.createdAt).toLocaleDateString()}
</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="rounded-lg min-h-[44px] min-w-[44px] p-0"
onClick={() => openEdit(entry)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="rounded-lg text-red-500 hover:text-red-700 min-h-[44px] min-w-[44px] p-0"
onClick={() => handleDelete(entry.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</AnimatePresence>
</div>
)}
{/* Timeline View */}
{viewMode === 'timeline' && filteredEntries.length > 0 && (
<div className="relative max-h-[800px] overflow-y-auto scrollbar-education">
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-amber-200/50 dark:bg-amber-900/30" />
<div className="space-y-4">
{filteredEntries.map((entry, i) => {
const typeConfig = getEntryTypeConfig(entry.entryType);
const TypeIcon = typeConfig.icon;
const entryTags: string[] = entry.tags ? (() => { try { return JSON.parse(entry.tags) as string[]; } catch { return []; } })() : [];
return (
<motion.div
key={entry.id}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.04 }}
className="relative flex items-start gap-4 pl-4"
>
<div className={`absolute left-3 w-3 h-3 rounded-full ring-2 ring-white dark:ring-gray-900 shrink-0 ${typeConfig.color.replace('text-', 'bg-').split(' ')[0]}`} style={{ zIndex: 1, top: '16px' }} />
<div className="ml-6 min-w-0 flex-1">
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<div className={`flex items-center justify-center w-7 h-7 rounded-lg ${typeConfig.color} shrink-0`}>
<TypeIcon className="h-3.5 w-3.5" />
</div>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{entry.title}</span>
<Badge className={`text-[10px] ${typeConfig.color}`}>{t(typeConfig.labelKey)}</Badge>
{entry.isPublic ? (
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-[10px]"><Eye className="h-3 w-3 mr-0.5" />{t('portfolio.public')}</Badge>
) : (
<Badge className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 text-[10px]"><EyeOff className="h-3 w-3 mr-0.5" />{t('portfolio.private')}</Badge>
)}
</div>
{entry.description && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.description}</p>
)}
{entry.content && (
<p className="text-xs text-gray-700 dark:text-gray-300 mt-1 italic">&ldquo;{entry.content}&rdquo;</p>
)}
{entry.competency && (
<Badge variant="outline" className="text-[10px] mt-1">{entry.competency.code} — {entry.competency.title}</Badge>
)}
{entryTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{entryTags.map((tag) => (
<Badge key={tag} className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 text-[10px] px-1.5 py-0">
<Tag className="h-2.5 w-2.5 mr-0.5" />{tag}
</Badge>
))}
</div>
)}
<p className="text-[10px] text-gray-400 mt-2">
<Calendar className="h-3 w-3 inline mr-1" />
{new Date(entry.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex gap-1 shrink-0">
<Button variant="ghost" size="sm" className="rounded-lg min-h-[44px] min-w-[44px] p-0" onClick={() => openEdit(entry)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="rounded-lg text-red-500 hover:text-red-700 min-h-[44px] min-w-[44px] p-0" onClick={() => handleDelete(entry.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</motion.div>
);
})}
</div>
</div>
)}
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editId ? t('portfolio.edit') : t('portfolio.create')}</DialogTitle>
<DialogDescription>{t('portfolio.title')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label className="text-xs font-medium">{t('portfolio.entry_title')}</Label>
<Input
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
className="mt-1 rounded-lg"
placeholder={t('portfolio.entry_title')}
/>
</div>
<div>
<Label className="text-xs font-medium">{t('portfolio.entry_type')}</Label>
<Select value={form.entryType} onValueChange={(v) => setForm((f) => ({ ...f, entryType: v }))}>
<SelectTrigger className="h-10 rounded-lg mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ENTRY_TYPES.map((et) => (
<SelectItem key={et.value} value={et.value}>
<span className="flex items-center gap-2">
<et.icon className="h-4 w-4" />
{t(et.labelKey)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs font-medium">{t('portfolio.description')}</Label>
<Textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
className="mt-1 rounded-lg"
rows={2}
placeholder={t('portfolio.description')}
/>
</div>
<div>
<Label className="text-xs font-medium">{t('portfolio.content')}</Label>
<Textarea
value={form.content}
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
className="mt-1 rounded-lg"
rows={4}
placeholder={t('portfolio.content')}
/>
</div>
<div>
<Label className="text-xs font-medium">{t('portfolio.tags')} (comma-separated)</Label>
<Input
value={form.tags}
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
className="mt-1 rounded-lg"
placeholder={t('portfolio.tags')}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">{entry.isPublic ? t('portfolio.public') : t('portfolio.private')}</Label>
<div className="flex items-center gap-2">
<EyeOff className="h-4 w-4 text-gray-400" />
<Switch
checked={form.isPublic}
onCheckedChange={(v) => setForm((f) => ({ ...f, isPublic: v }))}
/>
<Eye className="h-4 w-4 text-emerald-500" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="rounded-xl min-h-[44px]" onClick={() => setDialogOpen(false)}>{t('action.cancel')}</Button>
<Button
className="rounded-xl bg-amber-600 hover:bg-amber-700 text-white min-h-[44px]"
onClick={handleSave}
>
{editId ? t('action.save') : t('action.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+660 -1
View File
@@ -17,6 +17,7 @@ import {
Printer, ChevronRight, Sparkles, MessageSquare, Grid3X3, Trophy, Flag, Zap,
Rocket, Target, PenLine, Pencil, Home, ClipboardList, Star, BarChart3,
LucideIcon, Download, FileSpreadsheet, FileDown, Heart, Users as UsersIcon,
Eye, Plus, Trash2, Clock, CheckCircle2, XCircle, SlidersHorizontal, Lightbulb,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -34,9 +35,58 @@ import {
} from '@/components/ui/tooltip';
import { useAppStore, type CurrentUser } from '@/lib/store';
import { t } from '@/lib/i18n';
import { fetchStudentDetail, getReportPdfUrl, type StudentDetailData, fetchParentLinks, type ParentStudentLinkData } from '@/lib/api';
import { fetchStudentDetail, getReportPdfUrl, type StudentDetailData, fetchParentLinks, type ParentStudentLinkData, apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner';
import TeacherNotesSection from './teacher-notes-section';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Slider } from '@/components/ui/slider';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
// ─── Self-Assessment & Learning Goal Types ────────────────────────────────
interface SelfAssessmentData {
id: string;
schoolId: string;
studentId: string;
competencyId: string;
classGroupId: string | null;
selfLevel: number;
confidence: number | null;
reflection: string | null;
evidence: string | null;
goalId: string | null;
isDemo: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
student: { id: string; firstName: string; lastName: string };
competency: { id: string; code: string; title: string };
classGroup: { id: string; name: string } | null;
goal: { id: string; title: string; status: string } | null;
}
interface LearningGoalData {
id: string;
schoolId: string;
studentId: string;
competencyId: string | null;
title: string;
description: string | null;
targetLevel: number | null;
currentLevel: number | null;
deadline: string | null;
status: string;
progress: number;
isDemo: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
student: { id: string; firstName: string; lastName: string };
competency: { id: string; code: string; title: string } | null;
selfAssessments: { id: string; selfLevel: number; confidence: number | null; createdAt: string }[];
}
// ─── Journey Timeline Time Filter State ─────────────────────────────────
type JourneyTimeRange = '30' | '90' | 'all';
@@ -349,6 +399,17 @@ export default function StudentDetailView() {
const [selectedFlowerSubjectId, setSelectedFlowerSubjectId] = useState<string>('');
const [timeRange, setTimeRange] = useState<JourneyTimeRange>('all');
// Self-Assessment & Learning Goals state
const [selfAssessments, setSelfAssessments] = useState<SelfAssessmentData[]>([]);
const [learningGoals, setLearningGoals] = useState<LearningGoalData[]>([]);
const [saDialogOpen, setSaDialogOpen] = useState(false);
const [saEditId, setSaEditId] = useState<string | null>(null);
const [saForm, setSaForm] = useState({ competencyId: '', selfLevel: 3, confidence: 3, reflection: '', evidence: '', goalId: '' });
const [lgDialogOpen, setLgDialogOpen] = useState(false);
const [lgEditId, setLgEditId] = useState<string | null>(null);
const [lgForm, setLgForm] = useState({ title: '', description: '', competencyId: '', targetLevel: 4, currentLevel: 1, deadline: '', status: 'active', progress: 0 });
const [goalCelebration, setGoalCelebration] = useState<string | null>(null);
// Load parent links for parent users
useEffect(() => {
if (isParent && currentUser?.id) {
@@ -386,6 +447,26 @@ export default function StudentDetailView() {
return () => { cancelled = true; };
}, [currentStudentId]);
// Load self-assessments and learning goals
useEffect(() => {
if (!currentStudentId || !currentUser?.schoolId) return;
const schoolId = currentUser.schoolId;
async function loadSA() {
try {
const data = await apiGet<SelfAssessmentData[]>(`/api/self-assessments?schoolId=${schoolId}&studentId=${currentStudentId}`);
setSelfAssessments(data);
} catch { /* ignore */ }
}
async function loadLG() {
try {
const data = await apiGet<LearningGoalData[]>(`/api/learning-goals?schoolId=${schoolId}&studentId=${currentStudentId}`);
setLearningGoals(data);
} catch { /* ignore */ }
}
loadSA();
loadLG();
}, [currentStudentId, currentUser?.schoolId]);
if (loading) {
return (
<div className="space-y-4">
@@ -1234,6 +1315,584 @@ export default function StudentDetailView() {
</Card>
)}
</div>
{/* ─── Self-Assessment Section ─────────────────────────────────── */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-cyan-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-cyan-50/50 to-transparent dark:from-cyan-900/10 dark:to-transparent">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400">
<Eye className="h-4 w-4" />
</div>
{t('self_assessment.title')}
<Badge className="bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300 text-xs font-medium">
{selfAssessments.length}
</Badge>
</CardTitle>
<Button
variant="outline"
size="sm"
className="rounded-xl border-cyan-300 dark:border-cyan-700 text-cyan-700 dark:text-cyan-300 min-h-[44px]"
onClick={() => {
setSaEditId(null);
setSaForm({ competencyId: '', selfLevel: 3, confidence: 3, reflection: '', evidence: '', goalId: '' });
setSaDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-1" />
{t('self_assessment.add_new')}
</Button>
</div>
</CardHeader>
<CardContent>
{selfAssessments.length === 0 ? (
<div className="text-center py-8">
<Eye className="h-8 w-8 text-cyan-400 dark:text-cyan-500 mx-auto mb-2" />
<p className="text-gray-500 dark:text-gray-400">{t('self_assessment.no_assessments')}</p>
</div>
) : (
<>
{/* Comparison radar chart */}
{(() => {
const comparisonData = selfAssessments.map((sa) => {
const teacherEntry = data?.progressEntries.find(
(pe) => pe.competencyId === sa.competencyId
);
return {
competency: sa.competency.code.length > 12 ? sa.competency.code.slice(0, 12) : sa.competency.code,
self: sa.selfLevel,
teacher: teacherEntry ? teacherEntry.masteryLevelValue : 0,
};
}).filter((d) => d.teacher > 0);
if (comparisonData.length < 3) return null;
return (
<div className="mb-6">
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wider">{t('self_assessment.comparison_chart')}</p>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={comparisonData}>
<PolarGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<PolarAngleAxis dataKey="competency" tick={{ fontSize: 10, fill: '#6b7280' }} />
<PolarRadiusAxis domain={[0, 6]} tick={{ fontSize: 9 }} />
<Radar name={t('self_assessment.self')} dataKey="self" stroke="#06b6d4" fill="#06b6d4" fillOpacity={0.2} />
<Radar name={t('self_assessment.teacher')} dataKey="teacher" stroke="#f59e0b" fill="#f59e0b" fillOpacity={0.2} />
<Tooltip />
</RadarChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-center gap-6 mt-2">
<span className="flex items-center gap-1.5 text-xs text-gray-500"><span className="w-3 h-3 rounded-full bg-cyan-400 inline-block" />{t('self_assessment.self')}</span>
<span className="flex items-center gap-1.5 text-xs text-gray-500"><span className="w-3 h-3 rounded-full bg-amber-400 inline-block" />{t('self_assessment.teacher')}</span>
</div>
</div>
);
})()}
{/* Self-assessment list */}
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education">
{selfAssessments.map((sa, i) => {
const teacherEntry = data?.progressEntries.find((pe) => pe.competencyId === sa.competencyId);
const gap = teacherEntry ? sa.selfLevel - teacherEntry.masteryLevelValue : null;
return (
<motion.div
key={sa.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
className="p-3 rounded-xl bg-gradient-to-r from-cyan-50/40 to-transparent dark:from-cyan-900/10 dark:to-transparent border border-cyan-200/30 dark:border-cyan-900/20"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{sa.competency.code} — {sa.competency.title}</span>
<Badge className="bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300 text-xs">{t('self_assessment.level')}: {sa.selfLevel}/6</Badge>
{sa.confidence && (
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs">{t('self_assessment.confidence')}: {sa.confidence}/5</Badge>
)}
{gap !== null && (
<Badge className={`${Math.abs(gap) <= 1 ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'} text-xs`}>
{t('self_assessment.gap')}: {gap > 0 ? '+' : ''}{gap.toFixed(1)}
</Badge>
)}
</div>
{sa.reflection && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 italic">&ldquo;{sa.reflection}&rdquo;</p>
)}
{sa.evidence && (
<p className="text-xs text-cyan-600 dark:text-cyan-400 mt-0.5"><Lightbulb className="h-3 w-3 inline mr-1" />{sa.evidence}</p>
)}
<p className="text-[10px] text-gray-400 mt-1">{new Date(sa.createdAt).toLocaleDateString()}</p>
</div>
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
className="rounded-lg min-h-[44px] min-w-[44px]"
onClick={() => {
setSaEditId(sa.id);
setSaForm({
competencyId: sa.competencyId,
selfLevel: sa.selfLevel,
confidence: sa.confidence ?? 3,
reflection: sa.reflection ?? '',
evidence: sa.evidence ?? '',
goalId: sa.goalId ?? '',
});
setSaDialogOpen(true);
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="rounded-lg text-red-500 hover:text-red-700 min-h-[44px] min-w-[44px]"
onClick={async () => {
try {
await apiDelete(`/api/self-assessments/${sa.id}`);
setSelfAssessments((prev) => prev.filter((a) => a.id !== sa.id));
toast.success(t('action.delete'));
} catch { toast.error(t('error.generic')); }
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</motion.div>
);
})}
</div>
</>
)}
</CardContent>
</Card>
{/* ─── Learning Goals Section ─────────────────────────────────── */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-orange-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-orange-50/50 to-transparent dark:from-orange-900/10 dark:to-transparent">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400">
<Target className="h-4 w-4" />
</div>
{t('learning_goal.title')}
<Badge className="bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300 text-xs font-medium">
{learningGoals.filter((g) => g.status === 'active').length}
</Badge>
</CardTitle>
<Button
variant="outline"
size="sm"
className="rounded-xl border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300 min-h-[44px]"
onClick={() => {
setLgEditId(null);
setLgForm({ title: '', description: '', competencyId: '', targetLevel: 4, currentLevel: 1, deadline: '', status: 'active', progress: 0 });
setLgDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-1" />
{t('learning_goal.add_new')}
</Button>
</div>
</CardHeader>
<CardContent>
{learningGoals.length === 0 ? (
<div className="text-center py-8">
<Target className="h-8 w-8 text-orange-400 dark:text-orange-500 mx-auto mb-2" />
<p className="text-gray-500 dark:text-gray-400">{t('learning_goal.no_goals')}</p>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto scrollbar-education">
{learningGoals.map((goal, i) => {
const daysLeft = goal.deadline ? Math.ceil((new Date(goal.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) : null;
const isOverdue = daysLeft !== null && daysLeft < 0;
const statusColor = goal.status === 'completed' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' : goal.status === 'abandoned' ? 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300' : isOverdue ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300';
const statusLabel = goal.status === 'completed' ? t('learning_goal.completed') : goal.status === 'abandoned' ? t('learning_goal.abandoned') : t('learning_goal.active');
return (
<motion.div
key={goal.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
className="p-4 rounded-xl bg-gradient-to-r from-orange-50/40 to-transparent dark:from-orange-900/10 dark:to-transparent border border-orange-200/30 dark:border-orange-900/20"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{goal.title}</span>
<Badge className={`text-xs ${statusColor}`}>
{goal.status === 'completed' ? <CheckCircle2 className="h-3 w-3 mr-1" /> : goal.status === 'abandoned' ? <XCircle className="h-3 w-3 mr-1" /> : <Clock className="h-3 w-3 mr-1" />}
{statusLabel}
</Badge>
{goal.competency && (
<Badge variant="outline" className="text-xs">{goal.competency.code}</Badge>
)}
</div>
{goal.description && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{goal.description}</p>
)}
{/* Progress bar */}
<div className="mt-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-gray-500 dark:text-gray-400">{t('learning_goal.progress')}</span>
<span className="text-[10px] font-bold text-orange-600 dark:text-orange-400">{goal.progress}%</span>
</div>
<Progress value={goal.progress} className="h-2" />
</div>
{/* Level indicators */}
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
{goal.currentLevel !== null && (
<span>{t('learning_goal.current_level')}: <strong className="text-gray-700 dark:text-gray-300">{goal.currentLevel}</strong></span>
)}
{goal.targetLevel !== null && (
<span>{t('learning_goal.target_level')}: <strong className="text-gray-700 dark:text-gray-300">{goal.targetLevel}</strong></span>
)}
{daysLeft !== null && (
<span className={isOverdue ? 'text-red-500 font-semibold' : 'text-orange-500'}>
{isOverdue ? t('learning_goal.overdue') : `${daysLeft} ${t('learning_goal.days_left')}`}
</span>
)}
</div>
</div>
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
className="rounded-lg min-h-[44px] min-w-[44px]"
onClick={() => {
setLgEditId(goal.id);
setLgForm({
title: goal.title,
description: goal.description ?? '',
competencyId: goal.competencyId ?? '',
targetLevel: goal.targetLevel ?? 4,
currentLevel: goal.currentLevel ?? 1,
deadline: goal.deadline ? new Date(goal.deadline).toISOString().split('T')[0] : '',
status: goal.status,
progress: goal.progress,
});
setLgDialogOpen(true);
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{goal.status === 'active' && (
<Button
variant="ghost"
size="sm"
className="rounded-lg text-emerald-600 hover:text-emerald-700 min-h-[44px] min-w-[44px]"
onClick={async () => {
try {
await apiPut(`/api/learning-goals/${goal.id}`, { status: 'completed', progress: 100 });
setLearningGoals((prev) => prev.map((g) => g.id === goal.id ? { ...g, status: 'completed', progress: 100 } : g));
setGoalCelebration(goal.id);
setTimeout(() => setGoalCelebration(null), 3000);
toast.success(t('learning_goal.celebration'));
} catch { toast.error(t('error.generic')); }
}}
>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="rounded-lg text-red-500 hover:text-red-700 min-h-[44px] min-w-[44px]"
onClick={async () => {
try {
await apiDelete(`/api/learning-goals/${goal.id}`);
setLearningGoals((prev) => prev.filter((g) => g.id !== goal.id));
toast.success(t('action.delete'));
} catch { toast.error(t('error.generic')); }
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Celebration overlay */}
{goalCelebration === goal.id && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="mt-2 p-2 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 text-xs font-bold text-center"
>
{t('learning_goal.celebration')}
</motion.div>
)}
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* ─── Self-Assessment Dialog ───────────────────────────────── */}
<Dialog open={saDialogOpen} onOpenChange={setSaDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{saEditId ? t('self_assessment.edit') : t('self_assessment.create')}</DialogTitle>
<DialogDescription>{t('self_assessment.title')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label className="text-xs font-medium">{t('self_assessment.competency')}</Label>
<Select value={saForm.competencyId} onValueChange={(v) => setSaForm((f) => ({ ...f, competencyId: v }))}>
<SelectTrigger className="h-10 rounded-lg mt-1">
<SelectValue placeholder={t('self_assessment.competency')} />
</SelectTrigger>
<SelectContent>
{data?.progressEntries.map((pe) => (
<SelectItem key={pe.competencyId} value={pe.competencyId}>
{pe.competency.code} — {pe.competency.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs font-medium">{t('self_assessment.self_level')} ({saForm.selfLevel}/6)</Label>
<Slider
value={[saForm.selfLevel]}
onValueChange={([v]) => setSaForm((f) => ({ ...f, selfLevel: v }))}
min={1}
max={6}
step={1}
className="mt-2"
/>
<div className="flex justify-between text-[10px] text-gray-400 mt-1">
<span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span>
</div>
</div>
<div>
<Label className="text-xs font-medium">{t('self_assessment.confidence')} ({saForm.confidence}/5)</Label>
<Slider
value={[saForm.confidence]}
onValueChange={([v]) => setSaForm((f) => ({ ...f, confidence: v }))}
min={1}
max={5}
step={1}
className="mt-2"
/>
<div className="flex justify-between text-[10px] text-gray-400 mt-1">
<span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
</div>
</div>
<div>
<Label className="text-xs font-medium">{t('self_assessment.reflection')}</Label>
<Textarea
value={saForm.reflection}
onChange={(e) => setSaForm((f) => ({ ...f, reflection: e.target.value }))}
className="mt-1 rounded-lg"
rows={3}
placeholder={t('self_assessment.reflection')}
/>
</div>
<div>
<Label className="text-xs font-medium">{t('self_assessment.evidence')}</Label>
<Textarea
value={saForm.evidence}
onChange={(e) => setSaForm((f) => ({ ...f, evidence: e.target.value }))}
className="mt-1 rounded-lg"
rows={2}
placeholder={t('self_assessment.evidence')}
/>
</div>
{learningGoals.length > 0 && (
<div>
<Label className="text-xs font-medium">{t('learning_goal.title')}</Label>
<Select value={saForm.goalId} onValueChange={(v) => setSaForm((f) => ({ ...f, goalId: v }))}>
<SelectTrigger className="h-10 rounded-lg mt-1">
<SelectValue placeholder={t('learning_goal.title')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">—</SelectItem>
{learningGoals.filter((g) => g.status === 'active').map((g) => (
<SelectItem key={g.id} value={g.id}>{g.title}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" className="rounded-xl min-h-[44px]" onClick={() => setSaDialogOpen(false)}>{t('action.cancel')}</Button>
<Button
className="rounded-xl bg-cyan-600 hover:bg-cyan-700 text-white min-h-[44px]"
onClick={async () => {
if (!saForm.competencyId) { toast.error(t('self_assessment.competency')); return; }
try {
const payload = {
schoolId: currentUser?.schoolId,
studentId: currentStudentId,
competencyId: saForm.competencyId,
selfLevel: saForm.selfLevel,
confidence: saForm.confidence,
reflection: saForm.reflection || null,
evidence: saForm.evidence || null,
goalId: saForm.goalId && saForm.goalId !== 'none' ? saForm.goalId : null,
};
if (saEditId) {
const updated = await apiPut<SelfAssessmentData>(`/api/self-assessments/${saEditId}`, payload);
setSelfAssessments((prev) => prev.map((a) => a.id === saEditId ? updated : a));
} else {
const created = await apiPost<SelfAssessmentData>('/api/self-assessments', payload);
setSelfAssessments((prev) => [created, ...prev]);
}
setSaDialogOpen(false);
toast.success(saEditId ? t('action.save') : t('action.create'));
} catch { toast.error(t('error.generic')); }
}}
>
{saEditId ? t('action.save') : t('action.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ─── Learning Goal Dialog ───────────────────────────────── */}
<Dialog open={lgDialogOpen} onOpenChange={setLgDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{lgEditId ? t('learning_goal.edit') : t('learning_goal.create')}</DialogTitle>
<DialogDescription>{t('learning_goal.title')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label className="text-xs font-medium">{t('learning_goal.goal_title')}</Label>
<Input
value={lgForm.title}
onChange={(e) => setLgForm((f) => ({ ...f, title: e.target.value }))}
className="mt-1 rounded-lg"
placeholder={t('learning_goal.goal_title')}
/>
</div>
<div>
<Label className="text-xs font-medium">{t('learning_goal.description')}</Label>
<Textarea
value={lgForm.description}
onChange={(e) => setLgForm((f) => ({ ...f, description: e.target.value }))}
className="mt-1 rounded-lg"
rows={2}
placeholder={t('learning_goal.description')}
/>
</div>
<div>
<Label className="text-xs font-medium">{t('self_assessment.competency')}</Label>
<Select value={lgForm.competencyId} onValueChange={(v) => setLgForm((f) => ({ ...f, competencyId: v }))}>
<SelectTrigger className="h-10 rounded-lg mt-1">
<SelectValue placeholder={t('self_assessment.competency')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">—</SelectItem>
{data?.progressEntries.map((pe) => (
<SelectItem key={pe.competencyId} value={pe.competencyId}>
{pe.competency.code} — {pe.competency.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium">{t('learning_goal.current_level')}</Label>
<Slider
value={[lgForm.currentLevel]}
onValueChange={([v]) => setLgForm((f) => ({ ...f, currentLevel: v }))}
min={1}
max={6}
step={1}
className="mt-2"
/>
<p className="text-xs text-center mt-1 text-gray-500">{lgForm.currentLevel}/6</p>
</div>
<div>
<Label className="text-xs font-medium">{t('learning_goal.target_level')}</Label>
<Slider
value={[lgForm.targetLevel]}
onValueChange={([v]) => setLgForm((f) => ({ ...f, targetLevel: v }))}
min={1}
max={6}
step={1}
className="mt-2"
/>
<p className="text-xs text-center mt-1 text-gray-500">{lgForm.targetLevel}/6</p>
</div>
</div>
<div>
<Label className="text-xs font-medium">{t('learning_goal.deadline')}</Label>
<Input
type="date"
value={lgForm.deadline}
onChange={(e) => setLgForm((f) => ({ ...f, deadline: e.target.value }))}
className="mt-1 rounded-lg"
/>
</div>
<div>
<Label className="text-xs font-medium">{t('learning_goal.progress')} ({lgForm.progress}%)</Label>
<Slider
value={[lgForm.progress]}
onValueChange={([v]) => setLgForm((f) => ({ ...f, progress: v }))}
min={0}
max={100}
step={5}
className="mt-2"
/>
</div>
{lgEditId && (
<div>
<Label className="text-xs font-medium">{t('learning_goal.status')}</Label>
<Select value={lgForm.status} onValueChange={(v) => setLgForm((f) => ({ ...f, status: v }))}>
<SelectTrigger className="h-10 rounded-lg mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{t('learning_goal.active')}</SelectItem>
<SelectItem value="completed">{t('learning_goal.completed')}</SelectItem>
<SelectItem value="abandoned">{t('learning_goal.abandoned')}</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" className="rounded-xl min-h-[44px]" onClick={() => setLgDialogOpen(false)}>{t('action.cancel')}</Button>
<Button
className="rounded-xl bg-orange-600 hover:bg-orange-700 text-white min-h-[44px]"
onClick={async () => {
if (!lgForm.title) { toast.error(t('learning_goal.goal_title')); return; }
try {
const payload = {
schoolId: currentUser?.schoolId,
studentId: currentStudentId,
title: lgForm.title,
description: lgForm.description || null,
competencyId: lgForm.competencyId && lgForm.competencyId !== 'none' ? lgForm.competencyId : null,
targetLevel: lgForm.targetLevel,
currentLevel: lgForm.currentLevel,
deadline: lgForm.deadline || null,
status: lgForm.status,
progress: lgForm.progress,
};
if (lgEditId) {
const updated = await apiPut<LearningGoalData>(`/api/learning-goals/${lgEditId}`, payload);
setLearningGoals((prev) => prev.map((g) => g.id === lgEditId ? updated : g));
} else {
const created = await apiPost<LearningGoalData>('/api/learning-goals', payload);
setLearningGoals((prev) => [created, ...prev]);
}
setLgDialogOpen(false);
toast.success(lgEditId ? t('action.save') : t('action.create'));
} catch { toast.error(t('error.generic')); }
}}
>
{lgEditId ? t('action.save') : t('action.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}