Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ model School {
outboundMessages OutboundMessage[]
preferredTeachers PreferredTeacher[]
preferredReliefAssignments PreferredReliefAssignment[]
notifications Notification[]
}

// ─── Auth ────────────────────────────────────────────────
Expand All @@ -197,6 +198,7 @@ model User {
preferredTeachersAdded PreferredTeacher[] @relation("PreferredTeacherAddedBy")
preferredReliefCreated PreferredReliefAssignment[] @relation("PreferredReliefCreatedBy")
preferredReliefCancelled PreferredReliefAssignment[] @relation("PreferredReliefCancelledBy")
notifications Notification[]
}

model Session {
Expand Down Expand Up @@ -406,6 +408,8 @@ model ReliefAssignment {
date DateTime
ackToken String? @unique // signed token for one-tap acknowledge
acknowledgedAt DateTime?
declinedAt DateTime?
declineReason String?
// Phase 15 / D2 — who created this assignment? Null when system-created
// (seed, future cron paths). For multi-KP schools (Zhenghua), the
// dashboard reads this to show "Assigned by ___" so concurrent KPs can
Expand Down Expand Up @@ -913,3 +917,21 @@ model PreferredReliefAssignment {
@@index([preferredTeacherId, date])
@@index([absenceReportId])
}

// ─── Notifications ──────────────────────────────────────

model Notification {
id String @id @default(cuid())
userId String
schoolId String
title String
body String
read Boolean @default(false)
createdAt DateTime @default(now())

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
school School @relation(fields: [schoolId], references: [id], onDelete: Cascade)

@@index([userId, read])
@@index([schoolId])
}
51 changes: 50 additions & 1 deletion prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ async function main() {
}

// Link demo admin to a Teacher row
await prisma.teacher.create({
const demoAdminTeacher = await prisma.teacher.create({
data: { name: "Demo Admin", schoolId, userId: demoAdmin.id },
});

Expand Down Expand Up @@ -714,6 +714,55 @@ async function main() {
}
}

// ── Guaranteed demo teacher dashboard data ──
// Assign both David Chen (TEACHER login) and Demo Admin (demo cookie) as
// relief teachers for a few periods today so /demo/teacher always has data.
const demoReliefTargets = [
{ teacher: teachers[4], label: "David Chen" },
{ teacher: { id: demoAdminTeacher.id, name: "Demo Admin" }, label: "Demo Admin" },
];

const todayAbsences = await prisma.absenceReport.findMany({
where: { schoolId, startDate: { lte: today }, endDate: { gte: today } },
include: {
reliefAssignments: { where: { date: today }, select: { timetableEntryId: true } },
},
take: 10,
});

for (const target of demoReliefTargets) {
let count = 0;
for (const absence of todayAbsences) {
if (count >= 3) break;
const assignedEntryIds = new Set(absence.reliefAssignments.map((ra) => ra.timetableEntryId));
const uncoveredEntries = createdEntries.filter(
(e) =>
e.teacherId === absence.teacherId &&
e.dayOfWeek === today.getUTCDay() &&
!assignedEntryIds.has(e.id),
);
Comment on lines +733 to +743
for (const entry of uncoveredEntries) {
if (count >= 3) break;
const entryDateKey = `${entry.id}:${today.toISOString()}`;
if (assignedEntryDates.has(entryDateKey)) continue;
assignedEntryDates.add(entryDateKey);

await prisma.reliefAssignment.create({
data: {
absenceReportId: absence.id,
timetableEntryId: entry.id,
reliefTeacherId: target.teacher.id,
date: today,
schoolId,
},
});
reliefAssignmentCount++;
count++;
}
}
console.log(` ${count} guaranteed relief assignments for ${target.label} (teacher dashboard demo)`);
}

// ── Matching config ──
await prisma.matchingConfig.create({
data: {
Expand Down
12 changes: 9 additions & 3 deletions src/app/[slug]/dashboard/AbsentTeachersTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,15 @@ export default function AbsentTeachersTab({
<span>
P{slot.periodNumber} · {slot.className} {slot.subject}
</span>
<span className="text-xs opacity-70">
{slot.periodStartTime} – {slot.periodEndTime}
</span>
{slot.declinedAt && slot.reliefTeacherName ? (
<span className="text-xs text-orange-600 dark:text-orange-400">
Declined by {slot.reliefTeacherName}
</span>
) : (
<span className="text-xs opacity-70">
{slot.periodStartTime} – {slot.periodEndTime}
</span>
)}
</button>
);
})}
Expand Down
130 changes: 128 additions & 2 deletions src/app/[slug]/dashboard/DesktopTopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,139 @@
"use client";

import { LogOut } from "lucide-react";
import { useState, useEffect, useCallback, useRef } from "react";
import { LogOut, Bell, X } from "lucide-react";
import { useSchool } from "@/lib/SchoolContext";

type NotificationItem = {
id: string;
title: string;
body: string;
read: boolean;
createdAt: string;
};

export default function DesktopTopBar() {
const { slug } = useSchool();
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showPanel, setShowPanel] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);

const fetchNotifications = useCallback(async () => {
try {
const res = await fetch("/api/in-app-notifications");
if (!res.ok) return;
const data = await res.json();
setNotifications(data.notifications);
setUnreadCount(data.unreadCount);
} catch {
/* ignore */
}
}, []);

useEffect(() => {
fetchNotifications();
const interval = setInterval(fetchNotifications, 30_000);
return () => clearInterval(interval);
}, [fetchNotifications]);

useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setShowPanel(false);
}
}
if (showPanel) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showPanel]);

