Round 14: Self-Assessment, Learning Goals, Portfolio, Homework, Announcements
This commit is contained in:
1 parent
d325984786
commit
50c2d13d90
22 files changed
+5232
-2
No files matched your search
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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">“{entry.content}”</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">“{entry.content}”</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>
|
||||
);
|
||||
}
|
||||
@@ -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">“{sa.reflection}”</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user