Round 25: Substitute Teacher, School Newsletter, Career Guidance, Styling

- Substitute Teacher Management View: pool, absences, assignments, statistics, weekly schedule
- School Newsletter View: editor, templates, distribution, analytics, open/click tracking
- Student Career & Guidance View: career exploration, interest quiz, portfolio, goals, appointments
- 6 new Prisma models: SubstituteTeacher, TeacherAbsence, SubstitutionAssignment, CareerProfile, CareerGoal, CareerAppointment
- Enhanced Newsletter model with content, scheduling, and analytics fields
- Enhanced settings-view with gradient header, glassmorphism cards, danger zone
- Enhanced student-portal-view with animated welcome banner, quick action cards
- 16+ new API route files
- 300+ i18n keys added
- All views verified working in browser
This commit is contained in:
Mustafa Cetin committed 2026-07-30 06:36:34 +00:00
1 parent a159ed7fe6
commit 3d0d518bb9
57 files changed
+8370 -48

No files matched your search

+52 -2
View File
@@ -26,7 +26,7 @@ export async function GET(
}
}
// PUT /api/newsletters/[id] — update newsletter
// PUT /api/newsletters/[id] — update newsletter with extended fields
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
@@ -42,11 +42,21 @@ export async function PUT(
const data: Record<string, unknown> = {};
if (body.title !== undefined) data.title = body.title;
if (body.subject !== undefined) data.subject = body.subject;
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.bannerImageUrl !== undefined) data.bannerImageUrl = body.bannerImageUrl;
if (body.category !== undefined) data.category = body.category;
if (body.templateType !== undefined) data.templateType = body.templateType;
if (body.targetAudience !== undefined) data.targetAudience = JSON.stringify(body.targetAudience);
if (body.status !== undefined) data.status = body.status;
if (body.tags !== undefined) data.tags = JSON.stringify(body.tags);
if (body.scheduledAt !== undefined) data.scheduledAt = body.scheduledAt ? new Date(body.scheduledAt) : null;
if (body.openCount !== undefined) data.openCount = body.openCount;
if (body.clickCount !== undefined) data.clickCount = body.clickCount;
if (body.bounceCount !== undefined) data.bounceCount = body.bounceCount;
if (body.totalRecipients !== undefined) data.totalRecipients = body.totalRecipients;
const newsletter = await db.newsletter.update({
where: { id },
@@ -63,7 +73,7 @@ export async function PUT(
}
}
// POST /api/newsletters/[id] — publish/unpublish newsletter
// POST /api/newsletters/[id] — publish/unpublish/archive/duplicate newsletter
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
@@ -77,6 +87,45 @@ export async function POST(
return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 });
}
// Duplicate action
if (body.action === 'duplicate') {
const duplicated = await db.newsletter.create({
data: {
schoolId: existing.schoolId,
authorId: body.authorId || existing.authorId,
title: existing.title + ' (Kopie)',
subject: existing.subject,
content: existing.content,
summary: existing.summary,
imageUrl: existing.imageUrl,
bannerImageUrl: existing.bannerImageUrl,
category: existing.category,
templateType: existing.templateType,
targetAudience: existing.targetAudience,
status: 'draft',
tags: existing.tags,
isDemo: existing.isDemo,
},
include: {
author: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(duplicated, { status: 201 });
}
// Archive action
if (body.action === 'archive') {
const newsletter = await db.newsletter.update({
where: { id },
data: { status: 'archived' },
include: {
author: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(newsletter);
}
// Publish/unpublish
const isPublished = body.action === 'publish' ? true : body.action === 'unpublish' ? false : !existing.isPublished;
const newsletter = await db.newsletter.update({
@@ -84,6 +133,7 @@ export async function POST(
data: {
isPublished,
publishedAt: isPublished ? new Date() : null,
status: isPublished ? 'sent' : 'draft',
},
include: {
author: { select: { id: true, firstName: true, lastName: true } },
+115
View File
@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
// POST /api/newsletters/[id]/send — send newsletter to target audience
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await req.json();
const newsletter = await db.newsletter.findUnique({ where: { id } });
if (!newsletter || newsletter.deletedAt) {
return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 });
}
if (newsletter.status === 'sent') {
return NextResponse.json({ error: 'Newsletter already sent' }, { status: 400 });
}
// Parse target audience
let targetAudience: { type: string; ids?: string[] } = { type: 'all' };
if (newsletter.targetAudience) {
try {
targetAudience = JSON.parse(newsletter.targetAudience);
} catch {
targetAudience = { type: 'all' };
}
}
// Override with body targetAudience if provided
if (body.targetAudience) {
targetAudience = body.targetAudience;
}
// Count recipients based on target audience
let totalRecipients = 0;
if (targetAudience.type === 'all') {
// All users in the school
totalRecipients = await db.user.count({
where: { schoolId: newsletter.schoolId, deletedAt: null },
});
} else if (targetAudience.type === 'roles' && targetAudience.ids?.length) {
// Specific roles
totalRecipients = await db.user.count({
where: {
schoolId: newsletter.schoolId,
role: { in: targetAudience.ids },
deletedAt: null,
},
});
} else if (targetAudience.type === 'classes' && targetAudience.ids?.length) {
// Specific classes — count students + parents
const students = await db.student.findMany({
where: {
classGroupId: { in: targetAudience.ids },
deletedAt: null,
},
include: { _count: { select: { parentLinks: true } } },
});
totalRecipients = students.length + students.reduce((sum, s) => sum + s._count.parentLinks, 0);
} else if (targetAudience.type === 'teachers') {
totalRecipients = await db.user.count({
where: {
schoolId: newsletter.schoolId,
role: 'TEACHER',
deletedAt: null,
},
});
} else if (targetAudience.type === 'parents') {
const parents = await db.parentLink.findMany({
where: { student: { schoolId: newsletter.schoolId } },
distinct: ['email'],
});
totalRecipients = parents.length;
}
// Schedule for future or send now
const isScheduled = body.scheduledAt && new Date(body.scheduledAt) > new Date();
const updated = await db.newsletter.update({
where: { id },
data: {
status: isScheduled ? 'scheduled' : 'sent',
isPublished: true,
publishedAt: isScheduled ? null : new Date(),
scheduledAt: isScheduled ? new Date(body.scheduledAt) : null,
sentAt: isScheduled ? null : new Date(),
totalRecipients,
targetAudience: JSON.stringify(targetAudience),
},
include: {
author: { select: { id: true, firstName: true, lastName: true } },
},
});
// In a real app, we would trigger email sending here
// For now, we simulate the send with realistic stats
return NextResponse.json({
...updated,
sendResult: {
totalRecipients,
delivered: Math.floor(totalRecipients * 0.95),
bounced: Math.floor(totalRecipients * 0.05),
scheduled: isScheduled,
},
});
} catch (error) {
console.error('Error sending newsletter:', error);
return NextResponse.json({ error: 'Failed to send newsletter' }, { status: 500 });
}
}
+172
View File
@@ -0,0 +1,172 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
// GET /api/newsletters/[id]/stats — get newsletter analytics
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const { searchParams } = new URL(req.url);
const schoolId = searchParams.get('schoolId');
// Single newsletter stats
if (id !== 'aggregate') {
const newsletter = await db.newsletter.findUnique({ where: { id } });
if (!newsletter || newsletter.deletedAt) {
return NextResponse.json({ error: 'Newsletter not found' }, { status: 404 });
}
const openRate = newsletter.totalRecipients > 0
? (newsletter.openCount / newsletter.totalRecipients) * 100
: 0;
const clickRate = newsletter.totalRecipients > 0
? (newsletter.clickCount / newsletter.totalRecipients) * 100
: 0;
const bounceRate = newsletter.totalRecipients > 0
? (newsletter.bounceCount / newsletter.totalRecipients) * 100
: 0;
return NextResponse.json({
newsletterId: newsletter.id,
title: newsletter.title,
status: newsletter.status,
templateType: newsletter.templateType,
totalRecipients: newsletter.totalRecipients,
openCount: newsletter.openCount,
clickCount: newsletter.clickCount,
bounceCount: newsletter.bounceCount,
openRate: Math.round(openRate * 10) / 10,
clickRate: Math.round(clickRate * 10) / 10,
bounceRate: Math.round(bounceRate * 10) / 10,
sentAt: newsletter.sentAt,
publishedAt: newsletter.publishedAt,
});
}
// Aggregate stats for school
if (!schoolId) {
return NextResponse.json({ error: 'schoolId required for aggregate stats' }, { status: 400 });
}
const newsletters = await db.newsletter.findMany({
where: { schoolId, status: 'sent', deletedAt: null },
orderBy: { sentAt: 'desc' },
include: {
author: { select: { id: true, firstName: true, lastName: true } },
},
});
const totalSent = newsletters.length;
const totalRecipients = newsletters.reduce((sum, n) => sum + n.totalRecipients, 0);
const totalOpens = newsletters.reduce((sum, n) => sum + n.openCount, 0);
const totalClicks = newsletters.reduce((sum, n) => sum + n.clickCount, 0);
const totalBounces = newsletters.reduce((sum, n) => sum + n.bounceCount, 0);
const overallOpenRate = totalRecipients > 0 ? (totalOpens / totalRecipients) * 100 : 0;
const overallClickRate = totalRecipients > 0 ? (totalClicks / totalRecipients) * 100 : 0;
const overallBounceRate = totalRecipients > 0 ? (totalBounces / totalRecipients) * 100 : 0;
// Monthly trend data
const monthlyData: Record<string, { sent: number; opens: number; clicks: number; recipients: number }> = {};
newsletters.forEach((n) => {
if (n.sentAt) {
const month = new Date(n.sentAt).toISOString().slice(0, 7);
if (!monthlyData[month]) {
monthlyData[month] = { sent: 0, opens: 0, clicks: 0, recipients: 0 };
}
monthlyData[month].sent++;
monthlyData[month].opens += n.openCount;
monthlyData[month].clicks += n.clickCount;
monthlyData[month].recipients += n.totalRecipients;
}
});
// Best sending time analysis
const hourCounts: Record<number, { total: number; opens: number }> = {};
newsletters.forEach((n) => {
if (n.sentAt) {
const hour = new Date(n.sentAt).getHours();
if (!hourCounts[hour]) hourCounts[hour] = { total: 0, opens: 0 };
hourCounts[hour].total += n.totalRecipients;
hourCounts[hour].opens += n.openCount;
}
});
const bestHour = Object.entries(hourCounts).sort((a, b) => {
const rateA = a[1].total > 0 ? a[1].opens / a[1].total : 0;
const rateB = b[1].total > 0 ? b[1].opens / b[1].total : 0;
return rateB - rateA;
})[0];
// Template type performance
const templatePerformance: Record<string, { count: number; openRate: number; clickRate: number }> = {};
newsletters.forEach((n) => {
if (!templatePerformance[n.templateType]) {
templatePerformance[n.templateType] = { count: 0, openRate: 0, clickRate: 0 };
}
templatePerformance[n.templateType].count++;
if (n.totalRecipients > 0) {
templatePerformance[n.templateType].openRate += (n.openCount / n.totalRecipients) * 100;
templatePerformance[n.templateType].clickRate += (n.clickCount / n.totalRecipients) * 100;
}
});
// Average per template
Object.keys(templatePerformance).forEach((key) => {
const tp = templatePerformance[key];
if (tp.count > 0) {
tp.openRate = Math.round((tp.openRate / tp.count) * 10) / 10;
tp.clickRate = Math.round((tp.clickRate / tp.count) * 10) / 10;
}
});
return NextResponse.json({
overview: {
totalSent,
totalRecipients,
totalOpens,
totalClicks,
totalBounces,
overallOpenRate: Math.round(overallOpenRate * 10) / 10,
overallClickRate: Math.round(overallClickRate * 10) / 10,
overallBounceRate: Math.round(overallBounceRate * 10) / 10,
},
monthlyTrend: Object.entries(monthlyData)
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, data]) => ({
month,
openRate: data.recipients > 0 ? Math.round((data.opens / data.recipients) * 1000) / 10 : 0,
clickRate: data.recipients > 0 ? Math.round((data.clicks / data.recipients) * 1000) / 10 : 0,
sent: data.sent,
recipients: data.recipients,
})),
bestSendingTime: bestHour ? {
hour: parseInt(bestHour[0]),
openRate: bestHour[1].total > 0
? Math.round((bestHour[1].opens / bestHour[1].total) * 1000) / 10
: 0,
} : null,
templatePerformance,
recentNewsletters: newsletters.slice(0, 10).map((n) => ({
id: n.id,
title: n.title,
templateType: n.templateType,
sentAt: n.sentAt,
totalRecipients: n.totalRecipients,
openCount: n.openCount,
clickCount: n.clickCount,
openRate: n.totalRecipients > 0
? Math.round((n.openCount / n.totalRecipients) * 1000) / 10
: 0,
clickRate: n.totalRecipients > 0
? Math.round((n.clickCount / n.totalRecipients) * 1000) / 10
: 0,
})),
});
} catch (error) {
console.error('Error fetching newsletter stats:', error);
return NextResponse.json({ error: 'Failed to fetch newsletter stats' }, { status: 500 });
}
}
+41 -4
View File
@@ -1,13 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
// GET /api/newsletters — list newsletters
// GET /api/newsletters — list newsletters with extended filtering
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 status = searchParams.get('status');
const templateType = searchParams.get('templateType');
const authorId = searchParams.get('authorId');
const search = searchParams.get('search');
const limit = parseInt(searchParams.get('limit') || '20');
const offset = parseInt(searchParams.get('offset') || '0');
@@ -30,6 +34,26 @@ export async function GET(req: NextRequest) {
where.category = category;
}
if (status) {
where.status = status;
}
if (templateType) {
where.templateType = templateType;
}
if (authorId) {
where.authorId = authorId;
}
if (search) {
where.OR = [
{ title: { contains: search } },
{ summary: { contains: search } },
{ subject: { contains: search } },
];
}
const [newsletters, total] = await Promise.all([
db.newsletter.findMany({
where,
@@ -50,14 +74,21 @@ export async function GET(req: NextRequest) {
}
}
// POST /api/newsletters — create newsletter
// POST /api/newsletters — create newsletter with extended fields
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { schoolId, authorId, title, content, summary, imageUrl, category, tags, isDemo } = body;
const {
schoolId, authorId, title, subject, content, summary, imageUrl,
bannerImageUrl, category, templateType, targetAudience, status,
tags, scheduledAt, isDemo,
} = body;
if (!schoolId || !authorId || !title || !content) {
return NextResponse.json({ error: 'schoolId, authorId, title, content required' }, { status: 400 });
return NextResponse.json(
{ error: 'schoolId, authorId, title, content required' },
{ status: 400 }
);
}
const newsletter = await db.newsletter.create({
@@ -65,11 +96,17 @@ export async function POST(req: NextRequest) {
schoolId,
authorId,
title,
subject: subject || null,
content,
summary: summary || null,
imageUrl: imageUrl || null,
bannerImageUrl: bannerImageUrl || null,
category: category || 'general',
templateType: templateType || 'monthly',
targetAudience: targetAudience ? JSON.stringify(targetAudience) : null,
status: status || 'draft',
tags: tags ? JSON.stringify(tags) : null,
scheduledAt: scheduledAt ? new Date(scheduledAt) : null,
isDemo: isDemo || false,
},
include: {