feat(school): teacher dashboard + classes + invite + student view — 9.2

This commit is contained in:
reloop
2026-04-12 15:47:01 +09:00
parent f4b80d380a
commit bed5b802d4
16 changed files with 2657 additions and 28 deletions

View File

@@ -3,7 +3,7 @@ import {
UnauthorizedException,
ConflictException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { OrgRole, Prisma } from '@prisma/client';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
@@ -54,10 +54,25 @@ export class AuthService {
}
async me(userId: number) {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
});
return this.safeUser(user);
const [user, memberships] = await Promise.all([
this.prisma.user.findUniqueOrThrow({
where: { id: userId },
}),
this.prisma.organizationMember.findMany({
where: { userId },
select: { role: true },
}),
]);
const organizationRole = memberships.reduce<OrgRole | null>((highest, membership) => {
if (!highest) return membership.role;
const rank = rolePriority(membership.role);
const highestRank = rolePriority(highest);
return rank < highestRank ? membership.role : highest;
}, null);
return this.safeUser(user, organizationRole);
}
private sign(id: number, email: string): string {
@@ -65,22 +80,25 @@ export class AuthService {
return this.jwt.sign(payload);
}
private safeUser(u: {
id: number;
email: string;
nickname: string;
avatarUrl: string | null;
persona: string;
currentGrade: number | null;
targetGrade: number | null;
targetExamYear: number | null;
focusSubjects: Prisma.JsonValue | null;
reviewIntensity: string;
onboardedAt: Date | null;
subscriptionTier: string;
subscriptionUntil: Date | null;
createdAt: Date;
}) {
private safeUser(
u: {
id: number;
email: string;
nickname: string;
avatarUrl: string | null;
persona: string;
currentGrade: number | null;
targetGrade: number | null;
targetExamYear: number | null;
focusSubjects: Prisma.JsonValue | null;
reviewIntensity: string;
onboardedAt: Date | null;
subscriptionTier: string;
subscriptionUntil: Date | null;
createdAt: Date;
},
organizationRole: OrgRole | null = null,
) {
return {
id: u.id,
email: u.email,
@@ -97,7 +115,20 @@ export class AuthService {
onboarded: u.onboardedAt !== null,
subscriptionTier: u.subscriptionTier,
subscriptionUntil: u.subscriptionUntil,
organizationRole,
createdAt: u.createdAt,
};
}
}
function rolePriority(role: OrgRole) {
switch (role) {
case OrgRole.admin:
return 0;
case OrgRole.teacher:
return 1;
case OrgRole.student:
default:
return 2;
}
}

View File

@@ -103,6 +103,23 @@ export class OrganizationsService {
classes: true,
},
},
members: {
select: {
id: true,
userId: true,
role: true,
joinedAt: true,
user: {
select: {
id: true,
email: true,
nickname: true,
avatarUrl: true,
},
},
},
orderBy: [{ role: "asc" }, { joinedAt: "asc" }],
},
},
},
},
@@ -117,8 +134,6 @@ export class OrganizationsService {
? null
: membership.organization.inviteCode,
myRole: membership.role,
membersCount: membership.organization._count.members,
classesCount: membership.organization._count.classes,
};
}

View File

