From 0363a40ca0b57f803b400c1c53c704187691aff9 Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Wed, 29 Jul 2026 12:38:19 +0000 Subject: [PATCH] Round 18: Seating Chart, Student Avatars, Newsletter, Badges, Deep Styling Polish --- prisma/schema.prisma | 48 +- src/app/api/newsletters/[id]/route.ts | 123 +++++ src/app/api/newsletters/route.ts | 85 ++++ src/app/api/students/[id]/details/route.ts | 2 + src/app/api/students/route.ts | 1 + src/app/globals.css | 277 ++++++++++ src/components/assessments-view.tsx | 5 +- src/components/attendance-view.tsx | 5 +- src/components/classes-view.tsx | 560 ++++++++++++++++++++- src/components/dashboard-view.tsx | 269 +++++++++- src/components/grading-view.tsx | 21 +- src/components/progress-entries-view.tsx | 16 +- src/components/reports-view.tsx | 9 +- src/components/student-avatar.tsx | 113 +++++ src/components/student-detail-view.tsx | 86 +++- src/lib/api.ts | 73 +++ src/lib/i18n.ts | 88 ++++ worklog.md | 107 ++++ 18 files changed, 1838 insertions(+), 50 deletions(-) create mode 100644 src/app/api/newsletters/[id]/route.ts create mode 100644 src/app/api/newsletters/route.ts create mode 100644 src/components/student-avatar.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a68392..a9720ca 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -73,6 +73,7 @@ model School { healthRecords HealthRecord[] badges Badge[] studentBadges StudentBadge[] + newsletters Newsletter[] district SchoolDistrict? @relation(fields: [districtId], references: [id]) districtId String? } @@ -120,6 +121,7 @@ model User { organizedSchoolEvents SchoolEvent[] eventRegistrations EventRegistration[] awardedBadges StudentBadge[] + newsletters Newsletter[] } model SchoolYear { @@ -188,15 +190,17 @@ model ClassGroupTeacher { } model Student { - id String @id @default(cuid()) - schoolId String - firstName String - lastName String - dateOfBirth DateTime? - externalId String? // for import matching - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + id String @id @default(cuid()) + schoolId String + firstName String + lastName String + dateOfBirth DateTime? + externalId String? // for import matching + avatarUrl String? // student photo/avatar URL + avatarInitials String? // computed from firstName + lastName first chars + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? school School @relation(fields: [schoolId], references: [id]) enrollments Enrollment[] @@ -1588,3 +1592,29 @@ model StudentBadge { @@index([schoolId, studentId]) @@map("student_badges") } + +// ─── Newsletter / School Blog ────────────────────────────────────── + +model Newsletter { + id String @id @default(cuid()) + schoolId String + authorId String + title String + content String // rich text content (HTML) + summary String? // short summary for preview + imageUrl String? // cover image URL + category String @default("general") // general, academic, sports, arts, events, community + isPublished Boolean @default(false) + publishedAt DateTime? + tags String? // JSON array of tags + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + author User @relation(fields: [authorId], references: [id]) + + @@index([schoolId, isPublished, publishedAt]) + @@map("newsletters") +} diff --git a/src/app/api/newsletters/[id]/route.ts b/src/app/api/newsletters/[id]/route.ts new file mode 100644 index 0000000..0584089 --- /dev/null +++ b/src/app/api/newsletters/[id]/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/newsletters/[id] — get single newsletter +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const newsletter = await db.newsletter.findUnique({ + where: { id }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + if (!newsletter || newsletter.deletedAt) { + return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 }); + } + + return NextResponse.json(newsletter); + } catch (error) { + console.error('Error fetching newsletter:', error); + return NextResponse.json({ error: 'Failed to fetch newsletter' }, { status: 500 }); + } +} + +// PUT /api/newsletters/[id] — update newsletter +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const body = await req.json(); + + const existing = await db.newsletter.findUnique({ where: { id } }); + if (!existing || existing.deletedAt) { + return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 }); + } + + const data: Record = {}; + if (body.title !== undefined) data.title = body.title; + if (body.content !== undefined) data.content = body.content; + if (body.summary !== undefined) data.summary = body.summary; + if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl; + if (body.category !== undefined) data.category = body.category; + if (body.tags !== undefined) data.tags = JSON.stringify(body.tags); + + const newsletter = await db.newsletter.update({ + where: { id }, + data, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(newsletter); + } catch (error) { + console.error('Error updating newsletter:', error); + return NextResponse.json({ error: 'Failed to update newsletter' }, { status: 500 }); + } +} + +// POST /api/newsletters/[id] — publish/unpublish newsletter +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const body = await req.json(); + + const existing = await db.newsletter.findUnique({ where: { id } }); + if (!existing || existing.deletedAt) { + return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 }); + } + + const isPublished = body.action === 'publish' ? true : body.action === 'unpublish' ? false : !existing.isPublished; + + const newsletter = await db.newsletter.update({ + where: { id }, + data: { + isPublished, + publishedAt: isPublished ? new Date() : null, + }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(newsletter); + } catch (error) { + console.error('Error toggling newsletter publish:', error); + return NextResponse.json({ error: 'Failed to toggle newsletter' }, { status: 500 }); + } +} + +// DELETE /api/newsletters/[id] — soft delete newsletter +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + + const existing = await db.newsletter.findUnique({ where: { id } }); + if (!existing || existing.deletedAt) { + return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 }); + } + + await db.newsletter.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting newsletter:', error); + return NextResponse.json({ error: 'Failed to delete newsletter' }, { status: 500 }); + } +} diff --git a/src/app/api/newsletters/route.ts b/src/app/api/newsletters/route.ts new file mode 100644 index 0000000..d01fa8c --- /dev/null +++ b/src/app/api/newsletters/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/newsletters — list newsletters +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const schoolId = searchParams.get('schoolId'); + const published = searchParams.get('published'); + const category = searchParams.get('category'); + const limit = parseInt(searchParams.get('limit') || '20'); + const offset = parseInt(searchParams.get('offset') || '0'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId required' }, { status: 400 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (published === 'true') { + where.isPublished = true; + } else if (published === 'false') { + where.isPublished = false; + } + + if (category) { + where.category = category; + } + + const [newsletters, total] = await Promise.all([ + db.newsletter.findMany({ + where, + orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }], + skip: offset, + take: limit, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + }, + }), + db.newsletter.count({ where }), + ]); + + return NextResponse.json({ newsletters, total }); + } catch (error) { + console.error('Error fetching newsletters:', error); + return NextResponse.json({ error: 'Failed to fetch newsletters' }, { status: 500 }); + } +} + +// POST /api/newsletters — create newsletter +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { schoolId, authorId, title, content, summary, imageUrl, category, tags, isDemo } = body; + + if (!schoolId || !authorId || !title || !content) { + return NextResponse.json({ error: 'schoolId, authorId, title, content required' }, { status: 400 }); + } + + const newsletter = await db.newsletter.create({ + data: { + schoolId, + authorId, + title, + content, + summary: summary || null, + imageUrl: imageUrl || null, + category: category || 'general', + tags: tags ? JSON.stringify(tags) : null, + isDemo: isDemo || false, + }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(newsletter, { status: 201 }); + } catch (error) { + console.error('Error creating newsletter:', error); + return NextResponse.json({ error: 'Failed to create newsletter' }, { status: 500 }); + } +} diff --git a/src/app/api/students/[id]/details/route.ts b/src/app/api/students/[id]/details/route.ts index de530cd..1154163 100644 --- a/src/app/api/students/[id]/details/route.ts +++ b/src/app/api/students/[id]/details/route.ts @@ -23,6 +23,8 @@ export async function GET( lastName: true, dateOfBirth: true, externalId: true, + avatarUrl: true, + avatarInitials: true, createdAt: true, schoolId: true, school: { select: { id: true, name: true } }, diff --git a/src/app/api/students/route.ts b/src/app/api/students/route.ts index 4cd7c5e..14adf8c 100644 --- a/src/app/api/students/route.ts +++ b/src/app/api/students/route.ts @@ -143,6 +143,7 @@ export async function PUT(request: Request) { .optional() .transform((v) => (v ? new Date(v) : v === null ? null : undefined)), externalId: z.string().nullable().optional(), + avatarUrl: z.string().nullable().optional(), }); const parsed = updateSchema.safeParse(body); diff --git a/src/app/globals.css b/src/app/globals.css index 8788b9f..77b4234 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2509,4 +2509,281 @@ .section-header-icon:hover { opacity: 0.8; } + + /* ─── Gradient Card Background ─────────────────────────────────── */ + .gradient-card-bg { + background: linear-gradient(135deg, oklch(0.97 0.02 155) 0%, oklch(0.98 0.01 175) 100%); + border: 1px solid oklch(0.90 0.02 155); + transition: all 0.3s ease; + } + .gradient-card-bg:hover { + box-shadow: 0 4px 12px oklch(0.55 0.17 155 / 0.1); + } + .dark .gradient-card-bg { + background: linear-gradient(135deg, oklch(0.18 0.02 260) 0%, oklch(0.16 0.01 155) 100%); + border-color: oklch(0.30 0.02 260); + } + + /* ─── Sparkline Mini Chart ─────────────────────────────────────── */ + .sparkline-mini { + display: inline-flex; + align-items: flex-end; + gap: 1px; + height: 16px; + } + .sparkline-mini span { + display: block; + width: 2px; + border-radius: 1px; + background: oklch(0.55 0.17 155); + transition: height 0.3s ease; + } + .dark .sparkline-mini span { + background: oklch(0.65 0.17 155); + } + + /* ─── Grade Color Badges (German 1-6 System) ──────────────────── */ + .grade-badge-1 { + background: linear-gradient(135deg, #10b981, #059669); + color: white; + border: 1px solid #059669; + } + .grade-badge-2 { + background: linear-gradient(135deg, #14b8a6, #0d9488); + color: white; + border: 1px solid #0d9488; + } + .grade-badge-3 { + background: linear-gradient(135deg, #f59e0b, #d97706); + color: white; + border: 1px solid #d97706; + } + .grade-badge-4 { + background: linear-gradient(135deg, #f97316, #ea580c); + color: white; + border: 1px solid #ea580c; + } + .grade-badge-5 { + background: linear-gradient(135deg, #f43f5e, #e11d48); + color: white; + border: 1px solid #e11d48; + } + .grade-badge-6 { + background: linear-gradient(135deg, #dc2626, #b91c1c); + color: white; + border: 1px solid #b91c1c; + } + + /* ─── Mastery Level Badges ─────────────────────────────────────── */ + .mastery-badge-low { + background: linear-gradient(135deg, #fda4af, #fb7185); + color: #9f1239; + border: 1px solid #fda4af; + } + .dark .mastery-badge-low { + color: #fda4af; + background: linear-gradient(135deg, rgba(253,164,175,0.15), rgba(251,113,133,0.15)); + border-color: rgba(251,113,133,0.3); + } + .mastery-badge-medium { + background: linear-gradient(135deg, #fde68a, #fbbf24); + color: #92400e; + border: 1px solid #fde68a; + } + .dark .mastery-badge-medium { + color: #fde68a; + background: linear-gradient(135deg, rgba(253,230,138,0.15), rgba(251,191,36,0.15)); + border-color: rgba(251,191,36,0.3); + } + .mastery-badge-high { + background: linear-gradient(135deg, #6ee7b7, #34d399); + color: #065f46; + border: 1px solid #6ee7b7; + } + .dark .mastery-badge-high { + color: #6ee7b7; + background: linear-gradient(135deg, rgba(110,231,183,0.15), rgba(52,211,153,0.15)); + border-color: rgba(52,211,153,0.3); + } + + /* ─── Newsletter Category Badge ────────────────────────────────── */ + .category-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: all 0.2s ease; + } + .category-badge-general { + background: oklch(0.95 0.01 260); + color: oklch(0.35 0.02 260); + } + .dark .category-badge-general { + background: oklch(0.22 0.02 260); + color: oklch(0.75 0.02 260); + } + .category-badge-academic { + background: #dbeafe; + color: #1e40af; + } + .dark .category-badge-academic { + background: rgba(59,130,246,0.15); + color: #93c5fd; + } + .category-badge-sports { + background: #dcfce7; + color: #166534; + } + .dark .category-badge-sports { + background: rgba(34,197,94,0.15); + color: #86efac; + } + .category-badge-arts { + background: #fae8ff; + color: #86198f; + } + .dark .category-badge-arts { + background: rgba(168,85,247,0.15); + color: #d8b4fe; + } + .category-badge-events { + background: #fef3c7; + color: #92400e; + } + .dark .category-badge-events { + background: rgba(245,158,11,0.15); + color: #fcd34d; + } + .category-badge-community { + background: #ffe4e6; + color: #9f1239; + } + .dark .category-badge-community { + background: rgba(244,63,94,0.15); + color: #fda4af; + } + + /* ─── Newsletter Preview Card ──────────────────────────────────── */ + .newsletter-card { + border-radius: 0.75rem; + overflow: hidden; + transition: all 0.3s ease; + border: 1px solid oklch(0.90 0.01 155); + background: white; + } + .newsletter-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px oklch(0.55 0.17 155 / 0.1); + } + .dark .newsletter-card { + background: oklch(0.18 0.015 260); + border-color: oklch(0.30 0.015 260); + } + + /* ─── Student Avatar Circle ────────────────────────────────────── */ + .avatar-circle { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + font-weight: 600; + font-size: 0.75rem; + line-height: 1; + transition: all 0.2s ease; + user-select: none; + flex-shrink: 0; + } + .avatar-circle:hover { + transform: scale(1.08); + } + + /* ─── Student Photo Circle ─────────────────────────────────────── */ + .avatar-photo { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + overflow: hidden; + transition: all 0.2s ease; + flex-shrink: 0; + } + .avatar-photo img { + width: 100%; + height: 100%; + object-fit: cover; + } + .avatar-photo:hover { + transform: scale(1.08); + box-shadow: 0 0 0 2px oklch(0.55 0.17 155); + } + + /* ─── Animated Section Underline ───────────────────────────────── */ + .animated-underline { + position: relative; + display: inline-block; + } + .animated-underline::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 2px; + background: linear-gradient(90deg, #10b981, #14b8a6); + border-radius: 1px; + transition: width 0.4s ease; + } + .animated-underline:hover::after { + width: 100%; + } + + /* ─── Count-up Animation ───────────────────────────────────────── */ + @keyframes count-up-fade { + 0% { opacity: 0; transform: translateY(8px); } + 100% { opacity: 1; transform: translateY(0); } + } + .animate-count-up { + animation: count-up-fade 0.6s ease-out forwards; + } + + /* ─── Leaf Animation ───────────────────────────────────────────── */ + @keyframes leaf-sway { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(5deg); } + 75% { transform: rotate(-5deg); } + } + .animate-leaf-sway { + animation: leaf-sway 3s ease-in-out infinite; + } + + /* ─── Staggered Card Entrance ──────────────────────────────────── */ + @keyframes card-entrance { + 0% { opacity: 0; transform: translateY(12px) scale(0.97); } + 100% { opacity: 1; transform: translateY(0) scale(1); } + } + .animate-card-entrance { + animation: card-entrance 0.4s ease-out forwards; + } + + /* ─── Gradient Border for Class Cards ──────────────────────────── */ + .gradient-border-card { + position: relative; + border-radius: 0.75rem; + overflow: hidden; + } + .gradient-border-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #10b981, #14b8a6); + border-radius: 0.75rem 0.75rem 0 0; + } } diff --git a/src/components/assessments-view.tsx b/src/components/assessments-view.tsx index af4885f..4dbf820 100644 --- a/src/components/assessments-view.tsx +++ b/src/components/assessments-view.tsx @@ -20,6 +20,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; +import StudentAvatar from '@/components/student-avatar'; import { fetchClasses, fetchClassStudents, fetchSubjects, fetchAssessments, createAssessment, @@ -718,9 +719,7 @@ export default function AssessmentsView() { >
-
- {s.firstName[0]}{s.lastName[0]} -
+ {s.firstName} {s.lastName} {isBest && ( diff --git a/src/components/attendance-view.tsx b/src/components/attendance-view.tsx index 3c7070b..f8de8c3 100644 --- a/src/components/attendance-view.tsx +++ b/src/components/attendance-view.tsx @@ -84,6 +84,7 @@ import { } from '@/components/ui/tooltip'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; +import StudentAvatar from '@/components/student-avatar'; import { fetchAttendanceSessions, createAttendanceSession, @@ -1767,9 +1768,7 @@ export default function AttendanceView() { {sessions[0]?.records.map((record) => (
-
- {record.student.firstName[0]}{record.student.lastName[0]} -
+ {record.student.firstName} {record.student.lastName}
diff --git a/src/components/classes-view.tsx b/src/components/classes-view.tsx index abe5cbf..dd320a4 100644 --- a/src/components/classes-view.tsx +++ b/src/components/classes-view.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { motion } from 'framer-motion'; import { Users, Plus, Search, BookOpen, UserPlus, Grid3X3, School, @@ -10,6 +10,8 @@ import { GraduationCap, Library, Backpack, Sprout, Leaf, TreePine, Trees, QrCode, FileDown, + Shuffle, Printer, Eraser, Columns3, Rows3, Move, + Camera, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -22,6 +24,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; +import StudentAvatar from '@/components/student-avatar'; import { fetchClasses, fetchClassStudents, fetchStudents, createStudent, enrollStudent, fetchSubjects, fetchCompetencyTemplates, createClassCompetencyAssignment, @@ -30,6 +33,7 @@ import { type LearningProgressEntry, type ClassGroup, type Student, type Subject, type CompetencyTemplate, type ClassCompetencyAssignment, } from '@/lib/api'; +import { apiGet, apiPut } from '@/lib/api'; import { generateQRCodeSync, downloadQRCode, type QRCodeData } from '@/lib/qrcode'; import { toast } from 'sonner'; @@ -194,6 +198,19 @@ export default function ClassesView() { const [dragIdx, setDragIdx] = useState(null); const [reorderSaving, setReorderSaving] = useState(false); + // Visual seating chart state + const [seatingChartOpen, setSeatingChartOpen] = useState(false); + const [seatingRows, setSeatingRows] = useState(5); + const [seatingCols, setSeatingCols] = useState(6); + const [seatingGrid, setSeatingGrid] = useState>>([]); + const [seatingSaving, setSeatingSaving] = useState(false); + const [seatingDragStudent, setSeatingDragStudent] = useState(null); + const [seatingDragFrom, setSeatingDragFrom] = useState<{ row: number; col: number } | null>(null); + const [seatingDragOverCell, setSeatingDragOverCell] = useState<{ row: number; col: number } | null>(null); + const [touchDragStudent, setTouchDragStudent] = useState(null); + const [touchDragFrom, setTouchDragFrom] = useState<{ row: number; col: number } | null>(null); + const seatingChartRef = useRef(null); + // QR Code dialog const [classQrOpen, setClassQrOpen] = useState(false); const [classQrDataUrl, setClassQrDataUrl] = useState(''); @@ -544,6 +561,238 @@ export default function ClassesView() { return Math.min((cls.studentCount ?? 0) * 10, 100); }; + // ─── Visual Seating Chart helpers ────────────────────────────────── + interface SeatingPosition { studentId: string; row: number; col: number } + interface SeatingResponse { students: Array<{ id: string; firstName: string; lastName: string }>; seatingOrder: SeatingPosition[]; classId: string } + + const loadSeatingChart = useCallback(async (classId: string) => { + try { + const data = await apiGet(`/api/classes/${classId}/seating`); + const existingOrder = data.seatingOrder || []; + const studentList = data.students || []; + // Determine grid dimensions + let maxRow = 4; + let maxCol = 5; + if (existingOrder.length > 0) { + maxRow = Math.max(maxRow, ...existingOrder.map((p) => p.row)) + 1; + maxCol = Math.max(maxCol, ...existingOrder.map((p) => p.col)) + 1; + } + // Ensure grid fits all students + const totalNeeded = studentList.length; + while (maxRow * maxCol < totalNeeded) { + if (maxCol <= maxRow) maxCol++; + else maxRow++; + } + setSeatingRows(maxRow); + setSeatingCols(maxCol); + // Build the grid + const grid: Array> = Array.from({ length: maxRow }, () => + Array.from({ length: maxCol }, () => null) + ); + if (existingOrder.length > 0) { + for (const pos of existingOrder) { + if (pos.row < maxRow && pos.col < maxCol) { + grid[pos.row][pos.col] = pos.studentId; + } + } + } + // Place any unplaced students in empty cells + const placedIds = new Set(existingOrder.map((p) => p.studentId)); + const unplaced = studentList.filter((s) => !placedIds.has(s.id)); + let r = 0; + let c = 0; + for (const s of unplaced) { + while (r < maxRow && grid[r][c] !== null) { + c++; + if (c >= maxCol) { c = 0; r++; } + } + if (r < maxRow) { + grid[r][c] = s.id; + } + } + setSeatingGrid(grid); + } catch (err) { + console.error('Failed to load seating chart:', err); + // Build a default grid from the current students list + const total = students.length; + const rows = Math.max(1, Math.ceil(total / 6)); + const cols = Math.min(6, total || 6); + setSeatingRows(rows); + setSeatingCols(cols); + const grid: Array> = Array.from({ length: rows }, () => + Array.from({ length: cols }, () => null) + ); + let idx = 0; + for (let ri = 0; ri < rows; ri++) { + for (let ci = 0; ci < cols; ci++) { + if (idx < students.length) { + grid[ri][ci] = students[idx].id; + idx++; + } + } + } + setSeatingGrid(grid); + } + }, [students]); + + const saveSeatingChart = useCallback(async () => { + if (!selectedClass) return; + setSeatingSaving(true); + try { + const positions: SeatingPosition[] = []; + for (let r = 0; r < seatingGrid.length; r++) { + for (let c = 0; c < seatingGrid[r].length; c++) { + if (seatingGrid[r][c]) { + positions.push({ studentId: seatingGrid[r][c]!, row: r, col: c }); + } + } + } + await apiPut<{ success: boolean }>(`/api/classes/${selectedClass.id}/seating`, { seatingOrder: positions }); + toast.success(t('toast.saved')); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('error.generic')); + } finally { + setSeatingSaving(false); + } + }, [selectedClass, seatingGrid, t]); + + const randomizeSeating = useCallback(() => { + const allIds: (string | null)[] = seatingGrid.flat().filter((id): id is string => id !== null); + // Fisher-Yates shuffle + for (let i = allIds.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [allIds[i], allIds[j]] = [allIds[j], allIds[i]]; + } + const newGrid: Array> = Array.from({ length: seatingRows }, () => + Array.from({ length: seatingCols }, () => null) + ); + let idx = 0; + for (let r = 0; r < seatingRows; r++) { + for (let c = 0; c < seatingCols; c++) { + if (idx < allIds.length) { + newGrid[r][c] = allIds[idx]; + idx++; + } + } + } + setSeatingGrid(newGrid); + }, [seatingGrid, seatingRows, seatingCols]); + + const clearSeating = useCallback(() => { + const newGrid: Array> = Array.from({ length: seatingRows }, () => + Array.from({ length: seatingCols }, () => null) + ); + setSeatingGrid(newGrid); + }, [seatingRows, seatingCols]); + + const handleSeatingDragStart = useCallback((studentId: string, row: number, col: number) => { + setSeatingDragStudent(studentId); + setSeatingDragFrom({ row, col }); + }, []); + + const handleSeatingDragOver = useCallback((e: React.DragEvent, row: number, col: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setSeatingDragOverCell({ row, col }); + }, []); + + const handleSeatingDrop = useCallback((row: number, col: number) => { + if (!seatingDragStudent || !seatingDragFrom) return; + const newGrid = seatingGrid.map((r) => [...r]); + // If target has a student, swap them + const targetStudent = newGrid[row][col]; + newGrid[row][col] = seatingDragStudent; + newGrid[seatingDragFrom.row][seatingDragFrom.col] = targetStudent; + setSeatingGrid(newGrid); + setSeatingDragStudent(null); + setSeatingDragFrom(null); + setSeatingDragOverCell(null); + }, [seatingDragStudent, seatingDragFrom, seatingGrid]); + + const handleSeatingDragEnd = useCallback(() => { + setSeatingDragStudent(null); + setSeatingDragFrom(null); + setSeatingDragOverCell(null); + }, []); + + // Touch-friendly drag handlers + const handleTouchStart = useCallback((studentId: string, row: number, col: number) => { + setTouchDragStudent(studentId); + setTouchDragFrom({ row, col }); + }, []); + + const handleTouchEnd = useCallback((row: number, col: number) => { + if (!touchDragStudent || !touchDragFrom) return; + if (touchDragFrom.row === row && touchDragFrom.col === col) { + setTouchDragStudent(null); + setTouchDragFrom(null); + return; + } + const newGrid = seatingGrid.map((r) => [...r]); + const targetStudent = newGrid[row][col]; + newGrid[row][col] = touchDragStudent; + newGrid[touchDragFrom.row][touchDragFrom.col] = targetStudent; + setSeatingGrid(newGrid); + setTouchDragStudent(null); + setTouchDragFrom(null); + }, [touchDragStudent, touchDragFrom, seatingGrid]); + + const handleResizeGrid = useCallback((newRows: number, newCols: number) => { + const r = Math.max(1, Math.min(10, newRows)); + const c = Math.max(1, Math.min(10, newCols)); + setSeatingRows(r); + setSeatingCols(c); + const newGrid: Array> = Array.from({ length: r }, (_, ri) => + Array.from({ length: c }, (_, ci) => + seatingGrid[ri]?.[ci] ?? null + ) + ); + setSeatingGrid(newGrid); + }, [seatingGrid]); + + const handlePrintSeating = useCallback(() => { + if (!seatingChartRef.current) return; + const printWindow = window.open('', '_blank'); + if (!printWindow) return; + const studentMap = new Map(students.map((s) => [s.id, s])); + let html = `${t('seating.title')}`; + html += `

${t('seating.title')} - ${selectedClass?.name || ''}

`; + html += `
${t('seating.rows')}: ${seatingRows} · ${t('seating.columns')}: ${seatingCols}
`; + html += ''; + for (let r = 0; r < seatingGrid.length; r++) { + html += ''; + html += ``; + for (let c = 0; c < seatingGrid[r].length; c++) { + const sid = seatingGrid[r][c]; + if (sid) { + const s = studentMap.get(sid); + html += ``; + } else { + html += ``; + } + } + html += ''; + } + html += '
${r + 1}${s ? `${s.firstName} ${s.lastName}` : ''}${t('seating.empty_seat')}
'; + printWindow.document.write(html); + printWindow.document.close(); + printWindow.print(); + }, [seatingGrid, seatingRows, seatingCols, selectedClass, students, t]); + + const getStudentById = useCallback((id: string | null) => { + if (!id) return null; + return students.find((s) => s.id === id) || null; + }, [students]); + if (loading) { return (
@@ -903,6 +1152,20 @@ export default function ClassesView() { {t('classes.seating_order')} +
{s.lastName}
-
- {s.firstName[0]}{s.lastName[0]} -
+ {s.firstName}
@@ -1167,6 +1433,292 @@ export default function ClassesView() { )} + + {/* ─── Class Photo Gallery ─────────────────────────────────── */} + {students.length > 0 && ( + + + + +
+ +
+ {locale === 'de' ? 'Klassenfoto' : 'Class Photo'} + {students.length} +
+
+ +
+ {students.map((s, idx) => ( + { + useAppStore.getState().navigateToStudentDetail(s.id, 'classes'); + }} + > + + + {s.firstName} + + + ))} +
+
+
+
+ )} + + {/* ─── Interactive Seating Chart ─────────────────────────────────── */} + {seatingChartOpen && selectedClass && ( + + + +
+ +
+ +
+ {t('seating.title')} + {students.length} +
+ +
+ {/* Grid size controls */} +
+
+ + {t('seating.rows')}: + + {seatingRows} + +
+
+
+ + {t('seating.columns')}: + + {seatingCols} + +
+
+
+
+ {/* Action buttons */} +
+ + + + +
+ {/* Hint */} + + + {t('seating.drag_to_rearrange')} + + + + {students.length === 0 ? ( +
+
+ +
+

{t('seating.no_students')}

+
+
+
+
+ ) : ( +
+
+ {/* Column labels */} +
+ {Array.from({ length: seatingCols }).map((_, ci) => ( +
+ {String.fromCharCode(65 + ci)} +
+ ))} +
+ {/* Grid rows */} + {seatingGrid.map((row, ri) => ( +
+ {/* Row label */} +
+ {ri + 1} +
+ {/* Cells */} + {row.map((studentId, ci) => { + const student = getStudentById(studentId); + const isDragOver = seatingDragOverCell?.row === ri && seatingDragOverCell?.col === ci; + const isDragFrom = seatingDragFrom?.row === ri && seatingDragFrom?.col === ci; + const isTouchSelected = touchDragFrom?.row === ri && touchDragFrom?.col === ci; + return ( +
{ + if (!studentId) return; + handleSeatingDragStart(studentId, ri, ci); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', studentId); + }} + onDragOver={(e) => handleSeatingDragOver(e, ri, ci)} + onDragLeave={() => { + if (seatingDragOverCell?.row === ri && seatingDragOverCell?.col === ci) { + setSeatingDragOverCell(null); + } + }} + onDrop={() => handleSeatingDrop(ri, ci)} + onDragEnd={handleSeatingDragEnd} + onTouchStart={() => { + if (studentId) handleTouchStart(studentId, ri, ci); + }} + onTouchEnd={() => handleTouchEnd(ri, ci)} + role="gridcell" + aria-label={student ? `${student.firstName} ${student.lastName} - ${t('seating.row')} ${ri + 1}, ${t('seating.column')} ${String.fromCharCode(65 + ci)}` : t('seating.empty_seat')} + > + {student ? ( + <> +
+ {student.firstName[0]}{student.lastName[0]} +
+ + {student.firstName} + + + {student.lastName} + + + ) : ( +
+ + {t('seating.empty_seat')} +
+ )} +
+ ); + })} +
+ ))} + {/* Teacher desk indicator */} +
+
+ + {t('seating.teacher_desk')} +
+
+
+
+ )} + + + + )} )}
diff --git a/src/components/dashboard-view.tsx b/src/components/dashboard-view.tsx index d437319..043c11a 100644 --- a/src/components/dashboard-view.tsx +++ b/src/components/dashboard-view.tsx @@ -59,6 +59,11 @@ import { Music, Flag, Tent, + Newspaper, + Share2, + PenSquare, + Eye, + Tag, } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -96,6 +101,7 @@ import { useAppStore, type ViewName, type CurrentUser } from '@/lib/store'; import { t } from '@/lib/i18n'; import { fetchDashboard, type DashboardData, getStoredNotifications, type AppNotification, addNotification, markNotificationsRead, fetchParentLinks, type ParentStudentLinkData, fetchStudents } from '@/lib/api'; import { apiGet, apiPost, apiPut } from '@/lib/api'; +import { fetchNewsletters, createNewsletter, type NewsletterData } from '@/lib/api'; const containerVariants = { hidden: { opacity: 0 }, @@ -1815,14 +1821,14 @@ export default function DashboardView() { transition={{ delay: 0.2, duration: 0.4, type: 'spring', stiffness: 150 }} className="flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/30 shrink-0" > - +

{locale === 'de' ? 'Papier gespart' : 'Paper Saved'}

- + {data.stats.totalProgressEntries * 50} @@ -1884,6 +1890,10 @@ export default function DashboardView() {
+ + {/* ===== SCHOOL NEWS / NEWSLETTER SECTION ===== */} + + ); } @@ -2409,3 +2419,258 @@ function DashboardSchoolEventsCard({ currentUser, locale }: { currentUser: Curre ); } + +/* ── Dashboard Newsletter Card ──────────────────────────────────── */ + +const categoryConfig: Record = { + general: { icon: Newspaper, color: 'text-gray-600 dark:text-gray-300', bg: 'category-badge-general' }, + academic: { icon: BookOpen, color: 'text-blue-600 dark:text-blue-400', bg: 'category-badge-academic' }, + sports: { icon: Trophy, color: 'text-green-600 dark:text-green-400', bg: 'category-badge-sports' }, + arts: { icon: Palette, color: 'text-purple-600 dark:text-purple-400', bg: 'category-badge-arts' }, + events: { icon: CalendarDays, color: 'text-amber-600 dark:text-amber-400', bg: 'category-badge-events' }, + community: { icon: UsersIcon, color: 'text-rose-600 dark:text-rose-400', bg: 'category-badge-community' }, +}; + +function DashboardNewsletterCard({ currentUser, locale }: { currentUser: CurrentUser | null; locale: string }) { + const [newsletters, setNewsletters] = useState([]); + const [loading, setLoading] = useState(true); + const [viewingNewsletter, setViewingNewsletter] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [createForm, setCreateForm] = useState({ title: '', content: '', summary: '', category: 'general', imageUrl: '' }); + const [creating, setCreating] = useState(false); + + const isTeacherOrAdmin = currentUser?.role === 'TEACHER' || currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN'; + + useEffect(() => { + if (!currentUser?.schoolId) return; + setLoading(true); + fetchNewsletters(currentUser.schoolId, true, undefined, 3) + .then((data) => { setNewsletters(data.newsletters); setLoading(false); }) + .catch(() => setLoading(false)); + }, [currentUser?.schoolId]); + + const handleCreate = async () => { + if (!currentUser?.schoolId || !createForm.title || !createForm.content) return; + setCreating(true); + try { + const nl = await createNewsletter({ + schoolId: currentUser.schoolId, + authorId: currentUser.id, + title: createForm.title, + content: createForm.content, + summary: createForm.summary || undefined, + category: createForm.category, + imageUrl: createForm.imageUrl || undefined, + }); + await apiPost(`/api/newsletters/${nl.id}`, { action: 'publish' }); + toast.success(t('newsletter.published')); + setCreateOpen(false); + setCreateForm({ title: '', content: '', summary: '', category: 'general', imageUrl: '' }); + const data = await fetchNewsletters(currentUser.schoolId, true, undefined, 3); + setNewsletters(data.newsletters); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Error'); + } finally { + setCreating(false); + } + }; + + const formatDate = (dateStr: string) => { + const d = new Date(dateStr); + return d.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { day: '2-digit', month: 'short', year: 'numeric' }); + }; + + const handleShare = (nl: NewsletterData) => { + if (navigator.share) { + navigator.share({ title: nl.title, text: nl.summary || '', url: window.location.href }); + } else { + navigator.clipboard.writeText(`${nl.title} - ${nl.summary || ''}`); + toast.success(locale === 'de' ? 'Link kopiert!' : 'Link copied!'); + } + }; + + return ( + + + +
+ +
+ +
+ {t('newsletter.school_news')} +
+ {isTeacherOrAdmin && ( + + + + + + + {t('newsletter.create')} + {locale === 'de' ? 'Erstellen Sie einen neuen Schulnachrichten-Eintrag.' : 'Create a new school newsletter entry.'} + +
+
+ + setCreateForm({ ...createForm, title: e.target.value })} placeholder={locale === 'de' ? 'Nachrichtentitel...' : 'Newsletter title...'} className="mt-1" /> +
+
+ + setCreateForm({ ...createForm, summary: e.target.value })} placeholder={locale === 'de' ? 'Kurze Zusammenfassung...' : 'Short summary...'} className="mt-1" /> +
+
+ +