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:
@@ -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;
|
||||
@@ -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[]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user