@@ -1,2 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -9,12 +9,18 @@ import { Icon } from '@/components/ui/Icon';
import { Button, Card } from '@/components/ui/primitives';
import {
api,
getAssignment,
getAssignments,
getMyClasses,
getMyOrganizations,
type DashboardSummary,
type MeUser,
type MasteryPathResponse,
type Organization,
type Persona,
type StudyLog,
} from '@/lib/api';
import { canManageSchool, formatOrganizationType, pendingAssignmentsForClass } from '@/lib/school';
import { animations, theme } from '@/styles/theme';
interface SubjectStats {
@@ -70,6 +76,11 @@ function DashboardBody() {
const [activityLogs, setActivityLogs] = useState<StudyLog[]>([]);
const [subjects, setSubjects] = useState<SubjectStats[]>([]);
const [masteryPreview, setMasteryPreview] = useState<MasteryPreviewItem[]>([]);
const [studentOrganizations, setStudentOrganizations] = useState<Organization[]>([]);
const [studentClasses, setStudentClasses] = useState<Awaited<ReturnType<typeof getMyClasses>>>([]);
const [studentAssignmentMap, setStudentAssignmentMap] = useState<
Map<number, Awaited<ReturnType<typeof getAssignment>> | null>
>(new Map());
const [coachDismissed, setCoachDismissed] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -85,12 +96,15 @@ function DashboardBody() {
setError(null);
try {
const [meRes, summaryRes, recentRes, activityRes, subjectsRes] = await Promise.all([
const [meRes, summaryRes, recentRes, activityRes, subjectsRes, orgsRes, classesRes, assignmentsRes] = await Promise.all([
api.get<MeUser>('/auth/me'),
api.get<DashboardSummary>('/dashboard/summary'),
api.get<StudyLog[]>('/study-logs', { params: { limit: 5 } }),
api.get<StudyLog[]>('/study-logs', { params: { limit: 200 } }),
api.get<SubjectStats[]>('/stats/subjects'),
getMyOrganizations(),
getMyClasses(),
getAssignments(),
]);
if (cancelled) return;
@@ -105,6 +119,26 @@ function DashboardBody() {
setRecentLogs(nextRecentLogs);
setActivityLogs(nextActivityLogs);
setSubjects(nextSubjects);
setStudentOrganizations(orgsRes.filter((organization) => organization.myRole === 'student'));
setStudentClasses(classesRes.filter((classItem) => classItem.teacherId !== meRes.data.id));
const accessibleStudentAssignments = assignmentsRes.filter(
(assignment) => assignment.class?.teacher?.id !== meRes.data.id,
);
const studentAssignmentDetails = await Promise.all(
accessibleStudentAssignments.map((assignment) => getAssignment(assignment.id).catch(() => null)),
);
if (cancelled) return;
setStudentAssignmentMap(
new Map(
accessibleStudentAssignments.map((assignment, index) => [
assignment.id,
studentAssignmentDetails[index],
]),
),
);
const candidates = buildMasteryCandidates(nextSubjects).slice(0, 3);
@@ -175,6 +209,9 @@ function DashboardBody() {
const hasStudyLogs = data.recentLogs.length > 0;
const hasReviewQueue = queueCount > 0;
const isEmpty = !hasSubjects && !hasStudyLogs;
const studentAssignments = Array.from(studentAssignmentMap.values()).filter(
(assignment): assignment is Awaited<ReturnType<typeof getAssignment>> => assignment !== null,
);
if (isEmpty) {
return (
@@ -210,6 +247,25 @@ function DashboardBody() {
</HeaderCard>
<StatsGrid>
{canManageSchool(me.organizationRole) ? (
<MetricCard>
<CardHeader>
<CardTitle> </CardTitle>
<Icon name="chalkboard-teacher" size={18} color={theme.color.textMute} />
</CardHeader>
<BigValueRow>
<BigValue>{studentOrganizations.length + studentClasses.length}</BigValue>
<ValueUnit></ValueUnit>
</BigValueRow>
<ActionLink href="/school">
<Button as="span" $variant="secondary" $block>
<Icon name="arrow-right" weight="bold" size={16} />
</Button>
</ActionLink>
</MetricCard>
) : null}
<ReviewQueueCard>
<CardHeader>
<CardTitle> </CardTitle>
@@ -302,6 +358,44 @@ function DashboardBody() {
</StatsGrid>
<ContentGrid>
{!canManageSchool(me.organizationRole) && studentClasses.length > 0 ? (
<SectionCard>
<SectionHead>
<SectionTitleRow>
<SectionDot />
<SectionTitle> </SectionTitle>
</SectionTitleRow>
</SectionHead>
<SectionBody>
<ClassList>
{studentClasses.map((classItem) => {
const submissionMap = new Map(
studentAssignments.map((assignment) => [
assignment.id,
assignment.submissions?.[0] ?? null,
]),
);
const pendingCount = pendingAssignmentsForClass(
studentAssignments,
submissionMap,
classItem.id,
);
return (
<ClassCard key={classItem.id} href={`/school/my-class/${classItem.id}`}>
<ClassCardTitle>{classItem.name}</ClassCardTitle>
<ClassCardMeta>
<span>{classItem.organization?.name ?? '소속 기관'}</span>
<span>{formatOrganizationType(classItem.organization?.type ?? 'academy')}</span>
<span> {pendingCount}</span>
</ClassCardMeta>
</ClassCard>
);
})}
</ClassList>
</SectionBody>
</SectionCard>
) : null}
<SectionCard>
<SectionHead>
<SectionTitleRow>
@@ -1389,3 +1483,44 @@ const CoachClose = styled.button`
background: rgba(255, 255, 255, 0.04);
}
`;
const ClassList = styled.div`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: ${theme.space.sm};
@media (max-width: ${theme.breakpoint.mobile}) {
grid-template-columns: 1fr;
}
`;
const ClassCard = styled(Link)`
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
border-radius: ${theme.radius.lg};
border: 1px solid ${theme.color.border};
background: rgba(255, 255, 255, 0.03);
transition:
transform 0.18s ease,
border-color 0.18s ease;
&:hover {
transform: translateY(-2px);
border-color: ${theme.color.accent};
}
`;
const ClassCardTitle = styled.strong`
color: ${theme.color.textBright};
font-size: 15px;
`;
const ClassCardMeta = styled.div`
display: flex;
flex-wrap: wrap;
gap: 10px 12px;
color: ${theme.color.textSub};
font-size: 13px;
`;

View File

@@ -0,0 +1,408 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import styled from 'styled-components';
import { useToast } from '@/components/ui/Toast';
import { Icon } from '@/components/ui/Icon';
import { Button } from '@/components/ui/primitives';
import {
type AssignmentSubmission,
type AssignmentSummary,
type ClassDetail,
type MeUser,
api,
getAssignmentSubmissions,
getAssignments,
getClass,
removeClassMember,
} from '@/lib/api';
import { canManageSchool, formatDate, formatDateTime } from '@/lib/school';
import {
Badge,
DataTable,
DesktopOnly,
EmptyCard,
EmptyText,
EmptyTitle,
HeaderRow,
HeroCard,
HeroText,
MobileCard,
MobileList,
PageDescription,
PageTitle,
PageWrap,
SectionCard,
SectionDescription,
SectionHeader,
SectionTitle,
TableWrap,
} from '@/components/school/SchoolUI';
import { theme } from '@/styles/theme';
type TabKey = 'students' | 'assignments' | 'grades';
export default function ClassDetailPage() {
const params = useParams<{ id: string }>();
const classId = Number(params.id);
const { showToast } = useToast();
const [me, setMe] = useState<MeUser | null>(null);
const [classRoom, setClassRoom] = useState<ClassDetail | null>(null);
const [assignments, setAssignments] = useState<AssignmentSummary[]>([]);
const [submissionsByAssignmentId, setSubmissionsByAssignmentId] = useState<
Record<number, AssignmentSubmission[]>
>({});
const [activeTab, setActiveTab] = useState<TabKey>('students');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const [meResponse, classResponse] = await Promise.all([
api.get<MeUser>('/auth/me').then((response) => response.data),
getClass(classId),
]);
const assignmentList = await getAssignments({ classId });
const submissionGroups = await Promise.all(
assignmentList.map((assignment) => getAssignmentSubmissions(assignment.id).catch(() => [])),
);
if (cancelled) return;
setMe(meResponse);
setClassRoom(classResponse);
setAssignments(assignmentList);
setSubmissionsByAssignmentId(
Object.fromEntries(assignmentList.map((assignment, index) => [assignment.id, submissionGroups[index]])),
);
} catch {
if (!cancelled) {
setError('학급 상세 정보를 불러오지 못했어요.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
if (!Number.isNaN(classId)) {
void load();
}
return () => {
cancelled = true;
};
}, [classId]);
const canManage = canManageSchool(me?.organizationRole);
const gradeMatrix = useMemo(
() =>
classRoom?.members.map((member) => ({
member,
scores: assignments.map((assignment) => {
const submission = submissionsByAssignmentId[assignment.id]?.find(
(item) => item.userId === member.userId,
);
return {
assignmentId: assignment.id,
score: submission?.score ?? null,
completedAt: submission?.completedAt ?? null,
};
}),
})) ?? [],
[assignments, classRoom, submissionsByAssignmentId],
);
const handleRemoveMember = async (userId: number) => {
if (!classRoom) return;
try {
await removeClassMember(classRoom.id, userId);
setClassRoom({
...classRoom,
members: classRoom.members.filter((member) => member.userId !== userId),
});
showToast({ message: '학생을 학급에서 제거했어요.', variant: 'success' });
} catch {
showToast({ message: '학생 제거에 실패했어요.', variant: 'danger' });
}
};
if (loading) return <LoadingText> ...</LoadingText>;
if (error || !classRoom) return <LoadingText>{error ?? '학급을 찾을 수 없어요.'}</LoadingText>;
if (!canManage) {
return (
<PageWrap>
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> `내 학급` .</EmptyText>
<Button as={Link} href={`/school/my-class/${classId}`}>
</Button>
</EmptyCard>
</PageWrap>
);
}
return (
<PageWrap>
<HeroCard>
<HeaderRow>
<HeroText>
<Badge>Class Detail</Badge>
<PageTitle>{classRoom.name}</PageTitle>
<PageDescription>
{classRoom.teacher?.nickname ?? '-'} · {classRoom.members.length} · {' '}
{assignments.length}
</PageDescription>
</HeroText>
<ActionRow>
<Button
as={Link}
href={`/school/invite?orgId=${classRoom.organizationId}`}
$variant="secondary"
>
</Button>
<Button as={Link} href="/school/classes" $variant="ghost">
</Button>
</ActionRow>
</HeaderRow>
</HeroCard>
<TabRow>
<TabButton type="button" $active={activeTab === 'students'} onClick={() => setActiveTab('students')}>
</TabButton>
<TabButton type="button" $active={activeTab === 'assignments'} onClick={() => setActiveTab('assignments')}>
</TabButton>
<TabButton type="button" $active={activeTab === 'grades'} onClick={() => setActiveTab('grades')}>
</TabButton>
</TabRow>
{activeTab === 'students' ? (
<SectionCard>
<SectionHeader>
<div>
<SectionTitle> </SectionTitle>
<SectionDescription> .</SectionDescription>
</div>
</SectionHeader>
<DesktopOnly>
<TableWrap>
<DataTable>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{classRoom.members.map((member) => (
<tr key={member.userId}>
<td>{member.user.nickname}</td>
<td>{member.user.email}</td>
<td>{formatDateTime(member.joinedAt)}</td>
<td>
<Button $variant="ghost" $size="sm" onClick={() => handleRemoveMember(member.userId)}>
</Button>
</td>
</tr>
))}
</tbody>
</DataTable>
</TableWrap>
</DesktopOnly>
<MobileList>
{classRoom.members.map((member) => (
<MobileCard key={member.userId}>
<SectionTitle>{member.user.nickname}</SectionTitle>
<SectionDescription>{member.user.email}</SectionDescription>
<Badge>{formatDateTime(member.joinedAt)}</Badge>
<Button $variant="ghost" onClick={() => handleRemoveMember(member.userId)}>
</Button>
</MobileCard>
))}
</MobileList>
</SectionCard>
) : null}
{activeTab === 'assignments' ? (
<SectionCard>
<SectionHeader>
<div>
<SectionTitle></SectionTitle>
<SectionDescription> .</SectionDescription>
</div>
</SectionHeader>
{assignments.length === 0 ? (
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
</EmptyCard>
) : (
<AssignmentList>
{assignments.map((assignment) => {
const submissionCount = submissionsByAssignmentId[assignment.id]?.length ?? 0;
return (
<AssignmentItem key={assignment.id}>
<AssignmentHeader>
<div>
<AssignmentTitle>{assignment.title}</AssignmentTitle>
<SectionDescription>{assignment.problemSet?.title ?? '문제집 정보 없음'}</SectionDescription>
</div>
<Badge>{assignment.status}</Badge>
</AssignmentHeader>
<AssignmentMeta>
<span> {formatDate(assignment.dueDate)}</span>
<span>
{submissionCount}/{classRoom.members.length}
</span>
</AssignmentMeta>
</AssignmentItem>
);
})}
</AssignmentList>
)}
</SectionCard>
) : null}
{activeTab === 'grades' ? (
<SectionCard>
<SectionHeader>
<div>
<SectionTitle></SectionTitle>
<SectionDescription> .</SectionDescription>
</div>
</SectionHeader>
{assignments.length === 0 ? (
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
</EmptyCard>
) : (
<MatrixWrap>
<DataTable>
<thead>
<tr>
<th></th>
{assignments.map((assignment) => (
<th key={assignment.id}>{assignment.title}</th>
))}
</tr>
</thead>
<tbody>
{gradeMatrix.map((row) => (
<tr key={row.member.userId}>
<td>{row.member.user.nickname}</td>
{row.scores.map((score) => (
<td key={`${row.member.userId}-${score.assignmentId}`}>
{score.score === null ? (score.completedAt ? '완료' : '-') : `${Math.round(score.score)}`}
</td>
))}
</tr>
))}
</tbody>
</DataTable>
</MatrixWrap>
)}
</SectionCard>
) : null}
</PageWrap>
);
}
const LoadingText = styled.div`
padding: 48px 0;
text-align: center;
color: ${theme.color.textSub};
`;
const ActionRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.space.sm};
`;
const TabRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.space.sm};
`;
const TabButton = styled.button<{ $active: boolean }>`
min-height: 42px;
padding: 0 16px;
border-radius: ${theme.radius.pill};
border: 1px solid ${({ $active }) => ($active ? theme.color.accent : theme.color.border)};
background: ${({ $active }) =>
$active ? 'rgba(99, 102, 241, 0.18)' : 'rgba(255, 255, 255, 0.04)'};
color: ${theme.color.textBright};
font-weight: 700;
`;
const AssignmentList = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
`;
const AssignmentItem = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.border};
background: rgba(255, 255, 255, 0.03);
`;
const AssignmentHeader = styled.div`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: ${theme.space.sm};
`;
const AssignmentTitle = styled.h3`
margin: 0;
font-size: 16px;
color: ${theme.color.textBright};
`;
const AssignmentMeta = styled.div`
display: flex;
flex-wrap: wrap;
gap: 12px;
color: ${theme.color.textSub};
font-size: 13px;
`;
const MatrixWrap = styled.div`
overflow-x: auto;
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.lg};
`;

View File

@@ -0,0 +1,329 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import styled from 'styled-components';
import { useToast } from '@/components/ui/Toast';
import { Icon } from '@/components/ui/Icon';
import { Button, Input, Label, Select } from '@/components/ui/primitives';
import {
type MeUser,
type Organization,
api,
createClass,
getMyClasses,
getMyOrganizations,
} from '@/lib/api';
import { canManageSchool, formatOrganizationType } from '@/lib/school';
import {
Badge,
DataTable,
DesktopOnly,
EmptyCard,
EmptyText,
EmptyTitle,
HeaderRow,
HeroCard,
HeroText,
MobileCard,
MobileList,
PageDescription,
PageTitle,
PageWrap,
SectionCard,
SectionDescription,
SectionHeader,
SectionTitle,
TableWrap,
} from '@/components/school/SchoolUI';
import { theme } from '@/styles/theme';
export default function SchoolClassesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { showToast } = useToast();
const [me, setMe] = useState<MeUser | null>(null);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [selectedOrgId, setSelectedOrgId] = useState<number | null>(null);
const [classes, setClasses] = useState<Awaited<ReturnType<typeof getMyClasses>>>([]);
const [openCreate, setOpenCreate] = useState(false);
const [className, setClassName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const [meResponse, orgs] = await Promise.all([
api.get<MeUser>('/auth/me').then((response) => response.data),
getMyOrganizations(),
]);
if (cancelled) return;
setMe(meResponse);
const manageableOrgs = orgs.filter((org) => canManageSchool(org.myRole));
setOrganizations(manageableOrgs);
const requestedOrgId = Number(searchParams.get('orgId'));
const targetOrg =
manageableOrgs.find((org) => org.id === requestedOrgId) ?? manageableOrgs[0] ?? null;
setSelectedOrgId(targetOrg?.id ?? null);
if (!targetOrg) {
setClasses([]);
return;
}
const nextClasses = await getMyClasses(targetOrg.id);
if (cancelled) return;
setClasses(nextClasses);
} catch {
if (!cancelled) {
setError('학급 목록을 불러오지 못했어요.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [searchParams]);
const selectedOrg = useMemo(
() => organizations.find((org) => org.id === selectedOrgId) ?? null,
[organizations, selectedOrgId],
);
const handleOrganizationChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const nextOrgId = Number(event.target.value);
setSelectedOrgId(nextOrgId);
router.replace(`/school/classes?orgId=${nextOrgId}`);
};
const handleCreateClass = async () => {
if (!selectedOrgId || !className.trim()) return;
try {
setSubmitting(true);
await createClass({ name: className.trim(), organizationId: selectedOrgId });
const nextClasses = await getMyClasses(selectedOrgId);
setClasses(nextClasses);
setClassName('');
setOpenCreate(false);
showToast({ message: '새 학급을 만들었어요.', variant: 'success' });
} catch {
showToast({ message: '학급 생성에 실패했어요.', variant: 'danger' });
} finally {
setSubmitting(false);
}
};
if (loading) return <LoadingText> ...</LoadingText>;
if (error) return <LoadingText>{error}</LoadingText>;
if (!me || !canManageSchool(me.organizationRole)) {
return (
<PageWrap>
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
</EmptyCard>
</PageWrap>
);
}
return (
<PageWrap>
<HeroCard>
<HeaderRow>
<HeroText>
<Badge>Classes</Badge>
<PageTitle> </PageTitle>
<PageDescription>
{selectedOrg
? `${selectedOrg.name}의 전체 학급을 확인하고 새 학급을 만들 수 있습니다.`
: '관리 가능한 기관을 선택하세요.'}
</PageDescription>
</HeroText>
<Toolbar>
{organizations.length > 1 ? (
<OrgSelect value={selectedOrgId ?? undefined} onChange={handleOrganizationChange}>
{organizations.map((organization) => (
<option key={organization.id} value={organization.id}>
{organization.name}
</option>
))}
</OrgSelect>
) : null}
<Button onClick={() => setOpenCreate(true)} disabled={!selectedOrgId}>
<Icon name="plus" size={16} weight="bold" />
</Button>
</Toolbar>
</HeaderRow>
</HeroCard>
<SectionCard>
<SectionHeader>
<div>
<SectionTitle> </SectionTitle>
<SectionDescription>
{selectedOrg
? `${formatOrganizationType(selectedOrg.type)} 단위로 정리된 학급 목록입니다.`
: '기관을 먼저 선택해 주세요.'}
</SectionDescription>
</div>
</SectionHeader>
{classes.length === 0 ? (
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
</EmptyCard>
) : (
<>
<DesktopOnly>
<TableWrap>
<DataTable>
<thead>
<tr>
<th></th>
<th> </th>
<th> </th>
<th> </th>
<th></th>
</tr>
</thead>
<tbody>
{classes.map((classItem) => (
<tr key={classItem.id}>
<td>{classItem.name}</td>
<td>{classItem.teacher?.nickname ?? '-'}</td>
<td>{classItem._count?.members ?? 0}</td>
<td>{classItem._count?.assignments ?? 0}</td>
<td>
<RowLink href={`/school/classes/${classItem.id}`}> </RowLink>
</td>
</tr>
))}
</tbody>
</DataTable>
</TableWrap>
</DesktopOnly>
<MobileList>
{classes.map((classItem) => (
<MobileCard key={classItem.id}>
<SectionTitle>{classItem.name}</SectionTitle>
<SectionDescription>
{classItem.teacher?.nickname ?? '-'}
</SectionDescription>
<ClassMeta>
<span> {classItem._count?.members ?? 0}</span>
<span> {classItem._count?.assignments ?? 0}</span>
</ClassMeta>
<Button as={Link} href={`/school/classes/${classItem.id}`} $variant="secondary">
</Button>
</MobileCard>
))}
</MobileList>
</>
)}
</SectionCard>
{openCreate ? (
<Overlay onClick={() => setOpenCreate(false)}>
<ModalCard onClick={(event) => event.stopPropagation()}>
<SectionTitle> </SectionTitle>
<SectionDescription> .</SectionDescription>
<Field>
<Label htmlFor="class-name"> </Label>
<Input
id="class-name"
value={className}
onChange={(event) => setClassName(event.target.value)}
placeholder="예: 2학년 3반"
/>
</Field>
<Toolbar>
<Button $variant="ghost" onClick={() => setOpenCreate(false)}>
</Button>
<Button onClick={handleCreateClass} disabled={submitting || !className.trim()}>
</Button>
</Toolbar>
</ModalCard>
</Overlay>
) : null}
</PageWrap>
);
}
const LoadingText = styled.div`
padding: 40px 0;
text-align: center;
color: ${theme.color.textSub};
`;
const Toolbar = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.space.sm};
align-items: center;
`;
const OrgSelect = styled(Select)`
min-width: 220px;
`;
const RowLink = styled(Link)`
color: ${theme.color.accentHover};
font-weight: 600;
`;
const ClassMeta = styled.div`
display: flex;
flex-wrap: wrap;
gap: 12px;
color: ${theme.color.textSub};
font-size: 13px;
`;
const Overlay = styled.div`
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: ${theme.space.lg};
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
`;
const ModalCard = styled(SectionCard)`
width: min(100%, 440px);
`;
const Field = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;

View File

@@ -0,0 +1,329 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { useRouter, useSearchParams } from 'next/navigation';
import { ConfirmDialog } from '@/components/ui/Modal';
import { useToast } from '@/components/ui/Toast';
import { Button, Input, Label, Select } from '@/components/ui/primitives';
import {
type MeUser,
type Organization,
api,
createOrganization,
getMyOrganizations,
regenerateOrganizationInviteCode,
} from '@/lib/api';
import { canManageSchool, formatOrganizationType, inviteLink } from '@/lib/school';
import {
Badge,
EmptyCard,
EmptyText,
EmptyTitle,
HeaderRow,
HeroCard,
HeroText,
PageDescription,
PageTitle,
PageWrap,
SectionCard,
SectionDescription,
SectionHeader,
SectionTitle,
} from '@/components/school/SchoolUI';
import { theme } from '@/styles/theme';
export default function SchoolInvitePage() {
const router = useRouter();
const searchParams = useSearchParams();
const { showToast } = useToast();
const [me, setMe] = useState<MeUser | null>(null);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [selectedOrgId, setSelectedOrgId] = useState<number | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
const [createMode, setCreateMode] = useState(false);
const [orgName, setOrgName] = useState('');
const [orgType, setOrgType] = useState<'school' | 'academy'>('school');
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const [meResponse, orgs] = await Promise.all([
api.get<MeUser>('/auth/me').then((response) => response.data),
getMyOrganizations(),
]);
if (cancelled) return;
setMe(meResponse);
setOrganizations(orgs);
const requestedOrgId = Number(searchParams.get('orgId'));
const initialOrg = orgs.find((org) => org.id === requestedOrgId) ?? orgs[0] ?? null;
setSelectedOrgId(initialOrg?.id ?? null);
setCreateMode(orgs.length === 0 || searchParams.get('create') === '1');
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [searchParams]);
const selectedOrg = useMemo(
() => organizations.find((org) => org.id === selectedOrgId) ?? null,
[organizations, selectedOrgId],
);
const shareUrl = selectedOrg?.inviteCode ? inviteLink(selectedOrg.inviteCode) : '';
const copyText = async (value: string, message: string) => {
try {
await navigator.clipboard.writeText(value);
showToast({ message, variant: 'success' });
} catch {
showToast({ message: '복사에 실패했어요.', variant: 'danger' });
}
};
const handleCreateOrganization = async () => {
if (!orgName.trim()) return;
try {
const created = await createOrganization({ name: orgName.trim(), type: orgType });
const nextOrgs = await getMyOrganizations();
setOrganizations(nextOrgs);
setSelectedOrgId(created.id);
setCreateMode(false);
setOrgName('');
showToast({ message: '기관을 만들었어요.', variant: 'success' });
router.replace(`/school/invite?orgId=${created.id}`);
} catch {
showToast({ message: '기관 생성에 실패했어요.', variant: 'danger' });
}
};
const handleRegenerate = async () => {
if (!selectedOrgId) return;
try {
const regenerated = await regenerateOrganizationInviteCode(selectedOrgId);
const nextOrgs = organizations.map((org) =>
org.id === selectedOrgId ? { ...org, inviteCode: regenerated.inviteCode } : org,
);
setOrganizations(nextOrgs);
setShowConfirm(false);
showToast({ message: '초대 코드를 재생성했어요.', variant: 'success' });
} catch {
showToast({ message: '코드 재생성에 실패했어요.', variant: 'danger' });
}
};
if (loading) {
return <LoadingText> ...</LoadingText>;
}
if (!me) {
return <LoadingText> .</LoadingText>;
}
if (createMode || organizations.length === 0) {
return (
<PageWrap>
<HeroCard>
<HeroText>
<Badge>New Organization</Badge>
<PageTitle> </PageTitle>
<PageDescription> .</PageDescription>
</HeroText>
</HeroCard>
<SectionCard>
<SectionHeader>
<div>
<SectionTitle> </SectionTitle>
<SectionDescription> .</SectionDescription>
</div>
</SectionHeader>
<Field>
<Label htmlFor="org-name"> </Label>
<Input
id="org-name"
value={orgName}
onChange={(event) => setOrgName(event.target.value)}
placeholder="예: 리루프 고등학교"
/>
</Field>
<Field>
<Label htmlFor="org-type"> </Label>
<Select
id="org-type"
value={orgType}
onChange={(event) => setOrgType(event.target.value as 'school' | 'academy')}
>
<option value="school"></option>
<option value="academy"></option>
</Select>
</Field>
<ActionRow>
{organizations.length > 0 ? (
<Button $variant="ghost" onClick={() => setCreateMode(false)}>
</Button>
) : null}
<Button onClick={handleCreateOrganization} disabled={!orgName.trim()}>
</Button>
</ActionRow>
</SectionCard>
</PageWrap>
);
}
if (!canManageSchool(me.organizationRole)) {
return (
<PageWrap>
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
</EmptyCard>
</PageWrap>
);
}
return (
<PageWrap>
<HeroCard>
<HeaderRow>
<HeroText>
<Badge>Invite</Badge>
<PageTitle> </PageTitle>
<PageDescription> .</PageDescription>
</HeroText>
<ActionRow>
{organizations.length > 1 ? (
<OrgSelect
value={selectedOrgId ?? undefined}
onChange={(event) => {
const nextOrgId = Number(event.target.value);
setSelectedOrgId(nextOrgId);
router.replace(`/school/invite?orgId=${nextOrgId}`);
}}
>
{organizations.map((organization) => (
<option key={organization.id} value={organization.id}>
{organization.name}
</option>
))}
</OrgSelect>
) : null}
<Button $variant="ghost" onClick={() => setCreateMode(true)}>
</Button>
</ActionRow>
</HeaderRow>
</HeroCard>
{selectedOrg ? (
<SectionCard>
<SectionHeader>
<div>
<SectionTitle>{selectedOrg.name}</SectionTitle>
<SectionDescription>{formatOrganizationType(selectedOrg.type)} </SectionDescription>
</div>
</SectionHeader>
<CodeBox>{selectedOrg.inviteCode ?? '--------'}</CodeBox>
<ActionRow>
<Button
$variant="secondary"
onClick={() => selectedOrg.inviteCode && copyText(selectedOrg.inviteCode, '초대 코드를 복사했어요.')}
>
</Button>
<Button
$variant="ghost"
onClick={() => copyText(shareUrl, '공유 링크를 복사했어요.')}
disabled={!selectedOrg.inviteCode}
>
</Button>
<Button $variant="danger" onClick={() => setShowConfirm(true)}>
</Button>
</ActionRow>
<LinkField>
<Label htmlFor="share-url"> </Label>
<Input id="share-url" value={shareUrl} readOnly />
</LinkField>
</SectionCard>
) : (
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
</EmptyCard>
)}
<ConfirmDialog
open={showConfirm}
title="초대 코드를 재생성할까요?"
body="기존 링크를 받은 학생은 새 코드로 다시 안내해야 합니다."
confirmLabel="재생성"
tone="danger"
onCancel={() => setShowConfirm(false)}
onConfirm={handleRegenerate}
/>
</PageWrap>
);
}
const LoadingText = styled.div`
padding: 40px 0;
text-align: center;
color: ${theme.color.textSub};
`;
const ActionRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.space.sm};
align-items: center;
`;
const Field = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const OrgSelect = styled(Select)`
min-width: 220px;
`;
const CodeBox = styled.div`
padding: 20px 24px;
border-radius: ${theme.radius.lg};
border: 1px solid ${theme.color.border};
background: rgba(255, 255, 255, 0.03);
color: ${theme.color.textBright};
font-family: ${theme.font.mono};
font-size: clamp(28px, 6vw, 40px);
font-weight: 700;
letter-spacing: 0.2em;
text-align: center;
`;
const LinkField = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;

View File

@@ -0,0 +1,166 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import styled, { css } from 'styled-components';
import { Button, Card, Input, Label } from '@/components/ui/primitives';
import { useToast } from '@/components/ui/Toast';
import { joinOrganization } from '@/lib/api';
import { animations, theme } from '@/styles/theme';
export default function SchoolJoinPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { showToast } = useToast();
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [shaking, setShaking] = useState(false);
const shakeTimeoutRef = useRef<number | null>(null);
useEffect(() => {
const presetCode = searchParams.get('code');
if (presetCode) {
setInviteCode(presetCode.toUpperCase());
}
}, [searchParams]);
useEffect(() => {
return () => {
if (shakeTimeoutRef.current) {
window.clearTimeout(shakeTimeoutRef.current);
}
};
}, []);
const triggerError = (message: string) => {
setError(message);
setShaking(false);
if (shakeTimeoutRef.current) {
window.clearTimeout(shakeTimeoutRef.current);
}
window.requestAnimationFrame(() => {
setShaking(true);
shakeTimeoutRef.current = window.setTimeout(() => setShaking(false), 420);
});
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedCode = inviteCode.trim().toLowerCase();
if (!/^[a-f0-9]{8}$/.test(normalizedCode)) {
triggerError('8자리 초대 코드를 확인해 주세요.');
return;
}
try {
setLoading(true);
setError(null);
await joinOrganization(normalizedCode);
showToast({ message: '학급에 가입했어요!', variant: 'success' });
router.replace('/dashboard');
} catch {
triggerError('유효하지 않은 초대 코드예요.');
} finally {
setLoading(false);
}
};
return (
<Center>
<JoinCard $shaking={shaking}>
<Eyebrow>School Join</Eyebrow>
<Title> </Title>
<Description> 8 .</Description>
<Form onSubmit={handleSubmit}>
<Field>
<Label htmlFor="invite-code"> </Label>
<JoinInput
id="invite-code"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value.toUpperCase())}
placeholder="A1B2C3D4"
maxLength={8}
/>
</Field>
{error ? <ErrorText role="alert">{error}</ErrorText> : null}
<Button type="submit" disabled={loading || inviteCode.trim().length === 0} $block>
</Button>
</Form>
</JoinCard>
</Center>
);
}
const Center = styled.div`
min-height: calc(100vh - 64px);
display: flex;
align-items: center;
justify-content: center;
padding: ${theme.space.xl} 0;
`;
const JoinCard = styled(Card)<{ $shaking: boolean }>`
width: min(100%, 460px);
display: flex;
flex-direction: column;
gap: ${theme.space.md};
background:
radial-gradient(circle at top, rgba(79, 70, 229, 0.18), transparent 35%),
${theme.color.surfaceDeep};
border-color: ${theme.color.borderBrightAlpha};
${({ $shaking }) =>
$shaking &&
css`
animation: ${animations.shake} 0.38s ease;
`}
`;
const Eyebrow = styled.span`
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: ${theme.color.textMute};
`;
const Title = styled.h1`
margin: 0;
font-size: 28px;
color: ${theme.color.textBright};
`;
const Description = styled.p`
margin: 0;
color: ${theme.color.textSub};
line-height: 1.6;
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: ${theme.space.md};
`;
const Field = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const JoinInput = styled(Input)`
text-align: center;
letter-spacing: 0.18em;
font-family: ${theme.font.mono};
font-size: 22px;
font-weight: 700;
`;
const ErrorText = styled.p`
margin: 0;
color: ${theme.color.danger};
font-size: 13px;
`;

View File

@@ -0,0 +1,8 @@
'use client';
import React from 'react';
import AppShell from '@/components/layout/AppShell';
export default function SchoolLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}

View File

@@ -0,0 +1,220 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import styled from 'styled-components';
import { Button } from '@/components/ui/primitives';
import {
type AssignmentSubmission,
type AssignmentSummary,
type ClassDetail,
getAssignment,
getAssignments,
getClass,
} from '@/lib/api';
import { formatDate } from '@/lib/school';
import {
Badge,
EmptyCard,
EmptyText,
EmptyTitle,
HeaderRow,
HeroCard,
HeroText,
PageDescription,
PageTitle,
PageWrap,
SectionCard,
SectionDescription,
SectionHeader,
SectionTitle,
} from '@/components/school/SchoolUI';
import { theme } from '@/styles/theme';
type AssignmentWithSubmission = AssignmentSummary & {
mySubmission: AssignmentSubmission | null;
};
export default function MyClassPage() {
const params = useParams<{ classId: string }>();
const classId = Number(params.classId);
const [classRoom, setClassRoom] = useState<ClassDetail | null>(null);
const [assignments, setAssignments] = useState<AssignmentWithSubmission[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const classResponse = await getClass(classId);
const assignmentList = await getAssignments({ classId });
const assignmentDetails = await Promise.all(
assignmentList.map((assignment) => getAssignment(assignment.id)),
);
if (cancelled) return;
setClassRoom(classResponse);
setAssignments(
assignmentList.map((assignment, index) => ({
...assignment,
mySubmission: assignmentDetails[index].submissions?.[0] ?? null,
})),
);
} catch {
if (!cancelled) {
setError('내 학급 정보를 불러오지 못했어요.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
if (!Number.isNaN(classId)) {
void load();
}
return () => {
cancelled = true;
};
}, [classId]);
const pendingCount = useMemo(
() => assignments.filter((assignment) => !assignment.mySubmission?.completedAt).length,
[assignments],
);
if (loading) return <LoadingText> ...</LoadingText>;
if (error || !classRoom) return <LoadingText>{error ?? '학급을 찾을 수 없어요.'}</LoadingText>;
return (
<PageWrap>
<HeroCard>
<HeaderRow>
<HeroText>
<Badge>My Class</Badge>
<PageTitle>{classRoom.name}</PageTitle>
<PageDescription>
{classRoom.teacher?.nickname ?? '-'} · {pendingCount}
</PageDescription>
</HeroText>
</HeaderRow>
</HeroCard>
<SectionCard>
<SectionHeader>
<div>
<SectionTitle> </SectionTitle>
<SectionDescription> .</SectionDescription>
</div>
</SectionHeader>
{assignments.length === 0 ? (
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
</EmptyCard>
) : (
<AssignmentList>
{assignments.map((assignment) => {
const completed = Boolean(assignment.mySubmission?.completedAt);
const score = assignment.mySubmission?.score;
return (
<AssignmentItem key={assignment.id}>
<AssignmentCopy>
<AssignmentTitle>{assignment.title}</AssignmentTitle>
<AssignmentMeta>
<span>{assignment.problemSet?.title ?? '문제집 정보 없음'}</span>
<span> {formatDate(assignment.dueDate)}</span>
<span>
{' '}
{completed
? typeof score === 'number'
? `${Math.round(score)}`
: '완료'
: '미완료'}
</span>
</AssignmentMeta>
</AssignmentCopy>
<ActionArea>
{completed ? (
<Button as={Link} href={`/study/exam/${assignment.problemSetId}/result`} $variant="secondary">
</Button>
) : (
<Button as={Link} href={`/study/exam/${assignment.problemSetId}`}>
</Button>
)}
</ActionArea>
</AssignmentItem>
);
})}
</AssignmentList>
)}
</SectionCard>
</PageWrap>
);
}
const LoadingText = styled.div`
padding: 40px 0;
text-align: center;
color: ${theme.color.textSub};
`;
const AssignmentList = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
`;
const AssignmentItem = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: ${theme.space.md};
padding: 18px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.border};
background: rgba(255, 255, 255, 0.03);
@media (max-width: ${theme.breakpoint.tablet}) {
flex-direction: column;
align-items: stretch;
}
`;
const AssignmentCopy = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
`;
const AssignmentTitle = styled.h3`
margin: 0;
font-size: 16px;
color: ${theme.color.textBright};
`;
const AssignmentMeta = styled.div`
display: flex;
flex-wrap: wrap;
gap: 12px;
color: ${theme.color.textSub};
font-size: 13px;
`;
const ActionArea = styled.div`
display: flex;
align-items: center;
`;

View File

@@ -0,0 +1,383 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import styled from 'styled-components';
import { Icon } from '@/components/ui/Icon';
import { Button, Select } from '@/components/ui/primitives';
import {
type AssignmentSubmission,
type AssignmentSummary,
type MeUser,
type Organization,
api,
getAssignmentSubmissions,
getAssignments,
getMyClasses,
getMyOrganizations,
} from '@/lib/api';
import { averageScore, canManageSchool, formatDate, formatOrganizationType, uniqueStudentCount } from '@/lib/school';
import {
Badge,
BadgeRow,
CardGrid,
ContentGrid,
EmptyCard,
EmptyText,
EmptyTitle,
Eyebrow,
FeatureCardLink,
HeaderRow,
HeroCard,
HeroText,
MetaList,
PageDescription,
PageTitle,
PageWrap,
SectionCard,
SectionDescription,
SectionHeader,
SectionTitle,
StatCard,
StatGrid,
StatHint,
StatLabel,
StatValue,
} from '@/components/school/SchoolUI';
import { theme } from '@/styles/theme';
export default function SchoolPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [me, setMe] = useState<MeUser | null>(null);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [selectedOrgId, setSelectedOrgId] = useState<number | null>(null);
const [classes, setClasses] = useState<Awaited<ReturnType<typeof getMyClasses>>>([]);
const [assignments, setAssignments] = useState<AssignmentSummary[]>([]);
const [submissions, setSubmissions] = useState<AssignmentSubmission[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const [meResponse, orgs] = await Promise.all([
api.get<MeUser>('/auth/me').then((response) => response.data),
getMyOrganizations(),
]);
if (cancelled) return;
setMe(meResponse);
const manageableOrgs = orgs.filter((org) => canManageSchool(org.myRole));
setOrganizations(manageableOrgs);
const requestedOrgId = Number(searchParams.get('orgId'));
const initialOrg =
manageableOrgs.find((org) => org.id === requestedOrgId) ?? manageableOrgs[0] ?? null;
setSelectedOrgId(initialOrg?.id ?? null);
if (!initialOrg) {
setClasses([]);
setAssignments([]);
setSubmissions([]);
setLoading(false);
return;
}
const nextClasses = await getMyClasses(initialOrg.id);
const classIds = new Set(nextClasses.map((item) => item.id));
const nextAssignments = (await getAssignments()).filter((assignment) =>
classIds.has(assignment.classId),
);
const submissionGroups = await Promise.all(
nextAssignments.map((assignment) => getAssignmentSubmissions(assignment.id).catch(() => [])),
);
if (cancelled) return;
setClasses(nextClasses);
setAssignments(nextAssignments);
setSubmissions(submissionGroups.flat());
} catch {
if (!cancelled) {
setError('학교 정보를 불러오지 못했어요.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [searchParams]);
const selectedOrg = useMemo(
() => organizations.find((org) => org.id === selectedOrgId) ?? null,
[organizations, selectedOrgId],
);
const stats = useMemo(() => {
const avg = averageScore(submissions);
return {
classCount: classes.length,
studentCount: uniqueStudentCount(classes),
activeAssignments: assignments.filter((assignment) => assignment.status === 'active').length,
averageScore: avg,
};
}, [assignments, classes, submissions]);
const recentAssignments = useMemo(
() =>
[...assignments]
.sort((a, b) => {
const left = new Date(b.createdAt ?? b.dueDate ?? 0).getTime();
const right = new Date(a.createdAt ?? a.dueDate ?? 0).getTime();
return left - right;
})
.slice(0, 5),
[assignments],
);
const handleOrganizationChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const nextOrgId = Number(event.target.value);
setSelectedOrgId(nextOrgId);
router.replace(`/school?orgId=${nextOrgId}`);
};
if (loading) {
return <LoadingText> ...</LoadingText>;
}
if (error) {
return <LoadingText>{error}</LoadingText>;
}
if (!me || !canManageSchool(me.organizationRole)) {
return (
<PageWrap>
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
<Button as={Link} href="/dashboard">
</Button>
</EmptyCard>
</PageWrap>
);
}
if (!selectedOrg) {
return (
<PageWrap>
<HeroCard>
<HeroText>
<Eyebrow>School</Eyebrow>
<PageTitle> </PageTitle>
<PageDescription>
. .
</PageDescription>
</HeroText>
</HeroCard>
<CardGrid>
<FeatureCardLink href="/school/join">
<Badge> </Badge>
<EmptyTitle> </EmptyTitle>
<EmptyText> 8 .</EmptyText>
</FeatureCardLink>
<CreateOrgCard>
<Badge $tone="success"> </Badge>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
<Button as={Link} href="/school/invite?create=1">
</Button>
</CreateOrgCard>
</CardGrid>
</PageWrap>
);
}
return (
<PageWrap>
<HeroCard>
<HeaderRow>
<HeroText>
<Eyebrow>School</Eyebrow>
<PageTitle> </PageTitle>
<PageDescription>
{selectedOrg.name} .
</PageDescription>
<BadgeRow>
<Badge>{formatOrganizationType(selectedOrg.type)}</Badge>
<Badge $tone="success">{selectedOrg.myRole === 'admin' ? '관리자' : '교사'}</Badge>
</BadgeRow>
</HeroText>
<HeaderActions>
{organizations.length > 1 ? (
<OrgSelect value={selectedOrg.id} onChange={handleOrganizationChange} aria-label="기관 선택">
{organizations.map((organization) => (
<option key={organization.id} value={organization.id}>
{organization.name}
</option>
))}
</OrgSelect>
) : null}
<Button as={Link} href="/school/classes">
<Icon name="arrow-right" size={16} weight="bold" />
</Button>
</HeaderActions>
</HeaderRow>
</HeroCard>
<StatGrid>
<StatCard>
<StatLabel> </StatLabel>
<StatValue>{stats.classCount}</StatValue>
<StatHint> </StatHint>
</StatCard>
<StatCard>
<StatLabel> </StatLabel>
<StatValue>{stats.studentCount}</StatValue>
<StatHint> </StatHint>
</StatCard>
<StatCard>
<StatLabel> </StatLabel>
<StatValue>{stats.activeAssignments}</StatValue>
<StatHint> </StatHint>
</StatCard>
<StatCard>
<StatLabel> </StatLabel>
<StatValue>{stats.averageScore === null ? '--' : `${Math.round(stats.averageScore)}`}</StatValue>
<StatHint> </StatHint>
</StatCard>
</StatGrid>
<ContentGrid>
<SectionCard>
<SectionHeader>
<div>
<SectionTitle> </SectionTitle>
<SectionDescription> .</SectionDescription>
</div>
<Button as={Link} href="/school/classes" $variant="secondary" $size="sm">
</Button>
</SectionHeader>
<CardGrid>
{classes.length === 0 ? (
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
</EmptyCard>
) : (
classes.map((classItem) => (
<FeatureCardLink key={classItem.id} href={`/school/classes/${classItem.id}`}>
<Badge>{classItem.teacher?.nickname ?? '담당 교사'}</Badge>
<EmptyTitle>{classItem.name}</EmptyTitle>
<MetaList>
<span> {classItem._count?.members ?? 0}</span>
<span> {classItem._count?.assignments ?? 0}</span>
</MetaList>
<EmptyText> , , .</EmptyText>
</FeatureCardLink>
))
)}
</CardGrid>
</SectionCard>
<SectionCard>
<SectionHeader>
<div>
<SectionTitle> </SectionTitle>
<SectionDescription> 5.</SectionDescription>
</div>
<Button as={Link} href="/school/invite" $variant="ghost" $size="sm">
</Button>
</SectionHeader>
{recentAssignments.length === 0 ? (
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
</EmptyCard>
) : (
<AssignmentList>
{recentAssignments.map((assignment) => (
<AssignmentItem key={assignment.id}>
<AssignmentTitle>{assignment.title}</AssignmentTitle>
<MetaList>
<span>{assignment.class?.name ?? `학급 #${assignment.classId}`}</span>
<span> {formatDate(assignment.dueDate)}</span>
<span> {assignment._count?.submissions ?? 0}</span>
</MetaList>
</AssignmentItem>
))}
</AssignmentList>
)}
</SectionCard>
</ContentGrid>
</PageWrap>
);
}
const LoadingText = styled.div`
padding: 48px 0;
text-align: center;
color: ${theme.color.textSub};
`;
const OrgSelect = styled(Select)`
min-width: 220px;
`;
const HeaderActions = styled.div`
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: ${theme.space.sm};
`;
const AssignmentList = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
`;
const AssignmentItem = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 18px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.border};
background: rgba(255, 255, 255, 0.03);
`;
const AssignmentTitle = styled.strong`
font-size: 15px;
color: ${theme.color.textBright};
`;
const CreateOrgCard = styled(EmptyCard)`
justify-content: space-between;
`;

View File

@@ -9,7 +9,7 @@ import type { MeUser } from '@/lib/api';
import { resolveAssetUrl } from '@/lib/api';
import { theme } from '@/styles/theme';
const TABS = [
const BASE_TABS = [
{ href: '/dashboard', label: '대시보드', icon: 'squares-four' as const },
{ href: '/review', label: '복습', icon: 'arrows-clockwise' as const },
{ href: '/exams', label: '문제집', icon: 'books' as const },
@@ -26,10 +26,19 @@ export default function BottomNav({ user }: BottomNavProps) {
const pathname = usePathname();
const initial = user.nickname.trim().charAt(0).toUpperCase() || 'U';
const avatarSrc = resolveAssetUrl(user.avatarUrl);
const canManageSchool =
user.organizationRole === 'admin' || user.organizationRole === 'teacher';
const tabs = canManageSchool
? [
BASE_TABS[0],
{ href: '/school', label: '학교', icon: 'chalkboard-teacher' as const },
...BASE_TABS.slice(1),
]
: BASE_TABS;
return (
<Nav>
{TABS.map((t) => {
{tabs.map((t) => {
const active = pathname.startsWith(t.href);
const isProfile = t.href === '/profile';
return (

View File

@@ -9,7 +9,7 @@ import { resolveAssetUrl, type MeUser, type SubscriptionTier } from '@/lib/api';
import { Icon, type IconName } from '@/components/ui/Icon';
import { theme } from '@/styles/theme';
const NAV_ITEMS: Array<{
const BASE_NAV_ITEMS: Array<{
href: string;
label: string;
icon: IconName;
@@ -99,6 +99,21 @@ export default function SideNav({ user, reviewCount }: SideNavProps) {
const initial = user.nickname.trim().charAt(0).toUpperCase() || 'U';
const avatarSrc = resolveAssetUrl(user.avatarUrl);
const canManageSchool =
user.organizationRole === 'admin' || user.organizationRole === 'teacher';
const navItems = canManageSchool
? [
...BASE_NAV_ITEMS.slice(0, 1),
{
href: '/school',
label: '학교',
icon: 'chalkboard-teacher' as const,
match: (currentPath: string) => currentPath.startsWith('/school'),
},
...BASE_NAV_ITEMS.slice(1),
]
: BASE_NAV_ITEMS;
const MENU_ITEMS = [
{ label: '프로필', href: '/profile', icon: 'user' as const },
@@ -116,7 +131,7 @@ export default function SideNav({ user, reviewCount }: SideNavProps) {
</Brand>
<NavList>
{NAV_ITEMS.map((item) => {
{navItems.map((item) => {
const isActive = item.match(pathname);
const shouldShowBadge =
item.showBadge && typeof reviewCount === 'number' && reviewCount > 0;

View File

@@ -0,0 +1,294 @@
'use client';
import Link from 'next/link';
import styled from 'styled-components';
import { Card } from '@/components/ui/primitives';
import { theme } from '@/styles/theme';
export const PageWrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
export const HeroCard = styled(Card)`
display: flex;
flex-direction: column;
gap: ${theme.space.md};
overflow: hidden;
background:
radial-gradient(circle at top right, rgba(79, 70, 229, 0.22), transparent 34%),
radial-gradient(circle at left center, rgba(56, 189, 248, 0.12), transparent 32%),
${theme.color.surfaceDeep};
border-color: ${theme.color.borderBrightAlpha};
box-shadow: ${theme.shadow.cardElevated};
`;
export const HeaderRow = styled.div`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.tablet}) {
flex-direction: column;
}
`;
export const HeroText = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
`;
export const Eyebrow = styled.span`
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: ${theme.color.textMute};
`;
export const PageTitle = styled.h1`
margin: 0;
font-size: clamp(28px, 4vw, 40px);
line-height: 1.05;
font-weight: 800;
color: ${theme.color.textBright};
`;
export const PageDescription = styled.p`
margin: 0;
max-width: 720px;
color: ${theme.color.textSub};
line-height: 1.6;
`;
export const BadgeRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.space.sm};
`;
export const Badge = styled.span<{ $tone?: 'default' | 'success' | 'warning' | 'danger' }>`
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 0 12px;
border-radius: ${theme.radius.pill};
border: 1px solid ${theme.color.borderBrightAlpha};
background: ${({ $tone = 'default' }) => {
switch ($tone) {
case 'success':
return 'rgba(34, 197, 94, 0.14)';
case 'warning':
return 'rgba(245, 158, 11, 0.14)';
case 'danger':
return 'rgba(239, 68, 68, 0.14)';
default:
return 'rgba(255, 255, 255, 0.06)';
}
}};
color: ${theme.color.textBright};
font-size: 12px;
font-weight: 700;
`;
export const StatGrid = styled.div`
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.desktop}) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: ${theme.breakpoint.mobile}) {
grid-template-columns: 1fr;
}
`;
export const StatCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 10px;
`;
export const StatLabel = styled.span`
font-size: 13px;
color: ${theme.color.textSub};
`;
export const StatValue = styled.strong`
font-size: clamp(26px, 4vw, 34px);
line-height: 1;
font-weight: 800;
color: ${theme.color.textBright};
`;
export const StatHint = styled.span`
font-size: 12px;
color: ${theme.color.textMute};
`;
export const ContentGrid = styled.div`
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.8fr);
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.desktop}) {
grid-template-columns: 1fr;
}
`;
export const SectionCard = styled(Card)`
display: flex;
flex-direction: column;
gap: ${theme.space.md};
`;
export const SectionHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.mobile}) {
flex-direction: column;
align-items: flex-start;
}
`;
export const SectionTitle = styled.h2`
margin: 0;
font-size: 18px;
font-weight: 700;
color: ${theme.color.textBright};
`;
export const SectionDescription = styled.p`
margin: 0;
color: ${theme.color.textSub};
line-height: 1.5;
`;
export const CardGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.desktop}) {
grid-template-columns: 1fr;
}
`;
export const FeatureCardLink = styled(Link)`
display: flex;
flex-direction: column;
gap: 10px;
min-height: 180px;
padding: ${theme.space.lg};
border-radius: ${theme.radius.lg};
border: 1px solid ${theme.color.border};
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
transition:
transform 0.18s ease,
border-color 0.18s ease;
&:hover {
transform: translateY(-2px);
border-color: ${theme.color.accent};
}
`;
export const MetaList = styled.div`
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
font-size: 13px;
color: ${theme.color.textSub};
`;
export const EmptyCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
`;
export const EmptyTitle = styled.h3`
margin: 0;
font-size: 18px;
color: ${theme.color.textBright};
`;
export const EmptyText = styled.p`
margin: 0;
color: ${theme.color.textSub};
line-height: 1.6;
`;
export const TableWrap = styled.div`
overflow-x: auto;
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.lg};
`;
export const DataTable = styled.table`
width: 100%;
border-collapse: collapse;
min-width: 720px;
th,
td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid ${theme.color.border};
}
th {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: ${theme.color.textMute};
background: rgba(255, 255, 255, 0.03);
}
td {
font-size: 14px;
color: ${theme.color.textMain};
}
tbody tr:last-child td {
border-bottom: 0;
}
`;
export const MobileList = styled.div`
display: none;
@media (max-width: ${theme.breakpoint.tablet}) {
display: grid;
gap: ${theme.space.sm};
}
`;
export const MobileCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 10px;
`;
export const DesktopOnly = styled.div`
display: block;
@media (max-width: ${theme.breakpoint.tablet}) {
display: none;
}
`;
export const InlineValue = styled.strong`
font-size: 14px;
color: ${theme.color.textBright};
`;

View File

@@ -39,6 +39,7 @@ export type ReviewIntensity = 'strict' | 'moderate' | 'relaxed';
export type StudyResult = 'correct' | 'incorrect' | 'partial';
export type ReviewStatus = 'pending' | 'done' | 'skipped' | 'expired';
export type SubscriptionTier = 'free' | 'pro' | 'school';
export type OrganizationRole = 'admin' | 'teacher' | 'student';
export interface MeUser {
id: number;
@@ -54,9 +55,116 @@ export interface MeUser {
onboarded: boolean;
subscriptionTier: SubscriptionTier;
subscriptionUntil: string | null;
organizationRole: OrganizationRole | null;
createdAt: string;
}
export interface Organization {
id: number;
name: string;
type: 'school' | 'academy';
inviteCode: string | null;
createdAt: string;
myRole?: OrganizationRole;
_count?: { members: number; classes: number };
}
export interface OrgMember {
id: number;
userId: number;
role: OrganizationRole;
user: {
id: number;
nickname: string;
email: string;
avatarUrl?: string | null;
};
joinedAt: string;
}
export interface AssignmentSummary {
id: number;
title: string;
problemSetId: number;
classId: number;
status: string;
dueDate?: string | null;
createdAt?: string;
class?: {
id: number;
name: string;
organizationId: number;
teacher?: { id: number; nickname: string; email?: string };
};
problemSet?: {
id: number;
title: string;
year?: number;
examType?: string;
subjectName?: string;
_count?: { problems: number };
};
_count?: { submissions: number };
}
export interface ClassSummary {
id: number;
name: string;
organizationId: number;
teacherId: number;
organization?: { id: number; name: string; type: 'school' | 'academy' };
teacher?: { id?: number; nickname: string; email?: string };
_count?: { members: number; assignments: number };
}
export interface ClassDetail extends ClassSummary {
members: Array<{
userId: number;
joinedAt?: string;
user: {
id: number;
nickname: string;
email: string;
avatarUrl?: string | null;
};
}>;
assignments: AssignmentSummary[];
}
export interface AssignmentSubmission {
id: number;
userId: number;
assignmentId: number;
score: number | null;
totalProblems: number | null;
correctCount: number | null;
completedAt: string | null;
createdAt: string;
studyLogIds?: number[] | null;
user?: {
id: number;
nickname: string;
email: string;
avatarUrl?: string | null;
};
}
interface OrganizationMembershipResponse {
id: number;
role: OrganizationRole;
joinedAt: string;
organization: Organization & {
updatedAt?: string;
_count?: { members: number; classes: number };
};
}
interface OrganizationDetailResponse extends Organization {
myRole: OrganizationRole;
members: OrgMember[];
_count: { members: number; classes: number };
}
export function resolveAssetUrl(path: string | null | undefined) {
if (!path) return null;
if (/^https?:\/\//.test(path)) return path;
@@ -329,3 +437,102 @@ export async function updateStudyLog(
}>(`/study-logs/${id}`, payload);
return response.data;
}
export async function getMyOrganizations() {
const response = await api.get<OrganizationMembershipResponse[]>('/organizations');
return response.data.map((membership) => ({
...membership.organization,
myRole: membership.role,
_count: membership.organization._count,
}));
}
export async function getOrganization(id: number) {
const response = await api.get<OrganizationDetailResponse>(`/organizations/${id}`);
return response.data;
}
export async function createOrganization(data: { name: string; type?: string }) {
const response = await api.post<Organization>('/organizations', data);
return response.data;
}
export async function joinOrganization(inviteCode: string) {
const response = await api.post('/organizations/join', { inviteCode });
return response.data;
}
export async function regenerateOrganizationInviteCode(id: number) {
const response = await api.post<{ id: number; inviteCode: string }>(
`/organizations/${id}/regenerate-code`,
);
return response.data;
}
export async function getMyClasses(orgId?: number) {
const response = await api.get<ClassSummary[]>('/classes', {
params: orgId ? { organizationId: orgId } : {},
});
return response.data.map((item) => ({
...item,
_count: item._count ?? {
members: (item as ClassSummary & { membersCount?: number }).membersCount ?? 0,
assignments:
(item as ClassSummary & { assignmentCount?: number })._count?.assignments ??
(item as ClassSummary & { assignmentCount?: number }).assignmentCount ??
0,
},
}));
}
export async function getClass(id: number) {
const response = await api.get<
Omit<ClassDetail, 'assignments'> & {
assignments?: AssignmentSummary[];
membersCount?: number;
assignmentCount?: number;
}
>(`/classes/${id}`);
return {
...response.data,
assignments: response.data.assignments ?? [],
_count: response.data._count ?? {
members: response.data.membersCount ?? response.data.members.length,
assignments: response.data.assignmentCount ?? 0,
},
};
}
export async function createClass(data: { name: string; organizationId: number }) {
const response = await api.post<ClassSummary>('/classes', data);
return response.data;
}
export async function addClassMember(classId: number, userId: number) {
const response = await api.post(`/classes/${classId}/members`, { userId });
return response.data;
}
export async function removeClassMember(classId: number, userId: number) {
const response = await api.delete(`/classes/${classId}/members/${userId}`);
return response.data;
}
export async function getAssignments(params?: { classId?: number; status?: string }) {
const response = await api.get<AssignmentSummary[]>('/assignments', { params });
return response.data;
}
export async function getAssignment(id: number) {
const response = await api.get<
AssignmentSummary & {
submissions?: AssignmentSubmission[];
}
>(`/assignments/${id}`);
return response.data;
}
export async function getAssignmentSubmissions(id: number) {
const response = await api.get<AssignmentSubmission[]>(`/assignments/${id}/submissions`);
return response.data;
}

View File

@@ -0,0 +1,77 @@
'use client';
import type {
AssignmentSubmission,
AssignmentSummary,
ClassSummary,
Organization,
OrganizationRole,
} from '@/lib/api';
export function canManageSchool(role: OrganizationRole | null | undefined) {
return role === 'admin' || role === 'teacher';
}
export function formatOrganizationType(type: Organization['type']) {
return type === 'school' ? '학교' : '학원';
}
export function formatRole(role: OrganizationRole) {
switch (role) {
case 'admin':
return '관리자';
case 'teacher':
return '교사';
case 'student':
default:
return '학생';
}
}
export function formatDate(date?: string | null) {
if (!date) return '-';
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(date));
}
export function formatDateTime(date?: string | null) {
if (!date) return '-';
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(date));
}
export function pendingAssignmentsForClass(
assignments: AssignmentSummary[],
submissionsByAssignmentId: Map<number, AssignmentSubmission | null>,
classId: number,
) {
return assignments.filter((assignment) => {
if (assignment.classId !== classId || assignment.status !== 'active') {
return false;
}
return !submissionsByAssignmentId.get(assignment.id)?.completedAt;
}).length;
}
export function averageScore(submissions: AssignmentSubmission[]) {
const scored = submissions.filter((submission) => typeof submission.score === 'number');
if (scored.length === 0) return null;
const total = scored.reduce((sum, submission) => sum + (submission.score ?? 0), 0);
return total / scored.length;
}
export function uniqueStudentCount(classes: ClassSummary[]) {
return classes.reduce((total, classItem) => total + (classItem._count?.members ?? 0), 0);
}
export function inviteLink(code: string) {
return `https://reloop.nabomhalang.co.kr/school/join?code=${code}`;
}