async function markAllRead() {
const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id);
if (unreadIds.length === 0) return;
await fetch("/api/in-app-notifications/read", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: unreadIds }),
});
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
setUnreadCount(0);
}

return (
<div className="hidden items-center justify-end border-b border-border bg-card px-6 py-2 md:flex">
<div className="hidden items-center justify-end gap-4 border-b border-border bg-card px-6 py-2 md:flex">
{/* Notification bell */}
<div className="relative" ref={panelRef}>
<button
onClick={() => setShowPanel((v) => !v)}
className="relative rounded-md p-1.5 text-muted-foreground transition-colors hover:text-foreground"
aria-label="Notifications"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>

{showPanel && (
<div className="absolute right-0 top-full z-50 mt-1 w-80 rounded-lg border border-border bg-card shadow-lg">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<p className="text-sm font-semibold text-foreground">
Notifications
</p>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="text-xs font-medium text-primary hover:text-primary/80"
>
Mark all read
</button>
)}
<button
onClick={() => setShowPanel(false)}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="max-h-80 overflow-y-auto">
{notifications.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
No notifications yet
</p>
) : (
notifications.map((n) => (
<div
key={n.id}
className={`border-b border-border px-4 py-3 last:border-b-0 ${
n.read ? "" : "bg-primary/5"
}`}
>
<p className="text-sm font-medium text-foreground">
{n.title}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{n.body}
</p>
<p className="mt-1 text-[10px] text-muted-foreground">
{new Date(n.createdAt).toLocaleString("en-SG", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
))
)}
</div>
</div>
)}
</div>

{slug === "demo" ? (
<a
href="/login"
Expand Down
5 changes: 4 additions & 1 deletion src/app/[slug]/dashboard/ReliefOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,10 @@ export default function ReliefOverview({
month: "long",
year: "numeric",
});
const summary = generateReliefSummary(data, formattedDate);
const teacherDashboardUrl = `${window.location.origin}/${slug}/teacher`;
const summary = generateReliefSummary(data, formattedDate, {
teacherDashboardUrl,
});
await navigator.clipboard.writeText(summary);
setCopiedFeedback(true);
setTimeout(() => setCopiedFeedback(false), 2000);
Expand Down
Loading