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
No files matched your search
@@ -133,6 +133,7 @@ model User {
|
||||
rewardRedemptions RewardRedemption[]
|
||||
rewardPoints RewardPoints[]
|
||||
studentAnswers StudentAnswer[]
|
||||
student Student?
|
||||
}
|
||||
|
||||
model SchoolYear {
|
||||
@@ -209,10 +210,12 @@ model Student {
|
||||
externalId String? // for import matching
|
||||
avatarUrl String? // student photo/avatar URL
|
||||
avatarInitials String? // computed from firstName + lastName first chars
|
||||
userId String? @unique // optional link to User account (for student self-service)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
enrollments Enrollment[]
|
||||
learningProgressEntries LearningProgressEntry[]
|
||||
@@ -236,6 +239,9 @@ model Student {
|
||||
transports StudentTransport[]
|
||||
healthRecords HealthRecord[]
|
||||
studentBadges StudentBadge[]
|
||||
goals StudentGoal[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Enrollment {
|
||||
@@ -1912,3 +1918,22 @@ model RewardPoints {
|
||||
@@index([source])
|
||||
@@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])
|
||||
}
|
||||
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,15 @@ export async function GET() {
|
||||
where: { deletedAt: null },
|
||||
include: {
|
||||
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' },
|
||||
@@ -18,7 +26,9 @@ export async function GET() {
|
||||
const result = districts.map((d) => ({
|
||||
...d,
|
||||
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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,31 @@ export async function GET(request: Request) {
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const search = searchParams.get('search');
|
||||
const userId = searchParams.get('userId');
|
||||
|
||||
const where: Record<string, unknown> = { deletedAt: null };
|
||||
|
||||
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) {
|
||||
where.enrollments = {
|
||||
some: { classGroupId, endDate: null },
|
||||
@@ -37,11 +57,22 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
const searchOr = [
|
||||
{ firstName: { contains: search } },
|
||||
{ lastName: { 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({
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
Library as LibraryIcon,
|
||||
Trophy,
|
||||
FolderOpen,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -131,6 +132,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
// Static imports for lightweight views
|
||||
import DashboardView from './dashboard-view';
|
||||
import StudentPortalView from './student-portal-view';
|
||||
import ClassesView from './classes-view';
|
||||
import CompetencyGridView from './competency-grid-view';
|
||||
import ProgressEntriesView from './progress-entries-view';
|
||||
@@ -153,6 +155,7 @@ import PortfolioView from './portfolio-view';
|
||||
import TimetableView from './timetable-view';
|
||||
import ResourceLibraryView from './resource-library-view';
|
||||
import CompetitionsView from './competitions-view';
|
||||
import DistrictManagementView from './district-management-view';
|
||||
|
||||
// Dynamic imports for heavy components with loading skeletons
|
||||
const AnalyticsView = dynamic(() => import('./analytics-view'), {
|
||||
@@ -218,6 +221,7 @@ const navSections: NavSection[] = [
|
||||
labelKey: 'polish.nav_setup',
|
||||
items: [
|
||||
{ key: 'competencies', icon: BookOpen, labelKey: 'nav.competencies' },
|
||||
{ key: 'districts', icon: Building2, labelKey: 'nav.districts' },
|
||||
{ key: 'settings', icon: Settings, labelKey: 'nav.settings' },
|
||||
],
|
||||
},
|
||||
@@ -230,6 +234,7 @@ const studentNavSections: NavSection[] = [
|
||||
labelKey: 'student.dashboard_title',
|
||||
items: [
|
||||
{ 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: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' },
|
||||
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
|
||||
@@ -361,6 +366,7 @@ function AnnouncementBanner({ schoolId }: { schoolId: string | null }) {
|
||||
function renderView(view: ViewName) {
|
||||
switch (view) {
|
||||
case 'dashboard': return <DashboardView />;
|
||||
case 'student-portal': return <StudentPortalView />;
|
||||
case 'classes': return <ClassesView />;
|
||||
case 'competencies': return <CompetencyGridView />;
|
||||
case 'progress': return <ProgressEntriesView />;
|
||||
@@ -387,6 +393,7 @@ function renderView(view: ViewName) {
|
||||
case 'timetable': return <TimetableView />;
|
||||
case 'resources': return <ResourceLibraryView />;
|
||||
case 'competitions': return <CompetitionsView />;
|
||||
case 'districts': return <DistrictManagementView />;
|
||||
default: return <DashboardView />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,30 @@ const motivationalTips = [
|
||||
{ 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 ────────────────────────────────────────
|
||||
function ParentDashboard({ currentUser, setCurrentView, locale }: {
|
||||
currentUser: CurrentUser;
|
||||
@@ -848,7 +872,7 @@ function SchoolAdminDashboard({ currentUser, setCurrentView, locale, data }: {
|
||||
<RechartsTooltip
|
||||
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>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -866,6 +890,9 @@ function SchoolAdminDashboard({ currentUser, setCurrentView, locale, data }: {
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
isAnimationActive
|
||||
animationDuration={1200}
|
||||
animationEasing="ease-out"
|
||||
>
|
||||
{gradeDistribution.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
@@ -1579,7 +1606,7 @@ export default function DashboardView() {
|
||||
<XAxis dataKey="subject" 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' }} />
|
||||
<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>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
@@ -1658,12 +1685,17 @@ export default function DashboardView() {
|
||||
{/* Top row: welcome + date */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<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" />
|
||||
</div>
|
||||
</motion.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">
|
||||
{t('dashboard.welcome')}, {currentUser?.firstName}!
|
||||
{getTimeGreeting()}, {currentUser?.firstName}!
|
||||
</h2>
|
||||
<p className="text-emerald-600/60 dark:text-emerald-400/40 mt-0.5 text-sm">{t('dashboard.overview')}</p>
|
||||
</div>
|
||||
@@ -1698,20 +1730,27 @@ export default function DashboardView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tip of the day */}
|
||||
{/* Tip of the day — enhanced with gradient border */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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">
|
||||
<dailyTip.icon className="h-4 w-4" />
|
||||
<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" />
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -1815,23 +1854,36 @@ export default function DashboardView() {
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<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}`}>
|
||||
<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">
|
||||
{stat.label}
|
||||
</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" />
|
||||
</div>
|
||||
</motion.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"><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' && (
|
||||
<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" />
|
||||
<span className="text-[9px] font-bold text-emerald-600 dark:text-emerald-400">{trendPct > 0 ? `+${trendPct}%` : ''}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{stat.value === 0 && (
|
||||
<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">
|
||||
<div className="flex items-center gap-1 text-[10px] text-gray-400 dark:text-gray-500">
|
||||
<RefreshCw className="h-2.5 w-2.5" />
|
||||
<span>{locale === 'de' ? 'Aktualisiert' : 'Updated'} {currentTime}</span>
|
||||
<span>{t('dashboard.updated_at')} {currentTime}</span>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</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">
|
||||
<Flower2 className="h-4 w-4" />
|
||||
</div>
|
||||
{t('teacher.competence_progress')}
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">{t('teacher.competence_progress_desc')}</span>
|
||||
{t('dashboard.competency_progress')}
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">{t('dashboard.competency_progress_desc')}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -2057,12 +2109,41 @@ export default function DashboardView() {
|
||||
<PolarGrid stroke="#e5e7eb" />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fontSize: 11, fill: '#6b7280' }} />
|
||||
<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' ? 'Ziel' : 'Target'} dataKey="target" stroke="#14b8a6" fill="#14b8a6" fillOpacity={0.1} strokeDasharray="5 5" />
|
||||
<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" isAnimationActive animationDuration={1500} animationEasing="ease-out" />
|
||||
<RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} />
|
||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||
</RadarChart>
|
||||
</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>
|
||||
</Card>
|
||||
</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">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
</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">
|
||||
· {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'short', day: 'numeric', month: 'short' })}
|
||||
</span>
|
||||
@@ -2137,8 +2218,8 @@ export default function DashboardView() {
|
||||
<CardContent>
|
||||
<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: 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: 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: 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: 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) => (
|
||||
@@ -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" />
|
||||
</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 ===== */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 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" />
|
||||
</motion.div>
|
||||
|
||||
{/* ===== RECENT ACTIVITY ===== */}
|
||||
{/* ===== RECENT ACTIVITY TIMELINE ===== */}
|
||||
<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">
|
||||
<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">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
</div>
|
||||
{t('dashboard.recent_activity')}
|
||||
</CardTitle>
|
||||
<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">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
</div>
|
||||
{t('dashboard.activity_timeline')}
|
||||
</CardTitle>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{t('dashboard.activity_timeline_desc')}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="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) => {
|
||||
<div className="relative activity-timeline max-h-96 overflow-y-auto scrollbar-education pr-1">
|
||||
{/* 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 studentName = `${entry.student.firstName} ${entry.student.lastName}`;
|
||||
const gradient = getAvatarGradient(studentName);
|
||||
return (
|
||||
<motion.div
|
||||
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 }}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
{/* 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 */}
|
||||
<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 ===== */}
|
||||
<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}>
|
||||
<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">
|
||||
<div className="flex items-center gap-4">
|
||||
<motion.div
|
||||
@@ -2685,30 +2873,38 @@ export default function DashboardView() {
|
||||
</motion.div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-gray-900 dark:text-gray-100 animate-count-up">
|
||||
{data.stats.totalProgressEntries * 50}
|
||||
<span className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<CountUp target={data.stats.totalProgressEntries * 50} />
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed mt-2">
|
||||
{locale === 'de'
|
||||
? '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!'}
|
||||
{t('dashboard.env_tip_text')}
|
||||
</p>
|
||||
<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" />
|
||||
{locale === 'de' ? '~0.5 Baume' : '~0.5 trees'}
|
||||
</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">
|
||||
~{(data.stats.totalProgressEntries * 50 / 8000).toFixed(1)} {t('dashboard.paper_saved_trees')}
|
||||
</motion.div>
|
||||
<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" />
|
||||
{locale === 'de' ? 'CO2 reduziert' : 'CO2 reduced'}
|
||||
</div>
|
||||
{(data.stats.totalProgressEntries * 50 * 0.005).toFixed(1)} {t('dashboard.paper_saved_co2')}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2751,6 +2947,53 @@ export default function DashboardView() {
|
||||
</motion.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 ===== */}
|
||||
<DashboardNewsletterCard currentUser={currentUser} locale={locale} />
|
||||
|
||||
|
||||
@@ -493,6 +493,22 @@ const de: TranslationDict = {
|
||||
|
||||
// New styling keys
|
||||
'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.yesterday': 'Gestern',
|
||||
'date.days_ago': '{count} Tage zuvor',
|
||||
@@ -511,6 +527,7 @@ const de: TranslationDict = {
|
||||
'header.notifications': 'Aktivitäten',
|
||||
'auth.feature_reports': 'Berichte & Zeugnisse',
|
||||
'auth.feature_flower': 'Kompetenzblume',
|
||||
'auth.feature_data_security': 'Datenschutz & Sicherheit',
|
||||
'flower.hide_average': 'Vergleich aus',
|
||||
'flower.show_average': 'Vergleich an',
|
||||
'flower.download_png': 'Als PNG',
|
||||
@@ -2665,6 +2682,41 @@ const de: TranslationDict = {
|
||||
'district.total_students': 'Gesamtschüler',
|
||||
'district.total_classes': 'Gesamtklassen',
|
||||
'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': 'Sie sind offline',
|
||||
@@ -3831,6 +3883,69 @@ const de: TranslationDict = {
|
||||
'admin.health_good': 'Gut',
|
||||
'admin.health_warning': 'Warnung',
|
||||
'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 = {
|
||||
@@ -4323,6 +4438,22 @@ const en: TranslationDict = {
|
||||
|
||||
// New styling keys
|
||||
'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.yesterday': 'Yesterday',
|
||||
'date.days_ago': '{count} days ago',
|
||||
@@ -4341,6 +4472,7 @@ const en: TranslationDict = {
|
||||
'header.notifications': 'Activity',
|
||||
'auth.feature_reports': 'Reports & Transcripts',
|
||||
'auth.feature_flower': 'Competence Flower',
|
||||
'auth.feature_data_security': 'Data Security & Privacy',
|
||||
'flower.hide_average': 'Hide Average',
|
||||
'flower.show_average': 'Show Average',
|
||||
'flower.download_png': 'As PNG',
|
||||
@@ -6494,6 +6626,41 @@ const en: TranslationDict = {
|
||||
'district.total_students': 'Total Students',
|
||||
'district.total_classes': 'Total Classes',
|
||||
'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': 'You are offline',
|
||||
@@ -7660,6 +7827,69 @@ const en: TranslationDict = {
|
||||
'admin.health_good': 'Good',
|
||||
'admin.health_warning': 'Warning',
|
||||
'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 };
|
||||
|
||||
@@ -29,7 +29,9 @@ export type ViewName =
|
||||
| 'portfolio'
|
||||
| 'timetable'
|
||||
| 'resources'
|
||||
| 'competitions';
|
||||
| 'competitions'
|
||||
| 'districts'
|
||||
| 'student-portal';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
|
||||