Round 16: Peer Assessment, Emergency Contacts, School Events, School Branding, Email Templates, Performance, Styling Polish, Onboarding 24 Steps
This commit is contained in:
1 parent
d45ba030f5
commit
6174f127f9
30 files changed
+4835
-142
No files matched your search
@@ -107,6 +107,7 @@ 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 type { School as SchoolType } from '@/lib/api';
|
||||
import OnboardingTour, { isOnboardingCompleted } from '@/components/onboarding-tour';
|
||||
import KeyboardShortcutsDialog from '@/components/keyboard-shortcuts-dialog';
|
||||
import {
|
||||
@@ -123,6 +124,10 @@ import { toast } from 'sonner';
|
||||
import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket';
|
||||
import { OfflineIndicator, PWAInstallPrompt, useServiceWorker, OfflineSyncManager } from '@/components/offline-indicator';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
// Static imports for lightweight views
|
||||
import DashboardView from './dashboard-view';
|
||||
import ClassesView from './classes-view';
|
||||
import CompetencyGridView from './competency-grid-view';
|
||||
@@ -133,23 +138,37 @@ import GradingView from './grading-view';
|
||||
import ReportsView from './reports-view';
|
||||
import SettingsView from './settings-view';
|
||||
import StudentDetailView from './student-detail-view';
|
||||
import AnalyticsView from './analytics-view';
|
||||
import MasteryMatrixView from './mastery-matrix-view';
|
||||
import AttendanceView from './attendance-view';
|
||||
import LessonPlansView from './lesson-plans-view';
|
||||
import CalendarView from './calendar-view';
|
||||
import ParentCommunicationView from './parent-communication-view';
|
||||
import BehaviorTrackingView from './behavior-tracking-view';
|
||||
import CurriculumCoverageView from './curriculum-coverage-view';
|
||||
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';
|
||||
import TimetableView from './timetable-view';
|
||||
import ResourceLibraryView from './resource-library-view';
|
||||
|
||||
// Dynamic imports for heavy components with loading skeletons
|
||||
const AnalyticsView = dynamic(() => import('./analytics-view'), {
|
||||
loading: () => <div className="space-y-4 p-4"><Skeleton className="h-8 w-48" /><div className="grid grid-cols-1 md:grid-cols-3 gap-4">{[1,2,3].map(i => <Skeleton key={i} className="h-32" />)}</div><Skeleton className="h-64" /></div>,
|
||||
});
|
||||
|
||||
const CalendarView = dynamic(() => import('./calendar-view'), {
|
||||
loading: () => <div className="space-y-4 p-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-96" /></div>,
|
||||
});
|
||||
|
||||
const NotebooksView = dynamic(() => import('./notebooks-view'), {
|
||||
loading: () => <div className="space-y-4 p-4"><Skeleton className="h-8 w-48" /><div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">{[1,2,3].map(i => <Skeleton key={i} className="h-48" />)}</div></div>,
|
||||
});
|
||||
|
||||
const DrawingView = dynamic(() => import('./drawing-view'), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex items-center justify-center h-96"><div className="flex items-center gap-3"><div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" /><span className="text-gray-500">Loading canvas...</span></div></div>,
|
||||
});
|
||||
|
||||
type NavItem = { key: ViewName; icon: React.ElementType; labelKey: string };
|
||||
type NavSection = { id: string; labelKey: string; items: NavItem[] };
|
||||
|
||||
@@ -375,6 +394,9 @@ export default function AppLayout() {
|
||||
const [schoolYears, setSchoolYears] = useState<SchoolYear[]>([]);
|
||||
const [selectedYearId, setSelectedYearId] = useState<string>(storeSchoolYearId ?? '');
|
||||
|
||||
// School branding state
|
||||
const [schoolBranding, setSchoolBranding] = useState<SchoolType | null>(null);
|
||||
|
||||
// Service Worker registration for PWA
|
||||
useServiceWorker();
|
||||
|
||||
@@ -755,6 +777,33 @@ export default function AppLayout() {
|
||||
loadYears();
|
||||
}, [currentUser?.schoolId]);
|
||||
|
||||
// Load school branding and apply CSS custom properties
|
||||
useEffect(() => {
|
||||
async function loadBranding() {
|
||||
if (!currentUser?.schoolId) return;
|
||||
try {
|
||||
const schools = await apiGet<SchoolType[]>('/api/schools');
|
||||
const mySchool = schools.find((s) => s.id === currentUser.schoolId);
|
||||
if (mySchool) {
|
||||
setSchoolBranding(mySchool);
|
||||
// Apply branding CSS variables
|
||||
const root = document.documentElement;
|
||||
if (mySchool.primaryColor) root.style.setProperty('--brand-primary', mySchool.primaryColor);
|
||||
if (mySchool.secondaryColor) root.style.setProperty('--brand-secondary', mySchool.secondaryColor);
|
||||
if (mySchool.accentColor) root.style.setProperty('--brand-accent', mySchool.accentColor);
|
||||
if (mySchool.fontFamily) root.style.setProperty('--brand-font', `"${mySchool.fontFamily}", sans-serif`);
|
||||
// Apply font family globally
|
||||
if (mySchool.fontFamily) {
|
||||
root.style.fontFamily = `"${mySchool.fontFamily}", sans-serif`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore branding errors
|
||||
}
|
||||
}
|
||||
loadBranding();
|
||||
}, [currentUser?.schoolId]);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
{/* Offline indicator bar */}
|
||||
@@ -774,11 +823,19 @@ export default function AppLayout() {
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-center gap-3 group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shrink-0 shadow-lg shadow-emerald-300/40 dark:shadow-emerald-900/40">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
</div>
|
||||
{schoolBranding?.logoUrl ? (
|
||||
<img
|
||||
src={schoolBranding.logoUrl}
|
||||
alt={schoolBranding.name}
|
||||
className="w-9 h-9 rounded-xl object-contain shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shrink-0 shadow-lg shadow-emerald-300/40 dark:shadow-emerald-900/40">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="group-data-[collapsible=icon]:hidden">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100 leading-tight">CompetenceTrack</h2>
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100 leading-tight">{schoolBranding?.name || 'CompetenceTrack'}</h2>
|
||||
<p className="text-[10px] text-emerald-600/70 dark:text-emerald-400/50 leading-tight">{t('app.subtitle')}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -827,7 +884,7 @@ export default function AppLayout() {
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="p-3">
|
||||
{/* Environmental message */}
|
||||
{/* School motto or Environmental message */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -836,7 +893,7 @@ export default function AppLayout() {
|
||||
>
|
||||
<Leaf className="h-4 w-4 text-emerald-500 shrink-0" />
|
||||
<p className="text-xs text-emerald-600/70 dark:text-emerald-400/50 font-medium leading-snug">
|
||||
{t('sidebar.eco_message')}
|
||||
{schoolBranding?.motto || t('sidebar.eco_message')}
|
||||
</p>
|
||||
</motion.div>
|
||||
<Separator className="mb-3 bg-emerald-200/50 dark:bg-emerald-900/30" />
|
||||
@@ -866,7 +923,14 @@ export default function AppLayout() {
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
title={t('theme.toggle')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4 text-amber-500" /> : <Moon className="h-4 w-4 text-gray-500" />}
|
||||
<motion.div
|
||||
key={theme}
|
||||
initial={{ rotate: -90, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.3, type: 'spring', stiffness: 200 }}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4 text-amber-500" /> : <Moon className="h-4 w-4 text-gray-500" />}
|
||||
</motion.div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -894,7 +958,7 @@ export default function AppLayout() {
|
||||
<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">
|
||||
<header className="flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-white/80 dark:bg-gray-950/80 backdrop-blur-xl px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10 transition-all duration-300">
|
||||
<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" />
|
||||
<Breadcrumb className="min-w-0 flex-1">
|
||||
@@ -907,7 +971,7 @@ export default function AppLayout() {
|
||||
{t('polish.breadcrumb_home')}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbSeparator><ChevronRight className="h-3 w-3 text-emerald-400/60" /></BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{breadcrumbLabel}
|
||||
@@ -979,7 +1043,12 @@ export default function AppLayout() {
|
||||
className="h-9 w-9 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 relative text-emerald-600 dark:text-emerald-400"
|
||||
title={t('notifications.title')}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
<motion.div
|
||||
animate={notifData && notifData.unreadCount > 0 ? { rotate: [0, -15, 15, -10, 10, 0] } : {}}
|
||||
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</motion.div>
|
||||
{notifData && notifData.unreadCount > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
|
||||
@@ -485,7 +485,7 @@ export default function AssessmentsView() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<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-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
|
||||
@@ -287,7 +287,7 @@ function MonthlyHeatmap({ sessions }: { sessions: AttendanceSession[] }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-lg rounded-xl bg-gradient-to-br from-white to-emerald-50/30 dark:from-gray-900 dark:to-emerald-950/20 overflow-hidden">
|
||||
<Card className="card-hover-lift border-0 shadow-lg rounded-xl bg-gradient-to-br from-white to-emerald-50/30 dark:from-gray-900 dark:to-emerald-950/20 overflow-hidden">
|
||||
<CardHeader className="pb-2 bg-gradient-to-r from-emerald-50/50 to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/5">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CalendarCheck className="h-5 w-5 text-emerald-500" />
|
||||
@@ -429,7 +429,7 @@ function StatsCards({ sessions }: { sessions: AttendanceSession[] }) {
|
||||
transition={{ duration: 0.3, delay: idx * 0.06 }}
|
||||
whileHover={{ y: -2 }}
|
||||
>
|
||||
<Card className={`border-2 ${card.border} shadow-md hover:shadow-lg transition-shadow rounded-xl overflow-hidden`}>
|
||||
<Card className={`card-hover-lift border-2 ${card.border} shadow-md hover:shadow-lg transition-shadow rounded-xl overflow-hidden`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<card.icon className="w-4 h-4" />
|
||||
|
||||
@@ -608,7 +608,7 @@ export default function ClassesView() {
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => handleSelectClass(cls)}
|
||||
className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 lift-on-hover ${
|
||||
className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 card-hover-lift ${
|
||||
selectedClass?.id === cls.id
|
||||
? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50'
|
||||
: 'hover:shadow-lg hover:shadow-emerald-200/30 dark:hover:shadow-emerald-900/20'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Users,
|
||||
@@ -53,6 +53,12 @@ import {
|
||||
Pin,
|
||||
X,
|
||||
Send,
|
||||
MapPin,
|
||||
Trophy,
|
||||
Users as UsersIcon,
|
||||
Music,
|
||||
Flag,
|
||||
Tent,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -89,7 +95,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';
|
||||
import { apiGet, apiPost, apiPut } from '@/lib/api';
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
@@ -101,6 +107,37 @@ const itemVariants = {
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
// ─── CountUp Animation Component ────────────────────────────────────
|
||||
function CountUp({ target, duration = 800 }: { target: number; duration?: number }) {
|
||||
const [count, setCount] = useState(0);
|
||||
const prevTarget = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (target === prevTarget.current) return;
|
||||
const start = prevTarget.current;
|
||||
const diff = target - start;
|
||||
if (diff === 0) { setCount(target); return; }
|
||||
const startTime = performance.now();
|
||||
let rafId: number;
|
||||
function step(now: number) {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
// Ease out cubic
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setCount(Math.round(start + diff * eased));
|
||||
if (progress < 1) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
} else {
|
||||
prevTarget.current = target;
|
||||
}
|
||||
}
|
||||
rafId = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [target, duration]);
|
||||
|
||||
return <>{count}</>;
|
||||
}
|
||||
|
||||
const statCardColors: Record<string, { bg: string; gradient: string; iconBg: string; iconText: string; borderAccent: string; glowColor: string; hoverShadow: string }> = {
|
||||
emerald: {
|
||||
bg: 'bg-gradient-to-br from-emerald-50 to-emerald-100/50 dark:from-emerald-900/20 dark:to-emerald-800/10',
|
||||
@@ -1000,7 +1037,7 @@ export default function DashboardView() {
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Card className={`border-0 shadow-sm rounded-xl border-l-3 ${colors.borderAccent} overflow-hidden transition-all duration-300 ${colors.hoverShadow} ${colors.glowColor} cursor-default ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30`}>
|
||||
<Card className={`card-hover-lift border-0 shadow-sm rounded-xl border-l-3 ${colors.borderAccent} overflow-hidden transition-all duration-300 ${colors.hoverShadow} ${colors.glowColor} cursor-default ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30`}>
|
||||
<CardContent className={`p-5 ${colors.bg}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 leading-tight line-clamp-2 min-h-[28px] break-words min-w-0">
|
||||
@@ -1011,7 +1048,7 @@ export default function DashboardView() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-1.5">
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100 leading-none">{stat.value}</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100 leading-none"><CountUp target={stat.value} /></p>
|
||||
{stat.value > 0 && stat.trend === 'up' && (
|
||||
<div className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-emerald-100/80 dark:bg-emerald-900/30">
|
||||
<ArrowUpRight className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
|
||||
@@ -1762,6 +1799,9 @@ export default function DashboardView() {
|
||||
<DashboardHomeworkCard currentUser={currentUser} locale={locale} />
|
||||
</div>
|
||||
|
||||
{/* ===== UPCOMING SCHOOL EVENTS ===== */}
|
||||
<DashboardSchoolEventsCard currentUser={currentUser} locale={locale} />
|
||||
|
||||
{/* ===== PAPER SAVED & ENVIRONMENTAL SECTION ===== */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Paper Saved Counter */}
|
||||
@@ -2202,3 +2242,170 @@ function DashboardHomeworkCard({ currentUser, locale }: { currentUser: CurrentUs
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── School Event Types ──────────────────────────────────────────────
|
||||
interface SchoolEventData {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
eventType: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
location: string | null;
|
||||
organizerId: string | null;
|
||||
classGroupId: string | null;
|
||||
isAllSchool: boolean;
|
||||
isPublic: boolean;
|
||||
requiresRegistration: boolean;
|
||||
maxParticipants: number | null;
|
||||
notes: string | null;
|
||||
isDemo: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
organizer: { id: string; firstName: string; lastName: string } | null;
|
||||
classGroup: { id: string; name: string } | null;
|
||||
registrations: { id: string; userId: string; status: string; user: { id: string; firstName: string; lastName: string } }[];
|
||||
}
|
||||
|
||||
const eventTypeIcon: Record<string, React.ElementType> = {
|
||||
assembly: UsersIcon,
|
||||
field_trip: MapPin,
|
||||
sports_day: Trophy,
|
||||
concert: Music,
|
||||
fair: Tent,
|
||||
parent_meeting: UsersIcon,
|
||||
graduation: GraduationCap,
|
||||
holiday: CalendarDays,
|
||||
};
|
||||
|
||||
const eventTypeColor: Record<string, string> = {
|
||||
assembly: 'bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400',
|
||||
field_trip: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400',
|
||||
sports_day: 'bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400',
|
||||
concert: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
|
||||
fair: 'bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400',
|
||||
parent_meeting: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400',
|
||||
graduation: 'bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400',
|
||||
holiday: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400',
|
||||
};
|
||||
|
||||
function DashboardSchoolEventsCard({ currentUser, locale }: { currentUser: CurrentUser | null; locale: string }) {
|
||||
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
||||
const [events, setEvents] = useState<SchoolEventData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser?.schoolId) return;
|
||||
setLoading(true);
|
||||
apiGet<SchoolEventData[]>(`/api/school-events?schoolId=${currentUser.schoolId}&upcoming=true`)
|
||||
.then((data) => {
|
||||
setEvents(data.slice(0, 6));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [currentUser?.schoolId]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-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-teal-50/50 to-transparent dark:from-teal-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-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
</div>
|
||||
{t('events.upcoming')}
|
||||
</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-[44px] text-teal-600 dark:text-teal-400 border-teal-200/50 dark:border-teal-900/30 hover:bg-teal-50 dark:hover:bg-teal-900/20"
|
||||
onClick={() => setCurrentView('calendar')}
|
||||
>
|
||||
{t('events.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>
|
||||
) : events.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-teal-100 dark:bg-teal-900/20 mx-auto mb-4">
|
||||
<CalendarDays className="h-8 w-8 text-teal-400 dark:text-teal-500" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{t('events.no_events')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education pr-1">
|
||||
{events.map((event) => {
|
||||
const Icon = eventTypeIcon[event.eventType] ?? CalendarDays;
|
||||
const iconColor = eventTypeColor[event.eventType] ?? 'bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400';
|
||||
const isRegistered = event.registrations.some((r) => r.userId === currentUser?.id && r.status === 'registered');
|
||||
return (
|
||||
<motion.div
|
||||
key={event.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-teal-200/60 dark:hover:border-teal-800/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${iconColor} shrink-0`}>
|
||||
<Icon 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">{event.title}</p>
|
||||
<div className="flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<span>{formatDate(event.startDate)}</span>
|
||||
{event.location && (
|
||||
<span className="flex items-center gap-0.5"><MapPin className="h-3 w-3" />{event.location}</span>
|
||||
)}
|
||||
{event.isAllSchool && (
|
||||
<Badge className="bg-teal-50 text-teal-700 dark:bg-teal-900/20 dark:text-teal-300 text-[9px] px-1 py-0">{t('events.all_school')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{event.requiresRegistration && (
|
||||
isRegistered ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs">
|
||||
{t('events.registered')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-[36px] text-xs rounded-lg border-teal-300 dark:border-teal-700 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/20"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiPost(`/api/school-events/${event.id}/register`, { userId: currentUser?.id });
|
||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...e, registrations: [...e.registrations, { id: 'new', userId: currentUser?.id ?? '', status: 'registered', user: { id: currentUser?.id ?? '', firstName: currentUser?.firstName ?? '', lastName: currentUser?.lastName ?? '' } }] } : e));
|
||||
toast.success(t('events.register'));
|
||||
} catch { toast.error(t('error.generic')); }
|
||||
}}
|
||||
>
|
||||
{t('events.register')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -368,7 +368,7 @@ export default function GradingView() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 overflow-hidden">
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-base font-bold 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">
|
||||
@@ -467,7 +467,7 @@ export default function GradingView() {
|
||||
) : (
|
||||
<>
|
||||
{/* Schemes */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<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-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
@@ -548,7 +548,7 @@ export default function GradingView() {
|
||||
</Card>
|
||||
|
||||
{/* Computed grades */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||
<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-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
@@ -769,7 +769,7 @@ export default function GradingView() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
|
||||
<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-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
|
||||
|
||||
@@ -494,7 +494,7 @@ export default function LessonPlansView() {
|
||||
</div>
|
||||
|
||||
{/* ── Filter bar ──────────────────────────────────────────────── */}
|
||||
<Card className="border-emerald-100 dark:border-emerald-900/40">
|
||||
<Card className="card-hover-lift border-emerald-100 dark:border-emerald-900/40">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X, BookOpen, BarChart3, Pencil, Flower2, FileText, PartyPopper,
|
||||
Users, ClipboardCheck, GraduationCap, Palette, Leaf,
|
||||
TreePine, CalendarDays, MessageSquare, BarChart, Target,
|
||||
Zap, Award, Shield, Notebook,
|
||||
TreePine, CalendarDays, MessageSquare, Target,
|
||||
Zap, Award, Shield, Notebook, Grid3X3, Calculator,
|
||||
CalendarCheck, BookCheck, Briefcase, Clock, Library,
|
||||
Settings as SettingsIcon, Ruler, MessageSquareText, Heart,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -19,6 +22,7 @@ import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
const ONBOARDING_KEY = 'ct_onboarding_completed';
|
||||
const DONT_SHOW_KEY = 'ct_onboarding_dont_show';
|
||||
|
||||
interface OnboardingStep {
|
||||
iconComponent: React.ElementType;
|
||||
@@ -30,86 +34,146 @@ interface OnboardingStep {
|
||||
const steps: OnboardingStep[] = [
|
||||
{
|
||||
iconComponent: BookOpen,
|
||||
titleKey: 'onboarding.welcome_title',
|
||||
descKey: 'onboarding.welcome_desc',
|
||||
titleKey: 'onboarding.step_1_title',
|
||||
descKey: 'onboarding.step_1_desc',
|
||||
accent: 'from-emerald-400 to-teal-500',
|
||||
},
|
||||
{
|
||||
iconComponent: BarChart3,
|
||||
titleKey: 'onboarding.dashboard_title',
|
||||
descKey: 'onboarding.dashboard_desc',
|
||||
titleKey: 'onboarding.step_2_title',
|
||||
descKey: 'onboarding.step_2_desc',
|
||||
accent: 'from-teal-400 to-emerald-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Users,
|
||||
titleKey: 'onboarding.classes_title',
|
||||
descKey: 'onboarding.classes_desc',
|
||||
titleKey: 'onboarding.step_3_title',
|
||||
descKey: 'onboarding.step_3_desc',
|
||||
accent: 'from-emerald-400 to-cyan-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Grid3X3,
|
||||
titleKey: 'onboarding.step_4_title',
|
||||
descKey: 'onboarding.step_4_desc',
|
||||
accent: 'from-cyan-400 to-emerald-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Pencil,
|
||||
titleKey: 'onboarding.progress_title',
|
||||
descKey: 'onboarding.progress_desc',
|
||||
titleKey: 'onboarding.step_5_title',
|
||||
descKey: 'onboarding.step_5_desc',
|
||||
accent: 'from-amber-400 to-emerald-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Flower2,
|
||||
titleKey: 'onboarding.flower_title',
|
||||
descKey: 'onboarding.flower_desc',
|
||||
titleKey: 'onboarding.step_6_title',
|
||||
descKey: 'onboarding.step_6_desc',
|
||||
accent: 'from-rose-400 to-emerald-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Grid3X3,
|
||||
titleKey: 'onboarding.step_7_title',
|
||||
descKey: 'onboarding.step_7_desc',
|
||||
accent: 'from-teal-400 to-cyan-500',
|
||||
},
|
||||
{
|
||||
iconComponent: ClipboardCheck,
|
||||
titleKey: 'onboarding.assessments_title',
|
||||
descKey: 'onboarding.assessments_desc',
|
||||
titleKey: 'onboarding.step_8_title',
|
||||
descKey: 'onboarding.step_8_desc',
|
||||
accent: 'from-amber-400 to-rose-500',
|
||||
},
|
||||
{
|
||||
iconComponent: GraduationCap,
|
||||
titleKey: 'onboarding.grading_title',
|
||||
descKey: 'onboarding.grading_desc',
|
||||
iconComponent: Calculator,
|
||||
titleKey: 'onboarding.step_9_title',
|
||||
descKey: 'onboarding.step_9_desc',
|
||||
accent: 'from-violet-400 to-emerald-500',
|
||||
},
|
||||
{
|
||||
iconComponent: FileText,
|
||||
titleKey: 'onboarding.reports_title',
|
||||
descKey: 'onboarding.reports_desc',
|
||||
accent: 'from-violet-400 to-emerald-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Notebook,
|
||||
titleKey: 'onboarding.notebooks_title',
|
||||
descKey: 'onboarding.notebooks_desc',
|
||||
accent: 'from-teal-400 to-emerald-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Palette,
|
||||
titleKey: 'onboarding.drawing_title',
|
||||
descKey: 'onboarding.drawing_desc',
|
||||
accent: 'from-rose-400 to-violet-500',
|
||||
iconComponent: CalendarCheck,
|
||||
titleKey: 'onboarding.step_10_title',
|
||||
descKey: 'onboarding.step_10_desc',
|
||||
accent: 'from-emerald-400 to-teal-500',
|
||||
},
|
||||
{
|
||||
iconComponent: CalendarDays,
|
||||
titleKey: 'onboarding.calendar_title',
|
||||
descKey: 'onboarding.calendar_desc',
|
||||
titleKey: 'onboarding.step_11_title',
|
||||
descKey: 'onboarding.step_11_desc',
|
||||
accent: 'from-amber-400 to-teal-500',
|
||||
},
|
||||
{
|
||||
iconComponent: CalendarDays,
|
||||
titleKey: 'onboarding.step_12_title',
|
||||
descKey: 'onboarding.step_12_desc',
|
||||
accent: 'from-teal-400 to-amber-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Shield,
|
||||
titleKey: 'onboarding.step_13_title',
|
||||
descKey: 'onboarding.step_13_desc',
|
||||
accent: 'from-rose-400 to-amber-500',
|
||||
},
|
||||
{
|
||||
iconComponent: MessageSquare,
|
||||
titleKey: 'onboarding.parents_title',
|
||||
descKey: 'onboarding.parents_desc',
|
||||
titleKey: 'onboarding.step_14_title',
|
||||
descKey: 'onboarding.step_14_desc',
|
||||
accent: 'from-cyan-400 to-emerald-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Leaf,
|
||||
titleKey: 'onboarding.environmental_title',
|
||||
descKey: 'onboarding.environmental_desc',
|
||||
accent: 'from-emerald-400 to-green-500',
|
||||
iconComponent: Ruler,
|
||||
titleKey: 'onboarding.step_15_title',
|
||||
descKey: 'onboarding.step_15_desc',
|
||||
accent: 'from-amber-400 to-teal-500',
|
||||
},
|
||||
{
|
||||
iconComponent: PartyPopper,
|
||||
titleKey: 'onboarding.done_title',
|
||||
descKey: 'onboarding.done_desc',
|
||||
iconComponent: MessageSquareText,
|
||||
titleKey: 'onboarding.step_16_title',
|
||||
descKey: 'onboarding.step_16_desc',
|
||||
accent: 'from-teal-400 to-emerald-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Notebook,
|
||||
titleKey: 'onboarding.step_17_title',
|
||||
descKey: 'onboarding.step_17_desc',
|
||||
accent: 'from-emerald-400 to-teal-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Palette,
|
||||
titleKey: 'onboarding.step_18_title',
|
||||
descKey: 'onboarding.step_18_desc',
|
||||
accent: 'from-rose-400 to-violet-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Briefcase,
|
||||
titleKey: 'onboarding.step_19_title',
|
||||
descKey: 'onboarding.step_19_desc',
|
||||
accent: 'from-violet-400 to-emerald-500',
|
||||
},
|
||||
{
|
||||
iconComponent: BookCheck,
|
||||
titleKey: 'onboarding.step_20_title',
|
||||
descKey: 'onboarding.step_20_desc',
|
||||
accent: 'from-amber-400 to-emerald-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Clock,
|
||||
titleKey: 'onboarding.step_21_title',
|
||||
descKey: 'onboarding.step_21_desc',
|
||||
accent: 'from-teal-400 to-cyan-500',
|
||||
},
|
||||
{
|
||||
iconComponent: Library,
|
||||
titleKey: 'onboarding.step_22_title',
|
||||
descKey: 'onboarding.step_22_desc',
|
||||
accent: 'from-emerald-400 to-amber-500',
|
||||
},
|
||||
{
|
||||
iconComponent: BarChart3,
|
||||
titleKey: 'onboarding.step_23_title',
|
||||
descKey: 'onboarding.step_23_desc',
|
||||
accent: 'from-teal-400 to-violet-500',
|
||||
},
|
||||
{
|
||||
iconComponent: SettingsIcon,
|
||||
titleKey: 'onboarding.step_24_title',
|
||||
descKey: 'onboarding.step_24_desc',
|
||||
accent: 'from-emerald-400 to-teal-500',
|
||||
},
|
||||
];
|
||||
@@ -121,6 +185,15 @@ interface OnboardingTourProps {
|
||||
|
||||
export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
const [showCelebration, setShowCelebration] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCurrentStep(0);
|
||||
setShowCelebration(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
@@ -129,12 +202,18 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
|
||||
// Complete onboarding
|
||||
try {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true');
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem(DONT_SHOW_KEY, 'true');
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
onClose();
|
||||
setShowCelebration(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
}, [currentStep, onClose]);
|
||||
}, [currentStep, onClose, dontShowAgain]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (currentStep > 0) {
|
||||
@@ -145,15 +224,19 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
|
||||
const handleSkip = useCallback(() => {
|
||||
try {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true');
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem(DONT_SHOW_KEY, 'true');
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
}, [onClose, dontShowAgain]);
|
||||
|
||||
const step = steps[currentStep];
|
||||
const isLastStep = currentStep === steps.length - 1;
|
||||
const isFirstStep = currentStep === 0;
|
||||
const progress = ((currentStep + 1) / steps.length) * 100;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) handleSkip(); }}>
|
||||
@@ -167,13 +250,23 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="absolute right-3 top-3 z-10 rounded-full p-1 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 transition-colors text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
className="absolute right-3 top-3 z-10 rounded-full p-1 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 transition-colors text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-gray-100 dark:bg-gray-800">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-emerald-400 to-teal-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.4, ease: 'easeInOut' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step content with animation */}
|
||||
<div className="relative overflow-hidden" style={{ minHeight: '320px' }}>
|
||||
<div className="relative overflow-hidden" style={{ minHeight: '300px' }}>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
@@ -181,16 +274,34 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -60 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className="flex flex-col items-center text-center px-8 pt-10 pb-6"
|
||||
className="flex flex-col items-center text-center px-8 pt-8 pb-6"
|
||||
>
|
||||
{/* Icon illustration */}
|
||||
{/* Icon illustration with animated highlight ring */}
|
||||
<div className="relative">
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-3xl bg-gradient-to-br opacity-30 tour-highlight-ring"
|
||||
style={{ background: `linear-gradient(135deg, var(--tw-gradient-from), var(--tw-gradient-to))` }}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.15, duration: 0.4, type: 'spring', stiffness: 200 }}
|
||||
className={`w-20 h-20 rounded-3xl bg-gradient-to-br ${step.accent} flex items-center justify-center shadow-lg mb-5`}
|
||||
>
|
||||
<step.iconComponent className="w-9 h-9 text-white" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Step number badge */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.15, duration: 0.4, type: 'spring', stiffness: 200 }}
|
||||
className={`w-24 h-24 rounded-3xl bg-gradient-to-br ${step.accent} flex items-center justify-center shadow-lg mb-6`}
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.2 }}
|
||||
className="mb-3"
|
||||
>
|
||||
<step.iconComponent className="w-10 h-10 text-white" />
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-[10px] font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{t('onboarding.step', { current: String(currentStep + 1), total: String(steps.length) })}
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
@@ -198,7 +309,7 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.3 }}
|
||||
className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3"
|
||||
className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2"
|
||||
>
|
||||
{t(step.titleKey)}
|
||||
</motion.h2>
|
||||
@@ -214,45 +325,104 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Celebration overlay */}
|
||||
<AnimatePresence>
|
||||
{showCelebration && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center bg-white/90 dark:bg-gray-950/90 backdrop-blur-sm px-8"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 15 }}
|
||||
>
|
||||
<PartyPopper className="w-16 h-16 text-emerald-500" />
|
||||
</motion.div>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-4"
|
||||
>
|
||||
{t('onboarding.celebration')}
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 mt-2"
|
||||
>
|
||||
{t('onboarding.complete')}
|
||||
</motion.p>
|
||||
{/* Confetti-like particles */}
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute w-2 h-2 rounded-full"
|
||||
style={{
|
||||
background: ['#10b981', '#14b8a6', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16'][i],
|
||||
left: `${30 + Math.random() * 40}%`,
|
||||
top: `${30 + Math.random() * 40}%`,
|
||||
}}
|
||||
initial={{ scale: 0, opacity: 1 }}
|
||||
animate={{
|
||||
scale: [0, 1.5, 0],
|
||||
opacity: [1, 0.8, 0],
|
||||
y: [0, -30 - Math.random() * 40],
|
||||
x: [(Math.random() - 0.5) * 60],
|
||||
}}
|
||||
transition={{ duration: 1.2, delay: i * 0.08, ease: 'easeOut' }}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Step indicator dots */}
|
||||
<div className="flex items-center justify-center gap-1.5 pb-4 flex-wrap px-4">
|
||||
{/* Step indicator dots (scrollable for 24 steps) */}
|
||||
<div className="flex items-center justify-center gap-1 pb-3 px-4 overflow-x-auto max-w-full">
|
||||
{steps.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentStep(i)}
|
||||
className={`rounded-full transition-all duration-300 ${
|
||||
className={`rounded-full transition-all duration-300 shrink-0 ${
|
||||
i === currentStep
|
||||
? 'w-6 h-2 bg-gradient-to-r from-emerald-400 to-teal-500'
|
||||
? 'w-5 h-1.5 bg-gradient-to-r from-emerald-400 to-teal-500'
|
||||
: i < currentStep
|
||||
? 'w-2 h-2 bg-emerald-400/60 dark:bg-emerald-500/40'
|
||||
: 'w-2 h-2 bg-gray-200 dark:bg-gray-700'
|
||||
? 'w-1.5 h-1.5 bg-emerald-400/60 dark:bg-emerald-500/40'
|
||||
: 'w-1.5 h-1.5 bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
title={t(steps[i].titleKey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step counter */}
|
||||
<div className="text-center pb-2">
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 font-medium">
|
||||
{t('onboarding.step', { current: String(currentStep + 1), total: String(steps.length) })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-emerald-100/50 dark:border-emerald-900/30 bg-emerald-50/30 dark:bg-emerald-950/10">
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
{!isFirstStep && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 min-h-[44px]"
|
||||
>
|
||||
{t('onboarding.back')}
|
||||
{t('onboarding.previous')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Don't show again checkbox */}
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<Checkbox
|
||||
checked={dontShowAgain}
|
||||
onCheckedChange={(checked) => setDontShowAgain(checked === true)}
|
||||
className="data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500"
|
||||
/>
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 hidden sm:inline">{t('onboarding.dont_show_again')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isLastStep && (
|
||||
@@ -260,7 +430,7 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSkip}
|
||||
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 min-h-[44px]"
|
||||
>
|
||||
{t('onboarding.skip')}
|
||||
</Button>
|
||||
@@ -268,7 +438,7 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleNext}
|
||||
className={`bg-gradient-to-r ${step.accent} text-white hover:opacity-90 shadow-md shadow-emerald-200/50 dark:shadow-emerald-900/30 px-6`}
|
||||
className={`bg-gradient-to-r ${step.accent} text-white hover:opacity-90 shadow-md shadow-emerald-200/50 dark:shadow-emerald-900/30 px-6 min-h-[44px]`}
|
||||
>
|
||||
{isLastStep ? t('onboarding.start') : t('onboarding.next')}
|
||||
</Button>
|
||||
@@ -286,3 +456,11 @@ export function isOnboardingCompleted(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isDontShowAgain(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DONT_SHOW_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -903,7 +903,7 @@ export default function ProgressEntriesView() {
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.005 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`flex-1 p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 transition-shadow duration-200 hover:shadow-lg ${
|
||||
className={`card-hover-lift flex-1 p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 transition-shadow duration-200 hover:shadow-lg ${
|
||||
isSelected && batchMode ? 'ring-2 ring-red-300/60 dark:ring-red-700/50 bg-red-50/30 dark:bg-red-900/10' : ''
|
||||
}`}
|
||||
style={{ borderLeftColor: catColor }}
|
||||
|
||||
@@ -500,7 +500,7 @@ export default function ReportsView() {
|
||||
|
||||
{!selectedClass ? (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-4">
|
||||
<Card className="relative border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<Card className="card-hover-lift relative border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<div className="absolute inset-0 bg-dots opacity-30 pointer-events-none" />
|
||||
<CardContent className="relative py-16 text-center">
|
||||
<motion.div
|
||||
@@ -522,7 +522,7 @@ export default function ReportsView() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 overflow-hidden">
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-base font-bold 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">
|
||||
@@ -717,7 +717,7 @@ export default function ReportsView() {
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -837,7 +837,7 @@ export default function ReportsView() {
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||
<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-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
@@ -897,7 +897,7 @@ export default function ReportsView() {
|
||||
|
||||
{/* PDF Template Selection + Batch Generate */}
|
||||
{selectedClass && students.length > 0 && (
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden mt-6">
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden mt-6">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-base font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
@@ -1106,7 +1106,7 @@ export default function ReportsView() {
|
||||
|
||||
{/* Schedule Calendar Preview */}
|
||||
{schedules.length > 0 && (
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-base font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
|
||||
+1095
-30
File diff suppressed because it is too large.
Load diff
@@ -18,6 +18,7 @@ import {
|
||||
Rocket, Target, PenLine, Pencil, Home, ClipboardList, Star, BarChart3,
|
||||
LucideIcon, Download, FileSpreadsheet, FileDown, Heart, Users as UsersIcon,
|
||||
Eye, Plus, Trash2, Clock, CheckCircle2, XCircle, SlidersHorizontal, Lightbulb,
|
||||
Phone, PhoneCall, MapPin, ShieldAlert, UserCheck, AlertCircle, Globe,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -46,6 +47,49 @@ import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
// ─── Self-Assessment & Learning Goal Types ────────────────────────────────
|
||||
interface PeerAssessmentData {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
assessorId: string;
|
||||
assessedId: string;
|
||||
competencyId: string | null;
|
||||
classGroupId: string | null;
|
||||
assessmentType: string;
|
||||
level: number | null;
|
||||
comment: string | null;
|
||||
rubricId: string | null;
|
||||
isAnonymous: boolean;
|
||||
isDemo: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
assessor: { id: string; firstName: string; lastName: string };
|
||||
assessed: { id: string; firstName: string; lastName: string };
|
||||
competency: { id: string; code: string; title: string } | null;
|
||||
classGroup: { id: string; name: string } | null;
|
||||
rubric: { id: string; title: string } | null;
|
||||
}
|
||||
|
||||
interface EmergencyContactData {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
studentId: string;
|
||||
name: string;
|
||||
relationship: string;
|
||||
phone: string;
|
||||
phoneAlt: string | null;
|
||||
email: string | null;
|
||||
address: string | null;
|
||||
isPrimary: boolean;
|
||||
priority: number;
|
||||
notes: string | null;
|
||||
isDemo: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
student: { id: string; firstName: string; lastName: string };
|
||||
}
|
||||
|
||||
interface SelfAssessmentData {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
@@ -410,6 +454,15 @@ export default function StudentDetailView() {
|
||||
const [lgForm, setLgForm] = useState({ title: '', description: '', competencyId: '', targetLevel: 4, currentLevel: 1, deadline: '', status: 'active', progress: 0 });
|
||||
const [goalCelebration, setGoalCelebration] = useState<string | null>(null);
|
||||
|
||||
// Peer Assessment & Emergency Contacts state
|
||||
const [peerAssessments, setPeerAssessments] = useState<PeerAssessmentData[]>([]);
|
||||
const [emergencyContacts, setEmergencyContacts] = useState<EmergencyContactData[]>([]);
|
||||
const [paDialogOpen, setPaDialogOpen] = useState(false);
|
||||
const [paForm, setPaForm] = useState({ assessmentType: 'competency', competencyId: '', level: 3, comment: '', isAnonymous: false });
|
||||
const [ecDialogOpen, setEcDialogOpen] = useState(false);
|
||||
const [ecEditId, setEcEditId] = useState<string | null>(null);
|
||||
const [ecForm, setEcForm] = useState({ name: '', relationship: 'mother', phone: '', phoneAlt: '', email: '', address: '', isPrimary: false, priority: 1, notes: '' });
|
||||
|
||||
// Load parent links for parent users
|
||||
useEffect(() => {
|
||||
if (isParent && currentUser?.id) {
|
||||
@@ -467,6 +520,26 @@ export default function StudentDetailView() {
|
||||
loadLG();
|
||||
}, [currentStudentId, currentUser?.schoolId]);
|
||||
|
||||
// Load peer assessments and emergency contacts
|
||||
useEffect(() => {
|
||||
if (!currentStudentId || !currentUser?.schoolId) return;
|
||||
const schoolId = currentUser.schoolId;
|
||||
async function loadPA() {
|
||||
try {
|
||||
const data = await apiGet<PeerAssessmentData[]>(`/api/peer-assessments?schoolId=${schoolId}&assessedId=${currentStudentId}`);
|
||||
setPeerAssessments(data);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
async function loadEC() {
|
||||
try {
|
||||
const data = await apiGet<EmergencyContactData[]>(`/api/emergency-contacts?schoolId=${schoolId}&studentId=${currentStudentId}`);
|
||||
setEmergencyContacts(data);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
loadPA();
|
||||
loadEC();
|
||||
}, [currentStudentId, currentUser?.schoolId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -1629,6 +1702,328 @@ export default function StudentDetailView() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Peer Assessment Section ─────────────────────────────────── */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-purple-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-purple-50/50 to-transparent dark:from-purple-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-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{t('peer.title')}
|
||||
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 text-xs font-medium">
|
||||
{peerAssessments.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl border-purple-300 dark:border-purple-700 text-purple-700 dark:text-purple-300 min-h-[44px]"
|
||||
onClick={() => {
|
||||
setPaForm({ assessmentType: 'competency', competencyId: '', level: 3, comment: '', isAnonymous: false });
|
||||
setPaDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('peer.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{peerAssessments.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<UsersIcon className="h-8 w-8 text-purple-400 dark:text-purple-500 mx-auto mb-2" />
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('peer.no_assessments')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 3-column radar comparison: Peer / Teacher / Self */}
|
||||
{(() => {
|
||||
const peerByCompetency = peerAssessments.filter((pa) => pa.competencyId && pa.level);
|
||||
const selfByCompetency = selfAssessments.filter((sa) => sa.selfLevel);
|
||||
const teacherByCompetency = data?.progressEntries ?? [];
|
||||
|
||||
const compIds = new Set<string>();
|
||||
peerByCompetency.forEach((pa) => { if (pa.competencyId) compIds.add(pa.competencyId); });
|
||||
selfByCompetency.forEach((sa) => compIds.add(sa.competencyId));
|
||||
teacherByCompetency.forEach((pe) => compIds.add(pe.competencyId));
|
||||
|
||||
const comparisonData = Array.from(compIds).slice(0, 8).map((cId) => {
|
||||
const peerLevels = peerByCompetency.filter((pa) => pa.competencyId === cId).map((pa) => pa.level!);
|
||||
const selfEntry = selfByCompetency.find((sa) => sa.competencyId === cId);
|
||||
const teacherEntry = teacherByCompetency.find((pe) => pe.competencyId === cId);
|
||||
const code = peerLevels.length > 0
|
||||
? (peerByCompetency.find((pa) => pa.competencyId === cId)?.competency?.code ?? cId.slice(0, 8))
|
||||
: selfEntry?.competency?.code ?? teacherEntry?.competency?.code ?? cId.slice(0, 8);
|
||||
return {
|
||||
competency: code.length > 12 ? code.slice(0, 12) : code,
|
||||
peer: peerLevels.length > 0 ? peerLevels.reduce((a, b) => a + b, 0) / peerLevels.length : 0,
|
||||
self: selfEntry ? selfEntry.selfLevel : 0,
|
||||
teacher: teacherEntry ? teacherEntry.masteryLevelValue : 0,
|
||||
};
|
||||
}).filter((d) => d.peer > 0 || d.self > 0 || 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('peer.comparison')}</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('peer.title')} dataKey="peer" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.2} />
|
||||
<Radar name={t('peer.teacher')} dataKey="teacher" stroke="#f59e0b" fill="#f59e0b" fillOpacity={0.2} />
|
||||
<Radar name={t('peer.self')} dataKey="self" stroke="#06b6d4" fill="#06b6d4" 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-purple-400 inline-block" />{t('peer.title')}</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('peer.teacher')}</span>
|
||||
<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('peer.self')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Average peer rating per competency */}
|
||||
{(() => {
|
||||
const groupedByComp = peerAssessments
|
||||
.filter((pa) => pa.competencyId && pa.level)
|
||||
.reduce((acc, pa) => {
|
||||
const key = pa.competencyId!;
|
||||
if (!acc[key]) acc[key] = { code: pa.competency?.code ?? '', title: pa.competency?.title ?? '', levels: [] };
|
||||
acc[key].levels.push(pa.level!);
|
||||
return acc;
|
||||
}, {} as Record<string, { code: string; title: string; levels: number[] }>);
|
||||
const averages = Object.entries(groupedByComp);
|
||||
if (averages.length === 0) return null;
|
||||
return (
|
||||
<div className="mb-4 p-3 rounded-xl bg-purple-50/50 dark:bg-purple-900/10 border border-purple-200/30 dark:border-purple-900/20">
|
||||
<p className="text-xs font-semibold text-purple-700 dark:text-purple-300 mb-2 uppercase tracking-wider">{t('peer.average')}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{averages.map(([cId, data]) => {
|
||||
const avg = data.levels.reduce((a, b) => a + b, 0) / data.levels.length;
|
||||
return (
|
||||
<div key={cId} className="flex items-center gap-2 p-2 rounded-lg bg-white dark:bg-gray-800/50 border border-purple-100/50 dark:border-purple-900/20">
|
||||
<Badge className={`${masteryBadge(avg)} text-xs`}>{avg.toFixed(1)}</Badge>
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300 truncate">{data.code || data.title}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Peer assessment list */}
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education">
|
||||
{peerAssessments.map((pa, i) => {
|
||||
const typeLabel = t(`peer.type_${pa.assessmentType}`) || pa.assessmentType;
|
||||
return (
|
||||
<motion.div
|
||||
key={pa.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-purple-50/40 to-transparent dark:from-purple-900/10 dark:to-transparent border border-purple-200/30 dark:border-purple-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">
|
||||
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 text-xs">{typeLabel}</Badge>
|
||||
{pa.level && (
|
||||
<Badge className={`${masteryBadge(pa.level)} text-xs`}>{pa.level}/6</Badge>
|
||||
)}
|
||||
{pa.isAnonymous && (
|
||||
<Badge className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 text-xs">
|
||||
<ShieldAlert className="h-3 w-3 mr-1" />{t('peer.anonymous')}
|
||||
</Badge>
|
||||
)}
|
||||
{pa.competency && (
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">{pa.competency.code} — {pa.competency.title}</span>
|
||||
)}
|
||||
</div>
|
||||
{!pa.isAnonymous && (
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('peer.from')}: {pa.assessor.firstName} {pa.assessor.lastName}
|
||||
</p>
|
||||
)}
|
||||
{pa.comment && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 italic">“{pa.comment}”</p>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-400 mt-1">{new Date(pa.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<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/peer-assessments/${pa.id}`);
|
||||
setPeerAssessments((prev) => prev.filter((a) => a.id !== pa.id));
|
||||
toast.success(t('action.delete'));
|
||||
} catch { toast.error(t('error.generic')); }
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Emergency Contacts Section ─────────────────────────────────── */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-rose-500 overflow-hidden">
|
||||
<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 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-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
</div>
|
||||
{t('emergency.title')}
|
||||
<Badge className="bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 text-xs font-medium">
|
||||
{emergencyContacts.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl border-rose-300 dark:border-rose-700 text-rose-700 dark:text-rose-300 min-h-[44px]"
|
||||
onClick={() => {
|
||||
setEcEditId(null);
|
||||
setEcForm({ name: '', relationship: 'mother', phone: '', phoneAlt: '', email: '', address: '', isPrimary: false, priority: 1, notes: '' });
|
||||
setEcDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('emergency.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{emergencyContacts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="h-8 w-8 text-rose-400 dark:text-rose-500 mx-auto mb-2" />
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('emergency.no_contacts')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto scrollbar-education">
|
||||
{emergencyContacts.map((ec, i) => {
|
||||
const relLabel = t(`emergency.${ec.relationship}`) || ec.relationship;
|
||||
return (
|
||||
<motion.div
|
||||
key={ec.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-rose-50/40 to-transparent dark:from-rose-900/10 dark:to-transparent border border-rose-200/30 dark:border-rose-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">{ec.name}</span>
|
||||
<Badge className="bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 text-xs">{relLabel}</Badge>
|
||||
{ec.isPrimary && (
|
||||
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs">
|
||||
<Star className="h-3 w-3 mr-1" />{t('emergency.primary')}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">{t('emergency.priority')}: {ec.priority}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<Phone className="h-3 w-3 text-rose-500" />
|
||||
{ec.phone}
|
||||
</span>
|
||||
{ec.phoneAlt && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<Phone className="h-3 w-3 text-gray-400" />
|
||||
{ec.phoneAlt}
|
||||
</span>
|
||||
)}
|
||||
{ec.email && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<Globe className="h-3 w-3" />
|
||||
{ec.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ec.address && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />{ec.address}
|
||||
</p>
|
||||
)}
|
||||
{ec.notes && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">{ec.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-lg text-rose-600 hover:text-rose-700 min-h-[44px] min-w-[44px]"
|
||||
title={t('emergency.quick_call')}
|
||||
onClick={() => {
|
||||
toast.success(`${t('emergency.phone')}: ${ec.phone}`);
|
||||
}}
|
||||
>
|
||||
<PhoneCall className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-lg min-h-[44px] min-w-[44px]"
|
||||
onClick={() => {
|
||||
setEcEditId(ec.id);
|
||||
setEcForm({
|
||||
name: ec.name,
|
||||
relationship: ec.relationship,
|
||||
phone: ec.phone,
|
||||
phoneAlt: ec.phoneAlt ?? '',
|
||||
email: ec.email ?? '',
|
||||
address: ec.address ?? '',
|
||||
isPrimary: ec.isPrimary,
|
||||
priority: ec.priority,
|
||||
notes: ec.notes ?? '',
|
||||
});
|
||||
setEcDialogOpen(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/emergency-contacts/${ec.id}`);
|
||||
setEmergencyContacts((prev) => prev.filter((c) => c.id !== ec.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>
|
||||
|
||||
{/* ─── Self-Assessment Dialog ───────────────────────────────── */}
|
||||
<Dialog open={saDialogOpen} onOpenChange={setSaDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
@@ -1893,6 +2288,250 @@ export default function StudentDetailView() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ─── Peer Assessment Dialog ───────────────────────────────── */}
|
||||
<Dialog open={paDialogOpen} onOpenChange={setPaDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('peer.create')}</DialogTitle>
|
||||
<DialogDescription>{t('peer.title')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('peer.assessment_type')}</Label>
|
||||
<Select value={paForm.assessmentType} onValueChange={(v) => setPaForm((f) => ({ ...f, assessmentType: v }))}>
|
||||
<SelectTrigger className="h-10 rounded-lg mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="competency">{t('peer.type_competency')}</SelectItem>
|
||||
<SelectItem value="project">{t('peer.type_project')}</SelectItem>
|
||||
<SelectItem value="presentation">{t('peer.type_presentation')}</SelectItem>
|
||||
<SelectItem value="teamwork">{t('peer.type_teamwork')}</SelectItem>
|
||||
<SelectItem value="behavior">{t('peer.type_behavior')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('peer.competency')}</Label>
|
||||
<Select value={paForm.competencyId} onValueChange={(v) => setPaForm((f) => ({ ...f, competencyId: v }))}>
|
||||
<SelectTrigger className="h-10 rounded-lg mt-1">
|
||||
<SelectValue placeholder={t('peer.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('peer.level')} ({paForm.level}/6)</Label>
|
||||
<Slider
|
||||
value={[paForm.level]}
|
||||
onValueChange={([v]) => setPaForm((f) => ({ ...f, level: 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('peer.comment')}</Label>
|
||||
<Textarea
|
||||
value={paForm.comment}
|
||||
onChange={(e) => setPaForm((f) => ({ ...f, comment: e.target.value }))}
|
||||
className="mt-1 rounded-lg"
|
||||
rows={3}
|
||||
placeholder={t('peer.comment')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={paForm.isAnonymous}
|
||||
onChange={(e) => setPaForm((f) => ({ ...f, isAnonymous: e.target.checked }))}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<Label className="text-xs font-medium">{t('peer.anonymous')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="rounded-xl min-h-[44px]" onClick={() => setPaDialogOpen(false)}>{t('action.cancel')}</Button>
|
||||
<Button
|
||||
className="rounded-xl bg-purple-600 hover:bg-purple-700 text-white min-h-[44px]"
|
||||
onClick={async () => {
|
||||
if (!paForm.assessmentType) { toast.error(t('peer.assessment_type')); return; }
|
||||
try {
|
||||
const payload = {
|
||||
schoolId: currentUser?.schoolId,
|
||||
assessorId: currentUser?.id,
|
||||
assessedId: currentStudentId,
|
||||
assessmentType: paForm.assessmentType,
|
||||
competencyId: paForm.competencyId || null,
|
||||
level: paForm.level,
|
||||
comment: paForm.comment || null,
|
||||
isAnonymous: paForm.isAnonymous,
|
||||
};
|
||||
const created = await apiPost<PeerAssessmentData>('/api/peer-assessments', payload);
|
||||
setPeerAssessments((prev) => [created, ...prev]);
|
||||
setPaDialogOpen(false);
|
||||
toast.success(t('action.create'));
|
||||
} catch { toast.error(t('error.generic')); }
|
||||
}}
|
||||
>
|
||||
{t('action.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ─── Emergency Contact Dialog ───────────────────────────────── */}
|
||||
<Dialog open={ecDialogOpen} onOpenChange={setEcDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{ecEditId ? t('emergency.edit') : t('emergency.add')}</DialogTitle>
|
||||
<DialogDescription>{t('emergency.title')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('emergency.name')}</Label>
|
||||
<Input
|
||||
value={ecForm.name}
|
||||
onChange={(e) => setEcForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="mt-1 rounded-lg"
|
||||
placeholder={t('emergency.name')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('emergency.relationship')}</Label>
|
||||
<Select value={ecForm.relationship} onValueChange={(v) => setEcForm((f) => ({ ...f, relationship: v }))}>
|
||||
<SelectTrigger className="h-10 rounded-lg mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mother">{t('emergency.mother')}</SelectItem>
|
||||
<SelectItem value="father">{t('emergency.father')}</SelectItem>
|
||||
<SelectItem value="guardian">{t('emergency.guardian')}</SelectItem>
|
||||
<SelectItem value="grandparent">{t('emergency.grandparent')}</SelectItem>
|
||||
<SelectItem value="other">{t('emergency.other')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('emergency.priority')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={ecForm.priority}
|
||||
onChange={(e) => setEcForm((f) => ({ ...f, priority: parseInt(e.target.value) || 1 }))}
|
||||
className="mt-1 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('emergency.phone')}</Label>
|
||||
<Input
|
||||
value={ecForm.phone}
|
||||
onChange={(e) => setEcForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
className="mt-1 rounded-lg"
|
||||
placeholder="+49 123 456789"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('emergency.phone_alt')}</Label>
|
||||
<Input
|
||||
value={ecForm.phoneAlt}
|
||||
onChange={(e) => setEcForm((f) => ({ ...f, phoneAlt: e.target.value }))}
|
||||
className="mt-1 rounded-lg"
|
||||
placeholder="+49 987 654321"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('emergency.email')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={ecForm.email}
|
||||
onChange={(e) => setEcForm((f) => ({ ...f, email: e.target.value }))}
|
||||
className="mt-1 rounded-lg"
|
||||
placeholder="name@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('emergency.address')}</Label>
|
||||
<Input
|
||||
value={ecForm.address}
|
||||
onChange={(e) => setEcForm((f) => ({ ...f, address: e.target.value }))}
|
||||
className="mt-1 rounded-lg"
|
||||
placeholder={t('emergency.address')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('emergency.notes')}</Label>
|
||||
<Textarea
|
||||
value={ecForm.notes}
|
||||
onChange={(e) => setEcForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
className="mt-1 rounded-lg"
|
||||
rows={2}
|
||||
placeholder={t('emergency.notes')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ecForm.isPrimary}
|
||||
onChange={(e) => setEcForm((f) => ({ ...f, isPrimary: e.target.checked }))}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<Label className="text-xs font-medium">{t('emergency.primary')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="rounded-xl min-h-[44px]" onClick={() => setEcDialogOpen(false)}>{t('action.cancel')}</Button>
|
||||
<Button
|
||||
className="rounded-xl bg-rose-600 hover:bg-rose-700 text-white min-h-[44px]"
|
||||
onClick={async () => {
|
||||
if (!ecForm.name || !ecForm.phone) { toast.error(t('emergency.name') + ' & ' + t('emergency.phone')); return; }
|
||||
try {
|
||||
const payload = {
|
||||
schoolId: currentUser?.schoolId,
|
||||
studentId: currentStudentId,
|
||||
name: ecForm.name,
|
||||
relationship: ecForm.relationship,
|
||||
phone: ecForm.phone,
|
||||
phoneAlt: ecForm.phoneAlt || null,
|
||||
email: ecForm.email || null,
|
||||
address: ecForm.address || null,
|
||||
isPrimary: ecForm.isPrimary,
|
||||
priority: ecForm.priority,
|
||||
notes: ecForm.notes || null,
|
||||
};
|
||||
if (ecEditId) {
|
||||
const updated = await apiPut<EmergencyContactData>(`/api/emergency-contacts/${ecEditId}`, payload);
|
||||
setEmergencyContacts((prev) => prev.map((c) => c.id === ecEditId ? updated : c));
|
||||
} else {
|
||||
const created = await apiPost<EmergencyContactData>('/api/emergency-contacts', payload);
|
||||
setEmergencyContacts((prev) => [created, ...prev]);
|
||||
}
|
||||
setEcDialogOpen(false);
|
||||
toast.success(ecEditId ? t('action.save') : t('action.create'));
|
||||
} catch { toast.error(t('error.generic')); }
|
||||
}}
|
||||
>
|
||||
{ecEditId ? t('action.save') : t('action.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user