feat(school): student progress + class reports — 9.4
This commit is contained in:
@@ -75,6 +75,11 @@ export class ClassesController {
|
||||
return this.svc.getOne(user.id, id);
|
||||
}
|
||||
|
||||
@Get(":id/report")
|
||||
report(@CurrentUser() user: AuthUser, @Param("id", ParseIntPipe) id: number) {
|
||||
return this.svc.getReport(user.id, id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
|
||||
@@ -116,6 +116,155 @@ export class ClassesService {
|
||||
};
|
||||
}
|
||||
|
||||
async getReport(userId: number, classId: number) {
|
||||
const classRoom = await this.prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
nickname: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: "asc" },
|
||||
},
|
||||
assignments: {
|
||||
include: {
|
||||
submissions: true,
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!classRoom) throw new NotFoundException("class");
|
||||
|
||||
await this.organizations.assertOrgRole(
|
||||
userId,
|
||||
classRoom.organizationId,
|
||||
ORG_TEACHER_OR_ADMIN_ROLES,
|
||||
);
|
||||
|
||||
const studentIds = classRoom.members.map((member) => member.userId);
|
||||
const studentIdSet = new Set(studentIds);
|
||||
|
||||
const studyLogGroups = studentIds.length
|
||||
? await this.prisma.studyLog.groupBy({
|
||||
by: ["userId", "result"],
|
||||
where: {
|
||||
userId: { in: studentIds },
|
||||
},
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const statsMap = new Map<
|
||||
number,
|
||||
{ correct: number; totalStudyLogs: number; accuracy: number | null }
|
||||
>();
|
||||
|
||||
for (const studentId of studentIds) {
|
||||
statsMap.set(studentId, {
|
||||
correct: 0,
|
||||
totalStudyLogs: 0,
|
||||
accuracy: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of studyLogGroups) {
|
||||
if (!studentIdSet.has(group.userId)) continue;
|
||||
const current = statsMap.get(group.userId);
|
||||
if (!current) continue;
|
||||
current.totalStudyLogs += group._count._all;
|
||||
if (group.result === "correct") {
|
||||
current.correct += group._count._all;
|
||||
}
|
||||
current.accuracy =
|
||||
current.totalStudyLogs > 0
|
||||
? (current.correct / current.totalStudyLogs) * 100
|
||||
: null;
|
||||
statsMap.set(group.userId, current);
|
||||
}
|
||||
|
||||
const students = classRoom.members.map((member) => {
|
||||
const assignmentSubmissions = classRoom.assignments.flatMap((assignment) =>
|
||||
assignment.submissions.filter((submission) => submission.userId === member.userId),
|
||||
);
|
||||
const stats = statsMap.get(member.userId) ?? {
|
||||
correct: 0,
|
||||
totalStudyLogs: 0,
|
||||
accuracy: null,
|
||||
};
|
||||
|
||||
return {
|
||||
userId: member.userId,
|
||||
nickname: member.user.nickname,
|
||||
avatarUrl: member.user.avatarUrl,
|
||||
accuracy: stats.accuracy,
|
||||
completedAssignments: assignmentSubmissions.filter(
|
||||
(submission) => submission.completedAt !== null,
|
||||
).length,
|
||||
totalStudyLogs: stats.totalStudyLogs,
|
||||
};
|
||||
});
|
||||
|
||||
const assignments = classRoom.assignments.map((assignment) => {
|
||||
const scoredSubmissions = assignment.submissions.filter(
|
||||
(submission) => typeof submission.score === "number",
|
||||
);
|
||||
const averageScore =
|
||||
scoredSubmissions.length > 0
|
||||
? scoredSubmissions.reduce(
|
||||
(sum, submission) => sum + (submission.score ?? 0),
|
||||
0,
|
||||
) / scoredSubmissions.length
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
averageScore,
|
||||
submissionCount: assignment.submissions.filter(
|
||||
(submission) => submission.completedAt !== null,
|
||||
).length,
|
||||
totalStudents: classRoom.members.length,
|
||||
};
|
||||
});
|
||||
|
||||
const scoreRanges = [
|
||||
{ label: "0-20", min: 0, max: 20 },
|
||||
{ label: "20-40", min: 20, max: 40 },
|
||||
{ label: "40-60", min: 40, max: 60 },
|
||||
{ label: "60-80", min: 60, max: 80 },
|
||||
{ label: "80-100", min: 80, max: 101 },
|
||||
];
|
||||
|
||||
const scoreDistribution = scoreRanges.map((range, index) => ({
|
||||
range: range.label,
|
||||
count: students.filter((student) => {
|
||||
const score = student.accuracy;
|
||||
if (score === null) {
|
||||
return index === 0;
|
||||
}
|
||||
return score >= range.min && score < range.max;
|
||||
}).length,
|
||||
}));
|
||||
|
||||
return {
|
||||
classId: classRoom.id,
|
||||
className: classRoom.name,
|
||||
studentCount: classRoom.members.length,
|
||||
assignments,
|
||||
students,
|
||||
scoreDistribution,
|
||||
};
|
||||
}
|
||||
|
||||
async update(userId: number, classId: number, data: { name?: string }) {
|
||||
const classRoom = await this.prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
|
||||
@@ -88,6 +88,15 @@ export class OrganizationsController {
|
||||
return this.svc.getOne(user.id, id);
|
||||
}
|
||||
|
||||
@Get(":orgId/students/:userId/stats")
|
||||
studentStats(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) targetUserId: number,
|
||||
) {
|
||||
return this.svc.getStudentStats(user.id, orgId, targetUserId);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
|
||||
@@ -137,6 +137,158 @@ export class OrganizationsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getStudentStats(
|
||||
userId: number,
|
||||
organizationId: number,
|
||||
targetUserId: number,
|
||||
) {
|
||||
await this.assertOrgRole(
|
||||
userId,
|
||||
organizationId,
|
||||
ORG_TEACHER_OR_ADMIN_ROLES,
|
||||
);
|
||||
|
||||
const membership = await this.prisma.organizationMember.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: targetUserId,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
nickname: true,
|
||||
email: true,
|
||||
avatarUrl: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFoundException("student");
|
||||
}
|
||||
|
||||
const [studyLogs, classMemberships] = await Promise.all([
|
||||
this.prisma.studyLog.findMany({
|
||||
where: { userId: targetUserId },
|
||||
include: {
|
||||
subject: {
|
||||
select: { id: true, name: true, color: true },
|
||||
},
|
||||
},
|
||||
orderBy: { studiedAt: "desc" },
|
||||
}),
|
||||
this.prisma.classMember.findMany({
|
||||
where: {
|
||||
userId: targetUserId,
|
||||
class: {
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
classId: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const classIds = classMemberships.map((membershipRow) => membershipRow.classId);
|
||||
|
||||
const assignments = classIds.length
|
||||
? await this.prisma.assignment.findMany({
|
||||
where: {
|
||||
classId: { in: classIds },
|
||||
},
|
||||
include: {
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
problemSet: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
year: true,
|
||||
examType: true,
|
||||
subjectName: true,
|
||||
},
|
||||
},
|
||||
submissions: {
|
||||
where: { userId: targetUserId },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: [{ dueDate: "asc" }, { createdAt: "desc" }],
|
||||
})
|
||||
: [];
|
||||
|
||||
const totalStudyLogs = studyLogs.length;
|
||||
const totalCorrect = studyLogs.filter((log) => log.result === "correct").length;
|
||||
const totalIncorrect = studyLogs.filter(
|
||||
(log) => log.result === "incorrect",
|
||||
).length;
|
||||
const accuracy =
|
||||
totalStudyLogs > 0 ? (totalCorrect / totalStudyLogs) * 100 : null;
|
||||
|
||||
const subjectBreakdownMap = new Map<
|
||||
string,
|
||||
{ subjectName: string; correct: number; total: number }
|
||||
>();
|
||||
|
||||
for (const log of studyLogs) {
|
||||
const key = log.subject.name;
|
||||
const current = subjectBreakdownMap.get(key) ?? {
|
||||
subjectName: log.subject.name,
|
||||
correct: 0,
|
||||
total: 0,
|
||||
};
|
||||
current.total += 1;
|
||||
if (log.result === "correct") {
|
||||
current.correct += 1;
|
||||
}
|
||||
subjectBreakdownMap.set(key, current);
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
student: membership.user,
|
||||
totalStudyLogs,
|
||||
totalCorrect,
|
||||
totalIncorrect,
|
||||
accuracy,
|
||||
recentLogs: studyLogs.slice(0, 10),
|
||||
assignments: assignments.map((assignment) => {
|
||||
const submission = assignment.submissions[0];
|
||||
return {
|
||||
id: submission?.id ?? -assignment.id,
|
||||
userId: targetUserId,
|
||||
assignmentId: assignment.id,
|
||||
score: submission?.score ?? null,
|
||||
totalProblems: submission?.totalProblems ?? null,
|
||||
correctCount: submission?.correctCount ?? null,
|
||||
completedAt: submission?.completedAt ?? null,
|
||||
createdAt: submission?.createdAt ?? assignment.createdAt,
|
||||
studyLogIds: submission?.studyLogIds ?? null,
|
||||
assignment: {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
dueDate: assignment.dueDate,
|
||||
class: assignment.class,
|
||||
problemSet: assignment.problemSet,
|
||||
},
|
||||
};
|
||||
}),
|
||||
subjectBreakdown: [...subjectBreakdownMap.values()].sort(
|
||||
(a, b) => b.total - a.total || a.subjectName.localeCompare(b.subjectName),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: number,
|
||||
organizationId: number,
|
||||
|
||||
@@ -221,7 +221,13 @@ export default function ClassDetailPage() {
|
||||
<tbody>
|
||||
{classRoom.members.map((member) => (
|
||||
<tr key={member.userId}>
|
||||
<td>{member.user.nickname}</td>
|
||||
<td>
|
||||
<StudentLink
|
||||
href={`/school/students/${member.userId}?orgId=${classRoom.organizationId}`}
|
||||
>
|
||||
{member.user.nickname}
|
||||
</StudentLink>
|
||||
</td>
|
||||
<td>{member.user.email}</td>
|
||||
<td>{formatDateTime(member.joinedAt)}</td>
|
||||
<td>
|
||||
@@ -239,7 +245,11 @@ export default function ClassDetailPage() {
|
||||
<MobileList>
|
||||
{classRoom.members.map((member) => (
|
||||
<MobileCard key={member.userId}>
|
||||
<SectionTitle>{member.user.nickname}</SectionTitle>
|
||||
<SectionTitle>
|
||||
<StudentLink href={`/school/students/${member.userId}?orgId=${classRoom.organizationId}`}>
|
||||
{member.user.nickname}
|
||||
</StudentLink>
|
||||
</SectionTitle>
|
||||
<SectionDescription>{member.user.email}</SectionDescription>
|
||||
<Badge>{formatDateTime(member.joinedAt)}</Badge>
|
||||
<Button $variant="ghost" onClick={() => handleRemoveMember(member.userId)}>
|
||||
@@ -409,3 +419,13 @@ const MatrixWrap = styled.div`
|
||||
border: 1px solid ${theme.color.border};
|
||||
border-radius: ${theme.radius.lg};
|
||||
`;
|
||||
|
||||
const StudentLink = styled(Link)`
|
||||
color: ${theme.color.textBright};
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.color.accent};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -241,6 +241,9 @@ export default function SchoolPage() {
|
||||
<Button as={Link} href="/school/assignments" $variant="secondary">
|
||||
과제 관리
|
||||
</Button>
|
||||
<Button as={Link} href="/school/reports" $variant="secondary">
|
||||
리포트
|
||||
</Button>
|
||||
<Button as={Link} href="/school/classes">
|
||||
학급 관리
|
||||
<Icon name="arrow-right" size={16} weight="bold" />
|
||||
@@ -338,6 +341,23 @@ export default function SchoolPage() {
|
||||
)}
|
||||
</SectionCard>
|
||||
</ContentGrid>
|
||||
|
||||
<SectionCard>
|
||||
<SectionHeader>
|
||||
<div>
|
||||
<SectionTitle>분석 도구</SectionTitle>
|
||||
<SectionDescription>학급별 리포트와 과제 평균, 학생 순위를 바로 확인합니다.</SectionDescription>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
<CardGrid>
|
||||
<FeatureCardLink href="/school/reports">
|
||||
<Badge>Reports</Badge>
|
||||
<EmptyTitle>학급 리포트</EmptyTitle>
|
||||
<EmptyText>과제 평균 점수, 학생 정확도 순위, 점수 분포를 한 화면에서 확인합니다.</EmptyText>
|
||||
</FeatureCardLink>
|
||||
</CardGrid>
|
||||
</SectionCard>
|
||||
</PageWrap>
|
||||
);
|
||||
}
|
||||
|
||||
581
frontend/src/app/school/reports/page.tsx
Normal file
581
frontend/src/app/school/reports/page.tsx
Normal file
@@ -0,0 +1,581 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import Select from '@/components/ui/Select';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import {
|
||||
Badge,
|
||||
DesktopOnly,
|
||||
EmptyCard,
|
||||
EmptyText,
|
||||
EmptyTitle,
|
||||
HeaderRow,
|
||||
HeroCard,
|
||||
HeroText,
|
||||
MobileCard,
|
||||
MobileList,
|
||||
PageDescription,
|
||||
PageTitle,
|
||||
PageWrap,
|
||||
SectionCard,
|
||||
SectionDescription,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
TableWrap,
|
||||
DataTable,
|
||||
} from '@/components/school/SchoolUI';
|
||||
import {
|
||||
type ClassReportResponse,
|
||||
type ClassSummary,
|
||||
type MeUser,
|
||||
api,
|
||||
getClassReport,
|
||||
getMyClasses,
|
||||
getMyOrganizations,
|
||||
} from '@/lib/api';
|
||||
import { canManageSchool } from '@/lib/school';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
type SortKey = 'accuracy' | 'completedAssignments' | 'totalStudyLogs' | 'nickname';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export default function SchoolReportsPage() {
|
||||
const [me, setMe] = useState<MeUser | null>(null);
|
||||
const [classes, setClasses] = useState<ClassSummary[]>([]);
|
||||
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
|
||||
const [report, setReport] = useState<ClassReportResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [reportLoading, setReportLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sortKey, setSortKey] = useState<SortKey>('accuracy');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
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(),
|
||||
]);
|
||||
|
||||
const manageableOrgs = orgs.filter((org) => canManageSchool(org.myRole));
|
||||
const classGroups = await Promise.all(manageableOrgs.map((org) => getMyClasses(org.id)));
|
||||
const nextClasses = classGroups.flat();
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setMe(meResponse);
|
||||
setClasses(nextClasses);
|
||||
setSelectedClassId(nextClasses[0]?.id ?? null);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError('학급 리포트를 불러오지 못했어요.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedClassId === null) {
|
||||
setReport(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const classId = selectedClassId;
|
||||
let cancelled = false;
|
||||
|
||||
async function loadReport() {
|
||||
setReportLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextReport = await getClassReport(classId);
|
||||
if (!cancelled) {
|
||||
setReport(nextReport);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setReport(null);
|
||||
setError('선택한 학급의 리포트를 불러오지 못했어요.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setReportLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadReport();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedClassId]);
|
||||
|
||||
const sortedStudents = useMemo(() => {
|
||||
if (!report) return [];
|
||||
|
||||
const copy = [...report.students];
|
||||
copy.sort((left, right) => {
|
||||
switch (sortKey) {
|
||||
case 'nickname':
|
||||
return sortDirection === 'asc'
|
||||
? left.nickname.localeCompare(right.nickname, 'ko')
|
||||
: right.nickname.localeCompare(left.nickname, 'ko');
|
||||
case 'completedAssignments': {
|
||||
const diff = left.completedAssignments - right.completedAssignments;
|
||||
return sortDirection === 'asc' ? diff : -diff;
|
||||
}
|
||||
case 'totalStudyLogs': {
|
||||
const diff = left.totalStudyLogs - right.totalStudyLogs;
|
||||
return sortDirection === 'asc' ? diff : -diff;
|
||||
}
|
||||
case 'accuracy':
|
||||
default: {
|
||||
const leftValue = left.accuracy ?? -1;
|
||||
const rightValue = right.accuracy ?? -1;
|
||||
const diff = leftValue - rightValue;
|
||||
return sortDirection === 'asc' ? diff : -diff;
|
||||
}
|
||||
}
|
||||
});
|
||||
return copy;
|
||||
}, [report, sortDirection, sortKey]);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingText>리포트를 불러오는 중...</LoadingText>;
|
||||
}
|
||||
|
||||
if (error && !report && classes.length === 0) {
|
||||
return <LoadingText>{error}</LoadingText>;
|
||||
}
|
||||
|
||||
if (!me || !canManageSchool(me.organizationRole)) {
|
||||
return (
|
||||
<PageWrap>
|
||||
<EmptyCard>
|
||||
<EmptyTitle>리포트 권한이 없어요</EmptyTitle>
|
||||
<EmptyText>교사 또는 관리자 계정만 학급 분석 리포트를 볼 수 있습니다.</EmptyText>
|
||||
<Button as={Link} href="/school">
|
||||
학교 관리로 이동
|
||||
</Button>
|
||||
</EmptyCard>
|
||||
</PageWrap>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrap>
|
||||
<HeroCard>
|
||||
<HeaderRow>
|
||||
<HeroText>
|
||||
<Badge>Class Reports</Badge>
|
||||
<PageTitle>학급 리포트</PageTitle>
|
||||
<PageDescription>과제 평균, 학생 순위, 점수 분포를 학급 단위로 분석합니다.</PageDescription>
|
||||
</HeroText>
|
||||
<Button as={Link} href="/school" $variant="secondary">
|
||||
학교 대시보드
|
||||
</Button>
|
||||
</HeaderRow>
|
||||
</HeroCard>
|
||||
|
||||
<SectionCard>
|
||||
<SectionHeader>
|
||||
<div>
|
||||
<SectionTitle>학급 선택</SectionTitle>
|
||||
<SectionDescription>리포트를 볼 학급을 선택하세요.</SectionDescription>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
{classes.length === 0 ? (
|
||||
<EmptyCard>
|
||||
<EmptyTitle>표시할 학급이 없어요</EmptyTitle>
|
||||
<EmptyText>먼저 학급을 만들거나 관리 가능한 기관에 연결되어야 합니다.</EmptyText>
|
||||
</EmptyCard>
|
||||
) : (
|
||||
<SelectorRow>
|
||||
<ClassSelect
|
||||
value={selectedClassId}
|
||||
onChange={(value) => setSelectedClassId(value as number)}
|
||||
options={classes.map((classItem) => ({
|
||||
label: classItem.organization?.name
|
||||
? `${classItem.organization.name} · ${classItem.name}`
|
||||
: classItem.name,
|
||||
value: classItem.id,
|
||||
}))}
|
||||
aria-label="리포트 학급 선택"
|
||||
/>
|
||||
</SelectorRow>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{reportLoading ? <LoadingText>학급 분석을 계산하는 중...</LoadingText> : null}
|
||||
|
||||
{report ? (
|
||||
<>
|
||||
<SectionCard>
|
||||
<SectionHeader>
|
||||
<div>
|
||||
<SectionTitle>과제별 평균 점수</SectionTitle>
|
||||
<SectionDescription>
|
||||
{report.className} · 학생 {report.studentCount}명 기준 평균 점수입니다.
|
||||
</SectionDescription>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
{report.assignments.length === 0 ? (
|
||||
<EmptyCard>
|
||||
<EmptyTitle>등록된 과제가 없어요</EmptyTitle>
|
||||
<EmptyText>과제가 생기면 평균 점수 차트가 여기에 표시됩니다.</EmptyText>
|
||||
</EmptyCard>
|
||||
) : (
|
||||
<ChartScroll>
|
||||
<AssignmentAverageChart rows={report.assignments} />
|
||||
</ChartScroll>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<SectionHeader>
|
||||
<div>
|
||||
<SectionTitle>학생 순위표</SectionTitle>
|
||||
<SectionDescription>기본 정렬은 정답률 높은 순입니다. 헤더를 눌러 정렬을 바꿀 수 있습니다.</SectionDescription>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
<DesktopOnly>
|
||||
<TableWrap>
|
||||
<DataTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>순위</th>
|
||||
<SortableHead>
|
||||
<SortButton type="button" onClick={() => toggleSort(sortKey, sortDirection, 'nickname', setSortKey, setSortDirection)}>
|
||||
학생 이름
|
||||
<SortIcon active={sortKey === 'nickname'} direction={sortDirection} />
|
||||
</SortButton>
|
||||
</SortableHead>
|
||||
<SortableHead>
|
||||
<SortButton type="button" onClick={() => toggleSort(sortKey, sortDirection, 'accuracy', setSortKey, setSortDirection)}>
|
||||
정답률
|
||||
<SortIcon active={sortKey === 'accuracy'} direction={sortDirection} />
|
||||
</SortButton>
|
||||
</SortableHead>
|
||||
<SortableHead>
|
||||
<SortButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleSort(
|
||||
sortKey,
|
||||
sortDirection,
|
||||
'completedAssignments',
|
||||
setSortKey,
|
||||
setSortDirection,
|
||||
)
|
||||
}
|
||||
>
|
||||
완료 과제 수
|
||||
<SortIcon active={sortKey === 'completedAssignments'} direction={sortDirection} />
|
||||
</SortButton>
|
||||
</SortableHead>
|
||||
<SortableHead>
|
||||
<SortButton
|
||||
type="button"
|
||||
onClick={() => toggleSort(sortKey, sortDirection, 'totalStudyLogs', setSortKey, setSortDirection)}
|
||||
>
|
||||
총 학습 수
|
||||
<SortIcon active={sortKey === 'totalStudyLogs'} direction={sortDirection} />
|
||||
</SortButton>
|
||||
</SortableHead>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedStudents.map((student, index) => (
|
||||
<tr key={student.userId}>
|
||||
<td>{renderRank(index)}</td>
|
||||
<td>{student.nickname}</td>
|
||||
<td>{student.accuracy === null ? '--' : `${Math.round(student.accuracy)}%`}</td>
|
||||
<td>{student.completedAssignments}</td>
|
||||
<td>{student.totalStudyLogs}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</TableWrap>
|
||||
</DesktopOnly>
|
||||
|
||||
<MobileList>
|
||||
{sortedStudents.map((student, index) => (
|
||||
<MobileCard key={student.userId}>
|
||||
<RankRow>
|
||||
<RankBadge>{renderRank(index)}</RankBadge>
|
||||
<SectionTitle>{student.nickname}</SectionTitle>
|
||||
</RankRow>
|
||||
<SectionDescription>
|
||||
정답률 {student.accuracy === null ? '--' : `${Math.round(student.accuracy)}%`}
|
||||
</SectionDescription>
|
||||
<SectionDescription>완료 과제 {student.completedAssignments}</SectionDescription>
|
||||
<SectionDescription>총 학습 수 {student.totalStudyLogs}</SectionDescription>
|
||||
</MobileCard>
|
||||
))}
|
||||
</MobileList>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<SectionHeader>
|
||||
<div>
|
||||
<SectionTitle>점수 분포</SectionTitle>
|
||||
<SectionDescription>학생별 정답률이 어느 구간에 분포하는지 보여줍니다.</SectionDescription>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
<ChartScroll>
|
||||
<ScoreDistributionChart rows={report.scoreDistribution} />
|
||||
</ChartScroll>
|
||||
</SectionCard>
|
||||
</>
|
||||
) : null}
|
||||
</PageWrap>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentAverageChart({
|
||||
rows,
|
||||
}: {
|
||||
rows: ClassReportResponse['assignments'];
|
||||
}) {
|
||||
const width = 900;
|
||||
const rowHeight = 72;
|
||||
const height = rows.length * rowHeight + 28;
|
||||
const labelWidth = 300;
|
||||
const barLeft = labelWidth + 24;
|
||||
const barWidth = width - barLeft - 32;
|
||||
|
||||
return (
|
||||
<ChartFrame>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} width="100%" height="100%" role="img" aria-label="과제별 평균 점수 막대 차트">
|
||||
<defs>
|
||||
<linearGradient id="assignmentAverageGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor={theme.color.brandIndigo} />
|
||||
<stop offset="100%" stopColor={theme.color.brandViolet} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{rows.map((row, index) => {
|
||||
const y = 16 + index * rowHeight;
|
||||
const ratio = Math.max(0, Math.min(1, (row.averageScore ?? 0) / 100));
|
||||
return (
|
||||
<g key={row.id} transform={`translate(0 ${y})`}>
|
||||
<text x="0" y="22" fill={theme.color.textBright} fontSize="14" fontWeight="700">
|
||||
{row.title}
|
||||
</text>
|
||||
<text x="0" y="42" fill={theme.color.textMute} fontSize="12">
|
||||
평균 {row.averageScore === null ? '--' : `${Math.round(row.averageScore)}%`} ·{' '}
|
||||
{row.submissionCount}/{row.totalStudents} 제출
|
||||
</text>
|
||||
<rect x={barLeft} y="12" width={barWidth} height="28" rx="14" fill="rgba(255, 255, 255, 0.06)" />
|
||||
<rect
|
||||
x={barLeft}
|
||||
y="12"
|
||||
width={row.averageScore === null ? 0 : Math.max(10, barWidth * ratio)}
|
||||
height="28"
|
||||
rx="14"
|
||||
fill="url(#assignmentAverageGradient)"
|
||||
/>
|
||||
<text x={width - 4} y="31" fill={theme.color.textBright} fontSize="13" textAnchor="end">
|
||||
{row.averageScore === null ? '--' : `${Math.round(row.averageScore)}%`}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreDistributionChart({
|
||||
rows,
|
||||
}: {
|
||||
rows: ClassReportResponse['scoreDistribution'];
|
||||
}) {
|
||||
const width = 760;
|
||||
const height = 320;
|
||||
const padding = { top: 24, right: 24, bottom: 52, left: 40 };
|
||||
const innerWidth = width - padding.left - padding.right;
|
||||
const innerHeight = height - padding.top - padding.bottom;
|
||||
const maxCount = Math.max(1, ...rows.map((row) => row.count));
|
||||
const barGap = 20;
|
||||
const barWidth = (innerWidth - barGap * (rows.length - 1)) / rows.length;
|
||||
|
||||
return (
|
||||
<DistributionFrame>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} width="100%" height="100%" role="img" aria-label="점수 분포 히스토그램">
|
||||
<defs>
|
||||
<linearGradient id="distributionGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={theme.color.brandViolet} />
|
||||
<stop offset="100%" stopColor={theme.color.brandIndigo} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((tick) => {
|
||||
const value = Math.round(maxCount * tick);
|
||||
const y = padding.top + innerHeight - innerHeight * tick;
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={y}
|
||||
x2={width - padding.right}
|
||||
y2={y}
|
||||
stroke="rgba(255, 255, 255, 0.08)"
|
||||
strokeDasharray="4 6"
|
||||
/>
|
||||
<text x={padding.left - 10} y={y + 4} fill={theme.color.textMute} fontSize="11" textAnchor="end">
|
||||
{value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{rows.map((row, index) => {
|
||||
const x = padding.left + index * (barWidth + barGap);
|
||||
const barHeight = (row.count / maxCount) * innerHeight;
|
||||
const y = padding.top + innerHeight - barHeight;
|
||||
return (
|
||||
<g key={row.range}>
|
||||
<rect x={x} y={y} width={barWidth} height={barHeight} rx="12" fill="url(#distributionGradient)" />
|
||||
<text x={x + barWidth / 2} y={y - 8} fill={theme.color.textBright} fontSize="12" textAnchor="middle">
|
||||
{row.count}
|
||||
</text>
|
||||
<text
|
||||
x={x + barWidth / 2}
|
||||
y={height - 18}
|
||||
fill={theme.color.textMute}
|
||||
fontSize="12"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{row.range}%
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</DistributionFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function toggleSort(
|
||||
currentKey: SortKey,
|
||||
currentDirection: SortDirection,
|
||||
nextKey: SortKey,
|
||||
setSortKey: React.Dispatch<React.SetStateAction<SortKey>>,
|
||||
setSortDirection: React.Dispatch<React.SetStateAction<SortDirection>>,
|
||||
) {
|
||||
if (currentKey === nextKey) {
|
||||
setSortDirection(currentDirection === 'desc' ? 'asc' : 'desc');
|
||||
return;
|
||||
}
|
||||
|
||||
setSortKey(nextKey);
|
||||
setSortDirection(nextKey === 'nickname' ? 'asc' : 'desc');
|
||||
}
|
||||
|
||||
function renderRank(index: number) {
|
||||
if (index < 3) {
|
||||
const colors = ['#fbbf24', '#cbd5e1', '#d97706'] as const;
|
||||
return <Icon name="medal" size={18} color={colors[index]} weight="fill" />;
|
||||
}
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
function SortIcon({
|
||||
active,
|
||||
direction,
|
||||
}: {
|
||||
active: boolean;
|
||||
direction: SortDirection;
|
||||
}) {
|
||||
return (
|
||||
<Icon
|
||||
name={direction === 'desc' ? 'caret-down' : 'caret-up'}
|
||||
size={14}
|
||||
color={active ? theme.color.textBright : theme.color.textMute}
|
||||
weight="bold"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const LoadingText = styled.div`
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const SelectorRow = styled.div`
|
||||
max-width: 420px;
|
||||
`;
|
||||
|
||||
const ClassSelect = styled(Select<number>)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const ChartScroll = styled.div`
|
||||
overflow-x: auto;
|
||||
`;
|
||||
|
||||
const ChartFrame = styled.div`
|
||||
min-width: 840px;
|
||||
min-height: 240px;
|
||||
`;
|
||||
|
||||
const DistributionFrame = styled.div`
|
||||
min-width: 720px;
|
||||
min-height: 320px;
|
||||
`;
|
||||
|
||||
const SortableHead = styled.th`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const SortButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const RankRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const RankBadge = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
522
frontend/src/app/school/students/[userId]/page.tsx
Normal file
522
frontend/src/app/school/students/[userId]/page.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import {
|
||||
Badge,
|
||||
DesktopOnly,
|
||||
EmptyCard,
|
||||
EmptyText,
|
||||
EmptyTitle,
|
||||
HeaderRow,
|
||||
HeroCard,
|
||||
HeroText,
|
||||
MobileCard,
|
||||
MobileList,
|
||||
PageDescription,
|
||||
PageTitle,
|
||||
PageWrap,
|
||||
SectionCard,
|
||||
SectionDescription,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
StatCard,
|
||||
StatGrid,
|
||||
StatHint,
|
||||
StatLabel,
|
||||
StatValue,
|
||||
TableWrap,
|
||||
DataTable,
|
||||
} from '@/components/school/SchoolUI';
|
||||
import {
|
||||
type MeUser,
|
||||
type StudentStatsResponse,
|
||||
api,
|
||||
getMyOrganizations,
|
||||
getStudentStats,
|
||||
resolveAssetUrl,
|
||||
} from '@/lib/api';
|
||||
import {
|
||||
calculateAssignmentPercent,
|
||||
canManageSchool,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
} from '@/lib/school';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
export default function StudentDetailPage() {
|
||||
const params = useParams<{ userId: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const userId = Number(params.userId);
|
||||
const [me, setMe] = useState<MeUser | null>(null);
|
||||
const [stats, setStats] = useState<StudentStatsResponse | null>(null);
|
||||
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 (!canManageSchool(meResponse.organizationRole)) {
|
||||
if (!cancelled) {
|
||||
setMe(meResponse);
|
||||
setError('학생 상세는 교사 또는 관리자만 볼 수 있어요.');
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const manageableOrgs = orgs.filter((org) => canManageSchool(org.myRole));
|
||||
const requestedOrgParam = searchParams.get('orgId');
|
||||
const requestedOrgId = requestedOrgParam ? Number(requestedOrgParam) : null;
|
||||
const candidateOrgIds = Array.from(
|
||||
new Set(
|
||||
[
|
||||
requestedOrgId !== null && Number.isFinite(requestedOrgId) ? requestedOrgId : null,
|
||||
...manageableOrgs.map((org) => org.id),
|
||||
].filter((value): value is number => value !== null),
|
||||
),
|
||||
);
|
||||
|
||||
let found: StudentStatsResponse | null = null;
|
||||
for (const orgId of candidateOrgIds) {
|
||||
try {
|
||||
found = await getStudentStats(orgId, userId);
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw new Error('not-found');
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setMe(meResponse);
|
||||
setStats(found);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError('학생 통계를 불러오지 못했어요.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isNaN(userId)) {
|
||||
void load();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [searchParams, userId]);
|
||||
|
||||
const completedAssignments = useMemo(
|
||||
() => stats?.assignments.filter((assignment) => assignment.completedAt !== null).length ?? 0,
|
||||
[stats],
|
||||
);
|
||||
|
||||
const avatarSrc = resolveAssetUrl(stats?.student.avatarUrl);
|
||||
const studentInitial = (stats?.student.nickname ?? '?').slice(0, 1).toUpperCase();
|
||||
|
||||
if (loading) {
|
||||
return <LoadingText>학생 정보를 불러오는 중...</LoadingText>;
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return <LoadingText>{error ?? '학생 정보를 찾을 수 없어요.'}</LoadingText>;
|
||||
}
|
||||
|
||||
if (!me || !canManageSchool(me.organizationRole)) {
|
||||
return (
|
||||
<PageWrap>
|
||||
<EmptyCard>
|
||||
<EmptyTitle>관리 권한이 필요해요</EmptyTitle>
|
||||
<EmptyText>학생 상세 리포트는 교사 또는 관리자만 확인할 수 있습니다.</EmptyText>
|
||||
<Button as={Link} href="/school">
|
||||
학교 관리로 이동
|
||||
</Button>
|
||||
</EmptyCard>
|
||||
</PageWrap>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrap>
|
||||
<HeroCard>
|
||||
<HeaderRow>
|
||||
<ProfileBlock>
|
||||
<AvatarShell>
|
||||
{avatarSrc ? <AvatarImage src={avatarSrc} alt={`${stats.student.nickname} avatar`} /> : studentInitial}
|
||||
</AvatarShell>
|
||||
<HeroText>
|
||||
<Badge>Student Progress</Badge>
|
||||
<PageTitle>{stats.student.nickname}</PageTitle>
|
||||
<PageDescription>{stats.student.email}</PageDescription>
|
||||
<MetaLine>가입일 {formatDate(stats.student.createdAt)}</MetaLine>
|
||||
</HeroText>
|
||||
</ProfileBlock>
|
||||
|
||||
<Button as={Link} href="/school/reports" $variant="secondary">
|
||||
학급 리포트
|
||||
</Button>
|
||||
</HeaderRow>
|
||||
</HeroCard>
|
||||
|
||||
<StatGrid>
|
||||
<StatCard>
|
||||
<StatLabel>총 학습 수</StatLabel>
|
||||
<StatValue>{stats.totalStudyLogs}</StatValue>
|
||||
<StatHint>누적 학습 로그</StatHint>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatLabel>정답률</StatLabel>
|
||||
<StatValue>{stats.accuracy === null ? '--' : `${Math.round(stats.accuracy)}%`}</StatValue>
|
||||
<StatHint>
|
||||
정답 {stats.totalCorrect} / 오답 {stats.totalIncorrect}
|
||||
</StatHint>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatLabel>완료 과제 수</StatLabel>
|
||||
<StatValue>
|
||||
{completedAssignments} / {stats.assignments.length}
|
||||
</StatValue>
|
||||
<StatHint>배정된 전체 과제 기준</StatHint>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatLabel>최근 활동</StatLabel>
|
||||
<StatValue>{stats.recentLogs.length}</StatValue>
|
||||
<StatHint>최근 10개 학습 기록</StatHint>
|
||||
</StatCard>
|
||||
</StatGrid>
|
||||
|
||||
<SectionCard>
|
||||
<SectionHeader>
|
||||
<div>
|
||||
<SectionTitle>과목별 숙련도</SectionTitle>
|
||||
<SectionDescription>과목별 정답 비율을 막대 차트로 확인합니다.</SectionDescription>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
{stats.subjectBreakdown.length === 0 ? (
|
||||
<EmptyCard>
|
||||
<EmptyTitle>아직 학습 데이터가 없어요</EmptyTitle>
|
||||
<EmptyText>학습 로그가 쌓이면 과목별 숙련도가 여기에 표시됩니다.</EmptyText>
|
||||
</EmptyCard>
|
||||
) : (
|
||||
<ChartScroll>
|
||||
<SubjectMasteryChart rows={stats.subjectBreakdown} />
|
||||
</ChartScroll>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<SectionHeader>
|
||||
<div>
|
||||
<SectionTitle>과제 현황</SectionTitle>
|
||||
<SectionDescription>학생에게 배정된 과제와 제출 상태를 확인합니다.</SectionDescription>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
{stats.assignments.length === 0 ? (
|
||||
<EmptyCard>
|
||||
<EmptyTitle>배정된 과제가 없어요</EmptyTitle>
|
||||
<EmptyText>이 학생이 속한 학급에 아직 등록된 과제가 없습니다.</EmptyText>
|
||||
</EmptyCard>
|
||||
) : (
|
||||
<>
|
||||
<DesktopOnly>
|
||||
<TableWrap>
|
||||
<DataTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>과제명</th>
|
||||
<th>점수</th>
|
||||
<th>완료 여부</th>
|
||||
<th>완료 시간</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.assignments.map((assignment) => (
|
||||
<tr key={`${assignment.assignmentId}-${assignment.id}`}>
|
||||
<td>{assignment.assignment?.title ?? `과제 #${assignment.assignmentId}`}</td>
|
||||
<td>
|
||||
{typeof assignment.score === 'number'
|
||||
? `${Math.round(assignment.score)}점`
|
||||
: formatAssignmentScore(assignment.correctCount, assignment.totalProblems)}
|
||||
</td>
|
||||
<td>
|
||||
<Badge $tone={assignment.completedAt ? 'success' : 'warning'}>
|
||||
{assignment.completedAt ? '완료' : '미완료'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>{formatDateTime(assignment.completedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</TableWrap>
|
||||
</DesktopOnly>
|
||||
|
||||
<MobileList>
|
||||
{stats.assignments.map((assignment) => (
|
||||
<MobileCard key={`${assignment.assignmentId}-${assignment.id}`}>
|
||||
<SectionTitle>{assignment.assignment?.title ?? `과제 #${assignment.assignmentId}`}</SectionTitle>
|
||||
<SectionDescription>
|
||||
점수{' '}
|
||||
{typeof assignment.score === 'number'
|
||||
? `${Math.round(assignment.score)}점`
|
||||
: formatAssignmentScore(assignment.correctCount, assignment.totalProblems)}
|
||||
</SectionDescription>
|
||||
<Badge $tone={assignment.completedAt ? 'success' : 'warning'}>
|
||||
{assignment.completedAt ? '완료' : '미완료'}
|
||||
</Badge>
|
||||
<MetaLine>완료 시간 {formatDateTime(assignment.completedAt)}</MetaLine>
|
||||
</MobileCard>
|
||||
))}
|
||||
</MobileList>
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<SectionHeader>
|
||||
<div>
|
||||
<SectionTitle>최근 학습</SectionTitle>
|
||||
<SectionDescription>가장 최근 학습한 문제 10개입니다.</SectionDescription>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
{stats.recentLogs.length === 0 ? (
|
||||
<EmptyCard>
|
||||
<EmptyTitle>최근 학습이 없어요</EmptyTitle>
|
||||
<EmptyText>학습을 시작하면 최근 기록이 이 영역에 표시됩니다.</EmptyText>
|
||||
</EmptyCard>
|
||||
) : (
|
||||
<>
|
||||
<DesktopOnly>
|
||||
<TableWrap>
|
||||
<DataTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>문항</th>
|
||||
<th>과목</th>
|
||||
<th>결과</th>
|
||||
<th>학습일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.recentLogs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td>{log.title}</td>
|
||||
<td>{log.subject?.name ?? '-'}</td>
|
||||
<td>
|
||||
<ResultBadge $result={log.result}>{formatResult(log.result)}</ResultBadge>
|
||||
</td>
|
||||
<td>{formatDateTime(log.studiedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</TableWrap>
|
||||
</DesktopOnly>
|
||||
|
||||
<MobileList>
|
||||
{stats.recentLogs.map((log) => (
|
||||
<MobileCard key={log.id}>
|
||||
<SectionTitle>{log.title}</SectionTitle>
|
||||
<SectionDescription>{log.subject?.name ?? '-'}</SectionDescription>
|
||||
<ResultBadge $result={log.result}>{formatResult(log.result)}</ResultBadge>
|
||||
<MetaLine>{formatDateTime(log.studiedAt)}</MetaLine>
|
||||
</MobileCard>
|
||||
))}
|
||||
</MobileList>
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
</PageWrap>
|
||||
);
|
||||
}
|
||||
|
||||
function SubjectMasteryChart({
|
||||
rows,
|
||||
}: {
|
||||
rows: StudentStatsResponse['subjectBreakdown'];
|
||||
}) {
|
||||
const width = 780;
|
||||
const rowHeight = 62;
|
||||
const height = rows.length * rowHeight + 24;
|
||||
const labelWidth = 150;
|
||||
const barLeft = labelWidth + 20;
|
||||
const barWidth = width - barLeft - 28;
|
||||
|
||||
return (
|
||||
<ChartFrame>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
role="img"
|
||||
aria-label="과목별 숙련도 차트"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="subjectMasteryGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor={theme.color.brandIndigo} />
|
||||
<stop offset="100%" stopColor={theme.color.brandViolet} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{rows.map((row, index) => {
|
||||
const y = 12 + index * rowHeight;
|
||||
const ratio = row.total > 0 ? row.correct / row.total : 0;
|
||||
const percent = Math.round(ratio * 100);
|
||||
return (
|
||||
<g key={row.subjectName} transform={`translate(0 ${y})`}>
|
||||
<text x="0" y="24" fill={theme.color.textBright} fontSize="14" fontWeight="700">
|
||||
{row.subjectName}
|
||||
</text>
|
||||
<text x="0" y="42" fill={theme.color.textMute} fontSize="12">
|
||||
{row.correct}/{row.total} 정답
|
||||
</text>
|
||||
<rect
|
||||
x={barLeft}
|
||||
y="14"
|
||||
width={barWidth}
|
||||
height="24"
|
||||
rx="12"
|
||||
fill="rgba(255, 255, 255, 0.06)"
|
||||
/>
|
||||
<rect
|
||||
x={barLeft}
|
||||
y="14"
|
||||
width={Math.max(8, barWidth * ratio)}
|
||||
height="24"
|
||||
rx="12"
|
||||
fill="url(#subjectMasteryGradient)"
|
||||
/>
|
||||
<text
|
||||
x={width - 4}
|
||||
y="31"
|
||||
fill={theme.color.textBright}
|
||||
fontSize="13"
|
||||
textAnchor="end"
|
||||
>
|
||||
{percent}%
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function formatResult(result: 'correct' | 'incorrect' | 'partial') {
|
||||
switch (result) {
|
||||
case 'correct':
|
||||
return '정답';
|
||||
case 'incorrect':
|
||||
return '오답';
|
||||
case 'partial':
|
||||
default:
|
||||
return '부분 정답';
|
||||
}
|
||||
}
|
||||
|
||||
function formatAssignmentScore(correctCount?: number | null, totalProblems?: number | null) {
|
||||
const percent = calculateAssignmentPercent(correctCount, totalProblems);
|
||||
return percent === null ? '-' : `${percent}%`;
|
||||
}
|
||||
|
||||
const LoadingText = styled.div`
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const ProfileBlock = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.md};
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
const AvatarShell = styled.div`
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
radial-gradient(circle at 30% 20%, rgba(129, 140, 248, 0.36), transparent 45%),
|
||||
${theme.color.surface2};
|
||||
border: 1px solid ${theme.color.borderBrightAlpha};
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const AvatarImage = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const MetaLine = styled.p`
|
||||
margin: 0;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const ChartScroll = styled.div`
|
||||
overflow-x: auto;
|
||||
`;
|
||||
|
||||
const ChartFrame = styled.div`
|
||||
min-width: 720px;
|
||||
min-height: 220px;
|
||||
`;
|
||||
|
||||
const ResultBadge = styled.span<{ $result: 'correct' | 'incorrect' | 'partial' }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
background: ${({ $result }) => {
|
||||
switch ($result) {
|
||||
case 'correct':
|
||||
return 'rgba(34, 197, 94, 0.14)';
|
||||
case 'incorrect':
|
||||
return 'rgba(239, 68, 68, 0.14)';
|
||||
case 'partial':
|
||||
default:
|
||||
return 'rgba(245, 158, 11, 0.14)';
|
||||
}
|
||||
}};
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
MagnifyingGlass, DownloadSimple, CalendarBlank, CalendarCheck, BellRinging, Bell, Sparkle,
|
||||
Minus, DotsThree, DotsThreeVertical, Translate, BookOpenText, SkipForward, Info, SignOut, Camera, Crown,
|
||||
Export, Gear, ArrowRight, Pause, type IconProps as PhIconProps, type IconWeight,
|
||||
Scales, Feather, Clock, Flag, CheckSquare, Circle, ArrowLeft, User, Books,
|
||||
Scales, Feather, Clock, Flag, CheckSquare, Circle, ArrowLeft, User, Books, Medal,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
// name → component map. 새 아이콘 추가 시 이 맵에만 등록.
|
||||
@@ -84,6 +84,7 @@ const ICON_MAP = {
|
||||
'circle': Circle,
|
||||
'user': User,
|
||||
'books': Books,
|
||||
'medal': Medal,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICON_MAP;
|
||||
|
||||
@@ -147,6 +147,22 @@ export interface AssignmentSubmission {
|
||||
email: string;
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
assignment?: {
|
||||
id: number;
|
||||
title: string;
|
||||
dueDate?: string | null;
|
||||
class?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
problemSet?: {
|
||||
id: number;
|
||||
title: string;
|
||||
year?: number;
|
||||
examType?: string;
|
||||
subjectName?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubmissionDetail {
|
||||
@@ -355,6 +371,53 @@ export interface DashboardSummary {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface StudentStatsResponse {
|
||||
organizationId: number;
|
||||
student: {
|
||||
id: number;
|
||||
nickname: string;
|
||||
email: string;
|
||||
avatarUrl: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
totalStudyLogs: number;
|
||||
totalCorrect: number;
|
||||
totalIncorrect: number;
|
||||
accuracy: number | null;
|
||||
recentLogs: StudyLog[];
|
||||
assignments: AssignmentSubmission[];
|
||||
subjectBreakdown: Array<{
|
||||
subjectName: string;
|
||||
correct: number;
|
||||
total: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ClassReportResponse {
|
||||
classId: number;
|
||||
className: string;
|
||||
studentCount: number;
|
||||
assignments: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
averageScore: number | null;
|
||||
submissionCount: number;
|
||||
totalStudents: number;
|
||||
}>;
|
||||
students: Array<{
|
||||
userId: number;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
accuracy: number | null;
|
||||
completedAssignments: number;
|
||||
totalStudyLogs: number;
|
||||
}>;
|
||||
scoreDistribution: Array<{
|
||||
range: string;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ForgetCurveResponse {
|
||||
tag: { id: number; name: string; subject: { id: number; name: string; color: string } };
|
||||
snapshot: { s0: number; lastUpdatedAt: string; sampleCount: number };
|
||||
@@ -474,6 +537,13 @@ export async function getOrganization(id: number) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getStudentStats(orgId: number, userId: number) {
|
||||
const response = await api.get<StudentStatsResponse>(
|
||||
`/organizations/${orgId}/students/${userId}/stats`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createOrganization(data: { name: string; type?: string }) {
|
||||
const response = await api.post<Organization>('/organizations', data);
|
||||
return response.data;
|
||||
@@ -525,6 +595,11 @@ export async function getClass(id: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getClassReport(id: number) {
|
||||
const response = await api.get<ClassReportResponse>(`/classes/${id}/report`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createClass(data: { name: string; organizationId: number }) {
|
||||
const response = await api.post<ClassSummary>('/classes', data);
|
||||
return response.data;
|
||||
|
||||
Reference in New Issue
Block a user