Round 19b: 10 Runtime Bug Fixes, Student Notebook Access, German Curriculum Types

Bug Fixes:
- Fixed duplicate i18n keys (notebooks.type_lined etc.)
- Fixed settings-view.tsx: studentsList undefined → students
- Fixed student-detail-view.tsx: pe.competencyId → pe.competency.id
- Fixed student-detail-view.tsx: locale variable not in scope
- Fixed student-detail-view.tsx: studentId/setStudent undefined
- Fixed notebooks-view.tsx: onClick={onAddPage} passing MouseEvent
- Fixed curriculum-coverage-view.tsx: Map import shadowing built-in
- Fixed api.ts: avatarUrl missing from AttendanceRecord/Report types
- Fixed badge-check.ts: invalid Prisma queries with wrong field names

Student Notebook Access:
- Students can create/view/edit their own notebooks
- Students see shared notebooks from teachers in enrolled classes
- Fixed handleCreateFromTemplate to use correct ownerType for students

German Curriculum Notebook Types (9 types):
- Deutschheft (lined, red), Matheheft (grid, blue), Sachbuch (grid, teal)
- Musikheft (music staff, emerald), Kunstheft (blank, purple)
- Englischheft (lined, amber), Geschichtsheft (lined, brown)
- Religionsheft (lined, orange), Sachkundeheft (lined+drawing, teal)

Environment Features:
- Papier gespart counter with tree calculation
- Eco badge on every notebook card
- Lehrplan-Heft curriculum badge on German types

60+ new i18n keys (DE/EN)
This commit is contained in:
Mustafa Cetin committed 2026-07-29 16:44:13 +00:00
1 parent 9b2168d775
commit 58cb7afd81
14 files changed
+593 -64

No files matched your search

