Round 20: Student Portal, District Management, userId field, styling enhancements, bug fixes

- Added userId field to Student model with User relation
- Created Student Portal view with 6 tabs (competencies, goals, achievements, schedule, homework, feedback)
- Created StudentGoal model + CRUD API with role-based access
- Created District Management view with CRUD, analytics, school assignment
- Enhanced auth page: animated gradient, floating particles, glassmorphism, staggered entrance
- Enhanced dashboard: time-based greeting, animated stat cards, class overview, school branding
- Enhanced student portal: hero section, circular progress, streak counter, celebration particles
- Fixed student-goals API 403 for STUDENT role (name-matching fallback for legacy data)
- Added userId filter to students API with OR fallback
- Added nav.student-portal i18n key
- 100+ new i18n keys in DE and EN
- All lint checks pass
This commit is contained in:
Mustafa Cetin committed 2026-07-29 18:45:41 +00:00
1 parent 9680cc9244
commit 04ae7aa80c
23 files changed
+4624 -2868

No files matched your search

+25
View File
@@ -133,6 +133,7 @@ model User {
rewardRedemptions RewardRedemption[] rewardRedemptions RewardRedemption[]
rewardPoints RewardPoints[] rewardPoints RewardPoints[]
studentAnswers StudentAnswer[] studentAnswers StudentAnswer[]
student Student?
} }
model SchoolYear { model SchoolYear {
@@ -209,10 +210,12 @@ model Student {
externalId String? // for import matching externalId String? // for import matching
avatarUrl String? // student photo/avatar URL avatarUrl String? // student photo/avatar URL
avatarInitials String? // computed from firstName + lastName first chars avatarInitials String? // computed from firstName + lastName first chars
userId String? @unique // optional link to User account (for student self-service)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
user User? @relation(fields: [userId], references: [id])
school School @relation(fields: [schoolId], references: [id]) school School @relation(fields: [schoolId], references: [id])
enrollments Enrollment[] enrollments Enrollment[]
learningProgressEntries LearningProgressEntry[] learningProgressEntries LearningProgressEntry[]
@@ -236,6 +239,9 @@ model Student {
transports StudentTransport[] transports StudentTransport[]
healthRecords HealthRecord[] healthRecords HealthRecord[]
studentBadges StudentBadge[] studentBadges StudentBadge[]
goals StudentGoal[]
@@index([userId])
} }
model Enrollment { model Enrollment {
@@ -1912,3 +1918,22 @@ model RewardPoints {
@@index([source]) @@index([source])
@@map("reward_points") @@map("reward_points")
} }
// ─── Student Goals (Student Portal) ─────────────────────────────────
model StudentGoal {
id String @id @default(cuid())
studentId String
title String
description String?
targetDate DateTime?
progress Int @default(0)
status String @default("active") // active, completed, abandoned
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
student Student @relation(fields: [studentId], references: [id])
@@index([studentId])
@@index([status])
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

@@ -58,3 +58,38 @@ export async function POST(
return NextResponse.json({ error: 'Failed to assign school to district' }, { status: 500 }); return NextResponse.json({ error: 'Failed to assign school to district' }, { status: 500 });
} }
} }
// DELETE /api/districts/[id]/schools — remove school from district
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const { searchParams } = new URL(_req.url);
const schoolId = searchParams.get('schoolId');
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
// Verify the school belongs to this district
const school = await db.school.findFirst({
where: { id: schoolId, districtId: id },
});
if (!school) {
return NextResponse.json({ error: 'School not found in this district' }, { status: 404 });
}
await db.school.update({
where: { id: schoolId },
data: { districtId: null },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('[districts/[id]/schools] DELETE error:', error);
return NextResponse.json({ error: 'Failed to remove school from district' }, { status: 500 });
}
}
+12 -2
View File
@@ -8,7 +8,15 @@ export async function GET() {
where: { deletedAt: null }, where: { deletedAt: null },
include: { include: {
schools: { schools: {
select: { id: true, name: true, schoolType: true, country: true }, select: {
id: true,
name: true,
schoolType: true,
country: true,
_count: {
select: { students: true, classGroups: true, users: true },
},
},
}, },
}, },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
@@ -18,7 +26,9 @@ export async function GET() {
const result = districts.map((d) => ({ const result = districts.map((d) => ({
...d, ...d,
schoolCount: d.schools.length, schoolCount: d.schools.length,
totalStudents: 0, // Would need aggregation query in production totalStudents: d.schools.reduce((acc, s) => acc + s._count.students, 0),
totalTeachers: d.schools.reduce((acc, s) => acc + s._count.users, 0),
totalClasses: d.schools.reduce((acc, s) => acc + s._count.classGroups, 0),
})); }));
return NextResponse.json(result); return NextResponse.json(result);
+175
View File
@@ -0,0 +1,175 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
/**
* Check if a student belongs to the current user (STUDENT role).
* Uses userId (direct link) with name-matching fallback for legacy data.
*/
function isOwnStudent(
student: { userId: string | null; firstName: string; lastName: string; schoolId: string | null },
user: { id: string; firstName: string; lastName: string; schoolId: string | null }
): boolean {
if (student.userId === user.id) return true;
// Fallback: match by name + school for legacy records without userId
if (
student.userId === null &&
student.firstName === user.firstName &&
student.lastName === user.lastName &&
student.schoolId === user.schoolId
) {
return true;
}
return false;
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const goal = await db.studentGoal.findUnique({
where: { id },
include: {
student: {
select: { id: true, firstName: true, lastName: true, schoolId: true, userId: true },
},
},
});
if (!goal) {
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
}
// Verify access
if (session.user?.role === 'STUDENT') {
if (!isOwnStudent(goal.student, session.user)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
} else if (session.user?.role !== 'SUPER_ADMIN') {
if (goal.student.schoolId !== session.user?.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
return NextResponse.json(goal);
} catch (error) {
console.error('StudentGoal GET by ID error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const { title, description, targetDate, progress, status } = body;
const existingGoal = await db.studentGoal.findUnique({
where: { id },
include: {
student: {
select: { id: true, firstName: true, lastName: true, schoolId: true, userId: true },
},
},
});
if (!existingGoal) {
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
}
// Verify access
if (session.user?.role === 'STUDENT') {
if (!isOwnStudent(existingGoal.student, session.user)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
} else if (session.user?.role !== 'SUPER_ADMIN') {
if (existingGoal.student.schoolId !== session.user?.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const updateData: Record<string, unknown> = {};
if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description;
if (targetDate !== undefined) updateData.targetDate = targetDate ? new Date(targetDate) : null;
if (progress !== undefined) updateData.progress = Math.min(100, Math.max(0, progress));
if (status !== undefined) updateData.status = status;
// Auto-complete if progress reaches 100
if (progress !== undefined && progress >= 100) {
updateData.status = 'completed';
updateData.progress = 100;
}
const goal = await db.studentGoal.update({
where: { id },
data: updateData,
});
return NextResponse.json(goal);
} catch (error) {
console.error('StudentGoal PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const existingGoal = await db.studentGoal.findUnique({
where: { id },
include: {
student: {
select: { id: true, firstName: true, lastName: true, schoolId: true, userId: true },
},
},
});
if (!existingGoal) {
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
}
// Verify access
if (session.user?.role === 'STUDENT') {
if (!isOwnStudent(existingGoal.student, session.user)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
} else if (session.user?.role !== 'SUPER_ADMIN') {
if (existingGoal.student.schoolId !== session.user?.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
await db.studentGoal.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('StudentGoal DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+115
View File
@@ -0,0 +1,115 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const studentId = searchParams.get('studentId');
const status = searchParams.get('status');
if (!studentId) {
return NextResponse.json({ error: 'studentId is required' }, { status: 400 });
}
// Verify the student belongs to the same school or is the student themselves
if (session.user?.role === 'STUDENT') {
const student = await db.student.findUnique({
where: { id: studentId },
select: { userId: true, schoolId: true },
});
if (!student || student.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
} else if (session.user?.role !== 'SUPER_ADMIN') {
const student = await db.student.findUnique({
where: { id: studentId },
select: { schoolId: true },
});
if (!student || student.schoolId !== session.user?.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const where: Record<string, unknown> = { studentId };
if (status) where.status = status;
const goals = await db.studentGoal.findMany({
where,
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(goals);
} catch (error) {
console.error('StudentGoal GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const { studentId, title, description, targetDate, progress, status } = body;
if (!studentId || !title) {
return NextResponse.json(
{ error: 'studentId and title are required' },
{ status: 400 }
);
}
// Verify the student belongs to the same school or is the student themselves
if (session.user?.role === 'STUDENT') {
const student = await db.student.findUnique({
where: { id: studentId },
select: { userId: true, schoolId: true, firstName: true, lastName: true },
});
if (!student) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Check by userId (direct link) or by name matching (fallback for legacy data)
const isOwnStudent = student.userId === session.user.id ||
(student.userId === null &&
student.firstName === session.user.firstName &&
student.lastName === session.user.lastName &&
student.schoolId === session.user.schoolId);
if (!isOwnStudent) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
} else if (session.user?.role !== 'SUPER_ADMIN') {
const student = await db.student.findUnique({
where: { id: studentId },
select: { schoolId: true },
});
if (!student || student.schoolId !== session.user?.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const goal = await db.studentGoal.create({
data: {
studentId,
title,
description: description || null,
targetDate: targetDate ? new Date(targetDate) : null,
progress: progress || 0,
status: status || 'active',
},
});
return NextResponse.json(goal, { status: 201 });
} catch (error) {
console.error('StudentGoal POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+32 -1
View File
@@ -25,11 +25,31 @@ export async function GET(request: Request) {
const schoolId = searchParams.get('schoolId'); const schoolId = searchParams.get('schoolId');
const classGroupId = searchParams.get('classGroupId'); const classGroupId = searchParams.get('classGroupId');
const search = searchParams.get('search'); const search = searchParams.get('search');
const userId = searchParams.get('userId');
const where: Record<string, unknown> = { deletedAt: null }; const where: Record<string, unknown> = { deletedAt: null };
if (schoolId) where.schoolId = schoolId; if (schoolId) where.schoolId = schoolId;
// Filter by userId (direct link to User account)
let userIdFilter: Record<string, unknown>[] | null = null;
if (userId) {
// For STUDENT role, also match by name + school as fallback for legacy data
if (session.user?.role === 'STUDENT' && session.user.id === userId) {
userIdFilter = [
{ userId },
{
userId: null,
firstName: session.user.firstName,
lastName: session.user.lastName,
schoolId: session.user.schoolId,
},
];
} else {
where.userId = userId;
}
}
if (classGroupId) { if (classGroupId) {
where.enrollments = { where.enrollments = {
some: { classGroupId, endDate: null }, some: { classGroupId, endDate: null },
@@ -37,11 +57,22 @@ export async function GET(request: Request) {
} }
if (search) { if (search) {
where.OR = [ const searchOr = [
{ firstName: { contains: search } }, { firstName: { contains: search } },
{ lastName: { contains: search } }, { lastName: { contains: search } },
{ externalId: { contains: search } }, { externalId: { contains: search } },
]; ];
// If userId filter also uses OR, combine with AND
if (userIdFilter) {
where.AND = [
{ OR: userIdFilter },
{ OR: searchOr },
];
} else {
where.OR = searchOr;
}
} else if (userIdFilter) {
where.OR = userIdFilter;
} }
const students = await db.student.findMany({ const students = await db.student.findMany({
+7
View File
@@ -59,6 +59,7 @@ import {
Library as LibraryIcon, Library as LibraryIcon,
Trophy, Trophy,
FolderOpen, FolderOpen,
Building2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Sidebar, Sidebar,
@@ -131,6 +132,7 @@ import { Skeleton } from '@/components/ui/skeleton';
// Static imports for lightweight views // Static imports for lightweight views
import DashboardView from './dashboard-view'; import DashboardView from './dashboard-view';
import StudentPortalView from './student-portal-view';
import ClassesView from './classes-view'; import ClassesView from './classes-view';
import CompetencyGridView from './competency-grid-view'; import CompetencyGridView from './competency-grid-view';
import ProgressEntriesView from './progress-entries-view'; import ProgressEntriesView from './progress-entries-view';
@@ -153,6 +155,7 @@ import PortfolioView from './portfolio-view';
import TimetableView from './timetable-view'; import TimetableView from './timetable-view';
import ResourceLibraryView from './resource-library-view'; import ResourceLibraryView from './resource-library-view';
import CompetitionsView from './competitions-view'; import CompetitionsView from './competitions-view';
import DistrictManagementView from './district-management-view';
// Dynamic imports for heavy components with loading skeletons // Dynamic imports for heavy components with loading skeletons
const AnalyticsView = dynamic(() => import('./analytics-view'), { const AnalyticsView = dynamic(() => import('./analytics-view'), {
@@ -218,6 +221,7 @@ const navSections: NavSection[] = [
labelKey: 'polish.nav_setup', labelKey: 'polish.nav_setup',
items: [ items: [
{ key: 'competencies', icon: BookOpen, labelKey: 'nav.competencies' }, { key: 'competencies', icon: BookOpen, labelKey: 'nav.competencies' },
{ key: 'districts', icon: Building2, labelKey: 'nav.districts' },
{ key: 'settings', icon: Settings, labelKey: 'nav.settings' }, { key: 'settings', icon: Settings, labelKey: 'nav.settings' },
], ],
}, },
@@ -230,6 +234,7 @@ const studentNavSections: NavSection[] = [
labelKey: 'student.dashboard_title', labelKey: 'student.dashboard_title',
items: [ items: [
{ key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, { key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ key: 'student-portal', icon: GraduationCap, labelKey: 'student_portal.title' },
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' }, { key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' },
{ key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' }, { key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' },
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' }, { key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
@@ -361,6 +366,7 @@ function AnnouncementBanner({ schoolId }: { schoolId: string | null }) {
function renderView(view: ViewName) { function renderView(view: ViewName) {
switch (view) { switch (view) {
case 'dashboard': return <DashboardView />; case 'dashboard': return <DashboardView />;
case 'student-portal': return <StudentPortalView />;
case 'classes': return <ClassesView />; case 'classes': return <ClassesView />;
case 'competencies': return <CompetencyGridView />; case 'competencies': return <CompetencyGridView />;
case 'progress': return <ProgressEntriesView />; case 'progress': return <ProgressEntriesView />;
@@ -387,6 +393,7 @@ function renderView(view: ViewName) {
case 'timetable': return <TimetableView />; case 'timetable': return <TimetableView />;
case 'resources': return <ResourceLibraryView />; case 'resources': return <ResourceLibraryView />;
case 'competitions': return <CompetitionsView />; case 'competitions': return <CompetitionsView />;
case 'districts': return <DistrictManagementView />;
default: return <DashboardView />; default: return <DashboardView />;
} }
} }
File diff suppressed because it is too large. Load diff
+296 -53
View File
@@ -234,6 +234,30 @@ const motivationalTips = [
{ text: 'Detailed records make parent conversations productive.', icon: MessageSquare }, { text: 'Detailed records make parent conversations productive.', icon: MessageSquare },
]; ];
// ─── Time-based Greeting Helper ────────────────────────────────────
function getTimeGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return t('dashboard.greeting_morning');
if (hour < 18) return t('dashboard.greeting_afternoon');
return t('dashboard.greeting_evening');
}
// ─── Gradient Border Card Wrapper ──────────────────────────────────
function GradientBorderCard({ children, className = '', gradientFrom = 'from-emerald-400', gradientTo = 'to-teal-500' }: {
children: React.ReactNode;
className?: string;
gradientFrom?: string;
gradientTo?: string;
}) {
return (
<div className={`relative rounded-xl p-[1.5px] bg-gradient-to-br ${gradientFrom} ${gradientTo} ${className}`}>
<div className="rounded-[10px] bg-white dark:bg-gray-950 h-full">
{children}
</div>
</div>
);
}
// ─── Parent Dashboard Component ──────────────────────────────────────── // ─── Parent Dashboard Component ────────────────────────────────────────
function ParentDashboard({ currentUser, setCurrentView, locale }: { function ParentDashboard({ currentUser, setCurrentView, locale }: {
currentUser: CurrentUser; currentUser: CurrentUser;
@@ -848,7 +872,7 @@ function SchoolAdminDashboard({ currentUser, setCurrentView, locale, data }: {
<RechartsTooltip <RechartsTooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }}
/> />
<Bar dataKey="students" fill="#10b981" radius={[4, 4, 0, 0]} /> <Bar dataKey="students" fill="#10b981" radius={[4, 4, 0, 0]} isAnimationActive animationDuration={1000} animationEasing="ease-out" />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -866,6 +890,9 @@ function SchoolAdminDashboard({ currentUser, setCurrentView, locale, data }: {
outerRadius={80} outerRadius={80}
paddingAngle={3} paddingAngle={3}
dataKey="value" dataKey="value"
isAnimationActive
animationDuration={1200}
animationEasing="ease-out"
> >
{gradeDistribution.map((entry, index) => ( {gradeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} /> <Cell key={`cell-${index}`} fill={entry.color} />
@@ -1579,7 +1606,7 @@ export default function DashboardView() {
<XAxis dataKey="subject" tick={{ fontSize: 10 }} stroke="#9ca3af" /> <XAxis dataKey="subject" tick={{ fontSize: 10 }} stroke="#9ca3af" />
<YAxis domain={[1, 6]} reversed tick={{ fontSize: 10 }} stroke="#9ca3af" /> <YAxis domain={[1, 6]} reversed tick={{ fontSize: 10 }} stroke="#9ca3af" />
<RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} /> <RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} />
<Bar dataKey="grade" fill="#10b981" radius={[4, 4, 0, 0]} /> <Bar dataKey="grade" fill="#10b981" radius={[4, 4, 0, 0]} isAnimationActive animationDuration={1000} animationEasing="ease-out" />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</CardContent> </CardContent>
@@ -1658,12 +1685,17 @@ export default function DashboardView() {
{/* Top row: welcome + date */} {/* Top row: welcome + date */}
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3.5"> <div className="flex items-center gap-3.5">
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/30 ring-2 ring-emerald-200/30 dark:ring-emerald-800/20"> <motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 150, damping: 12 }}
className="flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/30 ring-2 ring-emerald-200/30 dark:ring-emerald-800/20"
>
<Sparkles className="w-6 h-6" /> <Sparkles className="w-6 h-6" />
</div> </motion.div>
<div> <div>
<h2 className="text-2xl font-bold bg-gradient-to-r from-emerald-600 via-teal-600 to-emerald-500 dark:from-emerald-400 dark:via-teal-400 dark:to-emerald-300 bg-clip-text text-transparent"> <h2 className="text-2xl font-bold bg-gradient-to-r from-emerald-600 via-teal-600 to-emerald-500 dark:from-emerald-400 dark:via-teal-400 dark:to-emerald-300 bg-clip-text text-transparent">
{t('dashboard.welcome')}, {currentUser?.firstName}! {getTimeGreeting()}, {currentUser?.firstName}!
</h2> </h2>
<p className="text-emerald-600/60 dark:text-emerald-400/40 mt-0.5 text-sm">{t('dashboard.overview')}</p> <p className="text-emerald-600/60 dark:text-emerald-400/40 mt-0.5 text-sm">{t('dashboard.overview')}</p>
</div> </div>
@@ -1698,20 +1730,27 @@ export default function DashboardView() {
</div> </div>
</div> </div>
{/* Tip of the day */} {/* Tip of the day — enhanced with gradient border */}
<motion.div <motion.div
initial={{ opacity: 0, y: 6 }} initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.4 }} transition={{ delay: 0.3, duration: 0.4 }}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-gradient-to-r from-emerald-50/80 via-teal-50/40 to-transparent dark:from-emerald-900/15 dark:via-teal-900/10 dark:to-transparent border border-emerald-200/30 dark:border-emerald-800/20" className="relative"
> >
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm shrink-0"> <div className="absolute inset-0 rounded-xl bg-gradient-to-r from-amber-400/20 via-emerald-400/20 to-teal-400/20 dark:from-amber-400/10 dark:via-emerald-400/10 dark:to-teal-400/10 blur-sm" />
<dailyTip.icon className="h-4 w-4" /> <div className="relative flex items-center gap-3 px-4 py-3 rounded-xl bg-gradient-to-r from-emerald-50/80 via-teal-50/40 to-transparent dark:from-emerald-900/15 dark:via-teal-900/10 dark:to-transparent border border-emerald-200/30 dark:border-emerald-800/20">
<motion.div
animate={{ rotate: [0, 10, -10, 0] }}
transition={{ duration: 2, repeat: Infinity, repeatDelay: 5 }}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm shrink-0"
>
<dailyTip.icon className="h-4 w-4" />
</motion.div>
<p className="text-sm text-gray-600 dark:text-gray-300 leading-snug">
<span className="font-semibold text-emerald-700 dark:text-emerald-300">{t('dashboard.daily_tip')}:</span>{' '}
{dailyTip.text}
</p>
</div> </div>
<p className="text-sm text-gray-600 dark:text-gray-300 leading-snug">
<span className="font-semibold text-emerald-700 dark:text-emerald-300">{locale === 'de' ? 'Tipp des Tages' : 'Tip of the day'}:</span>{' '}
{dailyTip.text}
</p>
</motion.div> </motion.div>
</div> </div>
</motion.div> </motion.div>
@@ -1815,23 +1854,36 @@ export default function DashboardView() {
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Card className={`card-shadow-transition 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-shadow-transition border-0 shadow-sm rounded-xl 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-2 hover:ring-emerald-200/60 dark:hover:ring-emerald-700/40`}>
{/* Gradient top border */}
<div className={`h-1 bg-gradient-to-r ${colors.gradient}`} />
<CardContent className={`p-5 ${colors.bg}`}> <CardContent className={`p-5 ${colors.bg}`}>
<div className="flex items-start justify-between gap-2"> <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"> <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">
{stat.label} {stat.label}
</p> </p>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${colors.iconBg} ${colors.iconText} shadow-md shadow-emerald-200/30 shrink-0`}> <motion.div
whileHover={{ scale: 1.1, rotate: 5 }}
transition={{ duration: 0.15 }}
className={`flex items-center justify-center w-9 h-9 rounded-lg ${colors.iconBg} ${colors.iconText} shadow-md shadow-emerald-200/30 shrink-0`}
>
<stat.icon className="h-4 w-4" /> <stat.icon className="h-4 w-4" />
</div> </motion.div>
</div> </div>
<div className="mt-2 flex items-baseline gap-1.5"> <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"><CountUp target={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' && ( {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"> <motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5, type: 'spring', stiffness: 200 }}
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" /> <ArrowUpRight className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
<span className="text-[9px] font-bold text-emerald-600 dark:text-emerald-400">{trendPct > 0 ? `+${trendPct}%` : ''}</span> <span className="text-[9px] font-bold text-emerald-600 dark:text-emerald-400">{trendPct > 0 ? `+${trendPct}%` : ''}</span>
</div> </motion.div>
)} )}
{stat.value === 0 && ( {stat.value === 0 && (
<span className="text-[10px] font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wide">{t('dashboard.start_here')}</span> <span className="text-[10px] font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wide">{t('dashboard.start_here')}</span>
@@ -1868,7 +1920,7 @@ export default function DashboardView() {
<CardFooter className="px-5 pb-3 pt-0"> <CardFooter className="px-5 pb-3 pt-0">
<div className="flex items-center gap-1 text-[10px] text-gray-400 dark:text-gray-500"> <div className="flex items-center gap-1 text-[10px] text-gray-400 dark:text-gray-500">
<RefreshCw className="h-2.5 w-2.5" /> <RefreshCw className="h-2.5 w-2.5" />
<span>{locale === 'de' ? 'Aktualisiert' : 'Updated'} {currentTime}</span> <span>{t('dashboard.updated_at')} {currentTime}</span>
</div> </div>
</CardFooter> </CardFooter>
</Card> </Card>
@@ -2040,8 +2092,8 @@ export default function DashboardView() {
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm">
<Flower2 className="h-4 w-4" /> <Flower2 className="h-4 w-4" />
</div> </div>
{t('teacher.competence_progress')} {t('dashboard.competency_progress')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">{t('teacher.competence_progress_desc')}</span> <span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">{t('dashboard.competency_progress_desc')}</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -2057,12 +2109,41 @@ export default function DashboardView() {
<PolarGrid stroke="#e5e7eb" /> <PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis dataKey="subject" tick={{ fontSize: 11, fill: '#6b7280' }} /> <PolarAngleAxis dataKey="subject" tick={{ fontSize: 11, fill: '#6b7280' }} />
<PolarRadiusAxis angle={30} domain={[0, 4]} tick={{ fontSize: 10 }} /> <PolarRadiusAxis angle={30} domain={[0, 4]} tick={{ fontSize: 10 }} />
<Radar name={locale === 'de' ? 'Aktuell' : 'Current'} dataKey="current" stroke="#10b981" fill="#10b981" fillOpacity={0.3} /> <Radar name={locale === 'de' ? 'Aktuell' : 'Current'} dataKey="current" stroke="#10b981" fill="#10b981" fillOpacity={0.3} isAnimationActive animationDuration={1200} animationEasing="ease-out" />
<Radar name={locale === 'de' ? 'Ziel' : 'Target'} dataKey="target" stroke="#14b8a6" fill="#14b8a6" fillOpacity={0.1} strokeDasharray="5 5" /> <Radar name={locale === 'de' ? 'Ziel' : 'Target'} dataKey="target" stroke="#14b8a6" fill="#14b8a6" fillOpacity={0.1} strokeDasharray="5 5" isAnimationActive animationDuration={1500} animationEasing="ease-out" />
<RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} /> <RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} />
<Legend wrapperStyle={{ fontSize: '12px' }} /> <Legend wrapperStyle={{ fontSize: '12px' }} />
</RadarChart> </RadarChart>
</ResponsiveContainer> </ResponsiveContainer>
{/* Animated progress bars for each subject */}
<div className="mt-4 space-y-3 pt-4 border-t border-gray-100 dark:border-gray-800">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('dashboard.competency_progress')}</h4>
{[
{ subject: t('student.notebook_math'), current: 3.5, target: 4, color: 'from-amber-400 to-orange-500' },
{ subject: t('student.notebook_german'), current: 3.8, target: 4, color: 'from-emerald-400 to-teal-500' },
{ subject: t('student.notebook_english'), current: 2.9, target: 4, color: 'from-rose-400 to-pink-500' },
{ subject: t('student.notebook_science'), current: 3.2, target: 4, color: 'from-violet-400 to-purple-500' },
].map((item) => {
const pct = Math.round((item.current / item.target) * 100);
return (
<div key={item.subject} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">{item.subject}</span>
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400">{item.current}/{item.target}</span>
</div>
<div className="h-2.5 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 1, ease: 'easeOut', delay: 0.2 }}
className={`h-full rounded-full bg-gradient-to-r ${item.color}`}
/>
</div>
</div>
);
})}
</div>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
@@ -2128,7 +2209,7 @@ export default function DashboardView() {
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-emerald-500 text-white shadow-sm"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-emerald-500 text-white shadow-sm">
<CalendarDays className="h-4 w-4" /> <CalendarDays className="h-4 w-4" />
</div> </div>
{locale === 'de' ? 'Heutige Aufgaben' : 'Today\'s Schedule'} {t('dashboard.class_overview_grid')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline"> <span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline">
· {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'short', day: 'numeric', month: 'short' })} · {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'short', day: 'numeric', month: 'short' })}
</span> </span>
@@ -2137,8 +2218,8 @@ export default function DashboardView() {
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
{[ {[
{ icon: PenLine, label: locale === 'de' ? 'Fortschritte erfassen' : 'Log progress entries', desc: locale === 'de' ? 'Neue Beobachtungen eintragen' : 'Record new observations', view: 'progress', color: 'from-emerald-400 to-teal-500' }, { icon: PenLine, label: t('action.log_entry'), desc: t('dashboard.quick_action_assessment_desc'), view: 'progress', color: 'from-emerald-400 to-teal-500' },
{ icon: ClipboardCheck, label: locale === 'de' ? 'Uberpruefung erstellen' : 'Create assessment', desc: locale === 'de' ? 'Leistungsueberpruefung planen' : 'Plan performance assessment', view: 'assessments', color: 'from-amber-400 to-rose-500' }, { icon: ClipboardCheck, label: t('action.create_assessment'), desc: t('dashboard.quick_action_assessment_desc'), view: 'assessments', color: 'from-amber-400 to-rose-500' },
{ icon: BookOpen, label: locale === 'de' ? 'Kompetenzblume ansehen' : 'View competence flower', desc: locale === 'de' ? 'Radar-Chart pruefen' : 'Check radar chart', view: 'flower', color: 'from-violet-400 to-emerald-500' }, { icon: BookOpen, label: locale === 'de' ? 'Kompetenzblume ansehen' : 'View competence flower', desc: locale === 'de' ? 'Radar-Chart pruefen' : 'Check radar chart', view: 'flower', color: 'from-violet-400 to-emerald-500' },
{ icon: Notebook, label: locale === 'de' ? 'Notizbuch aktualisieren' : 'Update notebook', desc: locale === 'de' ? 'Hefte bearbeiten' : 'Edit notebooks', view: 'notebooks', color: 'from-teal-400 to-emerald-500' }, { icon: Notebook, label: locale === 'de' ? 'Notizbuch aktualisieren' : 'Update notebook', desc: locale === 'de' ? 'Hefte bearbeiten' : 'Edit notebooks', view: 'notebooks', color: 'from-teal-400 to-emerald-500' },
].map((item, i) => ( ].map((item, i) => (
@@ -2329,6 +2410,99 @@ export default function DashboardView() {
<div className="flex-1 h-px bg-gradient-to-r from-emerald-200/60 dark:from-emerald-800/30 to-transparent" /> <div className="flex-1 h-px bg-gradient-to-r from-emerald-200/60 dark:from-emerald-800/30 to-transparent" />
</motion.div> </motion.div>
{/* ===== CLASS OVERVIEW MINI CARD GRID ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-5 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">
<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-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
<School className="h-4 w-4" />
</div>
{t('dashboard.class_overview_grid')}
{data.classesOverview.length > 0 && (
<Badge variant="secondary" className="ml-1 text-[10px] bg-emerald-100/80 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300">
{data.classesOverview.length}
</Badge>
)}
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView('classes')}
className="text-xs text-emerald-600 dark:text-emerald-400 hover:text-emerald-700 dark:hover:text-emerald-300 min-h-[44px] px-2"
>
{t('dashboard.view_all')}
<ChevronRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
{data.classesOverview.length === 0 ? (
<div className="text-center py-8">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-emerald-100 dark:bg-emerald-900/20 mx-auto mb-4">
<Target className="h-8 w-8 text-emerald-400 dark:text-emerald-500" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{t('dashboard.no_classes')}</p>
<p className="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('dashboard.start_here')}</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{data.classesOverview.map((cls, idx) => {
const gradientIdx = idx % avatarGradients.length;
const totalStudents = cls.studentCount;
const maxStudents = Math.max(...data.classesOverview.map(c => c.studentCount), 1);
const fillPct = Math.round((totalStudents / maxStudents) * 100);
return (
<motion.div
key={cls.id}
whileHover={{ scale: 1.03, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.15 }}
className="cursor-pointer"
onClick={() => {
useAppStore.getState().setCurrentClass(cls.id);
setCurrentView('classes');
}}
>
<div className="relative rounded-xl overflow-hidden border border-gray-200/40 dark:border-gray-700/40 bg-white dark:bg-gray-900/50 hover:shadow-md transition-shadow duration-200">
{/* Gradient top strip */}
<div className={`h-1.5 bg-gradient-to-r ${avatarGradients[gradientIdx]}`} />
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br ${avatarGradients[gradientIdx]} text-white text-[10px] font-bold shadow-sm shrink-0`}>
{cls.name.substring(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-xs font-semibold text-gray-900 dark:text-gray-100 truncate">{cls.name}</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('label.grade')} {cls.gradeLevel}</p>
</div>
</div>
{/* Mini student count bar */}
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-gray-500 dark:text-gray-400">{t('dashboard.total_students')}</span>
<span className="text-[10px] font-semibold text-gray-700 dark:text-gray-300">{cls.studentCount}</span>
</div>
<div className="h-1.5 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${fillPct}%` }}
transition={{ duration: 0.8, ease: 'easeOut', delay: idx * 0.05 }}
className={`h-full rounded-full bg-gradient-to-r ${avatarGradients[gradientIdx]}`}
/>
</div>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* ===== MAIN CONTENT GRID ===== */} {/* ===== MAIN CONTENT GRID ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Classes overview */} {/* Classes overview */}
@@ -2481,16 +2655,19 @@ export default function DashboardView() {
<div className="flex-1 h-px bg-gradient-to-r from-emerald-200/60 dark:from-emerald-800/30 to-transparent" /> <div className="flex-1 h-px bg-gradient-to-r from-emerald-200/60 dark:from-emerald-800/30 to-transparent" />
</motion.div> </motion.div>
{/* ===== RECENT ACTIVITY ===== */} {/* ===== RECENT ACTIVITY TIMELINE ===== */}
<motion.div variants={itemVariants}> <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]"> <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"> <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 flex items-center gap-2"> <div className="flex items-center justify-between">
<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"> <CardTitle className="text-lg flex items-center gap-2">
<TrendingUp className="h-4 w-4" /> <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">
</div> <TrendingUp className="h-4 w-4" />
{t('dashboard.recent_activity')} </div>
</CardTitle> {t('dashboard.activity_timeline')}
</CardTitle>
<span className="text-xs text-gray-500 dark:text-gray-400">{t('dashboard.activity_timeline_desc')}</span>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{data.recentEntries.length === 0 ? ( {data.recentEntries.length === 0 ? (
@@ -2502,18 +2679,27 @@ export default function DashboardView() {
<p className="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('dashboard.start_here')}</p> <p className="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('dashboard.start_here')}</p>
</div> </div>
) : ( ) : (
<div className="activity-timeline max-h-96 overflow-y-auto scrollbar-education pr-1"> <div className="relative activity-timeline max-h-96 overflow-y-auto scrollbar-education pr-1">
{data.recentEntries.slice(0, typeof window !== 'undefined' && window.innerWidth < 640 ? 5 : 10).map((entry) => { {/* Timeline connector line */}
<div className="absolute left-[22px] top-4 bottom-4 w-px bg-gradient-to-b from-teal-200 via-emerald-200 to-transparent dark:from-teal-800 dark:via-emerald-800" />
{data.recentEntries.slice(0, typeof window !== 'undefined' && window.innerWidth < 640 ? 5 : 10).map((entry, idx) => {
const catColor = entry.competency.category.color ?? '#10b981'; const catColor = entry.competency.category.color ?? '#10b981';
const studentName = `${entry.student.firstName} ${entry.student.lastName}`; const studentName = `${entry.student.firstName} ${entry.student.lastName}`;
const gradient = getAvatarGradient(studentName); const gradient = getAvatarGradient(studentName);
return ( return (
<motion.div <motion.div
key={entry.id} key={entry.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: idx * 0.05 }}
whileHover={{ scale: 1.01, x: 2 }} whileHover={{ scale: 1.01, x: 2 }}
transition={{ duration: 0.15 }} className="activity-timeline-item group flex items-center justify-between p-3.5 rounded-xl bg-gradient-to-r from-gray-50/80 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-100/60 dark:border-gray-800/40 hover:border-emerald-200/60 dark:hover:border-emerald-800/30 transition-colors relative overflow-hidden mb-2 ml-6"
className="activity-timeline-item group flex items-center justify-between p-3.5 rounded-xl bg-gradient-to-r from-gray-50/80 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-100/60 dark:border-gray-800/40 hover:border-emerald-200/60 dark:hover:border-emerald-800/30 transition-colors relative overflow-hidden"
> >
{/* Timeline dot */}
<div className="absolute -left-6 top-1/2 -translate-y-1/2 flex items-center justify-center">
<div className="w-3 h-3 rounded-full border-2 border-white dark:border-gray-900 shadow-sm" style={{ backgroundColor: catColor }} />
</div>
{/* Left border color indicator */} {/* Left border color indicator */}
<div className="absolute left-0 top-0 bottom-0 w-1 rounded-l-xl transition-all duration-200 group-hover:w-1.5" style={{ backgroundColor: catColor }} /> <div className="absolute left-0 top-0 bottom-0 w-1 rounded-l-xl transition-all duration-200 group-hover:w-1.5" style={{ backgroundColor: catColor }} />
@@ -2670,9 +2856,11 @@ export default function DashboardView() {
{/* ===== PAPER SAVED & ENVIRONMENTAL SECTION ===== */} {/* ===== PAPER SAVED & ENVIRONMENTAL SECTION ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Paper Saved Counter */} {/* Paper Saved Counter — enhanced with CountUp */}
<motion.div variants={itemVariants}> <motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden bg-white dark:bg-gray-950 ring-1 ring-black/[0.03] dark:ring-white/[0.05] card-hover-lift"> <Card className="border-0 shadow-sm rounded-xl overflow-hidden bg-white dark:bg-gray-950 ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
{/* Gradient top border */}
<div className="h-1.5 bg-gradient-to-r from-emerald-400 via-teal-400 to-emerald-500" />
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<motion.div <motion.div
@@ -2685,30 +2873,38 @@ export default function DashboardView() {
</motion.div> </motion.div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-emerald-700 dark:text-emerald-300 mb-1"> <h3 className="text-sm font-bold text-emerald-700 dark:text-emerald-300 mb-1">
{locale === 'de' ? 'Papier gespart' : 'Paper Saved'} {t('dashboard.paper_saved')}
</h3> </h3>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900 dark:text-gray-100 animate-count-up"> <span className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{data.stats.totalProgressEntries * 50} <CountUp target={data.stats.totalProgressEntries * 50} />
</span> </span>
<span className="text-sm text-emerald-600/70 dark:text-emerald-400/60 font-medium"> <span className="text-sm text-emerald-600/70 dark:text-emerald-400/60 font-medium">
{locale === 'de' ? 'Seiten' : 'pages'} {t('dashboard.paper_saved_pages')}
</span> </span>
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed mt-2"> <p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed mt-2">
{locale === 'de' {t('dashboard.env_tip_text')}
? 'Jeder digitale Eintrag spart ca. 50 Seiten Papier. Danke fuer Ihren Beitrag zum Umweltschutz!'
: 'Each digital entry saves ~50 pages of paper. Thank you for contributing to environmental protection!'}
</p> </p>
<div className="flex items-center gap-2 mt-3"> <div className="flex items-center gap-2 mt-3">
<div className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-emerald-100/60 dark:bg-emerald-900/30 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400"> <motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5, duration: 0.3 }}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-emerald-100/60 dark:bg-emerald-900/30 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400"
>
<TreePine className="h-3 w-3" /> <TreePine className="h-3 w-3" />
{locale === 'de' ? '~0.5 Baume' : '~0.5 trees'} ~{(data.stats.totalProgressEntries * 50 / 8000).toFixed(1)} {t('dashboard.paper_saved_trees')}
</div> </motion.div>
<div className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-teal-100/60 dark:bg-teal-900/30 text-[11px] font-semibold text-teal-600 dark:text-teal-400"> <motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.7, duration: 0.3 }}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-teal-100/60 dark:bg-teal-900/30 text-[11px] font-semibold text-teal-600 dark:text-teal-400"
>
<Sprout className="h-3 w-3" /> <Sprout className="h-3 w-3" />
{locale === 'de' ? 'CO2 reduziert' : 'CO2 reduced'} {(data.stats.totalProgressEntries * 50 * 0.005).toFixed(1)} {t('dashboard.paper_saved_co2')}
</div> </motion.div>
</div> </div>
</div> </div>
</div> </div>
@@ -2751,6 +2947,53 @@ export default function DashboardView() {
</motion.div> </motion.div>
</div> </div>
{/* ===== SCHOOL BRANDING SECTION ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
{/* Gradient top border */}
<div className="h-1.5 bg-gradient-to-r from-emerald-400 via-teal-400 to-violet-400" />
<CardContent className="p-6">
<div className="flex items-center gap-4">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.3, type: 'spring', stiffness: 120 }}
className="flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/30 shrink-0"
>
<School className="h-7 w-7" />
</motion.div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-emerald-700 dark:text-emerald-300 mb-1">
{t('dashboard.school_branding')}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
{t('dashboard.school_branding_desc')}
</p>
<div className="flex items-center gap-2 mt-3">
<div className="flex items-center gap-1">
<div className="w-4 h-4 rounded-full bg-emerald-500 shadow-sm" />
<div className="w-4 h-4 rounded-full bg-teal-500 shadow-sm" />
<div className="w-4 h-4 rounded-full bg-violet-500 shadow-sm" />
<div className="w-4 h-4 rounded-full bg-amber-500 shadow-sm" />
<div className="w-4 h-4 rounded-full bg-rose-500 shadow-sm" />
</div>
<span className="text-[10px] text-gray-400 dark:text-gray-500">{t('app.name')}</span>
</div>
</div>
<div className="hidden sm:flex flex-col items-end gap-1 shrink-0">
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs">
<GraduationCap className="h-3 w-3 mr-1" />
{currentUser?.schoolId ? t('nav.classes') : 'CompetenceTrack'}
</Badge>
<span className="text-[10px] text-gray-400 dark:text-gray-500">
{t('dashboard.total_students')}: {data.stats.totalStudents} · {t('dashboard.total_classes')}: {data.stats.totalClasses}
</span>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* ===== SCHOOL NEWS / NEWSLETTER SECTION ===== */} {/* ===== SCHOOL NEWS / NEWSLETTER SECTION ===== */}
<DashboardNewsletterCard currentUser={currentUser} locale={locale} /> <DashboardNewsletterCard currentUser={currentUser} locale={locale} />
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+230
View File
@@ -493,6 +493,22 @@ const de: TranslationDict = {
// New styling keys // New styling keys
'dashboard.start_here': 'Jetzt beginnen!', 'dashboard.start_here': 'Jetzt beginnen!',
'dashboard.greeting_morning': 'Guten Morgen',
'dashboard.greeting_afternoon': 'Guten Tag',
'dashboard.greeting_evening': 'Guten Abend',
'dashboard.school_branding': 'Schulprofil',
'dashboard.school_branding_desc': 'Ihre Schule im Überblick',
'dashboard.competency_progress': 'Kompetenzfortschritt',
'dashboard.competency_progress_desc': 'Aktuelle Entwicklung der Kompetenzniveaus',
'dashboard.paper_saved_pages': 'Seiten',
'dashboard.paper_saved_trees': 'Baeume gerettet',
'dashboard.paper_saved_co2': 'kg CO2 eingespart',
'dashboard.daily_tip': 'Tipp des Tages',
'dashboard.class_overview_grid': 'Klassenuebersicht',
'dashboard.class_overview_grid_desc': 'Schnellzugriff auf alle Klassen',
'dashboard.activity_timeline': 'Aktivitaetenverlauf',
'dashboard.activity_timeline_desc': 'Neueste Eintraege im Zeitverlauf',
'dashboard.updated_at': 'Aktualisiert',
'date.today': 'Heute', 'date.today': 'Heute',
'date.yesterday': 'Gestern', 'date.yesterday': 'Gestern',
'date.days_ago': '{count} Tage zuvor', 'date.days_ago': '{count} Tage zuvor',
@@ -511,6 +527,7 @@ const de: TranslationDict = {
'header.notifications': 'Aktivitäten', 'header.notifications': 'Aktivitäten',
'auth.feature_reports': 'Berichte & Zeugnisse', 'auth.feature_reports': 'Berichte & Zeugnisse',
'auth.feature_flower': 'Kompetenzblume', 'auth.feature_flower': 'Kompetenzblume',
'auth.feature_data_security': 'Datenschutz & Sicherheit',
'flower.hide_average': 'Vergleich aus', 'flower.hide_average': 'Vergleich aus',
'flower.show_average': 'Vergleich an', 'flower.show_average': 'Vergleich an',
'flower.download_png': 'Als PNG', 'flower.download_png': 'Als PNG',
@@ -2665,6 +2682,41 @@ const de: TranslationDict = {
'district.total_students': 'Gesamtschüler', 'district.total_students': 'Gesamtschüler',
'district.total_classes': 'Gesamtklassen', 'district.total_classes': 'Gesamtklassen',
'district.total_teachers': 'Gesamtlehrer', 'district.total_teachers': 'Gesamtlehrer',
'district.management': 'Bezirksverwaltung',
'district.overview': 'Übersicht',
'district.teacher_count': 'Anzahl Lehrer',
'district.add_district': 'Bezirk hinzufügen',
'district.edit_district': 'Bezirk bearbeiten',
'district.delete_district': 'Bezirk löschen',
'district.no_schools': 'Keine Schulen vorhanden',
'district.remove_school': 'Schule entfernen',
'district.performance_comparison': 'Leistungsvergleich',
'district.recent_activity': 'Letzte Aktivität',
'district.active': 'Aktiv',
'district.inactive': 'Inaktiv',
'district.details': 'Details',
'district.save_success': 'Bezirk erfolgreich gespeichert',
'district.delete_success': 'Bezirk erfolgreich gelöscht',
'district.school_assigned': 'Schule erfolgreich zugeordnet',
'district.school_removed': 'Schule erfolgreich entfernt',
'district.unassigned_schools': 'Nicht zugeordnete Schulen',
'district.no_unassigned_schools': 'Alle Schulen sind zugeordnet',
'district.all_schools': 'Alle Schulen',
'district.search_districts': 'Bezirke durchsuchen',
'district.overview_stats': 'Bezirksstatistik',
'district.avg_performance': 'Durchschnittliche Leistung',
'district.school_type': 'Schultyp',
'district.select_district': 'Bezirk auswählen',
'district.district_info': 'Bezirksinformationen',
'district.activity_created': 'Bezirk erstellt',
'district.activity_updated': 'Bezirk aktualisiert',
'district.activity_school_added': 'Schule hinzugefügt',
'district.activity_school_removed': 'Schule entfernt',
'district.performance_score': 'Leistungswert',
'district.attendance_rate': 'Anwesenheitsrate',
'district.student_teacher_ratio': 'Schüler-Lehrer-Verhältnis',
'nav.districts': 'Bezirksverwaltung',
'nav.student-portal': 'Mein Portal',
// PWA / Offline // PWA / Offline
'pwa.offline': 'Sie sind offline', 'pwa.offline': 'Sie sind offline',
@@ -3831,6 +3883,69 @@ const de: TranslationDict = {
'admin.health_good': 'Gut', 'admin.health_good': 'Gut',
'admin.health_warning': 'Warnung', 'admin.health_warning': 'Warnung',
'admin.health_critical': 'Kritisch', 'admin.health_critical': 'Kritisch',
// Student Portal
'student_portal.title': 'Mein Portal',
'student_portal.my_competencies': 'Meine Kompetenzen',
'student_portal.my_goals': 'Meine Ziele',
'student_portal.my_achievements': 'Meine Erfolge',
'student_portal.my_schedule': 'Mein Stundenplan',
'student_portal.my_homework': 'Meine Hausaufgaben',
'student_portal.my_feedback': 'Mein Feedback',
'student_portal.goal_set': 'Ziel setzen',
'student_portal.goal_progress': 'Zielfortschritt',
'student_portal.goal_complete': 'Ziel abschliessen',
'student_portal.no_competencies': 'Noch keine Kompetenzdaten vorhanden',
'student_portal.no_goals': 'Noch keine Ziele gesetzt',
'student_portal.no_achievements': 'Noch keine Erfolge freigeschaltet',
'student_portal.points_earned': 'Punkte gesammelt',
'student_portal.badges_earned': 'Abzeichen verdient',
'student_portal.rewards_available': 'Verfuegbare Belohnungen',
'student_portal.today_schedule': 'Heutiger Stundenplan',
'student_portal.next_period': 'Naechste Stunde',
'student_portal.current_period': 'Aktuelle Stunde',
'student_portal.overdue': 'Ueberfaellig',
'student_portal.due_today': 'Heute faellig',
'student_portal.upcoming': 'Anstehend',
'student_portal.mastery_level': 'Kompetenzniveau',
'student_portal.level_1': 'Anfaenger',
'student_portal.level_2': 'Fortgeschritten',
'student_portal.level_3': 'Kompetent',
'student_portal.level_4': 'Experte',
'student_portal.add_goal': 'Neues Ziel',
'student_portal.goal_title': 'Zieltitel',
'student_portal.goal_description': 'Zielbeschreibung',
'student_portal.target_date': 'Zieldatum',
'student_portal.active_goals': 'Aktive Ziele',
'student_portal.completed_goals': 'Abgeschlossene Ziele',
'student_portal.abandon_goal': 'Ziel aufgeben',
'student_portal.update_progress': 'Fortschritt aktualisieren',
'student_portal.badge_name': 'Abzeichen',
'student_portal.earned_on': 'Verdient am',
'student_portal.no_schedule': 'Kein Stundenplan fuer heute',
'student_portal.period': 'Stunde',
'student_portal.break': 'Pause',
'student_portal.no_homework_due': 'Keine Hausaufgaben faellig',
'student_portal.no_feedback': 'Noch kein Feedback vorhanden',
'student_portal.recent_feedback': 'Letztes Feedback',
'student_portal.from_teacher': 'Von',
'student_portal.subject': 'Fach',
'student_portal.competency_card': 'Kompetenzkarte',
'student_portal.progress_bar': 'Fortschrittsbalken',
'student_portal.total_points': 'Gesamtpunkte',
'student_portal.level': 'Stufe',
'student_portal.no_class': 'Keine Klasse',
'student_portal.schedule_for': 'Stundenplan fuer',
'student_portal.good_morning': 'Guten Morgen',
'student_portal.good_afternoon': 'Guten Tag',
'student_portal.good_evening': 'Guten Abend',
'student_portal.hero_message': 'Weiter so! Jeder Schritt bringt dich naeher an dein Ziel.',
'student_portal.streak_days': 'Tage Strich',
'student_portal.days': 'Tage',
'student_portal.learning_streak': 'Lernstrich',
'student_portal.streak_message': 'Tolle Serie! Halte durch, um deine Tagesreihe zu verlaengern.',
'student_portal.level_up_progress': 'Aufstiegsfortschritt',
'student_portal.level_up_detail': 'Aktuelle Stufe {level} von {max}',
}; };
const en: TranslationDict = { const en: TranslationDict = {
@@ -4323,6 +4438,22 @@ const en: TranslationDict = {
// New styling keys // New styling keys
'dashboard.start_here': 'Start here!', 'dashboard.start_here': 'Start here!',
'dashboard.greeting_morning': 'Good morning',
'dashboard.greeting_afternoon': 'Good afternoon',
'dashboard.greeting_evening': 'Good evening',
'dashboard.school_branding': 'School Profile',
'dashboard.school_branding_desc': 'Your school at a glance',
'dashboard.competency_progress': 'Competency Progress',
'dashboard.competency_progress_desc': 'Current development of competency levels',
'dashboard.paper_saved_pages': 'pages',
'dashboard.paper_saved_trees': 'trees saved',
'dashboard.paper_saved_co2': 'kg CO2 saved',
'dashboard.daily_tip': 'Tip of the day',
'dashboard.class_overview_grid': 'Class Overview',
'dashboard.class_overview_grid_desc': 'Quick access to all classes',
'dashboard.activity_timeline': 'Activity Timeline',
'dashboard.activity_timeline_desc': 'Latest entries over time',
'dashboard.updated_at': 'Updated',
'date.today': 'Today', 'date.today': 'Today',
'date.yesterday': 'Yesterday', 'date.yesterday': 'Yesterday',
'date.days_ago': '{count} days ago', 'date.days_ago': '{count} days ago',
@@ -4341,6 +4472,7 @@ const en: TranslationDict = {
'header.notifications': 'Activity', 'header.notifications': 'Activity',
'auth.feature_reports': 'Reports & Transcripts', 'auth.feature_reports': 'Reports & Transcripts',
'auth.feature_flower': 'Competence Flower', 'auth.feature_flower': 'Competence Flower',
'auth.feature_data_security': 'Data Security & Privacy',
'flower.hide_average': 'Hide Average', 'flower.hide_average': 'Hide Average',
'flower.show_average': 'Show Average', 'flower.show_average': 'Show Average',
'flower.download_png': 'As PNG', 'flower.download_png': 'As PNG',
@@ -6494,6 +6626,41 @@ const en: TranslationDict = {
'district.total_students': 'Total Students', 'district.total_students': 'Total Students',
'district.total_classes': 'Total Classes', 'district.total_classes': 'Total Classes',
'district.total_teachers': 'Total Teachers', 'district.total_teachers': 'Total Teachers',
'district.management': 'District Management',
'district.overview': 'Overview',
'district.teacher_count': 'Teacher Count',
'district.add_district': 'Add District',
'district.edit_district': 'Edit District',
'district.delete_district': 'Delete District',
'district.no_schools': 'No schools found',
'district.remove_school': 'Remove School',
'district.performance_comparison': 'Performance Comparison',
'district.recent_activity': 'Recent Activity',
'district.active': 'Active',
'district.inactive': 'Inactive',
'district.details': 'Details',
'district.save_success': 'District saved successfully',
'district.delete_success': 'District deleted successfully',
'district.school_assigned': 'School assigned successfully',
'district.school_removed': 'School removed successfully',
'district.unassigned_schools': 'Unassigned Schools',
'district.no_unassigned_schools': 'All schools are assigned',
'district.all_schools': 'All Schools',
'district.search_districts': 'Search districts',
'district.overview_stats': 'District Statistics',
'district.avg_performance': 'Average Performance',
'district.school_type': 'School Type',
'district.select_district': 'Select District',
'district.district_info': 'District Information',
'district.activity_created': 'District created',
'district.activity_updated': 'District updated',
'district.activity_school_added': 'School added',
'district.activity_school_removed': 'School removed',
'district.performance_score': 'Performance Score',
'district.attendance_rate': 'Attendance Rate',
'district.student_teacher_ratio': 'Student-Teacher Ratio',
'nav.districts': 'District Management',
'nav.student-portal': 'My Portal',
// PWA / Offline // PWA / Offline
'pwa.offline': 'You are offline', 'pwa.offline': 'You are offline',
@@ -7660,6 +7827,69 @@ const en: TranslationDict = {
'admin.health_good': 'Good', 'admin.health_good': 'Good',
'admin.health_warning': 'Warning', 'admin.health_warning': 'Warning',
'admin.health_critical': 'Critical', 'admin.health_critical': 'Critical',
// Student Portal
'student_portal.title': 'My Portal',
'student_portal.my_competencies': 'My Competencies',
'student_portal.my_goals': 'My Goals',
'student_portal.my_achievements': 'My Achievements',
'student_portal.my_schedule': 'My Schedule',
'student_portal.my_homework': 'My Homework',
'student_portal.my_feedback': 'My Feedback',
'student_portal.goal_set': 'Set Goal',
'student_portal.goal_progress': 'Goal Progress',
'student_portal.goal_complete': 'Complete Goal',
'student_portal.no_competencies': 'No competency data yet',
'student_portal.no_goals': 'No goals set yet',
'student_portal.no_achievements': 'No achievements unlocked yet',
'student_portal.points_earned': 'Points Earned',
'student_portal.badges_earned': 'Badges Earned',
'student_portal.rewards_available': 'Rewards Available',
'student_portal.today_schedule': 'Today\'s Schedule',
'student_portal.next_period': 'Next Period',
'student_portal.current_period': 'Current Period',
'student_portal.overdue': 'Overdue',
'student_portal.due_today': 'Due Today',
'student_portal.upcoming': 'Upcoming',
'student_portal.mastery_level': 'Mastery Level',
'student_portal.level_1': 'Beginner',
'student_portal.level_2': 'Developing',
'student_portal.level_3': 'Competent',
'student_portal.level_4': 'Expert',
'student_portal.add_goal': 'New Goal',
'student_portal.goal_title': 'Goal Title',
'student_portal.goal_description': 'Goal Description',
'student_portal.target_date': 'Target Date',
'student_portal.active_goals': 'Active Goals',
'student_portal.completed_goals': 'Completed Goals',
'student_portal.abandon_goal': 'Abandon Goal',
'student_portal.update_progress': 'Update Progress',
'student_portal.badge_name': 'Badge',
'student_portal.earned_on': 'Earned on',
'student_portal.no_schedule': 'No schedule for today',
'student_portal.period': 'Period',
'student_portal.break': 'Break',
'student_portal.no_homework_due': 'No homework due',
'student_portal.no_feedback': 'No feedback yet',
'student_portal.recent_feedback': 'Recent Feedback',
'student_portal.from_teacher': 'From',
'student_portal.subject': 'Subject',
'student_portal.competency_card': 'Competency Card',
'student_portal.progress_bar': 'Progress Bar',
'student_portal.total_points': 'Total Points',
'student_portal.level': 'Level',
'student_portal.no_class': 'No Class',
'student_portal.schedule_for': 'Schedule for',
'student_portal.good_morning': 'Good Morning',
'student_portal.good_afternoon': 'Good Afternoon',
'student_portal.good_evening': 'Good Evening',
'student_portal.hero_message': 'Keep going! Every step brings you closer to your goal.',
'student_portal.streak_days': 'Day Streak',
'student_portal.days': 'days',
'student_portal.learning_streak': 'Learning Streak',
'student_portal.streak_message': 'Great streak! Keep going to extend your daily streak.',
'student_portal.level_up_progress': 'Level Up Progress',
'student_portal.level_up_detail': 'Current level {level} of {max}',
}; };
const translations: Record<string, TranslationDict> = { de, en }; const translations: Record<string, TranslationDict> = { de, en };
+3 -1
View File
@@ -29,7 +29,9 @@ export type ViewName =
| 'portfolio' | 'portfolio'
| 'timetable' | 'timetable'
| 'resources' | 'resources'
| 'competitions'; | 'competitions'
| 'districts'
| 'student-portal';
export interface CurrentUser { export interface CurrentUser {
id: string; id: string;
+174 -2461
View File
File diff suppressed because it is too large. Load diff