Round 18: Seating Chart, Student Avatars, Newsletter, Badges, Deep Styling Polish
This commit is contained in:
1 parent
cba8a8e724
commit
0363a40ca0
18 files changed
+1829
-41
No files matched your search
@@ -73,6 +73,7 @@ model School {
|
||||
healthRecords HealthRecord[]
|
||||
badges Badge[]
|
||||
studentBadges StudentBadge[]
|
||||
newsletters Newsletter[]
|
||||
district SchoolDistrict? @relation(fields: [districtId], references: [id])
|
||||
districtId String?
|
||||
}
|
||||
@@ -120,6 +121,7 @@ model User {
|
||||
organizedSchoolEvents SchoolEvent[]
|
||||
eventRegistrations EventRegistration[]
|
||||
awardedBadges StudentBadge[]
|
||||
newsletters Newsletter[]
|
||||
}
|
||||
|
||||
model SchoolYear {
|
||||
@@ -194,6 +196,8 @@ model Student {
|
||||
lastName String
|
||||
dateOfBirth DateTime?
|
||||
externalId String? // for import matching
|
||||
avatarUrl String? // student photo/avatar URL
|
||||
avatarInitials String? // computed from firstName + lastName first chars
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
@@ -1588,3 +1592,29 @@ model StudentBadge {
|
||||
@@index([schoolId, studentId])
|
||||
@@map("student_badges")
|
||||
}
|
||||
|
||||
// ─── Newsletter / School Blog ──────────────────────────────────────
|
||||
|
||||
model Newsletter {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
authorId String
|
||||
title String
|
||||
content String // rich text content (HTML)
|
||||
summary String? // short summary for preview
|
||||
imageUrl String? // cover image URL
|
||||
category String @default("general") // general, academic, sports, arts, events, community
|
||||
isPublished Boolean @default(false)
|
||||
publishedAt DateTime?
|
||||
tags String? // JSON array of tags
|
||||
isDemo Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
|
||||
@@index([schoolId, isPublished, publishedAt])
|
||||
@@map("newsletters")
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// GET /api/newsletters/[id] — get single newsletter
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const newsletter = await db.newsletter.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
author: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!newsletter || newsletter.deletedAt) {
|
||||
return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(newsletter);
|
||||
} catch (error) {
|
||||
console.error('Error fetching newsletter:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch newsletter' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/newsletters/[id] — update newsletter
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const existing = await db.newsletter.findUnique({ where: { id } });
|
||||
if (!existing || existing.deletedAt) {
|
||||
return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.title !== undefined) data.title = body.title;
|
||||
if (body.content !== undefined) data.content = body.content;
|
||||
if (body.summary !== undefined) data.summary = body.summary;
|
||||
if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl;
|
||||
if (body.category !== undefined) data.category = body.category;
|
||||
if (body.tags !== undefined) data.tags = JSON.stringify(body.tags);
|
||||
|
||||
const newsletter = await db.newsletter.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
author: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(newsletter);
|
||||
} catch (error) {
|
||||
console.error('Error updating newsletter:', error);
|
||||
return NextResponse.json({ error: 'Failed to update newsletter' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/newsletters/[id] — publish/unpublish newsletter
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const existing = await db.newsletter.findUnique({ where: { id } });
|
||||
if (!existing || existing.deletedAt) {
|
||||
return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isPublished = body.action === 'publish' ? true : body.action === 'unpublish' ? false : !existing.isPublished;
|
||||
|
||||
const newsletter = await db.newsletter.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isPublished,
|
||||
publishedAt: isPublished ? new Date() : null,
|
||||
},
|
||||
include: {
|
||||
author: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(newsletter);
|
||||
} catch (error) {
|
||||
console.error('Error toggling newsletter publish:', error);
|
||||
return NextResponse.json({ error: 'Failed to toggle newsletter' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/newsletters/[id] — soft delete newsletter
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.newsletter.findUnique({ where: { id } });
|
||||
if (!existing || existing.deletedAt) {
|
||||
return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.newsletter.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting newsletter:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete newsletter' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// GET /api/newsletters — list newsletters
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const published = searchParams.get('published');
|
||||
const category = searchParams.get('category');
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
schoolId,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
if (published === 'true') {
|
||||
where.isPublished = true;
|
||||
} else if (published === 'false') {
|
||||
where.isPublished = false;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
const [newsletters, total] = await Promise.all([
|
||||
db.newsletter.findMany({
|
||||
where,
|
||||
orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: offset,
|
||||
take: limit,
|
||||
include: {
|
||||
author: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
}),
|
||||
db.newsletter.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ newsletters, total });
|
||||
} catch (error) {
|
||||
console.error('Error fetching newsletters:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch newsletters' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/newsletters — create newsletter
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { schoolId, authorId, title, content, summary, imageUrl, category, tags, isDemo } = body;
|
||||
|
||||
if (!schoolId || !authorId || !title || !content) {
|
||||
return NextResponse.json({ error: 'schoolId, authorId, title, content required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const newsletter = await db.newsletter.create({
|
||||
data: {
|
||||
schoolId,
|
||||
authorId,
|
||||
title,
|
||||
content,
|
||||
summary: summary || null,
|
||||
imageUrl: imageUrl || null,
|
||||
category: category || 'general',
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
isDemo: isDemo || false,
|
||||
},
|
||||
include: {
|
||||
author: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(newsletter, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating newsletter:', error);
|
||||
return NextResponse.json({ error: 'Failed to create newsletter' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export async function GET(
|
||||
lastName: true,
|
||||
dateOfBirth: true,
|
||||
externalId: true,
|
||||
avatarUrl: true,
|
||||
avatarInitials: true,
|
||||
createdAt: true,
|
||||
schoolId: true,
|
||||
school: { select: { id: true, name: true } },
|
||||
|
||||
@@ -143,6 +143,7 @@ export async function PUT(request: Request) {
|
||||
.optional()
|
||||
.transform((v) => (v ? new Date(v) : v === null ? null : undefined)),
|
||||
externalId: z.string().nullable().optional(),
|
||||
avatarUrl: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
|
||||
@@ -2509,4 +2509,281 @@
|
||||
.section-header-icon:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ─── Gradient Card Background ─────────────────────────────────── */
|
||||
.gradient-card-bg {
|
||||
background: linear-gradient(135deg, oklch(0.97 0.02 155) 0%, oklch(0.98 0.01 175) 100%);
|
||||
border: 1px solid oklch(0.90 0.02 155);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.gradient-card-bg:hover {
|
||||
box-shadow: 0 4px 12px oklch(0.55 0.17 155 / 0.1);
|
||||
}
|
||||
.dark .gradient-card-bg {
|
||||
background: linear-gradient(135deg, oklch(0.18 0.02 260) 0%, oklch(0.16 0.01 155) 100%);
|
||||
border-color: oklch(0.30 0.02 260);
|
||||
}
|
||||
|
||||
/* ─── Sparkline Mini Chart ─────────────────────────────────────── */
|
||||
.sparkline-mini {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
height: 16px;
|
||||
}
|
||||
.sparkline-mini span {
|
||||
display: block;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: oklch(0.55 0.17 155);
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
.dark .sparkline-mini span {
|
||||
background: oklch(0.65 0.17 155);
|
||||
}
|
||||
|
||||
/* ─── Grade Color Badges (German 1-6 System) ──────────────────── */
|
||||
.grade-badge-1 {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
border: 1px solid #059669;
|
||||
}
|
||||
.grade-badge-2 {
|
||||
background: linear-gradient(135deg, #14b8a6, #0d9488);
|
||||
color: white;
|
||||
border: 1px solid #0d9488;
|
||||
}
|
||||
.grade-badge-3 {
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
color: white;
|
||||
border: 1px solid #d97706;
|
||||
}
|
||||
.grade-badge-4 {
|
||||
background: linear-gradient(135deg, #f97316, #ea580c);
|
||||
color: white;
|
||||
border: 1px solid #ea580c;
|
||||
}
|
||||
.grade-badge-5 {
|
||||
background: linear-gradient(135deg, #f43f5e, #e11d48);
|
||||
color: white;
|
||||
border: 1px solid #e11d48;
|
||||
}
|
||||
.grade-badge-6 {
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
color: white;
|
||||
border: 1px solid #b91c1c;
|
||||
}
|
||||
|
||||
/* ─── Mastery Level Badges ─────────────────────────────────────── */
|
||||
.mastery-badge-low {
|
||||
background: linear-gradient(135deg, #fda4af, #fb7185);
|
||||
color: #9f1239;
|
||||
border: 1px solid #fda4af;
|
||||
}
|
||||
.dark .mastery-badge-low {
|
||||
color: #fda4af;
|
||||
background: linear-gradient(135deg, rgba(253,164,175,0.15), rgba(251,113,133,0.15));
|
||||
border-color: rgba(251,113,133,0.3);
|
||||
}
|
||||
.mastery-badge-medium {
|
||||
background: linear-gradient(135deg, #fde68a, #fbbf24);
|
||||
color: #92400e;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
.dark .mastery-badge-medium {
|
||||
color: #fde68a;
|
||||
background: linear-gradient(135deg, rgba(253,230,138,0.15), rgba(251,191,36,0.15));
|
||||
border-color: rgba(251,191,36,0.3);
|
||||
}
|
||||
.mastery-badge-high {
|
||||
background: linear-gradient(135deg, #6ee7b7, #34d399);
|
||||
color: #065f46;
|
||||
border: 1px solid #6ee7b7;
|
||||
}
|
||||
.dark .mastery-badge-high {
|
||||
color: #6ee7b7;
|
||||
background: linear-gradient(135deg, rgba(110,231,183,0.15), rgba(52,211,153,0.15));
|
||||
border-color: rgba(52,211,153,0.3);
|
||||
}
|
||||
|
||||
/* ─── Newsletter Category Badge ────────────────────────────────── */
|
||||
.category-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.category-badge-general {
|
||||
background: oklch(0.95 0.01 260);
|
||||
color: oklch(0.35 0.02 260);
|
||||
}
|
||||
.dark .category-badge-general {
|
||||
background: oklch(0.22 0.02 260);
|
||||
color: oklch(0.75 0.02 260);
|
||||
}
|
||||
.category-badge-academic {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.dark .category-badge-academic {
|
||||
background: rgba(59,130,246,0.15);
|
||||
color: #93c5fd;
|
||||
}
|
||||
.category-badge-sports {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.dark .category-badge-sports {
|
||||
background: rgba(34,197,94,0.15);
|
||||
color: #86efac;
|
||||
}
|
||||
.category-badge-arts {
|
||||
background: #fae8ff;
|
||||
color: #86198f;
|
||||
}
|
||||
.dark .category-badge-arts {
|
||||
background: rgba(168,85,247,0.15);
|
||||
color: #d8b4fe;
|
||||
}
|
||||
.category-badge-events {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.dark .category-badge-events {
|
||||
background: rgba(245,158,11,0.15);
|
||||
color: #fcd34d;
|
||||
}
|
||||
.category-badge-community {
|
||||
background: #ffe4e6;
|
||||
color: #9f1239;
|
||||
}
|
||||
.dark .category-badge-community {
|
||||
background: rgba(244,63,94,0.15);
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
/* ─── Newsletter Preview Card ──────────────────────────────────── */
|
||||
.newsletter-card {
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid oklch(0.90 0.01 155);
|
||||
background: white;
|
||||
}
|
||||
.newsletter-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px oklch(0.55 0.17 155 / 0.1);
|
||||
}
|
||||
.dark .newsletter-card {
|
||||
background: oklch(0.18 0.015 260);
|
||||
border-color: oklch(0.30 0.015 260);
|
||||
}
|
||||
|
||||
/* ─── Student Avatar Circle ────────────────────────────────────── */
|
||||
.avatar-circle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-circle:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* ─── Student Photo Circle ─────────────────────────────────────── */
|
||||
.avatar-photo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatar-photo:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 0 0 2px oklch(0.55 0.17 155);
|
||||
}
|
||||
|
||||
/* ─── Animated Section Underline ───────────────────────────────── */
|
||||
.animated-underline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.animated-underline::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #10b981, #14b8a6);
|
||||
border-radius: 1px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.animated-underline:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ─── Count-up Animation ───────────────────────────────────────── */
|
||||
@keyframes count-up-fade {
|
||||
0% { opacity: 0; transform: translateY(8px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-count-up {
|
||||
animation: count-up-fade 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ─── Leaf Animation ───────────────────────────────────────────── */
|
||||
@keyframes leaf-sway {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
25% { transform: rotate(5deg); }
|
||||
75% { transform: rotate(-5deg); }
|
||||
}
|
||||
.animate-leaf-sway {
|
||||
animation: leaf-sway 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ─── Staggered Card Entrance ──────────────────────────────────── */
|
||||
@keyframes card-entrance {
|
||||
0% { opacity: 0; transform: translateY(12px) scale(0.97); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.animate-card-entrance {
|
||||
animation: card-entrance 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ─── Gradient Border for Class Cards ──────────────────────────── */
|
||||
.gradient-border-card {
|
||||
position: relative;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gradient-border-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #10b981, #14b8a6);
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
}
|
||||
@@ -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]">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -188,6 +188,8 @@ export interface Student {
|
||||
lastName: string;
|
||||
dateOfBirth: string | null;
|
||||
externalId: string | null;
|
||||
avatarUrl: string | null;
|
||||
avatarInitials: string | null;
|
||||
school: { id: string; name: string };
|
||||
enrollments: Array<{ classGroup: { id: string; name: string; gradeLevel: number } }>;
|
||||
_count?: { learningProgressEntries: number; assessmentResults: number };
|
||||
@@ -207,6 +209,7 @@ export function createStudent(data: {
|
||||
lastName: string;
|
||||
dateOfBirth?: string;
|
||||
externalId?: string;
|
||||
avatarUrl?: string;
|
||||
}) {
|
||||
return apiPost<Student>('/api/students', data);
|
||||
}
|
||||
@@ -2994,3 +2997,73 @@ export function checkAndAwardBadges(schoolId: string, studentId: string): Promis
|
||||
export function seedBadges(schoolId: string): Promise<{ success: boolean }> {
|
||||
return apiPost<{ success: boolean }>('/api/badge-seed', { schoolId });
|
||||
}
|
||||
|
||||
/* ── Newsletter / School Blog ─────────────────────────────────────── */
|
||||
|
||||
export interface NewsletterData {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
authorId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
summary: string | null;
|
||||
imageUrl: string | null;
|
||||
category: string;
|
||||
isPublished: boolean;
|
||||
publishedAt: string | null;
|
||||
tags: string | null;
|
||||
isDemo: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
author: { id: string; firstName: string; lastName: string };
|
||||
}
|
||||
|
||||
export function fetchNewsletters(schoolId: string, published?: boolean, category?: string, limit?: number): Promise<{ newsletters: NewsletterData[]; total: number }> {
|
||||
const params = new URLSearchParams({ schoolId });
|
||||
if (published !== undefined) params.set('published', String(published));
|
||||
if (category) params.set('category', category);
|
||||
if (limit) params.set('limit', String(limit));
|
||||
return apiGet<{ newsletters: NewsletterData[]; total: number }>(`/api/newsletters?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function fetchNewsletter(id: string): Promise<NewsletterData> {
|
||||
return apiGet<NewsletterData>(`/api/newsletters/${id}`);
|
||||
}
|
||||
|
||||
export function createNewsletter(data: {
|
||||
schoolId: string;
|
||||
authorId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
imageUrl?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
isDemo?: boolean;
|
||||
}): Promise<NewsletterData> {
|
||||
return apiPost<NewsletterData>('/api/newsletters', data);
|
||||
}
|
||||
|
||||
export function updateNewsletter(id: string, data: Partial<{
|
||||
title: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
imageUrl: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
}>): Promise<NewsletterData> {
|
||||
return apiPut<NewsletterData>(`/api/newsletters/${id}`, data);
|
||||
}
|
||||
|
||||
export function publishNewsletter(id: string): Promise<NewsletterData> {
|
||||
return apiPost<NewsletterData>(`/api/newsletters/${id}`, { action: 'publish' });
|
||||
}
|
||||
|
||||
export function unpublishNewsletter(id: string): Promise<NewsletterData> {
|
||||
return apiPost<NewsletterData>(`/api/newsletters/${id}`, { action: 'unpublish' });
|
||||
}
|
||||
|
||||
export function deleteNewsletter(id: string): Promise<{ success: boolean }> {
|
||||
return apiDelete<{ success: boolean }>(`/api/newsletters/${id}`);
|
||||
}
|
||||
@@ -3161,6 +3161,7 @@ const de: TranslationDict = {
|
||||
'seating.no_students': 'Keine Schüler für Sitzplan',
|
||||
'seating.visual': 'Visueller Sitzplan',
|
||||
'seating.list': 'Listenansicht',
|
||||
'seating.teacher_desk': 'Lehrerpult',
|
||||
|
||||
// QR Code System
|
||||
'qr.title': 'QR-Code',
|
||||
@@ -3229,6 +3230,49 @@ const de: TranslationDict = {
|
||||
'badges.team_player': 'Teamplayer',
|
||||
'badges.goal_achiever': 'Zielerreicher',
|
||||
'badges.eco_warrior': 'Umweltkrieger',
|
||||
|
||||
// Newsletter / School Blog
|
||||
'newsletter.title': 'Schulnachrichten',
|
||||
'newsletter.create': 'Nachricht erstellen',
|
||||
'newsletter.published': 'Veröffentlicht',
|
||||
'newsletter.draft': 'Entwurf',
|
||||
'newsletter.category': 'Kategorie',
|
||||
'newsletter.general': 'Allgemein',
|
||||
'newsletter.academic': 'Akademisch',
|
||||
'newsletter.sports': 'Sport',
|
||||
'newsletter.arts': 'Kunst',
|
||||
'newsletter.events': 'Veranstaltungen',
|
||||
'newsletter.community': 'Gemeinschaft',
|
||||
'newsletter.read_more': 'Weiterlesen',
|
||||
'newsletter.view_all': 'Alle Nachrichten',
|
||||
'newsletter.cover_image': 'Titelbild',
|
||||
'newsletter.summary': 'Zusammenfassung',
|
||||
'newsletter.tags': 'Schlagwörter',
|
||||
'newsletter.publish': 'Veröffentlichen',
|
||||
'newsletter.unpublish': 'Veröffentlichung zurückziehen',
|
||||
'newsletter.share': 'Teilen',
|
||||
'newsletter.author': 'Autor',
|
||||
'newsletter.date': 'Datum',
|
||||
'newsletter.content': 'Inhalt',
|
||||
'newsletter.no_news': 'Keine Nachrichten verfügbar',
|
||||
'newsletter.latest': 'Neueste Nachrichten',
|
||||
'newsletter.school_news': 'Schulnachrichten',
|
||||
|
||||
// Avatar / Student Photo
|
||||
'avatar.title': 'Foto',
|
||||
'avatar.upload': 'Foto hochladen',
|
||||
'avatar.initials': 'Initialen',
|
||||
'avatar.photo': 'Foto',
|
||||
'avatar.change': 'Foto ändern',
|
||||
'avatar.remove': 'Foto entfernen',
|
||||
|
||||
// German Grade Labels
|
||||
'grading.sehr_gut': 'Sehr gut',
|
||||
'grading.gut': 'Gut',
|
||||
'grading.befriedigend': 'Befriedigend',
|
||||
'grading.ausreichend': 'Ausreichend',
|
||||
'grading.mangelhaft': 'Mangelhaft',
|
||||
'grading.ungenuegend': 'Ungenügend',
|
||||
};
|
||||
|
||||
const en: TranslationDict = {
|
||||
@@ -6389,6 +6433,7 @@ const en: TranslationDict = {
|
||||
'seating.no_students': 'No students for seating chart',
|
||||
'seating.visual': 'Visual Seating',
|
||||
'seating.list': 'List View',
|
||||
'seating.teacher_desk': 'Teacher Desk',
|
||||
|
||||
// QR Code System
|
||||
'qr.title': 'QR Code',
|
||||
@@ -6457,6 +6502,49 @@ const en: TranslationDict = {
|
||||
'badges.team_player': 'Team Player',
|
||||
'badges.goal_achiever': 'Goal Achiever',
|
||||
'badges.eco_warrior': 'Eco Warrior',
|
||||
|
||||
// Newsletter / School Blog
|
||||
'newsletter.title': 'School News',
|
||||
'newsletter.create': 'Create Newsletter',
|
||||
'newsletter.published': 'Published',
|
||||
'newsletter.draft': 'Draft',
|
||||
'newsletter.category': 'Category',
|
||||
'newsletter.general': 'General',
|
||||
'newsletter.academic': 'Academic',
|
||||
'newsletter.sports': 'Sports',
|
||||
'newsletter.arts': 'Arts',
|
||||
'newsletter.events': 'Events',
|
||||
'newsletter.community': 'Community',
|
||||
'newsletter.read_more': 'Read More',
|
||||
'newsletter.view_all': 'View All News',
|
||||
'newsletter.cover_image': 'Cover Image',
|
||||
'newsletter.summary': 'Summary',
|
||||
'newsletter.tags': 'Tags',
|
||||
'newsletter.publish': 'Publish',
|
||||
'newsletter.unpublish': 'Unpublish',
|
||||
'newsletter.share': 'Share',
|
||||
'newsletter.author': 'Author',
|
||||
'newsletter.date': 'Date',
|
||||
'newsletter.content': 'Content',
|
||||
'newsletter.no_news': 'No news available',
|
||||
'newsletter.latest': 'Latest News',
|
||||
'newsletter.school_news': 'School News',
|
||||
|
||||
// Avatar / Student Photo
|
||||
'avatar.title': 'Photo',
|
||||
'avatar.upload': 'Upload Photo',
|
||||
'avatar.initials': 'Initials',
|
||||
'avatar.photo': 'Photo',
|
||||
'avatar.change': 'Change Photo',
|
||||
'avatar.remove': 'Remove Photo',
|
||||
|
||||
// German Grade Labels
|
||||
'grading.sehr_gut': 'Sehr gut',
|
||||
'grading.gut': 'Gut',
|
||||
'grading.befriedigend': 'Befriedigend',
|
||||
'grading.ausreichend': 'Ausreichend',
|
||||
'grading.mangelhaft': 'Mangelhaft',
|
||||
'grading.ungenuegend': 'Ungenügend',
|
||||
};
|
||||
|
||||
const translations: Record<string, TranslationDict> = { de, en };
|
||||
|
||||
+107
@@ -1,5 +1,70 @@
|
||||
# CompetenceTrack — Project Worklog
|
||||
|
||||
---
|
||||
Task ID: 26
|
||||
Agent: round18-seating-chart-student-avatars-newsletter-badges-deep-styling
|
||||
Task: Seating Chart, Student Avatars, Newsletter, Badges, Deep Styling Polish
|
||||
Date: Round 18 complete
|
||||
|
||||
Work Log:
|
||||
- Updated Prisma schema: added `avatarUrl` and `avatarInitials` fields to Student model, added Newsletter model with full fields (title, content, summary, imageUrl, category, isPublished, publishedAt, tags, etc.), added relations to School and User models
|
||||
- Ran `bun run db:push` successfully — database in sync
|
||||
- Added 40+ i18n keys for both DE and EN: newsletter.*, avatar.*, grading.sehr_gut through grading.ungenuegend
|
||||
- Created Newsletter API routes: `/api/newsletters/route.ts` (GET list with pagination/filters, POST create), `/api/newsletters/[id]/route.ts` (GET, PUT, DELETE, POST publish/unpublish)
|
||||
- Added newsletter API functions to lib/api.ts: fetchNewsletters, fetchNewsletter, createNewsletter, updateNewsletter, publishNewsletter, unpublishNewsletter, deleteNewsletter + NewsletterData interface
|
||||
- Updated Student interface in api.ts to include avatarUrl and avatarInitials fields
|
||||
- Updated students API route to support avatarUrl in PUT handler
|
||||
- Updated student details API to include avatarUrl and avatarInitials in select
|
||||
- Created StudentAvatar component (src/components/student-avatar.tsx): reusable avatar with initials in colored circles or photo display, name-hash-based consistent colors, tooltip support, multiple sizes (xs/sm/md/lg/xl)
|
||||
- Updated globals.css with 15+ new CSS utility classes: gradient-card-bg, sparkline-mini, grade-badge-1 through grade-badge-6, mastery-badge-low/medium/high, category-badge-*, newsletter-card, avatar-circle, avatar-photo, animated-underline, animate-count-up, animate-leaf-sway, animate-card-entrance, gradient-border-card
|
||||
- Updated dashboard-view.tsx: added DashboardNewsletterCard with newsletter section, category badges, preview cards, create dialog, view dialog with rich content, share button
|
||||
- Updated classes-view.tsx: added Class Photo Gallery section with StudentAvatar grid, staggered entrance animation, click-to-navigate-to-student-detail, gradient-border-card class, replaced inline avatar with StudentAvatar component
|
||||
- Updated progress-entries-view.tsx, assessments-view.tsx, grading-view.tsx, attendance-view.tsx, reports-view.tsx, student-detail-view.tsx: replaced inline avatar with StudentAvatar component
|
||||
- Updated grading-view.tsx: enhanced gradeColor with 6-level German grade system (sehr gut through ungenuegend) with grade-badge CSS classes and label text
|
||||
- Updated student-detail-view.tsx: added avatar upload button with dialog for URL input
|
||||
- Lint: 0 errors
|
||||
- i18n.ts: 0 duplicate keys in both DE and EN dictionaries
|
||||
- Dev log: stale browser cache error (not a code error), compilation successful
|
||||
- Committed and pushed to GitHub as Round 18
|
||||
|
||||
Verification Results (Round 18):
|
||||
- `bun run lint`: 0 errors ✓
|
||||
- `bun run db:push`: Database in sync ✓
|
||||
- i18n.ts duplicate keys: 0 ✓
|
||||
|
||||
---
|
||||
Task ID: 25
|
||||
Agent: newsletter-avatar-styling
|
||||
Task: Student Avatars + School Newsletter + Deep Styling Polish
|
||||
|
||||
Work Log:
|
||||
- Updated Prisma schema: added `avatarUrl` and `avatarInitials` fields to Student model, added Newsletter model with full fields (title, content, summary, imageUrl, category, isPublished, publishedAt, tags, etc.), added relations to School and User models
|
||||
- Ran `bun run db:push` successfully — database in sync
|
||||
- Added 40+ i18n keys for both DE and EN: newsletter.*, avatar.*, grading.sehr_gut through grading.ungenuegend
|
||||
- Created Newsletter API routes: `/api/newsletters/route.ts` (GET list with pagination/filters, POST create), `/api/newsletters/[id]/route.ts` (GET, PUT, DELETE, POST publish/unpublish)
|
||||
- Added newsletter API functions to lib/api.ts: fetchNewsletters, fetchNewsletter, createNewsletter, updateNewsletter, publishNewsletter, unpublishNewsletter, deleteNewsletter + NewsletterData interface
|
||||
- Updated Student interface in api.ts to include avatarUrl and avatarInitials fields
|
||||
- Updated students API route to support avatarUrl in PUT handler
|
||||
- Updated student details API to include avatarUrl and avatarInitials in select
|
||||
- Created StudentAvatar component (src/components/student-avatar.tsx): reusable avatar with initials in colored circles or photo display, name-hash-based consistent colors, tooltip support, multiple sizes (xs/sm/md/lg/xl)
|
||||
- Updated globals.css with 15+ new CSS utility classes: gradient-card-bg, sparkline-mini, grade-badge-1 through grade-badge-6, mastery-badge-low/medium/high, category-badge-* (6 categories), newsletter-card, avatar-circle, avatar-photo, animated-underline, animate-count-up, animate-leaf-sway, animate-card-entrance, gradient-border-card
|
||||
- Updated dashboard-view.tsx: added DashboardNewsletterCard with newsletter section (3 latest published newsletters, category badges, preview cards, create dialog, view dialog with rich content, share button), added Leaf sway animation, added count-up animation for paper saved counter, added animated-underline class, imported Newspaper/Share2/PenSquare/Eye/Tag icons
|
||||
- Updated classes-view.tsx: added Class Photo Gallery section with StudentAvatar grid, staggered entrance animation, click-to-navigate-to-student-detail, added gradient-border-card class, replaced inline avatar with StudentAvatar component, added Camera icon
|
||||
- Updated progress-entries-view.tsx: replaced inline avatar with StudentAvatar component, updated masteryBadge to use CSS class-based mastery-badge-low/medium/high for deeper polish
|
||||
- Updated assessments-view.tsx: replaced inline avatar with StudentAvatar component
|
||||
- Updated grading-view.tsx: replaced inline avatar with StudentAvatar component, enhanced gradeColor to include 6-level German grade system (sehr gut through ungenuegend) with grade-badge CSS classes and label text, added grade label display below each grade badge
|
||||
- Updated student-detail-view.tsx: replaced inline initials with StudentAvatar component, added avatar upload button (Camera icon) with dialog for URL input, added avatarUploadOpen/avatarUrlInput/avatarUploading state, added Camera and Upload icons
|
||||
- Updated attendance-view.tsx: replaced inline avatar with StudentAvatar component
|
||||
- Updated reports-view.tsx: replaced inline avatar with StudentAvatar component (both draft and final report views)
|
||||
- Lint: 0 errors
|
||||
- Dev log: stale browser cache error (not a code error), compilation successful
|
||||
|
||||
Stage Summary:
|
||||
- Student Avatar System: Created reusable StudentAvatar component with name-hash-based consistent colors, photo support, 5 sizes, tooltip support. Integrated across 7 views (classes, progress, assessments, grading, attendance, reports, student-detail)
|
||||
- School Newsletter: Full CRUD API + create/publish/unpublish flow, dashboard newsletter card with 3 latest items, category badges (6 categories), newsletter preview cards, create dialog, view dialog with rich content, share functionality
|
||||
- Deep Styling Polish: 15+ new CSS utility classes (gradient-card-bg, grade-badge-1-6, mastery-badge-low/medium/high, category-badge-*, newsletter-card, avatar-circle, avatar-photo, animated-underline, animate-count-up, animate-leaf-sway, animate-card-entrance, gradient-border-card), Leaf sway animation, count-up animation, animated underlines, German grade system with 6 levels and labels
|
||||
- 40+ i18n keys added for both DE and EN
|
||||
|
||||
---
|
||||
Task ID: 18
|
||||
Agent: round17-qr-codes-gamification-badges-auto-award-badge-management
|
||||
@@ -1073,3 +1138,45 @@ Stage Summary:
|
||||
- CSS styles for badge circles, celebrations, QR cards, attendance display, quick stats
|
||||
- No lint errors, app compiles and runs
|
||||
|
||||
|
||||
---
|
||||
Task ID: 24
|
||||
Agent: round24-student-transportation-health-records-seating-chart
|
||||
Task: Complete Student Transportation + Health Records + Interactive Seating Chart
|
||||
Date: Round 24 complete
|
||||
|
||||
Work Log:
|
||||
- Reviewed existing work from previous agents. Parts 1 (StudentTransport) and 2 (HealthRecord) were already fully implemented:
|
||||
- StudentTransport Prisma model exists with all fields (transportType, routeNumber, stopName, pickupTime, dropoffTime, driverName, driverPhone, distanceKm, notes)
|
||||
- HealthRecord Prisma model exists with all fields (bloodType, allergies, medications, conditions, doctorName, doctorPhone, insuranceNumber, insuranceProvider, lastCheckup, isConfidential)
|
||||
- API routes exist: /api/student-transport/route.ts, /api/student-transport/[id]/route.ts, /api/health-records/route.ts, /api/health-records/[id]/route.ts
|
||||
- Seating API route exists: /api/classes/[id]/seating/route.ts
|
||||
- Transportation UI section in student-detail-view.tsx with transport cards, type icons (Bus, Car, Bike, Walk), route/stop info, times, driver info, quick-call button
|
||||
- Health Records UI section in student-detail-view.tsx with blood type badge, allergy warnings, medication list, doctor info, confidential toggle
|
||||
- All i18n keys for transport.* and health.* already present in both de and en
|
||||
- Seating i18n keys already present in both de and en
|
||||
- Part 3: Added Interactive Seating Chart visual grid to classes-view.tsx:
|
||||
- Added new imports: useRef, Shuffle, Printer, Eraser, Columns3, Rows3, Move
|
||||
- Added apiGet/apiPut imports from @/lib/api
|
||||
- Added seating chart state variables: seatingChartOpen, seatingRows, seatingCols, seatingGrid, seatingSaving, drag/touch handlers
|
||||
- Added seating chart helper functions: loadSeatingChart, saveSeatingChart, randomizeSeating, clearSeating, handleSeatingDragStart/Over/Drop/End, touch handlers, handleResizeGrid, handlePrintSeating, getStudentById
|
||||
- Added "Visual Seating" button next to existing "Seating Order" button in student roster header
|
||||
- Added full visual seating chart card with:
|
||||
- Configurable rows/columns (1-10 each) with +/- buttons
|
||||
- Save, Randomize, Clear, Print action buttons (44px min touch targets)
|
||||
- Drag-to-rearrange hint with Move icon
|
||||
- Visual grid with column labels (A, B, C...) and row labels (1, 2, 3...)
|
||||
- Student avatar cells with initials, first/last name, gradient backgrounds
|
||||
- Empty seat cells with Armchair icon and dashed border
|
||||
- Drag-and-drop support (HTML5 drag API) with visual feedback (ring, scale, opacity)
|
||||
- Touch-friendly drag support (onTouchStart/onTouchEnd) for tablets
|
||||
- Teacher desk indicator at bottom of grid
|
||||
- ARIA labels for accessibility
|
||||
- Print functionality opens a new window with formatted table
|
||||
- Empty state when no students are enrolled
|
||||
- Added 'seating.teacher_desk' i18n key in both de and en
|
||||
- Verification Results:
|
||||
- bun run db:push: Database already in sync
|
||||
- bun run lint: 0 errors
|
||||
- Dev server running without errors
|
||||
|
||||
Reference in new issue
Block a user