Round 18: Seating Chart, Student Avatars, Newsletter, Badges, Deep Styling Polish

This commit is contained in:
Mustafa Cetin committed 2026-07-29 12:38:19 +00:00
1 parent cba8a8e724
commit 0363a40ca0
18 files changed
+1838 -50

No files matched your search

+2 -3
View File
@@ -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() {
>
<TableCell className="font-semibold">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 text-emerald-600 dark:text-emerald-300 text-xs font-bold shrink-0 ring-1 ring-emerald-200/50 dark:ring-emerald-900/20">
{s.firstName[0]}{s.lastName[0]}
</div>
<StudentAvatar firstName={s.firstName} lastName={s.lastName} avatarUrl={s.avatarUrl} size="xs" />
<span>{s.firstName} {s.lastName}</span>
{isBest && (
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300 text-[10px]">
+2 -3
View File
@@ -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) => (
<div key={record.id} className="flex items-center justify-between p-2 rounded-lg bg-white dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700/50">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-xs font-medium">
{record.student.firstName[0]}{record.student.lastName[0]}
</div>
<StudentAvatar firstName={record.student.firstName} lastName={record.student.lastName} avatarUrl={record.student.avatarUrl} size="sm" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{record.student.firstName} {record.student.lastName}</span>
</div>
<StatusBadge status={record.status} />
+556 -4
View File
@@ -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<number | null>(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<Array<Array<string | null>>>([]);
const [seatingSaving, setSeatingSaving] = useState(false);
const [seatingDragStudent, setSeatingDragStudent] = useState<string | null>(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<string | null>(null);
const [touchDragFrom, setTouchDragFrom] = useState<{ row: number; col: number } | null>(null);
const seatingChartRef = useRef<HTMLDivElement>(null);
// QR Code dialog
const [classQrOpen, setClassQrOpen] = useState(false);
const [classQrDataUrl, setClassQrDataUrl] = useState<string>('');
@@ -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<SeatingResponse>(`/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<string | null>> = 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<string | null>> = 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<string | null>> = 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<string | null>> = 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<string | null>> = 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 = `<!DOCTYPE html><html><head><title>${t('seating.title')}</title><style>
body{font-family:system-ui,sans-serif;padding:20px;color:#1a1a1a}
h1{font-size:20px;margin-bottom:4px}
.subtitle{font-size:12px;color:#666;margin-bottom:16px}
table{border-collapse:collapse;width:100%}
td{border:1px solid #ccc;padding:8px;text-align:center;min-width:60px;height:44px;font-size:12px}
.empty{background:#f5f5f5;color:#999}
.student{background:#f0fdf4;font-weight:600}
.row-label{background:#f9fafb;font-size:10px;color:#999;width:24px}
</style></head><body>`;
html += `<h1>${t('seating.title')} - ${selectedClass?.name || ''}</h1>`;
html += `<div class="subtitle">${t('seating.rows')}: ${seatingRows} · ${t('seating.columns')}: ${seatingCols}</div>`;
html += '<table><tbody>';
for (let r = 0; r < seatingGrid.length; r++) {
html += '<tr>';
html += `<td class="row-label">${r + 1}</td>`;
for (let c = 0; c < seatingGrid[r].length; c++) {
const sid = seatingGrid[r][c];
if (sid) {
const s = studentMap.get(sid);
html += `<td class="student">${s ? `${s.firstName} ${s.lastName}` : ''}</td>`;
} else {
html += `<td class="empty">${t('seating.empty_seat')}</td>`;
}
}
html += '</tr>';
}
html += '</tbody></table></body></html>';
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 (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -903,6 +1152,20 @@ export default function ClassesView() {
<Armchair className="h-4 w-4 mr-1" />
{t('classes.seating_order')}
</Button>
<Button
size="sm"
variant={seatingChartOpen ? 'default' : 'outline'}
className={`rounded-xl ${seatingChartOpen ? 'bg-gradient-to-r from-violet-500 to-violet-600 text-white shadow-md shadow-violet-300/20' : 'border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300'}`}
onClick={() => {
if (!seatingChartOpen && selectedClass) {
loadSeatingChart(selectedClass.id);
}
setSeatingChartOpen(!seatingChartOpen);
}}
>
<Grid3X3 className="h-4 w-4 mr-1" />
{t('seating.visual')}
</Button>
<div className="relative">
<Filter className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-teal-400" />
<Input
@@ -1116,9 +1379,12 @@ export default function ClassesView() {
<TableCell className="font-semibold">{s.lastName}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className={`flex items-center justify-center w-8 h-8 rounded-full bg-gradient-to-br ${avatarGradientFor(s)} text-xs font-bold shrink-0 ring-2 shadow-sm`}>
{s.firstName[0]}{s.lastName[0]}
</div>
<StudentAvatar
firstName={s.firstName}
lastName={s.lastName}
avatarUrl={s.avatarUrl}
size="sm"
/>
{s.firstName}
</div>
</TableCell>
@@ -1167,6 +1433,292 @@ export default function ClassesView() {
)}
</CardContent>
</Card>
{/* ─── Class Photo Gallery ─────────────────────────────────── */}
{students.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden gradient-border-card">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/10">
<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 shadow-emerald-300/20">
<Camera className="h-4 w-4" />
</div>
<span className="animated-underline">{locale === 'de' ? 'Klassenfoto' : 'Class Photo'}</span>
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs font-medium">{students.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
{students.map((s, idx) => (
<motion.div
key={s.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: idx * 0.03, duration: 0.3 }}
className="flex flex-col items-center gap-1 cursor-pointer"
onClick={() => {
useAppStore.getState().navigateToStudentDetail(s.id, 'classes');
}}
>
<StudentAvatar
firstName={s.firstName}
lastName={s.lastName}
avatarUrl={s.avatarUrl}
size="lg"
showTooltip={true}
/>
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium text-center max-w-[3.5rem] truncate">
{s.firstName}
</span>
</motion.div>
))}
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* ─── Interactive Seating Chart ─────────────────────────────────── */}
{seatingChartOpen && selectedClass && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<div className="flex flex-wrap items-center justify-between gap-3">
<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-violet-400 to-violet-500 text-white shadow-sm shadow-violet-300/20">
<Grid3X3 className="h-4 w-4" />
</div>
{t('seating.title')}
<Badge className="bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 text-xs font-medium">{students.length}</Badge>
<div className="h-0.5 w-12 rounded-full bg-gradient-to-r from-violet-400 to-transparent" />
</CardTitle>
<div className="flex gap-2 flex-wrap items-center">
{/* Grid size controls */}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-violet-50/80 dark:bg-violet-900/20 border border-violet-200/50 dark:border-violet-900/30">
<div className="flex items-center gap-1.5">
<Rows3 className="h-3.5 w-3.5 text-violet-500" />
<span className="text-xs font-medium text-violet-700 dark:text-violet-300">{t('seating.rows')}:</span>
<Button
size="sm"
variant="outline"
className="h-7 w-7 p-0 rounded-lg border-violet-200 dark:border-violet-800 text-violet-600 dark:text-violet-400"
onClick={() => handleResizeGrid(seatingRows - 1, seatingCols)}
disabled={seatingRows <= 1}
>
-
</Button>
<span className="text-sm font-bold text-violet-800 dark:text-violet-200 w-5 text-center">{seatingRows}</span>
<Button
size="sm"
variant="outline"
className="h-7 w-7 p-0 rounded-lg border-violet-200 dark:border-violet-800 text-violet-600 dark:text-violet-400"
onClick={() => handleResizeGrid(seatingRows + 1, seatingCols)}
disabled={seatingRows >= 10}
>
+
</Button>
</div>
<div className="w-px h-5 bg-violet-200/50 dark:bg-violet-800/50" />
<div className="flex items-center gap-1.5">
<Columns3 className="h-3.5 w-3.5 text-violet-500" />
<span className="text-xs font-medium text-violet-700 dark:text-violet-300">{t('seating.columns')}:</span>
<Button
size="sm"
variant="outline"
className="h-7 w-7 p-0 rounded-lg border-violet-200 dark:border-violet-800 text-violet-600 dark:text-violet-400"
onClick={() => handleResizeGrid(seatingRows, seatingCols - 1)}
disabled={seatingCols <= 1}
>
-
</Button>
<span className="text-sm font-bold text-violet-800 dark:text-violet-200 w-5 text-center">{seatingCols}</span>
<Button
size="sm"
variant="outline"
className="h-7 w-7 p-0 rounded-lg border-violet-200 dark:border-violet-800 text-violet-600 dark:text-violet-400"
onClick={() => handleResizeGrid(seatingRows, seatingCols + 1)}
disabled={seatingCols >= 10}
>
+
</Button>
</div>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex flex-wrap items-center gap-2 mt-3">
<Button
size="sm"
className="bg-gradient-to-r from-violet-500 to-violet-600 hover:from-violet-600 hover:to-violet-700 text-white rounded-xl shadow-md shadow-violet-300/20 min-h-[44px]"
onClick={saveSeatingChart}
disabled={seatingSaving}
>
{seatingSaving ? (
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-1.5" />
) : null}
{t('seating.save')}
</Button>
<Button
size="sm"
variant="outline"
className="rounded-xl border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300 min-h-[44px]"
onClick={randomizeSeating}
>
<Shuffle className="h-4 w-4 mr-1.5" />
{t('seating.randomize')}
</Button>
<Button
size="sm"
variant="outline"
className="rounded-xl border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300 min-h-[44px]"
onClick={clearSeating}
>
<Eraser className="h-4 w-4 mr-1.5" />
{t('seating.clear')}
</Button>
<Button
size="sm"
variant="outline"
className="rounded-xl border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300 min-h-[44px]"
onClick={handlePrintSeating}
>
<Printer className="h-4 w-4 mr-1.5" />
{t('seating.print')}
</Button>
</div>
{/* Hint */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-3 px-3 py-2 rounded-lg bg-violet-50/60 dark:bg-violet-900/10 border border-violet-200/30 dark:border-violet-900/20 text-xs text-violet-600 dark:text-violet-400 flex items-center gap-2"
>
<Move className="h-3.5 w-3.5" />
<span>{t('seating.drag_to_rearrange')}</span>
</motion.div>
</CardHeader>
<CardContent>
{students.length === 0 ? (
<div className="text-center py-10">
<div className="relative flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-violet-100 to-violet-100 dark:from-violet-900/30 dark:to-violet-900/30 mx-auto mb-5 shadow-lg shadow-violet-200/50 dark:shadow-violet-900/20 ring-4 ring-violet-50 dark:ring-violet-900/30">
<Grid3X3 className="h-10 w-10 text-violet-500 dark:text-violet-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{t('seating.no_students')}</p>
<div className="flex justify-center">
<div className="h-1 w-16 rounded-full bg-gradient-to-r from-violet-300 to-violet-400 dark:from-violet-600 dark:to-violet-700" />
</div>
</div>
) : (
<div ref={seatingChartRef} className="overflow-x-auto">
<div className="min-w-fit">
{/* Column labels */}
<div className="flex gap-1 mb-1 pl-8">
{Array.from({ length: seatingCols }).map((_, ci) => (
<div
key={`col-${ci}`}
className="flex-1 min-w-[72px] text-center text-[10px] font-semibold uppercase tracking-wider text-violet-400/60 dark:text-violet-500/40"
>
{String.fromCharCode(65 + ci)}
</div>
))}
</div>
{/* Grid rows */}
{seatingGrid.map((row, ri) => (
<div key={`row-${ri}`} className="flex gap-1 mb-1">
{/* Row label */}
<div className="w-7 flex items-center justify-center text-[10px] font-semibold text-violet-400/60 dark:text-violet-500/40 shrink-0">
{ri + 1}
</div>
{/* 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 (
<div
key={`cell-${ri}-${ci}`}
className={`flex-1 min-w-[72px] min-h-[72px] rounded-xl border-2 transition-all duration-200 flex flex-col items-center justify-center gap-1 cursor-grab active:cursor-grabbing select-none ${
student
? `bg-gradient-to-br ${avatarGradientFor(student)} border-transparent shadow-sm ${
isDragFrom || isTouchSelected
? 'opacity-50 scale-95 ring-2 ring-violet-400 ring-offset-2'
: isDragOver
? 'ring-2 ring-violet-400 ring-offset-2 scale-105'
: 'hover:shadow-md hover:scale-[1.03]'
}`
: `bg-gray-50 dark:bg-gray-800/40 border-dashed border-gray-200 dark:border-gray-700 ${
isDragOver
? 'ring-2 ring-violet-400 ring-offset-2 bg-violet-50/50 dark:bg-violet-900/10 border-violet-300 dark:border-violet-700'
: ''
}`
}`}
draggable={!!studentId}
onDragStart={(e) => {
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 ? (
<>
<div className={`flex items-center justify-center w-10 h-10 rounded-full bg-gradient-to-br ${avatarGradientFor(student)} text-xs font-bold ring-2 ring-white/50 dark:ring-gray-800/50 shadow-sm`}>
{student.firstName[0]}{student.lastName[0]}
</div>
<span className="text-[10px] font-medium text-center leading-tight truncate max-w-[68px]">
{student.firstName}
</span>
<span className="text-[9px] text-gray-500 dark:text-gray-400 text-center leading-tight truncate max-w-[68px]">
{student.lastName}
</span>
</>
) : (
<div className="flex flex-col items-center gap-1 text-gray-300 dark:text-gray-600">
<Armchair className="h-5 w-5" />
<span className="text-[9px]">{t('seating.empty_seat')}</span>
</div>
)}
</div>
);
})}
</div>
))}
{/* Teacher desk indicator */}
<div className="flex justify-center mt-4">
<div className="px-6 py-2 rounded-xl bg-gradient-to-r from-violet-100 to-violet-50 dark:from-violet-900/20 dark:to-violet-900/10 border border-violet-200/50 dark:border-violet-900/30 text-xs font-medium text-violet-600 dark:text-violet-400 flex items-center gap-2">
<School className="h-3.5 w-3.5" />
{t('seating.teacher_desk')}
</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</motion.div>
)}
</>
)}
</div>
+267 -2
View File
@@ -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"
>
<Leaf className="h-8 w-8" />
<Leaf className="h-8 w-8 animate-leaf-sway" />
</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'}
</h3>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900 dark:text-gray-100">
<span className="text-3xl font-bold text-gray-900 dark:text-gray-100 animate-count-up">
{data.stats.totalProgressEntries * 50}
</span>
<span className="text-sm text-emerald-600/70 dark:text-emerald-400/60 font-medium">
@@ -1884,6 +1890,10 @@ export default function DashboardView() {
</Card>
</motion.div>
</div>
{/* ===== SCHOOL NEWS / NEWSLETTER SECTION ===== */}
<DashboardNewsletterCard currentUser={currentUser} locale={locale} />
</motion.div>
);
}
@@ -2409,3 +2419,258 @@ function DashboardSchoolEventsCard({ currentUser, locale }: { currentUser: Curre
</motion.div>
);
}
/* ── Dashboard Newsletter Card ──────────────────────────────────── */
const categoryConfig: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
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<NewsletterData[]>([]);
const [loading, setLoading] = useState(true);
const [viewingNewsletter, setViewingNewsletter] = useState<NewsletterData | null>(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 (
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-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-emerald-50/50 to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/10">
<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-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
<Newspaper className="h-4 w-4" />
</div>
<span className="animated-underline">{t('newsletter.school_news')}</span>
</CardTitle>
{isTeacherOrAdmin && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="min-h-[44px] text-emerald-600 dark:text-emerald-400 border-emerald-200/50 dark:border-emerald-900/30 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
<Plus className="h-3 w-3 mr-1" /> {t('newsletter.create')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('newsletter.create')}</DialogTitle>
<DialogDescription>{locale === 'de' ? 'Erstellen Sie einen neuen Schulnachrichten-Eintrag.' : 'Create a new school newsletter entry.'}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="text-sm font-medium">{locale === 'de' ? 'Titel' : 'Title'}</Label>
<Input value={createForm.title} onChange={(e) => setCreateForm({ ...createForm, title: e.target.value })} placeholder={locale === 'de' ? 'Nachrichtentitel...' : 'Newsletter title...'} className="mt-1" />
</div>
<div>
<Label className="text-sm font-medium">{t('newsletter.summary')}</Label>
<Input value={createForm.summary} onChange={(e) => setCreateForm({ ...createForm, summary: e.target.value })} placeholder={locale === 'de' ? 'Kurze Zusammenfassung...' : 'Short summary...'} className="mt-1" />
</div>
<div>
<Label className="text-sm font-medium">{t('newsletter.content')}</Label>
<Textarea value={createForm.content} onChange={(e) => setCreateForm({ ...createForm, content: e.target.value })} placeholder={locale === 'de' ? 'Inhalt der Nachricht...' : 'Newsletter content...'} className="mt-1 min-h-[120px]" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm font-medium">{t('newsletter.category')}</Label>
<Select value={createForm.category} onValueChange={(v) => setCreateForm({ ...createForm, category: v })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(categoryConfig).map(([key, cfg]) => (
<SelectItem key={key} value={key}>
<span className="flex items-center gap-2"><cfg.icon className="h-3.5 w-3.5" /> {t(`newsletter.${key}`)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm font-medium">{t('newsletter.cover_image')}</Label>
<Input value={createForm.imageUrl} onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })} placeholder="URL..." className="mt-1" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)} className="min-h-[44px]">{t('action.cancel')}</Button>
<Button onClick={handleCreate} disabled={creating || !createForm.title || !createForm.content} className="min-h-[44px] bg-gradient-to-r from-emerald-500 to-teal-500 text-white">
{creating ? '...' : t('newsletter.publish')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-48 w-full rounded-xl" />)}
</div>
) : newsletters.length === 0 ? (
<div className="text-center py-10">
<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">
<Newspaper 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('newsletter.no_news')}</p>
{isTeacherOrAdmin && (
<Button size="sm" variant="outline" className="mt-3 min-h-[44px] text-emerald-600 border-emerald-200/50" onClick={() => setCreateOpen(true)}>
<Plus className="h-3 w-3 mr-1" /> {t('newsletter.create')}
</Button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{newsletters.map((nl) => {
const cfg = categoryConfig[nl.category] || categoryConfig.general;
const Icon = cfg.icon;
return (
<motion.div
key={nl.id}
whileHover={{ y: -4, scale: 1.02 }}
transition={{ duration: 0.2 }}
className="newsletter-card"
>
{nl.imageUrl && (
<div className="h-28 overflow-hidden bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/20 dark:to-teal-900/20">
<img src={nl.imageUrl} alt="" className="w-full h-full object-cover" />
</div>
)}
{!nl.imageUrl && (
<div className="h-16 bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/20 dark:to-teal-900/20 flex items-center justify-center">
<Icon className="h-6 w-6 text-emerald-400 dark:text-emerald-500" />
</div>
)}
<div className="p-4">
<div className="flex items-center gap-2 mb-2">
<span className={`category-badge ${cfg.bg}`}>
<Icon className="h-3 w-3" />
{t(`newsletter.${nl.category}`)}
</span>
<span className="text-[10px] text-gray-400 dark:text-gray-500">{formatDate(nl.publishedAt || nl.createdAt)}</span>
</div>
<h4 className="text-sm font-bold text-gray-900 dark:text-gray-100 line-clamp-2 mb-1">{nl.title}</h4>
{nl.summary && (
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mb-3">{nl.summary}</p>
)}
<div className="flex items-center justify-between">
<Button size="sm" variant="ghost" className="min-h-[36px] text-emerald-600 dark:text-emerald-400 text-xs p-0 h-auto hover:bg-transparent" onClick={() => setViewingNewsletter(nl)}>
<Eye className="h-3 w-3 mr-1" /> {t('newsletter.read_more')}
</Button>
<Button size="sm" variant="ghost" className="min-h-[36px] text-gray-400 h-auto p-0 hover:bg-transparent" onClick={() => handleShare(nl)}>
<Share2 className="h-3 w-3" />
</Button>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Newsletter View Dialog */}
<Dialog open={!!viewingNewsletter} onOpenChange={() => setViewingNewsletter(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
{viewingNewsletter && (
<>
<DialogHeader>
<DialogTitle className="text-xl">{viewingNewsletter.title}</DialogTitle>
<DialogDescription className="flex items-center gap-3 mt-2">
<span className={`category-badge ${categoryConfig[viewingNewsletter.category]?.bg || 'category-badge-general'}`}>
{(() => { const Cfg = categoryConfig[viewingNewsletter.category] || categoryConfig.general; return <Cfg.icon className="h-3 w-3" />; })()}
{t(`newsletter.${viewingNewsletter.category}`)}
</span>
<span className="text-xs text-gray-500">{formatDate(viewingNewsletter.publishedAt || viewingNewsletter.createdAt)}</span>
<span className="text-xs text-gray-500">{viewingNewsletter.author.firstName} {viewingNewsletter.author.lastName}</span>
</DialogDescription>
</DialogHeader>
{viewingNewsletter.imageUrl && (
<div className="rounded-xl overflow-hidden mb-4">
<img src={viewingNewsletter.imageUrl} alt="" className="w-full h-48 object-cover" />
</div>
)}
<div className="prose prose-sm dark:prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: viewingNewsletter.content }} />
{viewingNewsletter.tags && (() => {
try {
const parsed = JSON.parse(viewingNewsletter.tags);
if (Array.isArray(parsed) && parsed.length > 0) {
return (
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
<Tag className="h-3.5 w-3.5 text-gray-400" />
{parsed.map((tag: string, i: number) => (
<Badge key={i} variant="outline" className="text-[10px]">{tag}</Badge>
))}
</div>
);
}
} catch { /* ignore */ }
return null;
})()}
<div className="flex justify-end mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
<Button size="sm" variant="outline" className="min-h-[44px]" onClick={() => handleShare(viewingNewsletter)}>
<Share2 className="h-3.5 w-3.5 mr-1.5" /> {t('newsletter.share')}
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
</motion.div>
);
}
+11 -10
View File
@@ -24,6 +24,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Textarea } from '@/components/ui/textarea';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import StudentAvatar from '@/components/student-avatar';
import {
fetchClasses, fetchClassStudents, fetchSubjects,
fetchGradingSchemes, createGradingScheme,
@@ -34,10 +35,12 @@ import {
import { toast } from 'sonner';
const gradeColor = (value: number) => {
if (value <= 1.5) return { bg: 'bg-gradient-to-br from-emerald-400 to-emerald-500', text: 'text-white', shadow: 'shadow-emerald-300/30', border: 'border-l-emerald-500' };
if (value <= 2.5) return { bg: 'bg-gradient-to-br from-teal-400 to-teal-500', text: 'text-white', shadow: 'shadow-teal-300/30', border: 'border-l-teal-500' };
if (value <= 3.5) return { bg: 'bg-gradient-to-br from-amber-400 to-amber-500', text: 'text-white', shadow: 'shadow-amber-300/30', border: 'border-l-amber-500' };
return { bg: 'bg-gradient-to-br from-red-400 to-red-500', text: 'text-white', shadow: 'shadow-red-300/30', border: 'border-l-red-500' };
if (value <= 1.5) return { bg: 'bg-gradient-to-br from-emerald-400 to-emerald-500', text: 'text-white', shadow: 'shadow-emerald-300/30', border: 'border-l-emerald-500', badge: 'grade-badge-1', label: t('grading.sehr_gut') };
if (value <= 2.5) return { bg: 'bg-gradient-to-br from-teal-400 to-teal-500', text: 'text-white', shadow: 'shadow-teal-300/30', border: 'border-l-teal-500', badge: 'grade-badge-2', label: t('grading.gut') };
if (value <= 3.5) return { bg: 'bg-gradient-to-br from-amber-400 to-amber-500', text: 'text-white', shadow: 'shadow-amber-300/30', border: 'border-l-amber-500', badge: 'grade-badge-3', label: t('grading.befriedigend') };
if (value <= 4.5) return { bg: 'bg-gradient-to-br from-orange-400 to-orange-500', text: 'text-white', shadow: 'shadow-orange-300/30', border: 'border-l-orange-500', badge: 'grade-badge-4', label: t('grading.ausreichend') };
if (value <= 5.5) return { bg: 'bg-gradient-to-br from-rose-400 to-rose-500', text: 'text-white', shadow: 'shadow-rose-300/30', border: 'border-l-rose-500', badge: 'grade-badge-5', label: t('grading.mangelhaft') };
return { bg: 'bg-gradient-to-br from-red-500 to-red-600', text: 'text-white', shadow: 'shadow-red-300/30', border: 'border-l-red-500', badge: 'grade-badge-6', label: t('grading.ungenuegend') };
};
const GradeIcon = ({ value, className = 'w-3 h-3' }: { value: number; className?: string }) => {
@@ -714,17 +717,15 @@ export default function GradingView() {
<TableRow key={grade.studentId} className={`hover:bg-teal-50/50 dark:hover:bg-teal-900/10 transition-all duration-150 ${idx % 2 === 1 ? 'bg-teal-50/20 dark:bg-teal-900/5' : ''}`}>
<TableCell className="font-semibold">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 text-emerald-600 dark:text-emerald-300 text-xs font-bold shrink-0 ring-1 ring-emerald-200/50 dark:ring-emerald-900/20">
{student ? `${student.firstName[0]}${student.lastName[0]}` : '?'}
</div>
<StudentAvatar firstName={student?.firstName ?? ''} lastName={student?.lastName ?? ''} avatarUrl={student?.avatarUrl} size="sm" />
{student ? `${student.firstName} ${student.lastName}` : grade.studentId}
</div>
</TableCell>
<TableCell className="text-center">
<div className={`inline-flex items-center justify-center w-14 h-14 rounded-xl bg-gradient-to-br ${colors.bg} ${colors.text} shadow-lg ${colors.shadow} font-bold text-lg animate-pulse-soft relative`}>
<div className={`inline-flex items-center justify-center w-14 h-14 rounded-xl ${colors.badge} shadow-lg font-bold text-lg relative`}>
{grade.computedValue.toFixed(1)}
<span className="absolute -bottom-1 -right-1 text-[9px] bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 px-1 rounded-md shadow-sm border border-gray-200 dark:border-gray-700">
<GradeIcon value={grade.computedValue} className="w-3 h-3" />
<span className="absolute -bottom-4 text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
{colors.label}
</span>
</div>
</TableCell>
+6 -10
View File
@@ -30,6 +30,7 @@ import {
} from '@/components/ui/alert-dialog';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import StudentAvatar from '@/components/student-avatar';
import {
fetchClasses, fetchClassStudents, fetchSubjects,
fetchClassCompetencyAssignments, fetchCompetencyTemplate,
@@ -68,13 +69,10 @@ function getMasteryIcon(level: number) {
}
const masteryBadge = (level: number) => {
const styles = [
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300',
];
return styles[Math.min(level - 1, 3)] ?? styles[0];
// Use CSS class-based mastery badges for deeper polish
if (level <= 2) return 'mastery-badge-low';
if (level <= 4) return 'mastery-badge-medium';
return 'mastery-badge-high';
};
const masteryBorderColor = (level: number) => {
@@ -636,9 +634,7 @@ export default function ProgressEntriesView() {
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{/* Avatar circle with gradient initials */}
<div className={`flex items-center justify-center w-9 h-9 rounded-full bg-gradient-to-br ${avatarGradientFor(s)} text-sm font-bold shrink-0 ring-2 shadow-sm`}>
{s.firstName[0]}{s.lastName[0]}
</div>
<StudentAvatar firstName={s.firstName} lastName={s.lastName} avatarUrl={s.avatarUrl} size="sm" />
<div className="min-w-0">
<p className="font-semibold text-gray-900 dark:text-gray-100 truncate">{s.firstName} {s.lastName}</p>
{/* Entry count badge + sparkline */}
+3 -6
View File
@@ -33,6 +33,7 @@ import {
} from '@/components/ui/alert-dialog';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import StudentAvatar from '@/components/student-avatar';
import {
fetchClasses, fetchClassStudents,
fetchReports, createReport, updateReport, getReportPdfUrl,
@@ -633,9 +634,7 @@ export default function ReportsView() {
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gradient-to-br from-amber-100 to-amber-200 dark:from-amber-900/30 dark:to-amber-800/30 text-amber-600 dark:text-amber-300 text-xs font-bold shrink-0">
{r.student.firstName[0]}{r.student.lastName[0]}
</div>
<StudentAvatar firstName={r.student.firstName} lastName={r.student.lastName} avatarUrl={r.student.avatarUrl} size="sm" />
<p className="font-semibold text-gray-900 dark:text-gray-100">
{r.student.firstName} {r.student.lastName}
</p>
@@ -672,9 +671,7 @@ export default function ReportsView() {
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gradient-to-br from-emerald-100 to-emerald-200 dark:from-emerald-900/30 dark:to-emerald-800/30 text-emerald-600 dark:text-emerald-300 text-xs font-bold shrink-0">
{r.student.firstName[0]}{r.student.lastName[0]}
</div>
<StudentAvatar firstName={r.student.firstName} lastName={r.student.lastName} avatarUrl={r.student.avatarUrl} size="sm" />
<p className="font-semibold text-gray-900 dark:text-gray-100">
{r.student.firstName} {r.student.lastName}
</p>
+113
View File
@@ -0,0 +1,113 @@
'use client';
import React from 'react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
// ─── Name-based color hash ─────────────────────────────────────────
const avatarGradients = [
'from-emerald-100 to-teal-100 dark:from-emerald-900/40 dark:to-teal-900/40 text-emerald-700 dark:text-emerald-300 ring-emerald-200/50 dark:ring-emerald-900/30',
'from-teal-100 to-emerald-100 dark:from-teal-900/40 dark:to-emerald-900/40 text-teal-700 dark:text-teal-300 ring-teal-200/50 dark:ring-teal-900/30',
'from-amber-100 to-rose-100 dark:from-amber-900/40 dark:to-rose-900/40 text-amber-700 dark:text-amber-300 ring-amber-200/50 dark:ring-amber-900/30',
'from-violet-100 to-rose-100 dark:from-violet-900/40 dark:to-rose-900/40 text-violet-700 dark:text-violet-300 ring-violet-200/50 dark:ring-violet-900/30',
'from-rose-100 to-amber-100 dark:from-rose-900/40 dark:to-amber-900/40 text-rose-700 dark:text-rose-300 ring-rose-200/50 dark:ring-rose-900/30',
'from-cyan-100 to-teal-100 dark:from-cyan-900/40 dark:to-teal-900/40 text-cyan-700 dark:text-cyan-300 ring-cyan-200/50 dark:ring-cyan-900/30',
'from-pink-100 to-rose-100 dark:from-pink-900/40 dark:to-rose-900/40 text-pink-700 dark:text-pink-300 ring-pink-200/50 dark:ring-pink-900/30',
'from-orange-100 to-amber-100 dark:from-orange-900/40 dark:to-amber-900/40 text-orange-700 dark:text-orange-300 ring-orange-200/50 dark:ring-orange-900/30',
];
function hashString(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
function getAvatarGradient(firstName: string, lastName: string): string {
const hash = hashString(`${firstName} ${lastName}`);
return avatarGradients[hash % avatarGradients.length];
}
function getInitials(firstName: string, lastName: string): string {
const first = firstName?.charAt(0)?.toUpperCase() || '';
const last = lastName?.charAt(0)?.toUpperCase() || '';
return first + last;
}
interface StudentAvatarProps {
firstName: string;
lastName: string;
avatarUrl?: string | null;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
showTooltip?: boolean;
onClick?: () => void;
className?: string;
}
const sizeClasses: Record<string, string> = {
xs: 'w-6 h-6 text-[8px]',
sm: 'w-8 h-8 text-[10px]',
md: 'w-10 h-10 text-xs',
lg: 'w-14 h-14 text-sm',
xl: 'w-20 h-20 text-base',
};
export default function StudentAvatar({
firstName,
lastName,
avatarUrl,
size = 'md',
showTooltip = false,
onClick,
className = '',
}: StudentAvatarProps) {
const gradient = getAvatarGradient(firstName, lastName);
const initials = getInitials(firstName, lastName);
const sizeClass = sizeClasses[size];
const avatarContent = avatarUrl ? (
<div
className={`avatar-photo ${sizeClass} ${className} ${onClick ? 'cursor-pointer' : ''}`}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick(); } : undefined}
>
<img src={avatarUrl} alt={`${firstName} ${lastName}`} className="rounded-full" />
</div>
) : (
<div
className={`avatar-circle bg-gradient-to-br ${gradient} ring-2 ${sizeClass} ${className} ${onClick ? 'cursor-pointer' : ''}`}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick(); } : undefined}
aria-label={`${firstName} ${lastName}`}
>
{initials}
</div>
);
if (showTooltip) {
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
{avatarContent}
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{firstName} {lastName}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return avatarContent;
}
// Export helper for getting initials without the component
export { getInitials, getAvatarGradient, hashString };
+83 -3
View File
@@ -22,6 +22,7 @@ import {
Bus, Car, Bike, Footprints, Train, Truck, Droplets, Pill, Stethoscope,
EyeOff, Shield, Activity, HeartPulse, Syringe,
QrCode, CalendarCheck, Leaf,
Camera, Upload,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -39,6 +40,7 @@ import {
} from '@/components/ui/tooltip';
import { useAppStore, type CurrentUser } from '@/lib/store';
import { t } from '@/lib/i18n';
import StudentAvatar from '@/components/student-avatar';
import { fetchStudentDetail, getReportPdfUrl, type StudentDetailData, fetchParentLinks, type ParentStudentLinkData, apiGet, apiPost, apiPut, apiDelete, fetchBadgeProgress, type BadgeProgressData, fetchStudentBadges, type StudentBadgeData, type BadgeData } from '@/lib/api';
import { generateQRCodeSync, downloadQRCode, type QRCodeData } from '@/lib/qrcode';
import { toast } from 'sonner';
@@ -519,6 +521,11 @@ export default function StudentDetailView() {
const [badgeDetailDialogOpen, setBadgeDetailDialogOpen] = useState(false);
const [selectedBadgeDetail, setSelectedBadgeDetail] = useState<BadgeProgressData | null>(null);
// Avatar upload dialog
const [avatarUploadOpen, setAvatarUploadOpen] = useState(false);
const [avatarUrlInput, setAvatarUrlInput] = useState('');
const [avatarUploading, setAvatarUploading] = useState(false);
// Load parent links for parent users
useEffect(() => {
if (isParent && currentUser?.id) {
@@ -758,13 +765,26 @@ export default function StudentDetailView() {
<div className="relative shrink-0">
{/* Decorative halo */}
<div className="absolute -inset-1.5 rounded-3xl bg-gradient-to-br from-emerald-400/30 via-teal-400/20 to-amber-400/20 dark:from-emerald-500/20 dark:to-teal-500/10 blur-md animate-pulse-soft" />
<div className="relative flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 via-teal-50 to-emerald-50 dark:from-emerald-900/40 dark:via-teal-900/30 dark:to-emerald-900/20 text-emerald-700 dark:text-emerald-200 text-2xl font-bold ring-2 ring-emerald-200/60 dark:ring-emerald-700/40 shadow-lg shadow-emerald-200/40 dark:shadow-emerald-900/30">
{initials}
</div>
<StudentAvatar
firstName={student.firstName}
lastName={student.lastName}
avatarUrl={student.avatarUrl}
size="xl"
className="w-20 h-20 text-2xl rounded-3xl ring-2 ring-emerald-200/60 dark:ring-emerald-700/40 shadow-lg shadow-emerald-200/40 dark:shadow-emerald-900/30"
/>
{/* Mastery badge */}
<div className="absolute -bottom-1.5 -right-1.5 flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-amber-300 to-amber-500 dark:from-amber-400 dark:to-amber-600 ring-2 ring-white dark:ring-gray-900 shadow-sm text-xs">
<Star className="w-3.5 h-3.5 text-amber-900" />
</div>
{/* Upload button */}
<button
className="absolute -top-1.5 -right-1.5 flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white ring-2 ring-white dark:ring-gray-900 shadow-md hover:scale-110 transition-transform"
onClick={() => setAvatarUploadOpen(true)}
title={t('avatar.change')}
aria-label={t('avatar.change')}
>
<Camera className="w-3.5 h-3.5" />
</button>
</div>
<div>
<h2 className="text-2xl font-bold gradient-text">{studentName}</h2>
@@ -3367,6 +3387,66 @@ export default function StudentDetailView() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Avatar Upload Dialog */}
<Dialog open={avatarUploadOpen} onOpenChange={setAvatarUploadOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('avatar.upload')}</DialogTitle>
<DialogDescription>{t('avatar.change')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-4">
<StudentAvatar
firstName={student?.firstName ?? ''}
lastName={student?.lastName ?? ''}
avatarUrl={avatarUrlInput || student?.avatarUrl}
size="xl"
/>
<div className="flex-1">
<Label className="text-sm font-medium">URL</Label>
<Input
value={avatarUrlInput}
onChange={(e) => setAvatarUrlInput(e.target.value)}
placeholder="https://example.com/photo.jpg"
className="mt-1"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{locale === 'de' ? 'Geben Sie eine URL für das Schülerfoto ein.' : 'Enter a URL for the student photo.'}
</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setAvatarUploadOpen(false); setAvatarUrlInput(''); }} className="min-h-[44px]">{t('action.cancel')}</Button>
<Button
onClick={async () => {
if (!student?.id) return;
setAvatarUploading(true);
try {
await apiPut(`/api/students/${student.id}`, { avatarUrl: avatarUrlInput || null });
toast.success(t('toast.saved'));
setAvatarUploadOpen(false);
setAvatarUrlInput('');
// Reload student detail
if (studentId) {
const fresh = await fetchStudentDetail(studentId);
setStudent(fresh);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
} finally {
setAvatarUploading(false);
}
}}
disabled={avatarUploading}
className="min-h-[44px] bg-gradient-to-r from-emerald-500 to-teal-500 text-white"
>
{avatarUploading ? '...' : t('action.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}