+3 -3
View File
@@ -121,7 +121,7 @@ export async function GET(request: Request) {
teacher: { select: { id: true, firstName: true, lastName: true } },
records: {
include: {
student: { select: { id: true, firstName: true, lastName: true } },
student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } },
},
orderBy: { student: { lastName: 'asc' } },
},
@@ -237,7 +237,7 @@ export async function POST(request: Request) {
teacher: { select: { id: true, firstName: true, lastName: true } },
records: {
include: {
student: { select: { id: true, firstName: true, lastName: true } },
student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } },
},
orderBy: { student: { lastName: 'asc' } },
},
@@ -341,7 +341,7 @@ export async function PUT(request: Request) {
teacher: { select: { id: true, firstName: true, lastName: true } },
records: {
include: {
student: { select: { id: true, firstName: true, lastName: true } },
student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } },
},
orderBy: { student: { lastName: 'asc' } },
},
+7 -1
View File
@@ -58,7 +58,13 @@ const updateNotebookSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(1000).optional().nullable(),
notebookType: z
.enum(['lined', 'grid', 'blank', 'dotted', 'music', 'calligraphy'])
.enum([
'lined', 'grid', 'blank', 'dotted', 'music', 'calligraphy',
// German curriculum types
'deutschheft', 'matheheft', 'sachbuch', 'musikheft',
'kunstheft', 'englischheft', 'geschichtsheft', 'religionsheft',
'sachkundeheft',
])
.optional(),
color: z.string().optional(),
icon: z.string().optional().nullable(),
+7 -1
View File
@@ -150,7 +150,13 @@ const createNotebookSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(1000).optional().nullable(),
notebookType: z
.enum(['lined', 'grid', 'blank', 'dotted', 'music', 'calligraphy'])
.enum([
'lined', 'grid', 'blank', 'dotted', 'music', 'calligraphy',
// German curriculum types
'deutschheft', 'matheheft', 'sachbuch', 'musikheft',
'kunstheft', 'englischheft', 'geschichtsheft', 'religionsheft',
'sachkundeheft',
])
.default('lined'),
color: z.string().default('#10b981'),
icon: z.string().optional().nullable(),
+55 -4
View File
@@ -2,7 +2,8 @@ import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── GET: Get public notebooks shared by other teachers in the same school ──
// ── GET: Get public notebooks shared by others in the same school ──
// Teachers see other teachers' notebooks; students see teacher notebooks shared with their classes
export async function GET(request: Request) {
try {
const session = await getSession();
@@ -20,19 +21,69 @@ export async function GET(request: Request) {
);
}
// Only show public notebooks from other teachers in the same school
const userRole = session.user.role;
if (userRole === 'STUDENT') {
// Students see public notebooks from teachers in their enrolled classes
const matchingStudents = await db.student.findMany({
where: {
schoolId,
firstName: session.user.firstName,
lastName: session.user.lastName,
deletedAt: null,
},
select: { id: true },
});
const studentIds = matchingStudents.map(s => s.id);
const studentEnrollments = studentIds.length > 0
? await db.enrollment.findMany({
where: { studentId: { in: studentIds } },
select: { classGroupId: true },
})
: [];
const enrolledClassGroupIds = studentEnrollments.map(e => e.classGroupId);
const sharedNotebooks = await db.notebook.findMany({
where: {
schoolId,
isPublic: true,
isArchived: false,
deletedAt: null,
ownerId: { not: session.user.id },
ownerType: 'TEACHER',
...(enrolledClassGroupIds.length > 0
? { classGroupId: { in: enrolledClassGroupIds } }
: {}),
},
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
owner: { select: { id: true, firstName: true, lastName: true } },
_count: { select: { pages: true } },
},
orderBy: { updatedAt: 'desc' },
});
return NextResponse.json(sharedNotebooks);
}
// Teachers / Admins: see public notebooks from other teachers in the same school
// Also include student notebooks shared publicly
const sharedNotebooks = await db.notebook.findMany({
where: {
schoolId,
isPublic: true,
isArchived: false,
deletedAt: null,
ownerId: { not: session.user.id }, // Exclude own notebooks
ownerId: { not: session.user.id },
},
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
owner: { select: { id: true, firstName: true, lastName: true } },
owner: { select: { id: true, firstName: true, lastName: true, role: true } },
_count: { select: { pages: true } },
},
orderBy: { updatedAt: 'desc' },
+3 -3
View File
@@ -43,7 +43,7 @@ export async function GET(request: Request) {
where,
orderBy: { generatedAt: 'desc' },
include: {
student: { select: { id: true, firstName: true, lastName: true } },
student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
schoolYear: { select: { id: true, label: true } },
generatedByUser: {
@@ -116,7 +116,7 @@ export async function POST(request: Request) {
: undefined,
},
include: {
student: { select: { id: true, firstName: true, lastName: true } },
student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } },
classGroup: { select: { id: true, name: true } },
schoolYear: { select: { id: true, label: true } },
generatedByUser: {
@@ -186,7 +186,7 @@ export async function PUT(request: Request) {
where: { id },
data: updateData,
include: {
student: { select: { id: true, firstName: true, lastName: true } },
student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
schoolYear: { select: { id: true, label: true } },
generatedByUser: {
+3 -3
View File
@@ -3,7 +3,7 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Map,
Map as MapIcon,
Plus,
Pencil,
Trash2,
@@ -372,7 +372,7 @@ export default function CurriculumCoverageView() {
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg">
<Map className="h-5 w-5" />
<MapIcon className="h-5 w-5" />
</div>
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
@@ -501,7 +501,7 @@ export default function CurriculumCoverageView() {
className="flex flex-col items-center justify-center py-16 text-center"
>
<div className="h-16 w-16 rounded-2xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center mb-4">
<Map className="h-8 w-8 text-emerald-500" />
<MapIcon className="h-8 w-8 text-emerald-500" />
</div>
<h3 className="text-lg font-semibold mb-2">{t('curriculum.empty_title')}</h3>
<p className="text-muted-foreground max-w-md mb-6">{t('curriculum.empty_desc')}</p>
+260 -7
View File
@@ -204,13 +204,23 @@ const SECTION_COLORS = [
// ─── Constants ───────────────────────────────────────────────────────
const NOTEBOOK_TYPES: Array<{ key: string; icon: React.ElementType; labelKey: string; descKey: string }> = [
const NOTEBOOK_TYPES: Array<{ key: string; icon: React.ElementType; labelKey: string; descKey: string; isCurriculum?: boolean }> = [
{ key: 'lined', icon: AlignLeft, labelKey: 'notebooks.type_lined', descKey: 'notebooks.type_lined_desc' },
{ key: 'grid', icon: Grid3X3, labelKey: 'notebooks.type_grid', descKey: 'notebooks.type_grid_desc' },
{ key: 'blank', icon: File, labelKey: 'notebooks.type_blank', descKey: 'notebooks.type_blank_desc' },
{ key: 'dotted', icon: MoreHorizontal, labelKey: 'notebooks.type_dotted', descKey: 'notebooks.type_dotted_desc' },
{ key: 'music', icon: Music, labelKey: 'notebooks.type_music', descKey: 'notebooks.type_music_desc' },
{ key: 'calligraphy', icon: PenTool, labelKey: 'notebooks.type_calligraphy', descKey: 'notebooks.type_calligraphy_desc' },
// German curriculum notebook types
{ key: 'deutschheft', icon: BookOpen, labelKey: 'notebooks.type_deutschheft', descKey: 'notebooks.type_deutschheft_desc', isCurriculum: true },
{ key: 'matheheft', icon: Calculator, labelKey: 'notebooks.type_matheheft', descKey: 'notebooks.type_matheheft_desc', isCurriculum: true },
{ key: 'sachbuch', icon: FlaskConical, labelKey: 'notebooks.type_sachbuch', descKey: 'notebooks.type_sachbuch_desc', isCurriculum: true },
{ key: 'musikheft', icon: Music, labelKey: 'notebooks.type_musikheft', descKey: 'notebooks.type_musikheft_desc', isCurriculum: true },
{ key: 'kunstheft', icon: Paintbrush, labelKey: 'notebooks.type_kunstheft', descKey: 'notebooks.type_kunstheft_desc', isCurriculum: true },
{ key: 'englischheft', icon: Languages, labelKey: 'notebooks.type_englischheft', descKey: 'notebooks.type_englischheft_desc', isCurriculum: true },
{ key: 'geschichtsheft', icon: BookMarked, labelKey: 'notebooks.type_geschichtsheft', descKey: 'notebooks.type_geschichtsheft_desc', isCurriculum: true },
{ key: 'religionsheft', icon: Heart, labelKey: 'notebooks.type_religionsheft', descKey: 'notebooks.type_religionsheft_desc', isCurriculum: true },
{ key: 'sachkundeheft', icon: Book, labelKey: 'notebooks.type_sachkundeheft', descKey: 'notebooks.type_sachkundeheft_desc', isCurriculum: true },
];
const COVER_COLORS = [
@@ -392,6 +402,150 @@ const NOTEBOOK_TEMPLATES: NotebookTemplate[] = [
{ title: 'Weltreligionen', content: '# Weltreligionen\n\n| Religion | Gruender | Heilige Schrift |\n|----------|----------|----------------|\n| Christentum | Jesus | Bibel |\n| Islam | Mohammed | Koran |\n| Judentum | Moses | Tora |\n| Buddhismus | Buddha | Tripitaka |\n| Hinduismus | - | Veden |\n' },
],
},
// ─── German Curriculum Notebook Types ──────────────────────────────────
{
key: 'deutschheft',
titleKey: 'notebooks.type_deutschheft',
descKey: 'notebooks.type_deutschheft_desc',
notebookType: 'deutschheft',
color: '#dc2626',
icon: 'BookOpen',
iconComponent: BookOpen,
pages: [
{ title: 'Aufsaetze', content: '# Aufsaetze\n\n**Einleitung:**\n\n**Hauptteil:**\n\n**Schluss:**\n\n' },
{ title: 'Lesetagebuch', content: '# Lesetagebuch\n\n| Datum | Buch | Seiten | Meine Meinung |\n|-------|------|--------|---------------|\n| | | | |\n' },
{ title: 'Grammatik', content: '# Grammatik\n\n**Wortart:** \n\n**Regel:** \n\n**Beispiele:**\n1. \n2. \n3. \n' },
{ title: 'Rechtschreibung', content: '# Rechtschreibung\n\n| Falsch | Richtig | Regel |\n|--------|---------|-------|\n| | | |\n' },
{ title: 'Kreatives Schreiben', content: '# Kreatives Schreiben\n\n**Freies Schreiben:**\n\n' },
{ title: 'Textanalyse', content: '# Textanalyse\n\n**Titel:** \n\n**Autor:** \n\n**Textsorte:** \n\n**Zusammenfassung:**\n\n**Sprachliche Mittel:**\n\n' },
],
},
{
key: 'matheheft',
titleKey: 'notebooks.type_matheheft',
descKey: 'notebooks.type_matheheft_desc',
notebookType: 'matheheft',
color: '#3b82f6',
icon: 'Hash',
iconComponent: Calculator,
pages: [
{ title: 'Aufgaben', content: '# Aufgaben\n\n1. \n2. \n3. \n' },
{ title: 'Rechnungen', content: '# Rechnungen\n\n' },
{ title: 'Formeln', content: '# Formeln\n\n| Name | Formel |\n|------|--------|\n| Flaeche Rechteck | A = a · b |\n| Umfang Rechteck | U = 2·(a+b) |\n| Flaeche Dreieck | A = ½ · g · h |\n' },
{ title: 'Geometrie', content: '# Geometrie\n\n' },
{ title: 'Ergebnisse', content: '# Ergebnisse\n\n' },
{ title: 'Kontrolle', content: '# Kontrolle\n\n| Aufgabe | Ergebnis | Richtig? |\n|---------|----------|----------|\n| 1 | | |\n| 2 | | |\n| 3 | | |\n' },
],
},
{
key: 'sachbuch',
titleKey: 'notebooks.type_sachbuch',
descKey: 'notebooks.type_sachbuch_desc',
notebookType: 'sachbuch',
color: '#14b8a6',
icon: 'Book',
iconComponent: FlaskConical,
pages: [
{ title: 'Versuche', content: '# Versuche\n\n**Fragestellung:**\n\n**Vermutung:**\n\n**Durchfuehrung:**\n\n**Beobachtung:**\n\n**Ergebnis:**\n' },
{ title: 'Beobachtungen', content: '# Beobachtungen\n\n' },
{ title: 'Ergebnisse', content: '# Ergebnisse\n\n' },
{ title: 'Versuchsprotokoll', content: '# Versuchsprotokoll\n\nDatum:\n\nMaterial:\n\n' },
{ title: 'Fragen', content: '# Fragen\n\n' },
],
},
{
key: 'musikheft',
titleKey: 'notebooks.type_musikheft',
descKey: 'notebooks.type_musikheft_desc',
notebookType: 'musikheft',
color: '#10b981',
icon: 'Music',
iconComponent: Music,
pages: [
{ title: 'Noten', content: '' },
{ title: 'Rhythmus', content: '# Rhythmus\n\n' },
{ title: 'Melodie', content: '# Melodie\n\n' },
{ title: 'Harmonie', content: '# Harmonie\n\n' },
{ title: 'Komposition', content: '# Komposition\n\n' },
],
},
{
key: 'kunstheft',
titleKey: 'notebooks.type_kunstheft',
descKey: 'notebooks.type_kunstheft_desc',
notebookType: 'kunstheft',
color: '#8b5cf6',
icon: 'Palette',
iconComponent: Paintbrush,
pages: [
{ title: 'Skizzen', content: '' },
{ title: 'Farbstudien', content: '' },
{ title: 'Komposition', content: '' },
{ title: 'Perspektive', content: '' },
{ title: 'Portfolio', content: '' },
],
},
{
key: 'englischheft',
titleKey: 'notebooks.type_englischheft',
descKey: 'notebooks.type_englischheft_desc',
notebookType: 'englischheft',
color: '#f59e0b',
icon: 'Globe',
iconComponent: Languages,
pages: [
{ title: 'Vocabulary', content: '# Vocabulary\n\n| English | Deutsch |\n|---------|--------|\n| | |\n| | |\n' },
{ title: 'Grammar', content: '# Grammar\n\n' },
{ title: 'Reading', content: '# Reading\n\n' },
{ title: 'Writing', content: '# Writing\n\n' },
{ title: 'Exercises', content: '# Exercises\n\n' },
],
},
{
key: 'geschichtsheft',
titleKey: 'notebooks.type_geschichtsheft',
descKey: 'notebooks.type_geschichtsheft_desc',
notebookType: 'geschichtsheft',
color: '#92400e',
icon: 'BookMarked',
iconComponent: BookMarked,
pages: [
{ title: 'Zeitstrahl', content: '# Zeitstrahl\n\n| Jahr | Ereignis |\n|------|----------|\n| | |\n' },
{ title: 'Quellenanalyse', content: '# Quellenanalyse\n\n**Quellenart:**\n\n**Entstehungszeit:**\n\n**Verfasser:**\n\n**Aussage:**\n\n**Historischer Kontext:**\n\n' },
{ title: 'Begriffe', content: '# Begriffe\n\n| Begriff | Definition |\n|---------|------------|\n| | |\n' },
{ title: 'Ursachen und Wirkungen', content: '# Ursachen und Wirkungen\n\n**Ursachen:**\n1. \n2. \n\n**Wirkungen:**\n1. \n2. \n' },
],
},
{
key: 'religionsheft',
titleKey: 'notebooks.type_religionsheft',
descKey: 'notebooks.type_religionsheft_desc',
notebookType: 'religionsheft',
color: '#f97316',
icon: 'Heart',
iconComponent: Heart,
pages: [
{ title: 'Bibelstellen', content: '# Bibelstellen\n\n| Stelle | Text | Bedeutung |\n|--------|------|----------|\n| | | |\n' },
{ title: 'Gedanken', content: '# Gedanken\n\n' },
{ title: 'Ethik', content: '# Ethik\n\n**Situation:**\n\n**Verschiedene Sichtweisen:**\n\n**Meine Meinung:**\n\n' },
{ title: 'Weltreligionen', content: '# Weltreligionen\n\n| Religion | Gruender | Heilige Schrift |\n|----------|----------|----------------|\n| Christentum | Jesus | Bibel |\n| Islam | Mohammed | Koran |\n| Judentum | Moses | Tora |\n| Buddhismus | Buddha | Tripitaka |\n| Hinduismus | - | Veden |\n' },
],
},
{
key: 'sachkundeheft',
titleKey: 'notebooks.type_sachkundeheft',
descKey: 'notebooks.type_sachkundeheft_desc',
notebookType: 'sachkundeheft',
color: '#0d9488',
icon: 'Book',
iconComponent: Book,
pages: [
{ title: 'Thema', content: '# Sachkunde\n\n**Thema:**\n\n**Was ich weiss:**\n\n**Was ich gelernt habe:**\n\n' },
{ title: 'Beobachtungen', content: '# Beobachtungen\n\n| Datum | Was ich beobachtet habe |\n|-------|------------------------|\n| | |\n' },
{ title: 'Zeichnung', content: '' },
{ title: 'Zusammenfassung', content: '# Zusammenfassung\n\n' },
],
},
];
// ─── CSS Background Patterns ─────────────────────────────────────────
@@ -399,12 +553,26 @@ const NOTEBOOK_TEMPLATES: NotebookTemplate[] = [
function getPageBackgroundCSS(type: string): React.CSSProperties {
switch (type) {
case 'lined':
case 'deutschheft': // German/Language notebook - lined paper with margin
case 'englischheft': // English notebook - lined paper
case 'religionsheft': // Religion/Ethics notebook - lined paper
return {
backgroundImage: `repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px)`,
backgroundSize: '100% 32px',
backgroundPosition: '0 16px',
};
case 'deutschheft_margin': // Deutschheft with margin line
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
linear-gradient(90deg, transparent 60px, #e5e7eb 60px, #e5e7eb 62px, transparent 62px)
`,
backgroundSize: '100% 32px, 100% 100%',
backgroundPosition: '0 16px, 0 0',
};
case 'grid':
case 'matheheft': // Math notebook - grid/dotted paper
case 'sachbuch': // Science notebook - grid paper
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
@@ -418,6 +586,7 @@ function getPageBackgroundCSS(type: string): React.CSSProperties {
backgroundSize: '24px 24px',
};
case 'music':
case 'musikheft': // Music notebook - music staff lines
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 7px, #d1d5db 7px, #d1d5db 8px),
@@ -438,7 +607,25 @@ function getPageBackgroundCSS(type: string): React.CSSProperties {
backgroundPosition: '0 16px',
};
case 'blank':
case 'kunstheft': // Art notebook - blank paper
return {};
case 'geschichtsheft': // History notebook - lined with timeline accent
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
linear-gradient(90deg, #f5f3ff 0px, #f5f3ff 40px, transparent 40px)
`,
backgroundSize: '100% 32px, 100% 100%',
backgroundPosition: '0 16px, 0 0',
};
case 'sachkundeheft': // General studies - lined with drawing space
return {
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px)
`,
backgroundSize: '100% 32px',
backgroundPosition: '0 16px',
};
default:
return {};
}
@@ -810,6 +997,22 @@ function NotebookCard({
<span>{t('notebooks.eco_tip')}</span>
</div>
{/* Curriculum badge for German curriculum types */}
{typeInfo.isCurriculum && (
<div className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400 pt-0.5">
<Bookmark className="w-3 h-3" />
<span>{t('notebooks.curriculum_badge')}</span>
</div>
)}
{/* Eco-friendly badge */}
<div className="flex items-center gap-1.5 pt-0.5">
<Badge className="bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border-0 text-[10px] px-1.5 py-0">
<Leaf className="w-2.5 h-2.5 mr-0.5" />
{t('notebooks.eco_badge')}
</Badge>
</div>
{/* Action buttons — only show for own notebooks, not shared */}
{!isShared && (
<div className="flex items-center gap-1 pt-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex-wrap">
@@ -956,8 +1159,9 @@ function CreateNotebookDialog({
{/* Notebook Type - Visual Selector */}
<div className="space-y-2">
<Label>{t('notebooks.field_type')}</Label>
{/* Base types */}
<div className="grid grid-cols-3 gap-2">
{NOTEBOOK_TYPES.map((nt) => {
{NOTEBOOK_TYPES.filter(nt => !nt.isCurriculum).map((nt) => {
const NtIcon = nt.icon;
const isSelected = notebookType === nt.key;
return (
@@ -976,6 +1180,33 @@ function CreateNotebookDialog({
);
})}
</div>
{/* German curriculum types */}
<div className="mt-3">
<Label className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1 mb-2">
<Bookmark className="w-3 h-3" />
{t('notebooks.curriculum_badge')}
</Label>
<div className="grid grid-cols-3 gap-2">
{NOTEBOOK_TYPES.filter(nt => nt.isCurriculum).map((nt) => {
const NtIcon = nt.icon;
const isSelected = notebookType === nt.key;
return (
<button
key={nt.key}
onClick={() => setNotebookType(nt.key)}
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 transition-all min-h-[64px] ${
isSelected
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:border-amber-300'
}`}
>
<NtIcon className="w-5 h-5" />
<span className="text-xs font-medium">{t(nt.labelKey)}</span>
</button>
);
})}
</div>
</div>
</div>
{/* Subject + Class */}
@@ -2065,7 +2296,7 @@ function NotebookDetailView({
<div className="w-64 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex flex-col">
<div className="p-3 space-y-2">
<Button
onClick={onAddPage}
onClick={() => onAddPage()}
className="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
size="sm"
>
@@ -2518,7 +2749,7 @@ function NotebookDetailView({
<p className="text-gray-500 dark:text-gray-400 text-lg">
{t('notebooks.no_notebooks_desc')}
</p>
<Button onClick={onAddPage} className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700">
<Button onClick={() => onAddPage()} className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700">
<Plus className="w-4 h-4 mr-1" />
{t('notebooks.add_page')}
</Button>
@@ -3806,6 +4037,12 @@ export default function NotebooksView() {
[notebooks]
);
// Tree calculation: ~8000 sheets of paper per tree, so 50 pages per notebook
const treesSaved = useMemo(
() => Math.max(0, totalPaperSaved / 8000),
[totalPaperSaved]
);
// Handlers
// BUG FIX: Call loadData() instead of just appending to local state
const handleCreate = useCallback(async (data: Partial<Notebook>) => {
@@ -3989,7 +4226,7 @@ export default function NotebooksView() {
const newNotebook = await apiPost<Notebook>('/api/notebooks', {
schoolId,
ownerId: currentUser?.id ?? '',
ownerType: 'TEACHER',
ownerType: currentUser?.role === 'STUDENT' ? 'STUDENT' : 'TEACHER',
title: t(template.titleKey),
description: t(template.descKey),
notebookType: template.notebookType,
@@ -4134,7 +4371,7 @@ export default function NotebooksView() {
</div>
{/* Stats */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap">
<Card className="px-3 py-2 border-0 shadow-sm bg-emerald-50 dark:bg-emerald-900/20">
<div className="text-xs text-emerald-600 dark:text-emerald-400">{t('notebooks.total_notebooks')}</div>
<div className="text-lg font-bold text-emerald-700 dark:text-emerald-300">{activeNotebooks.length}</div>
@@ -4145,6 +4382,14 @@ export default function NotebooksView() {
{totalPaperSaved} {t('notebooks.pages_unit')}
</div>
</Card>
{treesSaved > 0 && (
<Card className="px-3 py-2 border-0 shadow-sm bg-amber-50 dark:bg-amber-900/20">
<div className="text-xs text-amber-600 dark:text-amber-400">{t('notebooks.trees_saved')}</div>
<div className="text-lg font-bold text-amber-700 dark:text-amber-300">
{treesSaved < 1 ? treesSaved.toFixed(2) : treesSaved.toFixed(1)} {t('notebooks.trees_unit')}
</div>
</Card>
)}
</div>
{/* Search */}
@@ -4601,7 +4846,7 @@ export default function NotebooksView() {
{/* Eco footer bar */}
<div className="bg-emerald-50 dark:bg-emerald-900/20 border-t border-emerald-200 dark:border-emerald-800 px-4 sm:px-6 py-3">
<div className="flex items-center gap-3 text-emerald-700 dark:text-emerald-300 text-sm">
<div className="flex items-center gap-3 text-emerald-700 dark:text-emerald-300 text-sm flex-wrap">
<Leaf className="w-5 h-5" />
<span>
{t('notebooks.eco_total', {
@@ -4609,6 +4854,14 @@ export default function NotebooksView() {
pages: totalPaperSaved,
})}
</span>
{treesSaved > 0 && (
<>
<Separator orientation="vertical" className="h-4 bg-emerald-300 dark:bg-emerald-700" />
<span className="text-xs flex items-center gap-1">
{t('notebooks.trees_saved_message', { count: treesSaved < 1 ? treesSaved.toFixed(2) : treesSaved.toFixed(1) })}
</span>
</>
)}
<Separator orientation="vertical" className="h-4 bg-emerald-300 dark:bg-emerald-700" />
<span className="text-xs">{t('notebooks.eco_message')}</span>
</div>
+1 -1
View File
@@ -667,7 +667,7 @@ function BadgeManagementTab() {
<Select value={awardForm.studentId} onValueChange={(v) => setAwardForm(f => ({ ...f, studentId: v }))}>
<SelectTrigger><SelectValue placeholder={t('action.select')} /></SelectTrigger>
<SelectContent className="max-h-48">
{studentsList.map(s => <SelectItem key={s.id} value={s.id}>{s.firstName} {s.lastName}</SelectItem>)}
{students.map(s => <SelectItem key={s.id} value={s.id}>{s.firstName} {s.lastName}</SelectItem>)}
</SelectContent>
</Select>
</div>
+13 -13
View File
@@ -629,13 +629,13 @@ export default function StudentDetailView() {
const schoolId = currentUser.schoolId;
async function loadBadges() {
try {
const progress = await fetchBadgeProgress(schoolId, currentStudentId);
const progress = await fetchBadgeProgress(schoolId, currentStudentId!);
setBadgeProgress(progress);
} catch { /* ignore */ }
}
async function loadEarnedBadges() {
try {
const data = await fetchStudentBadges(schoolId, currentStudentId);
const data = await fetchStudentBadges(schoolId, currentStudentId!);
setEarnedBadges(data);
} catch { /* ignore */ }
}
@@ -1729,7 +1729,7 @@ export default function StudentDetailView() {
{(() => {
const comparisonData = selfAssessments.map((sa) => {
const teacherEntry = data?.progressEntries.find(
(pe) => pe.competencyId === sa.competencyId
(pe) => pe.competency.id === sa.competencyId
);
return {
competency: sa.competency.code.length > 12 ? sa.competency.code.slice(0, 12) : sa.competency.code,
@@ -1763,7 +1763,7 @@ export default function StudentDetailView() {
{/* Self-assessment list */}
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education">
{selfAssessments.map((sa, i) => {
const teacherEntry = data?.progressEntries.find((pe) => pe.competencyId === sa.competencyId);
const teacherEntry = data?.progressEntries.find((pe) => pe.competency.id === sa.competencyId);
const gap = teacherEntry ? sa.selfLevel - teacherEntry.masteryLevelValue : null;
return (
<motion.div
@@ -2046,12 +2046,12 @@ export default function StudentDetailView() {
const compIds = new Set<string>();
peerByCompetency.forEach((pa) => { if (pa.competencyId) compIds.add(pa.competencyId); });
selfByCompetency.forEach((sa) => compIds.add(sa.competencyId));
teacherByCompetency.forEach((pe) => compIds.add(pe.competencyId));
teacherByCompetency.forEach((pe) => compIds.add(pe.competency.id));
const comparisonData = Array.from(compIds).slice(0, 8).map((cId) => {
const peerLevels = peerByCompetency.filter((pa) => pa.competencyId === cId).map((pa) => pa.level!);
const selfEntry = selfByCompetency.find((sa) => sa.competencyId === cId);
const teacherEntry = teacherByCompetency.find((pe) => pe.competencyId === cId);
const teacherEntry = teacherByCompetency.find((pe) => pe.competency.id === cId);
const code = peerLevels.length > 0
? (peerByCompetency.find((pa) => pa.competencyId === cId)?.competency?.code ?? cId.slice(0, 8))
: selfEntry?.competency?.code ?? teacherEntry?.competency?.code ?? cId.slice(0, 8);
@@ -2895,7 +2895,7 @@ export default function StudentDetailView() {
</SelectTrigger>
<SelectContent>
{data?.progressEntries.map((pe) => (
<SelectItem key={pe.competencyId} value={pe.competencyId}>
<SelectItem key={pe.competency.id} value={pe.competency.id}>
{pe.competency.code} — {pe.competency.title}
</SelectItem>
))}
@@ -3038,7 +3038,7 @@ export default function StudentDetailView() {
<SelectContent>
<SelectItem value="none">—</SelectItem>
{data?.progressEntries.map((pe) => (
<SelectItem key={pe.competencyId} value={pe.competencyId}>
<SelectItem key={pe.competency.id} value={pe.competency.id}>
{pe.competency.code} — {pe.competency.title}
</SelectItem>
))}
@@ -3175,7 +3175,7 @@ export default function StudentDetailView() {
</SelectTrigger>
<SelectContent>
{data?.progressEntries.map((pe) => (
<SelectItem key={pe.competencyId} value={pe.competencyId}>
<SelectItem key={pe.competency.id} value={pe.competency.id}>
{pe.competency.code} — {pe.competency.title}
</SelectItem>
))}
@@ -3412,7 +3412,7 @@ export default function StudentDetailView() {
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.'}
{useAppStore.getState().locale === 'de' ? 'Geben Sie eine URL für das Schülerfoto ein.' : 'Enter a URL for the student photo.'}
</p>
</div>
</div>
@@ -3429,9 +3429,9 @@ export default function StudentDetailView() {
setAvatarUploadOpen(false);
setAvatarUrlInput('');
// Reload student detail
if (studentId) {
const fresh = await fetchStudentDetail(studentId);
setStudent(fresh);
if (currentStudentId) {
const fresh = await fetchStudentDetail(currentStudentId);
setData(fresh);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
+1 -1
View File
@@ -557,7 +557,7 @@ export default function TimetableView() {
draggable
onDragStart={() => handleDragStart(cellSlot.id)}
onDragEnd={handleDragEnd}
className={`rounded-md p-2 cursor-pointer min-h-[44px] flex flex-col gap-0.5 border ${cellSlot.isBreak ? BREAK_COLOR : getSubjectColor(cellSlot.subject?.name)}`}
className={`rounded-md p-2 cursor-pointer min-h-[44px] flex flex-col gap-0.5 border ${cellSlot.isBreak ? BREAK_COLOR : getSubjectColor(cellSlot.subject?.name ?? null)}`}
onClick={() => {
setEditSlot(cellSlot);
setDialogDay(day);
+5 -3
View File
@@ -582,7 +582,7 @@ export interface Report {
status: string;
pdfFilePath: string | null;
includesGrades: boolean;
student: { id: string; firstName: string; lastName: string };
student: { id: string; firstName: string; lastName: string; avatarUrl: string | null };
classGroup: { id: string; name: string; gradeLevel: number };
schoolYear: { id: string; label: string };
generatedByUser: { id: string; firstName: string; lastName: string };
@@ -890,7 +890,7 @@ export function downloadCsvExport(params: {
classGroupId?: string;
schoolYearId?: string;
schoolId?: string;
format?: 'csv' | 'json';
format?: 'csv' | 'json' | 'pdf';
dateFrom?: string;
dateTo?: string;
}): void {
@@ -1141,6 +1141,8 @@ export interface StudentDetailData {
lastName: string;
dateOfBirth: string | null;
externalId: string | null;
avatarUrl: string | null;
avatarInitials: string | null;
createdAt: string;
school: { id: string; name: string };
enrollments: Array<{
@@ -1548,7 +1550,7 @@ export interface AttendanceRecord {
status: AttendanceStatus;
arrivalTime: string | null;
comment: string | null;
student: { id: string; firstName: string; lastName: string };
student: { id: string; firstName: string; lastName: string; avatarUrl: string | null };
}
export interface AttendanceSession {
+23 -14
View File
@@ -197,7 +197,7 @@ async function checkBadgeRequirement(
const attendanceRecords = await db.attendanceRecord.findMany({
where: {
studentId,
schoolId,
student: { schoolId },
status: 'PRESENT',
session: { date: { gte: thirtyDaysAgo } },
},
@@ -211,7 +211,7 @@ async function checkBadgeRequirement(
const progressEntries = await db.learningProgressEntry.findMany({
where: {
studentId,
schoolId,
student: { schoolId },
masteryLevelValue: { gte: threshold },
},
});
@@ -225,7 +225,7 @@ async function checkBadgeRequirement(
const count = await db.learningProgressEntry.count({
where: {
studentId,
schoolId,
student: { schoolId },
date: { gte: oneMonthAgo },
},
});
@@ -253,15 +253,14 @@ async function checkBadgeRequirement(
case 'Notebook Champion': {
const count = await db.notebookPage.count({
where: {
notebook: { schoolId },
notebook: { createdById: studentId },
notebook: { schoolId, ownerId: studentId },
},
});
return count >= threshold;
}
case 'Drawing Artist': {
const count = await db.drawing.count({
where: { schoolId, createdById: studentId },
where: { schoolId, ownerId: studentId },
});
return count >= threshold;
}
@@ -297,7 +296,7 @@ async function checkBadgeRequirement(
case 'Eco Warrior': {
// Awarded if student has at least 1 notebook
const count = await db.notebook.count({
where: { schoolId, createdById: studentId },
where: { schoolId, ownerId: studentId },
});
return count >= threshold;
}
@@ -339,7 +338,17 @@ export async function getBadgeProgress(
});
const earnedBadgeMap = new Map(earnedBadges.map(eb => [eb.badgeId, eb.awardedAt]));
const results = [];
const results: Array<{
badgeId: string;
name: string;
icon: string;
color: string;
category: string;
earned: boolean;
progress: number;
current: number;
target: number;
}> = [];
for (const badge of badges) {
const earned = earnedBadgeMap.has(badge.id);
@@ -382,7 +391,7 @@ async function getCurrentProgress(
return db.attendanceRecord.count({
where: {
studentId,
schoolId,
student: { schoolId },
status: 'PRESENT',
session: { date: { gte: thirtyDaysAgo } },
},
@@ -390,7 +399,7 @@ async function getCurrentProgress(
}
case 'mastery_level': {
const entries = await db.learningProgressEntry.findMany({
where: { studentId, schoolId },
where: { studentId, student: { schoolId } },
orderBy: { masteryLevelValue: 'desc' },
take: 1,
});
@@ -400,7 +409,7 @@ async function getCurrentProgress(
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
return db.learningProgressEntry.count({
where: { studentId, schoolId, date: { gte: oneMonthAgo } },
where: { studentId, student: { schoolId }, date: { gte: oneMonthAgo } },
});
}
case 'behavior_count': {
@@ -411,9 +420,9 @@ async function getCurrentProgress(
case 'custom': {
switch (badge.name) {
case 'Notebook Champion':
return db.notebookPage.count({ where: { notebook: { schoolId, createdById: studentId } } });
return db.notebookPage.count({ where: { notebook: { schoolId, ownerId: studentId } } });
case 'Drawing Artist':
return db.drawing.count({ where: { schoolId, createdById: studentId } });
return db.drawing.count({ where: { schoolId, ownerId: studentId } });
case 'Homework Hero':
return db.homeworkSubmission.count({ where: { studentId, homework: { schoolId }, status: 'SUBMITTED' } });
case 'Team Player':
@@ -421,7 +430,7 @@ async function getCurrentProgress(
case 'Goal Achiever':
return db.learningGoal.count({ where: { studentId, schoolId, status: 'COMPLETED' } });
case 'Eco Warrior':
return db.notebook.count({ where: { schoolId, createdById: studentId } });
return db.notebook.count({ where: { schoolId, ownerId: studentId } });
default:
return 0;
}
+63 -10
View File
@@ -1914,11 +1914,6 @@ const de: TranslationDict = {
'notebooks.field_icon': 'Symbol',
'notebooks.field_public': 'Mit Klasse teilen',
'notebooks.field_public_hint': 'Andere können dieses Heft einsehen',
'notebooks.type_lined': 'Liniert',
'notebooks.type_grid': 'Kariert',
'notebooks.type_blank': 'Blanko',
'notebooks.type_dotted': 'Punktiert',
'notebooks.type_music': 'Musik',
'notebooks.type_calligraphy': 'Kalligraphie',
'notebooks.type_lined_desc': 'Liniertes Heft für Text',
'notebooks.type_grid_desc': 'Kariertes Heft für Mathematik',
@@ -2146,6 +2141,38 @@ const de: TranslationDict = {
'notebooks.type_blank': 'Unliniert',
'notebooks.type_dotted': 'Punktiert',
'notebooks.type_music': 'Notenlinien',
// German curriculum notebook types
'notebooks.type_deutschheft': 'Deutschheft',
'notebooks.type_deutschheft_desc': 'Liniertes Heft mit Rand — fuer Aufsaetze, Grammatik und Lesetagebuch',
'notebooks.type_matheheft': 'Matheheft',
'notebooks.type_matheheft_desc': 'Kariertes Heft — fuer Aufgaben, Formeln und Geometrie',
'notebooks.type_sachbuch': 'Sachbuch',
'notebooks.type_sachbuchheft_desc': 'Kariertes Heft mit Versuchsprotokoll — fuer Experimente und Beobachtungen',
'notebooks.type_sachbuch_desc': 'Kariertes Heft mit Versuchsprotokoll — fuer Experimente und Beobachtungen',
'notebooks.type_musikheft': 'Musikheft',
'notebooks.type_musikheft_desc': 'Heft mit Notenlinien — fuer Noten, Rhythmus und Komposition',
'notebooks.type_kunstheft': 'Kunstheft',
'notebooks.type_kunstheft_desc': 'Blanko-Heft — fuer Skizzen, Farbstudien und Portfolio',
'notebooks.type_englischheft': 'Englischheft',
'notebooks.type_englischheft_desc': 'Liniertes Heft — fuer Vokabeln, Grammatik und Reading',
'notebooks.type_geschichtsheft': 'Geschichtsheft',
'notebooks.type_geschichtsheft_desc': 'Liniertes Heft mit Zeitstrahl — fuer Quellenanalyse und Begriffe',
'notebooks.type_religionsheft': 'Religionsheft',
'notebooks.type_religionsheft_desc': 'Liniertes Heft — fuer Bibelstellen, Ethik und Weltreligionen',
'notebooks.type_sachkundeheft': 'Heft fuer Sachkunde',
'notebooks.type_sachkundeheft_desc': 'Liniertes Heft mit Zeichnungsbereich — fuer Beobachtungen und Sachkunde',
// Paper savings / eco features
'notebooks.trees_saved': 'Baeume gerettet',
'notebooks.trees_unit': 'Baeume',
'notebooks.trees_saved_message': '{count} Baeume gerettet',
'notebooks.eco_badge': 'Papierfrei',
'notebooks.curriculum_badge': 'Lehrplan-Heft',
// Student notebook access
'notebooks.student_create': 'Eigenes Heft erstellen',
'notebooks.student_share_teacher': 'Mit Lehrkraft teilen',
'notebooks.student_share_teacher_desc': 'Teile dieses Heft mit deiner Lehrkraft (nur lesbar)',
'notebooks.student_own_notebooks': 'Meine eigenen Hefte',
'notebooks.student_shared_by_teacher': 'Von Lehrkraft geteilt',
// ─── Page Templates ──────────────────────────────────────────────
'notebooks.page_template': 'Seitenvorlage',
@@ -5659,11 +5686,6 @@ const en: TranslationDict = {
'notebooks.field_icon': 'Icon',
'notebooks.field_public': 'Share with class',
'notebooks.field_public_hint': 'Others can view this notebook',
'notebooks.type_lined': 'Lined',
'notebooks.type_grid': 'Grid/Checkered',
'notebooks.type_blank': 'Blank',
'notebooks.type_dotted': 'Dotted',
'notebooks.type_music': 'Music',
'notebooks.type_calligraphy': 'Calligraphy',
'notebooks.type_lined_desc': 'Lined notebook for text',
'notebooks.type_grid_desc': 'Checkered notebook for math',
@@ -5891,6 +5913,37 @@ const en: TranslationDict = {
'notebooks.type_blank': 'Blank',
'notebooks.type_dotted': 'Dotted',
'notebooks.type_music': 'Music Lines',
// German curriculum notebook types
'notebooks.type_deutschheft': 'Deutschheft',
'notebooks.type_deutschheft_desc': 'Lined notebook with margin — for essays, grammar and reading journal',
'notebooks.type_matheheft': 'Matheheft',
'notebooks.type_matheheft_desc': 'Grid notebook — for problems, formulas and geometry',
'notebooks.type_sachbuch': 'Sachbuch',
'notebooks.type_sachbuch_desc': 'Grid notebook with experiment template — for experiments and observations',
'notebooks.type_musikheft': 'Musikheft',
'notebooks.type_musikheft_desc': 'Notebook with staff lines — for notes, rhythm and composition',
'notebooks.type_kunstheft': 'Kunstheft',
'notebooks.type_kunstheft_desc': 'Blank notebook — for sketches, color studies and portfolio',
'notebooks.type_englischheft': 'Englischheft',
'notebooks.type_englischheft_desc': 'Lined notebook — for vocabulary, grammar and reading',
'notebooks.type_geschichtsheft': 'Geschichtsheft',
'notebooks.type_geschichtsheft_desc': 'Lined notebook with timeline — for source analysis and terms',
'notebooks.type_religionsheft': 'Religionsheft',
'notebooks.type_religionsheft_desc': 'Lined notebook — for Bible passages, ethics and world religions',
'notebooks.type_sachkundeheft': 'Sachkundeheft',
'notebooks.type_sachkundeheft_desc': 'Lined notebook with drawing space — for observations and general studies',
// Paper savings / eco features
'notebooks.trees_saved': 'Trees Saved',
'notebooks.trees_unit': 'trees',
'notebooks.trees_saved_message': '{count} trees saved',
'notebooks.eco_badge': 'Paper-free',
'notebooks.curriculum_badge': 'Curriculum Notebook',
// Student notebook access
'notebooks.student_create': 'Create My Notebook',
'notebooks.student_share_teacher': 'Share with Teacher',
'notebooks.student_share_teacher_desc': 'Share this notebook with your teacher (read-only)',
'notebooks.student_own_notebooks': 'My Own Notebooks',
'notebooks.student_shared_by_teacher': 'Shared by Teacher',
// ─── Page Templates ──────────────────────────────────────────────
'notebooks.page_template': 'Page Template',