feat(school): assignment CRUD + submissions tracking UI — 9.3

This commit is contained in:
reloop
2026-04-12 15:57:34 +09:00
parent bed5b802d4
commit 8ebd419eee
10 changed files with 1642 additions and 17 deletions

View File

@@ -0,0 +1,604 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import styled, { css } from 'styled-components';
import DatePicker from '@/components/ui/DatePicker';
import { useToast } from '@/components/ui/Toast';
import { Icon } from '@/components/ui/Icon';
import { Button, Input, Label, Textarea } from '@/components/ui/primitives';
import {
type AssignmentDetail,
type ClassDetail,
type MeUser,
type SubmissionDetail,
api,
deleteAssignment,
getAssignment,
getClass,
updateAssignment,
} from '@/lib/api';
import {
calculateAssignmentPercent,
canManageSchool,
formatAssignmentStatus,
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';
export default function AssignmentDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const { showToast } = useToast();
const assignmentId = Number(params.id);
const [me, setMe] = useState<MeUser | null>(null);
const [assignment, setAssignment] = useState<AssignmentDetail | null>(null);
const [classRoom, setClassRoom] = useState<ClassDetail | null>(null);
const [editing, setEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState('');
const [draftDueDate, setDraftDueDate] = useState<string | null>(null);
const [draftDescription, setDraftDescription] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const [meResponse, assignmentResponse] = await Promise.all([
api.get<MeUser>('/auth/me').then((response) => response.data),
getAssignment(assignmentId),
]);
const classResponse = await getClass(assignmentResponse.class.id);
if (cancelled) return;
setMe(meResponse);
setAssignment(assignmentResponse);
setClassRoom(classResponse);
setDraftTitle(assignmentResponse.title);
setDraftDueDate(assignmentResponse.dueDate ?? null);
setDraftDescription(assignmentResponse.description ?? '');
} catch {
if (!cancelled) {
setError('과제 상세 정보를 불러오지 못했어요.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
if (!Number.isNaN(assignmentId)) {
void load();
}
return () => {
cancelled = true;
};
}, [assignmentId]);
const canManage = canManageSchool(me?.organizationRole);
const submissionMap = useMemo(
() => new Map((assignment?.submissions ?? []).map((submission) => [submission.userId, submission])),
[assignment],
);
const studentRows = useMemo(
() =>
(classRoom?.members ?? []).map((member) => {
const submission = submissionMap.get(member.userId) ?? null;
const percent = calculateAssignmentPercent(submission?.correctCount, submission?.totalProblems);
return {
member,
submission,
percent,
};
}),
[classRoom, submissionMap],
);
const summary = useMemo(() => {
const completed = studentRows.filter((row) => row.submission?.completedAt);
const average =
completed.length > 0
? Math.round(
completed.reduce((sum, row) => sum + (row.percent ?? row.submission?.score ?? 0), 0) / completed.length,
)
: null;
return {
completedCount: completed.length,
totalCount: studentRows.length,
average,
};
}, [studentRows]);
const handleSave = async () => {
if (!assignment || !draftTitle.trim()) return;
try {
setSaving(true);
await updateAssignment(assignment.id, {
title: draftTitle.trim(),
dueDate: draftDueDate ?? undefined,
description: draftDescription,
});
const refreshed = await getAssignment(assignment.id);
setAssignment(refreshed);
setDraftTitle(refreshed.title);
setDraftDueDate(refreshed.dueDate ?? null);
setDraftDescription(refreshed.description ?? '');
setEditing(false);
showToast({ message: '과제를 수정했어요.', variant: 'success' });
} catch {
showToast({ message: '과제 수정에 실패했어요.', variant: 'danger' });
} finally {
setSaving(false);
}
};
const handleCloseAssignment = async () => {
if (!assignment) return;
try {
setSaving(true);
await updateAssignment(assignment.id, { status: 'closed' });
setAssignment({ ...assignment, status: 'closed' });
showToast({ message: '과제를 마감했어요.', variant: 'success' });
} catch {
showToast({ message: '과제 마감에 실패했어요.', variant: 'danger' });
} finally {
setSaving(false);
}
};
const handleDeleteAssignment = async () => {
if (!assignment) return;
const confirmed = window.confirm('이 과제를 삭제할까요? 삭제 후에는 복구할 수 없습니다.');
if (!confirmed) return;
try {
setSaving(true);
await deleteAssignment(assignment.id);
showToast({ message: '과제를 삭제했어요.', variant: 'success' });
router.push('/school/assignments');
} catch {
showToast({ message: '과제 삭제에 실패했어요. 관리자 권한이 필요한 작업일 수 있습니다.', variant: 'danger' });
} finally {
setSaving(false);
}
};
if (loading) return <LoadingText> ...</LoadingText>;
if (error || !assignment || !classRoom) return <LoadingText>{error ?? '과제를 찾을 수 없어요.'}</LoadingText>;
if (!canManage) {
return (
<PageWrap>
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
</EmptyCard>
</PageWrap>
);
}
return (
<PageWrap>
<HeroCard>
<HeaderRow>
<HeroText>
<Badge>Assignment Detail</Badge>
<PageTitle>{assignment.title}</PageTitle>
<PageDescription>
{assignment.class.name} · {formatAssignmentStatus(assignment.status)}
</PageDescription>
</HeroText>
<HeaderActions>
<StatusBadge $status={assignment.status}>{formatAssignmentStatus(assignment.status)}</StatusBadge>
<Button type="button" $variant="secondary" onClick={() => setEditing((prev) => !prev)}>
</Button>
{assignment.status !== 'closed' ? (
<Button type="button" $variant="ghost" onClick={() => void handleCloseAssignment()} disabled={saving}>
</Button>
) : null}
<Button type="button" $variant="danger" onClick={() => void handleDeleteAssignment()} disabled={saving}>
</Button>
</HeaderActions>
</HeaderRow>
</HeroCard>
<SectionCard>
<SectionHeader>
<div>
<SectionTitle> </SectionTitle>
<SectionDescription>, , .</SectionDescription>
</div>
</SectionHeader>
<InfoGrid>
<InfoBlock>
<InfoLabel></InfoLabel>
<InfoValue as={Link} href="/exams">
{assignment.problemSet.title}
</InfoValue>
</InfoBlock>
<InfoBlock>
<InfoLabel></InfoLabel>
<InfoValue>{formatDate(assignment.dueDate)}</InfoValue>
</InfoBlock>
<InfoBlock $full>
<InfoLabel></InfoLabel>
<InfoText>{assignment.description ?? '안내 문구가 없습니다.'}</InfoText>
</InfoBlock>
</InfoGrid>
</SectionCard>
{editing ? (
<SectionCard>
<SectionHeader>
<div>
<SectionTitle> </SectionTitle>
<SectionDescription>, , .</SectionDescription>
</div>
</SectionHeader>
<EditGrid>
<Field>
<Label htmlFor="edit-title"> </Label>
<Input id="edit-title" value={draftTitle} onChange={(event) => setDraftTitle(event.target.value)} />
</Field>
<Field>
<Label htmlFor="edit-due-date"></Label>
<StyledDatePicker
id="edit-due-date"
value={draftDueDate}
onChange={(value) => setDraftDueDate(value)}
aria-label="마감일 수정"
/>
</Field>
<Field $full>
<Label htmlFor="edit-description"></Label>
<Textarea
id="edit-description"
value={draftDescription}
onChange={(event) => setDraftDescription(event.target.value)}
/>
</Field>
</EditGrid>
<EditActions>
<Button type="button" $variant="ghost" onClick={() => setEditing(false)}>
</Button>
<Button type="button" onClick={() => void handleSave()} disabled={saving || !draftTitle.trim()}>
{saving ? '저장 중...' : '저장'}
</Button>
</EditActions>
</SectionCard>
) : null}
<SectionCard>
<SectionHeader>
<div>
<SectionTitle> </SectionTitle>
<SectionDescription> .</SectionDescription>
</div>
</SectionHeader>
<SummaryBar>
<SummaryText>
{summary.completedCount}/{summary.totalCount} · {' '}
{summary.average !== null ? `${summary.average}` : '-'}
</SummaryText>
<SummaryTrack>
<SummaryFill
style={{
width: `${summary.totalCount > 0 ? (summary.completedCount / summary.totalCount) * 100 : 0}%`,
}}
/>
</SummaryTrack>
</SummaryBar>
<DesktopOnly>
<TableWrap>
<DataTable>
<thead>
<tr>
<th></th>
<th></th>
<th> </th>
<th></th>
</tr>
</thead>
<tbody>
{studentRows.map(({ member, submission, percent }) => (
<tr key={member.userId}>
<td>
<StudentCell>
<Avatar>{member.user.nickname.slice(0, 1)}</Avatar>
<div>
<StudentName>{member.user.nickname}</StudentName>
<StudentEmail>{member.user.email}</StudentEmail>
</div>
</StudentCell>
</td>
<td>
{submission?.completedAt ? (
<CompletionBadge $done>
<Icon name="check-circle" size={16} />
{percent !== null ? `${percent}%` : ''}
</CompletionBadge>
) : (
<CompletionBadge>
<Icon name="minus" size={16} />
</CompletionBadge>
)}
</td>
<td>{formatDateTime(submission?.completedAt)}</td>
<td>{formatScore(submission, percent)}</td>
</tr>
))}
</tbody>
</DataTable>
</TableWrap>
</DesktopOnly>
<MobileList>
{studentRows.map(({ member, submission, percent }) => (
<MobileCard key={member.userId}>
<StudentCell>
<Avatar>{member.user.nickname.slice(0, 1)}</Avatar>
<div>
<StudentName>{member.user.nickname}</StudentName>
<StudentEmail>{member.user.email}</StudentEmail>
</div>
</StudentCell>
{submission?.completedAt ? (
<CompletionBadge $done>
<Icon name="check-circle" size={16} />
{percent !== null ? `${percent}%` : ''}
</CompletionBadge>
) : (
<CompletionBadge>
<Icon name="minus" size={16} />
</CompletionBadge>
)}
<InfoText> {formatDateTime(submission?.completedAt)}</InfoText>
<InfoText>{formatScore(submission, percent)}</InfoText>
</MobileCard>
))}
</MobileList>
</SectionCard>
</PageWrap>
);
}
function formatScore(submission: SubmissionDetail | null, percent: number | null) {
if (!submission?.completedAt) return '-';
if (submission.correctCount !== null && submission.totalProblems !== null) {
return `${submission.correctCount}/${submission.totalProblems} 맞음 (${percent ?? submission.score ?? 0}%)`;
}
if (typeof submission.score === 'number') {
return `${submission.score}%`;
}
return '완료';
}
const LoadingText = styled.div`
padding: 48px 0;
text-align: center;
color: ${theme.color.textSub};
`;
const HeaderActions = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.space.sm};
@media (max-width: ${theme.breakpoint.mobile}) {
width: 100%;
}
`;
const StatusBadge = styled.span<{ $status: string }>`
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 14px;
border-radius: ${theme.radius.pill};
font-size: 13px;
font-weight: 800;
${({ $status }) =>
$status === 'active'
? css`
color: #c7d2fe;
background: rgba(79, 70, 229, 0.18);
border: 1px solid rgba(129, 140, 248, 0.4);
`
: $status === 'draft'
? css`
color: #fde68a;
background: rgba(245, 158, 11, 0.16);
border: 1px solid rgba(245, 158, 11, 0.34);
`
: css`
color: ${theme.color.textSub};
background: rgba(255, 255, 255, 0.05);
border: 1px solid ${theme.color.border};
`}
`;
const InfoGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.tablet}) {
grid-template-columns: 1fr;
}
`;
const InfoBlock = styled.div<{ $full?: boolean }>`
display: flex;
flex-direction: column;
gap: 8px;
padding: ${theme.space.md};
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.border};
background: rgba(255, 255, 255, 0.03);
${({ $full }) => $full && 'grid-column: 1 / -1;'}
`;
const InfoLabel = styled.span`
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: ${theme.color.textMute};
`;
const InfoValue = styled.span`
color: ${theme.color.textBright};
font-size: 16px;
font-weight: 700;
`;
const InfoText = styled.p`
margin: 0;
color: ${theme.color.textSub};
line-height: 1.6;
`;
const EditGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.tablet}) {
grid-template-columns: 1fr;
}
`;
const Field = styled.div<{ $full?: boolean }>`
${({ $full }) => $full && 'grid-column: 1 / -1;'}
`;
const StyledDatePicker = styled(DatePicker)`
width: 100%;
`;
const EditActions = styled.div`
display: flex;
justify-content: flex-end;
gap: ${theme.space.sm};
`;
const SummaryBar = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
padding: ${theme.space.md};
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.border};
background: rgba(255, 255, 255, 0.03);
`;
const SummaryText = styled.strong`
color: ${theme.color.textBright};
font-size: 15px;
`;
const SummaryTrack = styled.div`
height: 10px;
overflow: hidden;
border-radius: ${theme.radius.pill};
background: rgba(255, 255, 255, 0.08);
`;
const SummaryFill = styled.div`
height: 100%;
border-radius: inherit;
background: ${theme.color.brandGradient};
`;
const StudentCell = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;
const Avatar = styled.div`
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(99, 102, 241, 0.18);
color: ${theme.color.textBright};
font-weight: 800;
`;
const StudentName = styled.div`
color: ${theme.color.textBright};
font-weight: 700;
`;
const StudentEmail = styled.div`
color: ${theme.color.textSub};
font-size: 12px;
`;
const CompletionBadge = styled.span<{ $done?: boolean }>`
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 12px;
border-radius: ${theme.radius.pill};
color: ${({ $done }) => ($done ? '#86efac' : theme.color.textSub)};
background: ${({ $done }) => ($done ? 'rgba(34, 197, 94, 0.14)' : 'rgba(255, 255, 255, 0.05)')};
border: 1px solid ${({ $done }) => ($done ? 'rgba(34, 197, 94, 0.28)' : theme.color.border)};
font-size: 13px;
font-weight: 700;
`;

