feat: 멘토 페이지 학생별 코스 진도 현황 추가

- GET /api/mentor/course-progress: 학생별 완료 slug 목록 (MENTOR 전용, N+1 트레이드오프 주석)
- 멘토 페이지 상단에 학생 카드: 진도율 바 + 코스 완료 수 + 카테고리별 집계 칩

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-16 15:34:47 +09:00
parent b8c9da2d4a
commit c4c3bb0712
4 changed files with 123 additions and 7 deletions

View File

@ -31,4 +31,7 @@ public interface UserRepository extends JpaRepository<User, Long> {
/** 아이디와 이름이 모두 일치하는 사용자 조회 (비밀번호 재설정의 본인 확인용). */
Optional<User> findByUsernameAndName(String username, String name);
/** 역할별 사용자 목록, 이름순 (멘토 페이지의 학생 진도 조회용). */
List<User> findByRoleOrderByNameAsc(String role);
}

View File

@ -1,9 +1,12 @@
package dev.awesomedev.mirim.web;
import dev.awesomedev.mirim.domain.Submission;
import dev.awesomedev.mirim.repository.UserRepository;
import dev.awesomedev.mirim.service.CourseProgressService;
import dev.awesomedev.mirim.service.SubmissionService;
import dev.awesomedev.mirim.web.dto.FeedbackRequest;
import dev.awesomedev.mirim.web.dto.MentorSubmissionResponse;
import dev.awesomedev.mirim.web.dto.StudentCourseProgressResponse;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@ -23,9 +26,15 @@ import java.util.List;
public class MentorController {
private final SubmissionService submissionService;
private final CourseProgressService courseProgressService;
private final UserRepository userRepository;
public MentorController(SubmissionService submissionService) {
public MentorController(SubmissionService submissionService,
CourseProgressService courseProgressService,
UserRepository userRepository) {
this.submissionService = submissionService;
this.courseProgressService = courseProgressService;
this.userRepository = userRepository;
}
/** GET /api/mentor/submissions — 전체 제출물 + 학생 이름 + 과제 제목 (최신순) */
@ -36,6 +45,24 @@ public class MentorController {
.toList();
}
/**
* GET /api/mentor/course-progress 학생별 코스 완료 현황 (이름순).
* 학습 포인트: 학생이 4~5명이라 학생마다 조회(N+1)해도 충분히 빠르다.
* 사용자가 수백 명이 되면 번에 JOIN으로 가져오도록 개선해야 한다
* "지금 규모에 맞는 단순함" "미래의 개선 지점" 함께 아는 것이 중요하다.
*/
@GetMapping("/course-progress")
public List<StudentCourseProgressResponse> studentCourseProgress() {
return userRepository.findByRoleOrderByNameAsc("STUDENT").stream()
.map(student -> new StudentCourseProgressResponse(
student.getId(),
student.getUsername(),
student.getName(),
student.getTrack(),
courseProgressService.completedSlugs(student)))
.toList();
}
/** POST /api/mentor/submissions/{id}/feedback — 피드백 등록, 상태는 REVIEWED로 변경 */
@PostMapping("/submissions/{id}/feedback")
public MentorSubmissionResponse giveFeedback(@PathVariable Long id,

View File

@ -0,0 +1,18 @@
package dev.awesomedev.mirim.web.dto;
import java.util.List;
/**
* 파일이 하는 :
* 멘토 페이지의 "학생별 코스 진도" 응답 학생 정보 + 완료한 코스 slug 목록.
* 학습 포인트: 전체 코스 목록은 프론트(courseCatalog) 알고 있으므로,
* 서버는 "누가 무엇을 완료했는지" 주면 된다. 진도율 계산은 프론트 .
*/
public record StudentCourseProgressResponse(
Long userId,
String username,
String name,
String track,
List<String> completedSlugs
) {
}

View File

@ -1,12 +1,60 @@
// : ( ) ,
// .
// : ( ) ,
// .
import { useEffect, useState } from 'react';
import client from '../api/client';
import Card from '../components/Card';
import Badge from '../components/Badge';
import { COURSE_CATEGORIES, ALL_COURSES } from '../courseCatalog';
// .
// : " slug " , ·
// (courseCatalog) .
function StudentProgressCard({ student }) {
const done = new Set(student.completedSlugs);
const total = ALL_COURSES.length;
const doneCount = ALL_COURSES.filter((c) => done.has(c.slug)).length;
const percent = total === 0 ? 0 : Math.round((doneCount / total) * 100);
return (
<div className="card" style={{ height: '100%' }}>
<div className="assign-title-row">
<span className="assign-title">{student.name}</span>
<span className="badge badge-primary">
{student.track === 'DESIGN' ? '디자인' : '개발'}
</span>
<span className="muted" style={{ fontSize: 12.5 }}>@{student.username}</span>
</div>
<div className="stat-row" style={{ margin: '10px 0 12px' }}>
<div className="stat">
<span className="stat-num" style={{ fontSize: 26 }}>{percent}%</span>
<span className="stat-label">{doneCount} / {total} 코스</span>
</div>
<div className="progress-track" style={{ flex: 1, minWidth: 100 }}>
<div className="progress-fill" style={{ width: `${percent}%` }} />
</div>
</div>
{/* 카테고리별 완료 수 — 어느 영역을 파고 있는지 한눈에 */}
<div className="chip-row" style={{ marginTop: 0 }}>
{COURSE_CATEGORIES.map((group) => {
const groupDone = group.items.filter((i) => i.slug && done.has(i.slug)).length;
return (
<span
key={group.category}
className="chip"
style={groupDone > 0 ? { color: 'var(--teal)', fontWeight: 700 } : undefined}
>
{group.category} {groupDone}/{group.items.length}
</span>
);
})}
</div>
</div>
);
}
export default function MentorPage() {
const [submissions, setSubmissions] = useState([]);
const [students, setStudents] = useState([]); //
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState(null); // id
const [feedbackDraft, setFeedbackDraft] = useState('');
@ -14,9 +62,15 @@ export default function MentorPage() {
const [error, setError] = useState('');
function load() {
return client
.get('/mentor/submissions')
.then((res) => setSubmissions(res.data))
// .
return Promise.all([
client.get('/mentor/submissions'),
client.get('/mentor/course-progress'),
])
.then(([subsRes, progressRes]) => {
setSubmissions(subsRes.data);
setStudents(progressRes.data);
})
.finally(() => setLoading(false));
}
@ -67,9 +121,23 @@ export default function MentorPage() {
<div>
<h1 className="page-title">멘토 리뷰</h1>
<p className="page-desc">
학생들의 제출물이에요. 행을 클릭하면 내용을 보고 피드백을 남길 있어.
학생학습 진도를 확인하고, 제출물에 피드백을 남기세.
</p>
{/* ── 학생별 코스 진도 ── */}
<h2 className="section-title">학생별 코스 진도</h2>
{students.length === 0 ? (
<p className="empty">아직 학생 계정이 없어요.</p>
) : (
<div className="grid-cards" style={{ marginBottom: 24 }}>
{students.map((student) => (
<StudentProgressCard key={student.userId} student={student} />
))}
</div>
)}
{/* ── 제출물 리뷰 ── */}
<h2 className="section-title">제출물</h2>
{submissions.length === 0 ? (
<p className="empty">아직 제출물이 없어요.</p>
) : (