feat: 학교 관리 4대 기능 — 개인과제, 복습과제, 풀이상세, 선생님 노트

Backend:
- Assignment에 type(new/review), targetUserIds, teacherNote 필드 추가
- 학생 목록에서 targetUserIds 기반 개인 과제 필터링
- GET /assignments/:id/submissions/:userId/details 엔드포인트 (문제별 시간/필기/정오)

Frontend:
- 과제 생성: 유형 토글(새문제/복습) + 개별 학생 지정 + 선생님 해설 노트
- 과제 상세: 학생 클릭 → 풀이 상세 모달 (문제별 시간, 정오, 필기 이미지)
- 학생 뷰: 완료된 과제에 선생님 해설 노트 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-19 18:25:36 +09:00
parent b27d9a4240
commit b27417a10e
8 changed files with 700 additions and 15 deletions

View File

@@ -0,0 +1,6 @@
-- AlterTable: Add type, targetUserIds, teacherNoteUrl, teacherNoteText to assignments
ALTER TABLE `assignments`
ADD COLUMN `type` ENUM('newProblem', 'review') NOT NULL DEFAULT 'newProblem',
ADD COLUMN `targetUserIds` JSON NULL,
ADD COLUMN `teacherNoteUrl` VARCHAR(191) NULL,
ADD COLUMN `teacherNoteText` LONGTEXT NULL;

View File