View File

@@ -0,0 +1,266 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import styled from 'styled-components';
import DatePicker from '@/components/ui/DatePicker';
import Select from '@/components/ui/Select';
import { useToast } from '@/components/ui/Toast';
import { Button, Input, Label, Textarea } from '@/components/ui/primitives';
import {
type ClassSummary,
type MeUser,
type ProblemSetSummary,
api,
createAssignment,
getMyClasses,
getMyOrganizations,
getProblemSets,
} from '@/lib/api';
import { canManageSchool } from '@/lib/school';
import {
Badge,
EmptyCard,
EmptyText,
EmptyTitle,
HeaderRow,
HeroCard,
HeroText,
PageDescription,
PageTitle,
PageWrap,
SectionCard,
} from '@/components/school/SchoolUI';
import { theme } from '@/styles/theme';
export default function NewAssignmentPage() {
const router = useRouter();
const { showToast } = useToast();
const [me, setMe] = useState<MeUser | null>(null);
const [classes, setClasses] = useState<ClassSummary[]>([]);
const [problemSets, setProblemSets] = useState<ProblemSetSummary[]>([]);
const [title, setTitle] = useState('');
const [classId, setClassId] = useState<number | null>(null);
const [problemSetId, setProblemSetId] = useState<number | null>(null);
const [dueDate, setDueDate] = useState<string | null>(null);
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const [meResponse, orgs, fetchedProblemSets] = await Promise.all([
api.get<MeUser>('/auth/me').then((response) => response.data),
getMyOrganizations(),
getProblemSets(),
]);
const manageableOrgs = orgs.filter((org) => canManageSchool(org.myRole));
const classGroups = await Promise.all(manageableOrgs.map((org) => getMyClasses(org.id)));
const teacherClasses = classGroups.flat();
if (cancelled) return;
setMe(meResponse);
setClasses(teacherClasses);
setProblemSets(
[...fetchedProblemSets].sort((a, b) => {
if (b.year !== a.year) return b.year - a.year;
return a.title.localeCompare(b.title, 'ko');
}),
);
setClassId(teacherClasses[0]?.id ?? null);
setProblemSetId(fetchedProblemSets[0]?.id ?? null);
} catch {
if (!cancelled) {
setError('과제 생성 화면을 준비하지 못했어요.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, []);
const canSubmit = title.trim().length > 0 && classId !== null && problemSetId !== null && !submitting;
const problemSetOptions = useMemo(
() =>
problemSets.map((problemSet) => ({
label: `${problemSet.title} · ${problemSet.year} · ${problemSet.subjectName}`,
value: problemSet.id,
})),
[problemSets],
);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!canSubmit || classId === null || problemSetId === null) return;
try {
setSubmitting(true);
const created = await createAssignment({
title: title.trim(),
classId,
problemSetId,
dueDate: dueDate ?? undefined,
description: description.trim() || undefined,
});
showToast({ message: '과제를 생성했어요.', variant: 'success' });
router.push(`/school/assignments/${created.id}`);
} 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>New Assignment</Badge>
<PageTitle> </PageTitle>
<PageDescription> .</PageDescription>
</HeroText>
</HeaderRow>
</HeroCard>
<SectionCard as="form" onSubmit={handleSubmit}>
<FieldGrid>
<Field>
<Label htmlFor="assignment-title"> </Label>
<Input
id="assignment-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="예: 6월 전국연합 영어 과제"
required
/>
</Field>
<Field>
<Label htmlFor="assignment-class"></Label>
<ThemedSelect
id="assignment-class"
value={classId}
onChange={(value) => setClassId(value as number)}
options={classes.map((classItem) => ({ label: classItem.name, value: classItem.id }))}
placeholder="학급 선택"
aria-label="학급 선택"
/>
</Field>
<Field>
<Label htmlFor="assignment-problem-set"></Label>
<ThemedSelect
id="assignment-problem-set"
value={problemSetId}
onChange={(value) => setProblemSetId(value as number)}
options={problemSetOptions}
placeholder="문제집 선택"
aria-label="문제집 선택"
/>
</Field>
<Field>
<Label htmlFor="assignment-due-date"></Label>
<ThemedDatePicker
id="assignment-due-date"
value={dueDate}
onChange={(value) => setDueDate(value)}
placeholder="마감일 선택"
aria-label="마감일 선택"
/>
</Field>
<Field $full>
<Label htmlFor="assignment-description"></Label>
<Textarea
id="assignment-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="학생에게 보여줄 안내 문구를 입력하세요."
/>
</Field>
</FieldGrid>
<ActionRow>
<Button type="button" $variant="ghost" onClick={() => router.push('/school/assignments')}>
</Button>
<Button type="submit" disabled={!canSubmit}>
{submitting ? '생성 중...' : '과제 생성'}
</Button>
</ActionRow>
</SectionCard>
</PageWrap>
);
}
const LoadingText = styled.div`
padding: 48px 0;
text-align: center;
color: ${theme.color.textSub};
`;
const FieldGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.tablet}) {
grid-template-columns: 1fr;
}
`;
const Field = styled.div<{ $full?: boolean }>`
${({ $full }) => $full && 'grid-column: 1 / -1;'}
`;
const ThemedSelect = styled(Select<number>)`
width: 100%;
`;
const ThemedDatePicker = styled(DatePicker)`
width: 100%;
`;
const ActionRow = styled.div`
display: flex;
justify-content: flex-end;
gap: ${theme.space.sm};
@media (max-width: ${theme.breakpoint.mobile}) {
flex-direction: column-reverse;
}
`;

View File

@@ -0,0 +1,424 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import styled, { css } from 'styled-components';
import Select from '@/components/ui/Select';
import { Icon } from '@/components/ui/Icon';
import { Button } from '@/components/ui/primitives';
import {
type AssignmentSummary,
type ClassSummary,
type MeUser,
api,
getAssignments,
getMyClasses,
getMyOrganizations,
} from '@/lib/api';
import { canManageSchool, formatAssignmentStatus, 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 StatusFilter = 'all' | 'active' | 'closed' | 'draft';
const STATUS_FILTERS: Array<{ value: StatusFilter; label: string }> = [
{ value: 'all', label: '전체' },
{ value: 'active', label: '진행 중' },
{ value: 'closed', label: '마감' },
{ value: 'draft', label: '임시' },
];
export default function AssignmentListPage() {
const [me, setMe] = useState<MeUser | null>(null);
const [classes, setClasses] = useState<ClassSummary[]>([]);
const [assignments, setAssignments] = useState<AssignmentSummary[]>([]);
const [selectedClassId, setSelectedClassId] = useState<number | 'all'>('all');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
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(),
]);
const manageableOrgs = orgs.filter((org) => canManageSchool(org.myRole));
const classGroups = await Promise.all(manageableOrgs.map((org) => getMyClasses(org.id)));
const teacherClasses = classGroups.flat();
const classIds = new Set(teacherClasses.map((item) => item.id));
const assignmentList = (await getAssignments()).filter((assignment) => classIds.has(assignment.classId));
if (cancelled) return;
setMe(meResponse);
setClasses(teacherClasses);
setAssignments(assignmentList);
} catch {
if (!cancelled) {
setError('과제 목록을 불러오지 못했어요.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, []);
const classMap = useMemo(
() => new Map(classes.map((classItem) => [classItem.id, classItem])),
[classes],
);
const filteredAssignments = useMemo(
() =>
[...assignments]
.filter((assignment) => {
if (selectedClassId !== 'all' && assignment.classId !== selectedClassId) {
return false;
}
if (statusFilter !== 'all' && assignment.status !== statusFilter) {
return false;
}
return true;
})
.sort((a, b) => {
const left = new Date(a.dueDate ?? a.createdAt ?? 0).getTime();
const right = new Date(b.dueDate ?? b.createdAt ?? 0).getTime();
return left - right;
}),
[assignments, selectedClassId, statusFilter],
);
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>Assignments</Badge>
<PageTitle> </PageTitle>
<PageDescription> .</PageDescription>
</HeroText>
<Button as={Link} href="/school/assignments/new">
<Icon name="plus" size={16} weight="bold" />
</Button>
</HeaderRow>
</HeroCard>
<SectionCard>
<SectionHeader>
<div>
<SectionTitle></SectionTitle>
<SectionDescription> .</SectionDescription>
</div>
</SectionHeader>
<FilterRow>
<FilterField>
<FilterLabel></FilterLabel>
<ClassSelect
value={selectedClassId}
onChange={(value) => setSelectedClassId(value as number | 'all')}
options={[
{ label: '전체 학급', value: 'all' as const },
...classes.map((classItem) => ({ label: classItem.name, value: classItem.id })),
]}
aria-label="학급 필터"
/>
</FilterField>
<StatusPills>
{STATUS_FILTERS.map((filter) => (
<StatusPill
key={filter.value}
type="button"
$active={statusFilter === filter.value}
onClick={() => setStatusFilter(filter.value)}
>
{filter.label}
</StatusPill>
))}
</StatusPills>
</FilterRow>
</SectionCard>
<SectionCard>
<SectionHeader>
<div>
<SectionTitle> </SectionTitle>
<SectionDescription>{filteredAssignments.length} .</SectionDescription>
</div>
</SectionHeader>
{filteredAssignments.length === 0 ? (
<EmptyCard>
<EmptyTitle> </EmptyTitle>
<EmptyText> .</EmptyText>
</EmptyCard>
) : (
<AssignmentList>
{filteredAssignments.map((assignment) => {
const classRoom = classMap.get(assignment.classId);
const memberCount = classRoom?._count?.members ?? 0;
const completedCount = assignment._count?.submissions ?? 0;
const progress = memberCount > 0 ? Math.round((completedCount / memberCount) * 100) : 0;
return (
<AssignmentCard key={assignment.id} href={`/school/assignments/${assignment.id}`}>
<AssignmentTop>
<div>
<AssignmentTitle>{assignment.title}</AssignmentTitle>
<AssignmentMeta>
<span>{classRoom?.name ?? assignment.class?.name ?? '학급 정보 없음'}</span>
<span>{assignment.problemSet?.title ?? '문제집 정보 없음'}</span>
<span> {formatDate(assignment.dueDate)}</span>
</AssignmentMeta>
</div>
<StatusBadge $status={assignment.status}>{formatAssignmentStatus(assignment.status)}</StatusBadge>
</AssignmentTop>
<ProgressArea>
<ProgressLabel>
<span> </span>
<strong>
{completedCount}/{memberCount}
</strong>
</ProgressLabel>
<ProgressTrack>
<ProgressFill style={{ width: `${progress}%` }} />
</ProgressTrack>
</ProgressArea>
</AssignmentCard>
);
})}
</AssignmentList>
)}
</SectionCard>
</PageWrap>
);
}
const LoadingText = styled.div`
padding: 48px 0;
text-align: center;
color: ${theme.color.textSub};
`;
const FilterRow = styled.div`
display: flex;
align-items: flex-end;
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.tablet}) {
flex-direction: column;
align-items: stretch;
}
`;
const FilterField = styled.div`
min-width: 260px;
display: flex;
flex-direction: column;
gap: 8px;
`;
const FilterLabel = styled.span`
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: ${theme.color.textMute};
`;
const ClassSelect = styled(Select<number | 'all'>)`
width: 100%;
`;
const StatusPills = styled.div`
display: flex;
flex-wrap: wrap;
gap: 10px;
`;
const StatusPill = styled.button<{ $active: boolean }>`
min-height: 42px;
padding: 0 16px;
border-radius: ${theme.radius.pill};
border: 1px solid ${theme.color.border};
background: rgba(255, 255, 255, 0.03);
color: ${theme.color.textSub};
font-weight: 700;
transition:
border-color 0.18s ease,
background 0.18s ease,
color 0.18s ease;
${({ $active }) =>
$active &&
css`
border-color: ${theme.color.accent};
background: rgba(99, 102, 241, 0.18);
color: ${theme.color.textBright};
`}
`;
const AssignmentList = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.md};
`;
const AssignmentCard = styled(Link)`
display: flex;
flex-direction: column;
gap: ${theme.space.md};
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)),
${theme.color.surface};
transition:
transform 0.18s ease,
border-color 0.18s ease;
&:hover {
transform: translateY(-2px);
border-color: ${theme.color.accent};
}
`;
const AssignmentTop = 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;
}
`;
const AssignmentTitle = styled.h3`
margin: 0;
font-size: 18px;
color: ${theme.color.textBright};
`;
const AssignmentMeta = styled.div`
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
margin-top: 8px;
color: ${theme.color.textSub};
font-size: 13px;
`;
const StatusBadge = styled.span<{ $status: string }>`
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 12px;
border-radius: ${theme.radius.pill};
font-size: 12px;
font-weight: 800;
${({ $status }) =>
$status === 'active'
? css`
color: #c7d2fe;
background: rgba(79, 70, 229, 0.18);
border: 1px solid rgba(129, 140, 248, 0.4);
`
: $status === 'draft'
? css`
color: #fde68a;
background: rgba(245, 158, 11, 0.16);
border: 1px solid rgba(245, 158, 11, 0.34);
`
: css`
color: ${theme.color.textSub};
background: rgba(255, 255, 255, 0.05);
border: 1px solid ${theme.color.border};
`}
`;
const ProgressArea = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
`;
const ProgressLabel = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: ${theme.space.md};
color: ${theme.color.textSub};
font-size: 13px;
strong {
color: ${theme.color.textBright};
font-size: 14px;
}
`;
const ProgressTrack = styled.div`
width: 100%;
height: 10px;
overflow: hidden;
border-radius: ${theme.radius.pill};
background: rgba(255, 255, 255, 0.08);
`;
const ProgressFill = styled.div`
height: 100%;
border-radius: inherit;
background: ${theme.color.brandGradient};
`;

View File

@@ -169,10 +169,13 @@ export default function ClassDetailPage() {
</PageDescription>
</HeroText>
<ActionRow>
<Button as={Link} href="/school/assignments/new" $variant="secondary">
</Button>
<Button
as={Link}
href={`/school/invite?orgId=${classRoom.organizationId}`}
$variant="secondary"
$variant="ghost"
>
</Button>
@@ -267,7 +270,7 @@ export default function ClassDetailPage() {
{assignments.map((assignment) => {
const submissionCount = submissionsByAssignmentId[assignment.id]?.length ?? 0;
return (
<AssignmentItem key={assignment.id}>
<AssignmentItem key={assignment.id} as={Link} href={`/school/assignments/${assignment.id}`}>
<AssignmentHeader>
<div>
<AssignmentTitle>{assignment.title}</AssignmentTitle>

View File

@@ -13,7 +13,7 @@ import {
getAssignments,
getClass,
} from '@/lib/api';
import { formatDate } from '@/lib/school';
import { calculateAssignmentPercent, formatAssignmentCountdown, formatDate } from '@/lib/school';
import {
Badge,
EmptyCard,
@@ -92,6 +92,23 @@ export default function MyClassPage() {
[assignments],
);
const sortedAssignments = useMemo(
() =>
[...assignments].sort((a, b) => {
const aCompleted = Boolean(a.mySubmission?.completedAt);
const bCompleted = Boolean(b.mySubmission?.completedAt);
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
const left = a.dueDate ? new Date(a.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
const right = b.dueDate ? new Date(b.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
return left - right;
}),
[assignments],
);
if (loading) return <LoadingText> ...</LoadingText>;
if (error || !classRoom) return <LoadingText>{error ?? '학급을 찾을 수 없어요.'}</LoadingText>;
@@ -124,13 +141,22 @@ export default function MyClassPage() {
</EmptyCard>
) : (
<AssignmentList>
{assignments.map((assignment) => {
{sortedAssignments.map((assignment) => {
const completed = Boolean(assignment.mySubmission?.completedAt);
const score = assignment.mySubmission?.score;
const percent = calculateAssignmentPercent(
assignment.mySubmission?.correctCount,
assignment.mySubmission?.totalProblems,
);
return (
<AssignmentItem key={assignment.id}>
<AssignmentCopy>
<AssignmentTitle>{assignment.title}</AssignmentTitle>
<AssignmentHeader>
<AssignmentTitle>{assignment.title}</AssignmentTitle>
<CountdownBadge $closed={formatAssignmentCountdown(assignment.dueDate) === '마감됨'}>
{formatAssignmentCountdown(assignment.dueDate)}
</CountdownBadge>
</AssignmentHeader>
<AssignmentMeta>
<span>{assignment.problemSet?.title ?? '문제집 정보 없음'}</span>
<span> {formatDate(assignment.dueDate)}</span>
@@ -142,6 +168,17 @@ export default function MyClassPage() {
: '완료'
: '미완료'}
</span>
{completed ? (
<span>
{' '}
{assignment.mySubmission?.correctCount !== null &&
assignment.mySubmission?.correctCount !== undefined &&
assignment.mySubmission?.totalProblems !== null &&
assignment.mySubmission?.totalProblems !== undefined
? `${assignment.mySubmission.correctCount}/${assignment.mySubmission.totalProblems} (${percent ?? score ?? 0}%)`
: `${Math.round(score ?? 0)}`}
</span>
) : null}
</AssignmentMeta>
</AssignmentCopy>
@@ -151,9 +188,9 @@ export default function MyClassPage() {
</Button>
) : (
<Button as={Link} href={`/study/exam/${assignment.problemSetId}`}>
<SolveButton as={Link} href={`/study/exam/${assignment.problemSetId}`} $size="lg">
</Button>
</SolveButton>
)}
</ActionArea>
</AssignmentItem>
@@ -200,6 +237,17 @@ const AssignmentCopy = styled.div`
gap: 10px;
`;
const AssignmentHeader = styled.div`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
@media (max-width: ${theme.breakpoint.mobile}) {
flex-direction: column;
}
`;
const AssignmentTitle = styled.h3`
margin: 0;
font-size: 16px;
@@ -214,7 +262,26 @@ const AssignmentMeta = styled.div`
font-size: 13px;
`;
const CountdownBadge = styled.span<{ $closed: boolean }>`
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 12px;
border-radius: ${theme.radius.pill};
color: ${({ $closed }) => ($closed ? theme.color.textSub : '#c7d2fe')};
background: ${({ $closed }) => ($closed ? 'rgba(255, 255, 255, 0.06)' : 'rgba(79, 70, 229, 0.16)')};
border: 1px solid ${({ $closed }) => ($closed ? theme.color.border : 'rgba(129, 140, 248, 0.34)')};
font-size: 12px;
font-weight: 800;
`;
const ActionArea = styled.div`
display: flex;
align-items: center;
`;
const SolveButton = styled(Button)`
background: ${theme.color.brandGradient};
border: 0;
box-shadow: ${theme.shadow.glowIndigo};
`;

