feat(school): teacher dashboard + classes + invite + student view — 9.2
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
3
frontend/next-env.d.ts
vendored
3
frontend/next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
408
frontend/src/app/school/classes/[id]/page.tsx
Normal file
408
frontend/src/app/school/classes/[id]/page.tsx
Normal 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};
|
||||
`;
|
||||
329
frontend/src/app/school/classes/page.tsx
Normal file
329
frontend/src/app/school/classes/page.tsx
Normal 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;
|
||||
`;
|
||||
329
frontend/src/app/school/invite/page.tsx
Normal file
329
frontend/src/app/school/invite/page.tsx
Normal 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;
|
||||
`;
|
||||
166
frontend/src/app/school/join/page.tsx
Normal file
166
frontend/src/app/school/join/page.tsx
Normal 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;
|
||||
`;
|
||||
8
frontend/src/app/school/layout.tsx
Normal file
8
frontend/src/app/school/layout.tsx
Normal 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>;
|
||||
}
|
||||
220
frontend/src/app/school/my-class/[classId]/page.tsx
Normal file
220
frontend/src/app/school/my-class/[classId]/page.tsx
Normal 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;
|
||||
`;
|
||||
383
frontend/src/app/school/page.tsx
Normal file
383
frontend/src/app/school/page.tsx
Normal 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;
|
||||
`;
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
294
frontend/src/components/school/SchoolUI.tsx
Normal file
294
frontend/src/components/school/SchoolUI.tsx
Normal 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};
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
77
frontend/src/lib/school.ts
Normal file
77
frontend/src/lib/school.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user