@@ -73,6 +73,11 @@ enum AssignmentStatus {
draft
}
enum AssignmentType {
newProblem
review
}
// ─── Models ───────────────────────────────────────────────────────
model User {
@@ -286,16 +291,20 @@ model ClassMember {
}
model Assignment {
id Int @id @default(autoincrement())
title String
description String? @db.Text
classId Int
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
problemSetId Int
problemSet ProblemSet @relation(fields: [problemSetId], references: [id])
status AssignmentStatus @default(active)
dueDate DateTime?
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
title String
description String? @db.Text
classId Int
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
problemSetId Int
problemSet ProblemSet @relation(fields: [problemSetId], references: [id])
status AssignmentStatus @default(active)
type AssignmentType @default(newProblem)
targetUserIds Json? /// null = 전체 학급, [1,2,3] = 특정 학생만
teacherNoteUrl String? /// 선생님 풀이/필기 이미지 URL
teacherNoteText String? @db.Text /// 선생님 텍스트 해설
dueDate DateTime?
createdAt DateTime @default(now())
submissions AssignmentSubmission[]

View File

@@ -23,7 +23,7 @@ import {
Min,
MinLength,
} from "class-validator";
import { AssignmentStatus } from "@prisma/client";
import { AssignmentStatus, AssignmentType } from "@prisma/client";
import { CurrentUser } from "../auth/current-user.decorator";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
import { AuthUser } from "../auth/jwt.strategy";
@@ -51,6 +51,24 @@ class CreateAssignmentDto {
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(AssignmentType)
type?: AssignmentType;
@IsOptional()
@IsArray()
@Type(() => Number)
@IsInt({ each: true })
targetUserIds?: number[];
@IsOptional()
@IsString()
teacherNoteUrl?: string;
@IsOptional()
@IsString()
teacherNoteText?: string;
}
class ListAssignmentsQuery {
@@ -83,6 +101,24 @@ class UpdateAssignmentDto {
@IsOptional()
@IsEnum(AssignmentStatus)
status?: AssignmentStatus;
@IsOptional()
@IsEnum(AssignmentType)
type?: AssignmentType;
@IsOptional()
@IsArray()
@Type(() => Number)
@IsInt({ each: true })
targetUserIds?: number[];
@IsOptional()
@IsString()
teacherNoteUrl?: string;
@IsOptional()
@IsString()
teacherNoteText?: string;
}
class SubmitAssignmentDto {
@@ -151,6 +187,15 @@ export class AssignmentsController {
return this.svc.listSubmissions(user.id, id);
}
@Get(":id/submissions/:userId/details")
submissionDetails(
@CurrentUser() user: AuthUser,
@Param("id", ParseIntPipe) id: number,
@Param("userId", ParseIntPipe) userId: number,
) {
return this.svc.getSubmissionDetails(user.id, id, userId);
}
@Post(":id/submit")
submit(
@CurrentUser() user: AuthUser,

View File

@@ -3,7 +3,7 @@ import {
Injectable,
NotFoundException,
} from "@nestjs/common";
import { AssignmentStatus, OrgRole } from "@prisma/client";
import { AssignmentStatus, AssignmentType, OrgRole } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { OrganizationsService } from "../organizations/organizations.service";
import {
@@ -26,6 +26,10 @@ export class AssignmentsService {
problemSetId: number;
dueDate?: Date;
description?: string;
type?: AssignmentType;
targetUserIds?: number[];
teacherNoteUrl?: string;
teacherNoteText?: string;
},
) {
return this.prisma.$transaction(async (tx) => {
@@ -55,6 +59,10 @@ export class AssignmentsService {
classId: data.classId,
problemSetId: data.problemSetId,
dueDate: data.dueDate,
type: data.type ?? AssignmentType.newProblem,
targetUserIds: data.targetUserIds ?? null,
teacherNoteUrl: data.teacherNoteUrl ?? null,
teacherNoteText: normalizeOptionalText(data.teacherNoteText),
},
include: {
class: {
@@ -78,8 +86,11 @@ export class AssignmentsService {
});
}
list(userId: number, opts: { classId?: number; status?: AssignmentStatus }) {
return this.prisma.assignment.findMany({
async list(
userId: number,
opts: { classId?: number; status?: AssignmentStatus },
) {
const assignments = await this.prisma.assignment.findMany({
where: {
...(opts.classId !== undefined && { classId: opts.classId }),
...(opts.status !== undefined && { status: opts.status }),
@@ -126,6 +137,20 @@ export class AssignmentsService {
},
orderBy: [{ createdAt: "desc" }],
});
// 교사/관리자 여부 확인 (organizationId가 여러 개일 수 있으므로 역할 보유 여부로 판단)
const isTeacherOrAdmin = await this.prisma.organizationMember.findFirst({
where: { userId, role: { in: [...ORG_TEACHER_OR_ADMIN_ROLES] } },
select: { id: true },
});
if (isTeacherOrAdmin) return assignments;
// 학생: targetUserIds 필터링 (null = 전체 학급, 배열 = 지정 학생만)
return assignments.filter((a) => {
if (!a.targetUserIds) return true;
return (a.targetUserIds as number[]).includes(userId);
});
}
async getOne(userId: number, assignmentId: number) {
@@ -187,6 +212,10 @@ export class AssignmentsService {
description?: string;
dueDate?: Date | null;
status?: AssignmentStatus;
type?: AssignmentType;
targetUserIds?: number[] | null;
teacherNoteUrl?: string | null;
teacherNoteText?: string | null;
},
) {
const assignment = await this.prisma.assignment.findUnique({
@@ -214,6 +243,19 @@ export class AssignmentsService {
}),
...(data.dueDate !== undefined && { dueDate: data.dueDate }),
...(data.status !== undefined && { status: data.status }),
...(data.type !== undefined && { type: data.type }),
...(data.targetUserIds !== undefined && {
targetUserIds: data.targetUserIds,
}),
...(data.teacherNoteUrl !== undefined && {
teacherNoteUrl: data.teacherNoteUrl,
}),
...(data.teacherNoteText !== undefined && {
teacherNoteText:
data.teacherNoteText === null
? null
: normalizeOptionalText(data.teacherNoteText),
}),
},
});
}
@@ -239,6 +281,103 @@ export class AssignmentsService {
return { ok: true };
}
async getSubmissionDetails(
callerId: number,
assignmentId: number,
targetUserId: number,
) {
const assignment = await this.prisma.assignment.findUnique({
where: { id: assignmentId },
include: {
class: { select: { organizationId: true } },
problemSet: {
include: {
problems: {
orderBy: { number: "asc" },
select: {
id: true,
number: true,
title: true,
answerNumber: true,
},
},
},
},
},
});
if (!assignment) throw new NotFoundException("assignment");
await this.organizations.assertOrgRole(
callerId,
assignment.class.organizationId,
ORG_TEACHER_OR_ADMIN_ROLES,
);
const submission = await this.prisma.assignmentSubmission.findUnique({
where: {
userId_assignmentId: { userId: targetUserId, assignmentId },
},
include: {
user: { select: { id: true, nickname: true, email: true } },
},
});
if (!submission) throw new NotFoundException("submission");
const logIds = (submission.studyLogIds as number[]) ?? [];
const studyLogs =
logIds.length > 0
? await this.prisma.studyLog.findMany({
where: { id: { in: logIds } },
select: {
id: true,
problemId: true,
result: true,
chosenAnswer: true,
timeSpent: true,
imageUrl: true,
memo: true,
studiedAt: true,
},
orderBy: { id: "asc" },
})
: [];
const logByProblemId = new Map(studyLogs.map((l) => [l.problemId, l]));
const details = assignment.problemSet.problems.map((problem) => {
const log = logByProblemId.get(problem.id);
return {
problemId: problem.id,
number: problem.number,
title: problem.title,
correctAnswer: problem.answerNumber,
chosenAnswer: log?.chosenAnswer ?? null,
result: log?.result ?? "skipped",
timeSpent: log?.timeSpent ?? null,
imageUrl: log?.imageUrl ?? null,
memo: log?.memo ?? null,
};
});
return {
assignment: {
id: assignment.id,
title: assignment.title,
type: assignment.type,
},
student: submission.user,
score: submission.score,
totalProblems: submission.totalProblems,
correctCount: submission.correctCount,
completedAt: submission.completedAt,
totalTimeSpent: studyLogs.reduce(
(sum, l) => sum + (l.timeSpent ?? 0),
0,
),
details,
};
}
async listSubmissions(userId: number, assignmentId: number) {
const assignment = await this.prisma.assignment.findUnique({
where: { id: assignmentId },

View File

@@ -17,6 +17,7 @@ import {
deleteAssignment,
getAssignment,
getClass,
resolveAssetUrl,
updateAssignment,
} from '@/lib/api';
import {
@@ -49,6 +50,29 @@ import {
} from '@/components/school/SchoolUI';
import { theme } from '@/styles/theme';
interface ProblemDetail {
problemId: number;
number: number;
title: string;
correctAnswer: number | null;
chosenAnswer: number | null;
result: string;
timeSpent: number | null;
imageUrl: string | null;
memo: string | null;
}
interface SubmissionDetailData {
assignment: { id: number; title: string; type: string };
student: { id: number; nickname: string; email: string };
score: number | null;
totalProblems: number | null;
correctCount: number | null;
completedAt: string | null;
totalTimeSpent: number;
details: ProblemDetail[];
}
export default function AssignmentDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
@@ -64,6 +88,9 @@ export default function AssignmentDetailPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [detailUserId, setDetailUserId] = useState<number | null>(null);
const [detailData, setDetailData] = useState<SubmissionDetailData | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -168,6 +195,20 @@ export default function AssignmentDetailPage() {
}
};
const handleViewDetail = async (userId: number) => {
setDetailUserId(userId);
setDetailLoading(true);
try {
const res = await api.get<SubmissionDetailData>(`/assignments/${assignmentId}/submissions/${userId}/details`);
setDetailData(res.data);
} catch {
showToast({ message: '상세 정보를 불러오지 못했어요.', variant: 'danger' });
setDetailUserId(null);
} finally {
setDetailLoading(false);
}
};
const handleCloseAssignment = async () => {
if (!assignment) return;
@@ -352,7 +393,17 @@ export default function AssignmentDetailPage() {
{studentRows.map(({ member, submission, percent }) => (
<tr key={member.userId}>
<td>
<StudentCell>
<StudentCell
as="button"
type="button"
onClick={() => (submission?.completedAt ? void handleViewDetail(member.userId) : null)}
style={{
cursor: submission?.completedAt ? 'pointer' : 'default',
background: 'none',
border: 'none',
textAlign: 'left',
}}
>
<Avatar>{member.user.nickname.slice(0, 1)}</Avatar>
<div>
<StudentName>{member.user.nickname}</StudentName>
@@ -409,10 +460,81 @@ export default function AssignmentDetailPage() {
))}
</MobileList>
</SectionCard>
{detailUserId !== null && (
<DetailOverlay
onClick={() => {
setDetailUserId(null);
setDetailData(null);
}}
>
<DetailPanel onClick={(e) => e.stopPropagation()}>
<DetailHeader>
<DetailTitle>{detailData?.student.nickname ?? '학생'} </DetailTitle>
<CloseDetailBtn
type="button"
onClick={() => {
setDetailUserId(null);
setDetailData(null);
}}
>
<Icon name="x" size={18} weight="bold" color="currentColor" />
</CloseDetailBtn>
</DetailHeader>
{detailLoading && <DetailMsg> ...</DetailMsg>}
{!detailLoading && detailData && (
<>
<DetailSummary>
<SummaryChip> {detailData.totalProblems}</SummaryChip>
<SummaryChip $type="correct">{detailData.correctCount} </SummaryChip>
<SummaryChip $type="time"> {formatTimeSpent(detailData.totalTimeSpent)}</SummaryChip>
<SummaryChip> {detailData.score ?? 0}%</SummaryChip>
</DetailSummary>
<DetailList>
{detailData.details.map((d) => (
<DetailItem key={d.problemId} $result={d.result}>
<DetailNum>{d.number}</DetailNum>
<DetailContent>
<DetailRow>
<ResultIcon $result={d.result}>
{d.result === 'correct' ? '✓' : d.result === 'incorrect' ? '✕' : d.result === 'skipped' ? '-' : '△'}
</ResultIcon>
<span>
: {d.chosenAnswer ?? '-'} / : {d.correctAnswer ?? '?'}
</span>
{d.timeSpent !== null && (
<TimeChip>
<Icon name="clock" size={12} /> {formatTimeSpent(d.timeSpent)}
</TimeChip>
)}
</DetailRow>
{d.memo && <DetailMemo>{d.memo}</DetailMemo>}
{d.imageUrl && (
<DetailImage src={resolveAssetUrl(d.imageUrl) ?? ''} alt={`${d.number}번 필기`} />
)}
</DetailContent>
</DetailItem>
))}
</DetailList>
</>
)}
</DetailPanel>
</DetailOverlay>
)}
</PageWrap>
);
}
function formatTimeSpent(seconds: number): string {
if (seconds < 60) return `${seconds}`;
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return sec > 0 ? `${min}${sec}` : `${min}`;
}
function formatScore(submission: SubmissionDetail | null, percent: number | null) {
if (!submission?.completedAt) return '-';
if (submission.correctCount !== null && submission.totalProblems !== null) {
@@ -602,3 +724,174 @@ const CompletionBadge = styled.span<{ $done?: boolean }>`
font-size: 13px;
font-weight: 700;
`;
const DetailOverlay = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
`;
const DetailPanel = styled.div`
width: 100%;
max-width: 680px;
max-height: 80vh;
overflow-y: auto;
background: #1a1a24;
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: ${theme.radius.lg};
padding: ${theme.space.lg};
display: flex;
flex-direction: column;
gap: ${theme.space.md};
`;
const DetailHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const DetailTitle = styled.h3`
margin: 0;
font-size: 18px;
font-weight: 700;
color: ${theme.color.textBright};
`;
const CloseDetailBtn = styled.button`
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: ${theme.radius.sm};
border: none;
background: transparent;
color: ${theme.color.textMute};
cursor: pointer;
&:hover {
background: ${theme.color.surfaceHoverDeep};
color: ${theme.color.textSub};
}
`;
const DetailMsg = styled.p`
margin: 0;
text-align: center;
color: ${theme.color.textMute};
padding: 24px 0;
`;
const DetailSummary = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
`;
const SummaryChip = styled.span<{ $type?: 'correct' | 'time' }>`
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: ${theme.radius.pill};
font-size: 13px;
font-weight: 600;
background: ${({ $type }) =>
$type === 'correct'
? 'rgba(34, 197, 94, 0.15)'
: $type === 'time'
? 'rgba(59, 130, 246, 0.15)'
: 'rgba(255, 255, 255, 0.06)'};
color: ${({ $type }) =>
$type === 'correct' ? '#86efac' : $type === 'time' ? '#93c5fd' : theme.color.textBright};
border: 1px solid
${({ $type }) =>
$type === 'correct'
? 'rgba(34, 197, 94, 0.3)'
: $type === 'time'
? 'rgba(59, 130, 246, 0.3)'
: theme.color.borderSoftAlpha};
`;
const DetailList = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const DetailItem = styled.div<{ $result: string }>`
display: flex;
gap: 12px;
padding: 12px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.borderSoftAlpha};
background: ${({ $result }) =>
$result === 'correct'
? 'rgba(34, 197, 94, 0.05)'
: $result === 'incorrect'
? 'rgba(239, 68, 68, 0.05)'
: 'rgba(255, 255, 255, 0.02)'};
`;
const DetailNum = styled.span`
font-size: 14px;
font-weight: 700;
color: ${theme.color.textBright};
min-width: 36px;
`;
const DetailContent = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
`;
const DetailRow = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: ${theme.color.textSub};
`;
const ResultIcon = styled.span<{ $result: string }>`
font-weight: 700;
font-size: 15px;
color: ${({ $result }) =>
$result === 'correct' ? '#86efac' : $result === 'incorrect' ? '#fca5a5' : theme.color.textMute};
`;
const TimeChip = styled.span`
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border-radius: ${theme.radius.pill};
background: rgba(59, 130, 246, 0.12);
color: #93c5fd;
font-size: 11px;
font-weight: 600;
margin-left: auto;
`;
const DetailMemo = styled.p`
margin: 0;
font-size: 12px;
color: ${theme.color.textMute};
font-style: italic;
padding-left: 4px;
`;
const DetailImage = styled.img`
max-width: 100%;
max-height: 200px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.borderSoftAlpha};
object-fit: contain;
`;

View File

@@ -15,6 +15,7 @@ import {
api,
createAssignment,
createProblemSetFromPdf,
getClass,
getMyClasses,
getMyOrganizations,
getMyUploadedProblemSets,
@@ -50,6 +51,11 @@ export default function NewAssignmentPage() {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [assignmentType, setAssignmentType] = useState<'newProblem' | 'review'>('newProblem');
const [targetMode, setTargetMode] = useState<'all' | 'individual'>('all');
const [targetUserIds, setTargetUserIds] = useState<number[]>([]);
const [classMembers, setClassMembers] = useState<Array<{ userId: number; user: { id: number; nickname: string; email: string } }>>([]);
const [teacherNoteText, setTeacherNoteText] = useState('');
const [uploadingPdf, setUploadingPdf] = useState(false);
const pdfInputRef = React.useRef<HTMLInputElement>(null);
const answerPdfInputRef = React.useRef<HTMLInputElement>(null);
@@ -106,6 +112,11 @@ export default function NewAssignmentPage() {
};
}, []);
useEffect(() => {
if (!classId) { setClassMembers([]); return; }
getClass(classId).then((c) => setClassMembers(c.members)).catch(() => setClassMembers([]));
}, [classId]);
const canSubmit = title.trim().length > 0 && classId !== null && problemSetId !== null && !submitting;
const problemSetOptions = useMemo(
@@ -182,6 +193,9 @@ export default function NewAssignmentPage() {
problemSetId,
dueDate: dueDate ?? undefined,
description: description.trim() || undefined,
type: assignmentType,
targetUserIds: targetMode === 'individual' ? targetUserIds : undefined,
teacherNoteText: teacherNoteText.trim() || undefined,
});
showToast({ message: '과제를 생성했어요.', variant: 'success' });
router.push(`/school/assignments/${created.id}`);
@@ -220,6 +234,21 @@ export default function NewAssignmentPage() {
<SectionCard as="form" onSubmit={handleSubmit}>
<FieldGrid>
<Field $full>
<Label> </Label>
<TypeToggleRow>
<TypeToggleBtn type="button" $active={assignmentType === 'newProblem'} onClick={() => setAssignmentType('newProblem')}>
<Icon name="sparkle" size={16} />
</TypeToggleBtn>
<TypeToggleBtn type="button" $active={assignmentType === 'review'} onClick={() => setAssignmentType('review')}>
<Icon name="clock-counter-clockwise" size={16} />
</TypeToggleBtn>
</TypeToggleRow>
<TypeHint>
{assignmentType === 'newProblem' ? '학생이 처음 푸는 문제를 과제로 냅니다.' : '이전에 틀린 문제를 다시 복습하는 과제입니다.'}
</TypeHint>
</Field>
<Field>
<Label htmlFor="assignment-title"> </Label>
<Input
@@ -243,6 +272,37 @@ export default function NewAssignmentPage() {
/>
</Field>
<Field $full>
<Label> </Label>
<TypeToggleRow>
<TypeToggleBtn type="button" $active={targetMode === 'all'} onClick={() => { setTargetMode('all'); setTargetUserIds([]); }}>
</TypeToggleBtn>
<TypeToggleBtn type="button" $active={targetMode === 'individual'} onClick={() => setTargetMode('individual')}>
</TypeToggleBtn>
</TypeToggleRow>
{targetMode === 'individual' && (
<StudentCheckList>
{classMembers.map((m) => (
<StudentCheckItem key={m.userId}>
<Checkbox
type="checkbox"
checked={targetUserIds.includes(m.userId)}
onChange={(e) => {
if (e.target.checked) setTargetUserIds((prev) => [...prev, m.userId]);
else setTargetUserIds((prev) => prev.filter((id) => id !== m.userId));
}}
/>
<span>{m.user.nickname}</span>
<StudentEmailText>{m.user.email}</StudentEmailText>
</StudentCheckItem>
))}
{classMembers.length === 0 && <EmptyHint> .</EmptyHint>}
</StudentCheckList>
)}
</Field>
<Field>
<Label htmlFor="assignment-problem-set"></Label>
<ThemedSelect
@@ -299,6 +359,17 @@ export default function NewAssignmentPage() {
placeholder="학생에게 보여줄 안내 문구를 입력하세요."
/>
</Field>
<Field $full>
<Label htmlFor="teacher-note"> ( )</Label>
<Textarea
id="teacher-note"
value={teacherNoteText}
onChange={(e) => setTeacherNoteText(e.target.value)}
placeholder="풀이 과정이나 핵심 개념을 적어주세요. 학생이 과제를 풀고 나서 확인할 수 있습니다."
rows={4}
/>
</Field>
</FieldGrid>
<ActionRow>
@@ -393,3 +464,80 @@ const ActionRow = styled.div`
flex-direction: column-reverse;
}
`;
const TypeToggleRow = styled.div`
display: flex;
gap: 8px;
margin-top: 4px;
`;
const TypeToggleBtn = styled.button<{ $active: boolean }>`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
border-radius: ${theme.radius.md};
border: 1px solid ${({ $active }) => ($active ? 'rgba(99, 102, 241, 0.5)' : theme.color.borderSoftAlpha)};
background: ${({ $active }) => ($active ? 'rgba(99, 102, 241, 0.15)' : 'rgba(255, 255, 255, 0.03)')};
color: ${({ $active }) => ($active ? '#c7d2fe' : theme.color.textSub)};
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(99, 102, 241, 0.1);
color: #c7d2fe;
}
`;
const TypeHint = styled.p`
margin: 4px 0 0;
font-size: 12px;
color: ${theme.color.textMute};
`;
const StudentCheckList = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
max-height: 200px;
overflow-y: auto;
padding: 8px;
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: ${theme.radius.md};
background: rgba(255, 255, 255, 0.02);
`;
const StudentCheckItem = styled.label`
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: ${theme.radius.sm};
cursor: pointer;
font-size: 13px;
color: ${theme.color.textBright};
transition: background 0.15s;
&:hover { background: rgba(255, 255, 255, 0.04); }
`;
const Checkbox = styled.input`
accent-color: ${theme.color.brandIndigo};
width: 16px;
height: 16px;
`;
const StudentEmailText = styled.span`
color: ${theme.color.textMute};
font-size: 11px;
margin-left: auto;
`;
const EmptyHint = styled.p`
margin: 0;
text-align: center;
color: ${theme.color.textMute};
font-size: 13px;
padding: 12px 0;
`;

View File

@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import styled from 'styled-components';
import { Icon } from '@/components/ui/Icon';
import { Button } from '@/components/ui/primitives';
import {
type AssignmentSubmission,
@@ -34,6 +35,7 @@ import { theme } from '@/styles/theme';
type AssignmentWithSubmission = AssignmentSummary & {
mySubmission: AssignmentSubmission | null;
teacherNoteText?: string | null;
};
export default function MyClassPage() {
@@ -65,6 +67,7 @@ export default function MyClassPage() {
assignmentList.map((assignment, index) => ({
...assignment,
mySubmission: assignmentDetails[index].submissions?.[0] ?? null,
teacherNoteText: (assignmentDetails[index] as any).teacherNoteText ?? null,
})),
);
} catch {
@@ -180,6 +183,15 @@ export default function MyClassPage() {
</span>
) : null}
</AssignmentMeta>
{completed && assignment.teacherNoteText && (
<TeacherNote>
<TeacherNoteLabel>
<Icon name="chalkboard-teacher" size={14} />
</TeacherNoteLabel>
<TeacherNoteBody>{assignment.teacherNoteText}</TeacherNoteBody>
</TeacherNote>
)}
</AssignmentCopy>
<ActionArea>
@@ -285,3 +297,29 @@ const SolveButton = styled(Button)`
border: 0;
box-shadow: ${theme.shadow.glowIndigo};
`;
const TeacherNote = styled.div`
margin-top: 8px;
padding: 12px;
border-radius: ${theme.radius.md};
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.2);
`;
const TeacherNoteLabel = styled.div`
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #c7d2fe;
margin-bottom: 6px;
`;
const TeacherNoteBody = styled.p`
margin: 0;
font-size: 13px;
color: ${theme.color.textBright};
line-height: 1.6;
white-space: pre-wrap;
`;

View File

@@ -102,6 +102,10 @@ export interface AssignmentSummary {
status: string;
dueDate?: string | null;
createdAt?: string;
type?: 'newProblem' | 'review';
targetUserIds?: number[] | null;
teacherNoteUrl?: string | null;
teacherNoteText?: string | null;
class?: {
id: number;
name: string;
@@ -674,6 +678,9 @@ export async function createAssignment(data: {
problemSetId: number;
dueDate?: string;
description?: string;
type?: 'newProblem' | 'review';
targetUserIds?: number[];
teacherNoteText?: string;
}) {
const response = await api.post<{ id: number }>('/assignments', data);
return response.data;