View File

@@ -238,6 +238,9 @@ export default function SchoolPage() {
))}
</OrgSelect>
) : null}
<Button as={Link} href="/school/assignments" $variant="secondary">
</Button>
<Button as={Link} href="/school/classes">
<Icon name="arrow-right" size={16} weight="bold" />

View File

@@ -17,12 +17,16 @@ import { ConfirmDialog } from '@/components/ui/Modal';
import Select from '@/components/ui/Select';
import { useToast } from '@/components/ui/Toast';
import {
api,
getAssignments,
getProblemSet,
resolveUploadUrl,
submitProblemSetStudy,
type MeUser,
type ProblemSetDetail,
} from '@/lib/api';
import { hasToken } from '@/lib/auth';
import { canManageSchool } from '@/lib/school';
import { theme } from '@/styles/theme';
import {
formatClock,
@@ -62,6 +66,7 @@ export default function ExamPage() {
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
const [audioPlaying, setAudioPlaying] = useState(false);
const [assignmentNotice, setAssignmentNotice] = useState<{ id: number; className: string } | null>(null);
const examStartedAtRef = useRef<number>(0);
const problemEnteredAtRef = useRef<number>(0);
@@ -154,6 +159,47 @@ export default function ExamPage() {
void loadProblemSet();
}, [problemSetId, router, searchParams]);
useEffect(() => {
let cancelled = false;
async function loadAssignmentNotice() {
try {
const meResponse = await api.get<MeUser>('/auth/me').then((response) => response.data);
if (canManageSchool(meResponse.organizationRole)) {
if (!cancelled) setAssignmentNotice(null);
return;
}
const matched = (await getAssignments({ status: 'active' })).find(
(assignment) => assignment.problemSetId === problemSetId,
);
if (!cancelled) {
setAssignmentNotice(
matched
? {
id: matched.id,
className: matched.class?.name ?? '내 학급',
}
: null,
);
}
} catch {
if (!cancelled) {
setAssignmentNotice(null);
}
}
}
if (Number.isFinite(problemSetId) && hasToken()) {
void loadAssignmentNotice();
}
return () => {
cancelled = true;
};
}, [problemSetId]);
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -565,6 +611,18 @@ export default function ExamPage() {
</TopBarActions>
</TopBar>
{assignmentNotice ? (
<AssignmentNoticeCard>
<AssignmentNoticeBadge>
<Icon name="check-square" size={16} />
</AssignmentNoticeBadge>
<AssignmentNoticeText>
{assignmentNotice.className} . .
</AssignmentNoticeText>
</AssignmentNoticeCard>
) : null}
{hasAudioTracks ? (
<AudioBarSection>
<AudioPlayerCard>
@@ -1618,6 +1676,41 @@ const StateCard = styled.div`
color: ${theme.color.textBright};
`;
const AssignmentNoticeCard = styled.div`
margin: 0 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(129, 140, 248, 0.34);
background: rgba(79, 70, 229, 0.12);
@media (max-width: ${theme.breakpoint.tablet}) {
margin: 0 12px;
}
`;
const AssignmentNoticeBadge = styled.span`
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 0 12px;
border-radius: ${theme.radius.pill};
color: #c7d2fe;
background: rgba(79, 70, 229, 0.18);
font-size: 12px;
font-weight: 800;
`;
const AssignmentNoticeText = styled.p`
margin: 0;
color: ${theme.color.textBright};
line-height: 1.6;
`;
const RetryButton = styled.button`
min-height: 46px;
padding: 0 16px;

View File

@@ -6,8 +6,9 @@ import styled, { css } from 'styled-components';
import { OmrBubbleChoices, ProblemImageStage } from '@/components/exam/ProblemVisual';
import { Icon } from '@/components/ui/Icon';
import { readExamResult } from '@/components/exam/shared';
import { resolveUploadUrl } from '@/lib/api';
import { api, getAssignments, resolveUploadUrl, type MeUser } from '@/lib/api';
import { hasToken } from '@/lib/auth';
import { canManageSchool } from '@/lib/school';
import { theme } from '@/styles/theme';
export default function ExamResultPage() {
@@ -16,6 +17,7 @@ export default function ExamResultPage() {
const problemSetId = Number(params.problemSetId);
const [result, setResult] = useState<ReturnType<typeof readExamResult>>(null);
const [checked, setChecked] = useState(false);
const [assignmentNotice, setAssignmentNotice] = useState<{ className: string } | null>(null);
useEffect(() => {
if (!hasToken()) {
@@ -27,6 +29,40 @@ export default function ExamResultPage() {
setChecked(true);
}, [problemSetId, router]);
useEffect(() => {
let cancelled = false;
async function loadAssignmentNotice() {
try {
const meResponse = await api.get<MeUser>('/auth/me').then((response) => response.data);
if (canManageSchool(meResponse.organizationRole)) {
if (!cancelled) setAssignmentNotice(null);
return;
}
const matched = (await getAssignments({ status: 'active' })).find(
(assignment) => assignment.problemSetId === problemSetId,
);
if (!cancelled) {
setAssignmentNotice(matched ? { className: matched.class?.name ?? '내 학급' } : null);
}
} catch {
if (!cancelled) {
setAssignmentNotice(null);
}
}
}
if (Number.isFinite(problemSetId) && hasToken()) {
void loadAssignmentNotice();
}
return () => {
cancelled = true;
};
}, [problemSetId]);
const sortedResults = useMemo(
() => [...(result?.summary.results ?? [])].sort((a, b) => a.number - b.number),
[result],
@@ -72,6 +108,18 @@ export default function ExamResultPage() {
.
</HeroSub>
{assignmentNotice ? (
<AssignmentNotice>
<AssignmentNoticeBadge>
<Icon name="check-square" size={16} />
</AssignmentNoticeBadge>
<AssignmentNoticeText>
{assignmentNotice.className} . .
</AssignmentNoticeText>
</AssignmentNotice>
) : null}
<ChipRow>
<Chip $tone="correct"> {result.summary.correct}</Chip>
<Chip $tone="incorrect"> {result.summary.incorrect}</Chip>
@@ -204,6 +252,37 @@ const HeroSub = styled.p`
line-height: 1.7;
`;
const AssignmentNotice = styled.div`
margin-top: 18px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(129, 140, 248, 0.34);
background: rgba(79, 70, 229, 0.12);
`;
const AssignmentNoticeBadge = styled.span`
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 0 12px;
border-radius: ${theme.radius.pill};
color: #c7d2fe;
background: rgba(79, 70, 229, 0.18);
font-size: 12px;
font-weight: 800;
`;
const AssignmentNoticeText = styled.p`
margin: 0;
color: ${theme.color.textBright};
line-height: 1.6;
`;
const ChipRow = styled.div`
display: flex;
flex-wrap: wrap;

View File

@@ -134,21 +134,43 @@ export interface ClassDetail extends ClassSummary {
export interface AssignmentSubmission {
id: number;
userId: number;
assignmentId: number;
assignmentId?: number;
score: number | null;
totalProblems: number | null;
correctCount: number | null;
completedAt: string | null;
createdAt: string;
createdAt?: string;
studyLogIds?: number[] | null;
user?: {
id: number;
id?: number;
nickname: string;
email: string;
avatarUrl?: string | null;
};
}
export interface SubmissionDetail {
id: number;
userId: number;
user: { nickname: string; email: string; avatarUrl?: string | null };
score: number | null;
totalProblems: number | null;
correctCount: number | null;
completedAt: string | null;
}
export interface AssignmentDetail extends AssignmentSummary {
description: string | null;
class: {
id: number;
name: string;
organizationId: number;
teacher?: { id: number; nickname: string; email?: string };
};
problemSet: ProblemSetSummary;
submissions: SubmissionDetail[];
}
interface OrganizationMembershipResponse {
id: number;
role: OrganizationRole;
@@ -524,15 +546,35 @@ export async function getAssignments(params?: { classId?: number; status?: strin
}
export async function getAssignment(id: number) {
const response = await api.get<
AssignmentSummary & {
submissions?: AssignmentSubmission[];
}
>(`/assignments/${id}`);
const response = await api.get<AssignmentDetail>(`/assignments/${id}`);
return response.data;
}
export async function createAssignment(data: {
title: string;
classId: number;
problemSetId: number;
dueDate?: string;
description?: string;
}) {
const response = await api.post<{ id: number }>('/assignments', data);
return response.data;
}
export async function updateAssignment(
id: number,
data: Partial<{ title: string; status: string; dueDate: string; description: string }>,
) {
const response = await api.patch(`/assignments/${id}`, data);
return response.data;
}
export async function deleteAssignment(id: number) {
const response = await api.delete(`/assignments/${id}`);
return response.data;
}
export async function getAssignmentSubmissions(id: number) {
const response = await api.get<AssignmentSubmission[]>(`/assignments/${id}/submissions`);
const response = await api.get<SubmissionDetail[]>(`/assignments/${id}/submissions`);
return response.data;
}

View File

@@ -48,6 +48,50 @@ export function formatDateTime(date?: string | null) {
}).format(new Date(date));
}
export function formatAssignmentStatus(status: string) {
switch (status) {
case 'active':
return '진행 중';
case 'closed':
return '마감';
case 'draft':
return '임시';
default:
return status;
}
}
export function assignmentStatusTone(status: string): 'default' | 'success' | 'warning' | 'danger' {
switch (status) {
case 'active':
return 'default';
case 'closed':
return 'success';
case 'draft':
return 'warning';
default:
return 'default';
}
}
export function calculateAssignmentPercent(correctCount?: number | null, totalProblems?: number | null) {
if (!totalProblems || totalProblems <= 0 || correctCount === null || correctCount === undefined) {
return null;
}
return Math.round((correctCount / totalProblems) * 100);
}
export function formatAssignmentCountdown(dueDate?: string | null, now = new Date()) {
if (!dueDate) return '마감일 없음';
const due = new Date(dueDate);
const diffMs = due.getTime() - now.getTime();
if (diffMs < 0) return '마감됨';
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'D-Day';
return `D-${diffDays}`;
}
export function pendingAssignmentsForClass(
assignments: AssignmentSummary[],
submissionsByAssignmentId: Map<number, AssignmentSubmission | null>,