feat(school): student progress + class reports — 9.4

This commit is contained in:
reloop
2026-04-12 16:07:32 +09:00
parent 8ebd419eee
commit f9c8b9ab2c
10 changed files with 1537 additions and 3 deletions

View File

@@ -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,

View File

@@ -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 },

View File

@@ -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,

View File

@@ -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,

View File

@@ -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};
}
`;

View File

@@ -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>
);
}

View 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};
`;

View 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;
`;

View File

@@ -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;

View File

@@ -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;