feat(school): assignment CRUD + submissions tracking UI — 9.3
This commit is contained in:
604
frontend/src/app/school/assignments/[id]/page.tsx
Normal file
604
frontend/src/app/school/assignments/[id]/page.tsx
Normal 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;
|
||||
`;
|
||||
266
frontend/src/app/school/assignments/new/page.tsx
Normal file
266
frontend/src/app/school/assignments/new/page.tsx
Normal 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;
|
||||
}
|
||||
`;
|
||||
424
frontend/src/app/school/assignments/page.tsx
Normal file
424
frontend/src/app/school/assignments/page.tsx
Normal 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};
|
||||
`;
|
||||
@@ -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>
|
||||
|
||||
@@ -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};
|
||||
`;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user