feat: 멘토 페이지 학생별 코스 진도 현황 추가
- GET /api/mentor/course-progress: 학생별 완료 slug 목록 (MENTOR 전용, N+1 트레이드오프 주석) - 멘토 페이지 상단에 학생 카드: 진도율 바 + 코스 완료 수 + 카테고리별 집계 칩 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b8c9da2d4a
commit
c4c3bb0712
@ -31,4 +31,7 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
/** 아이디와 이름이 모두 일치하는 사용자 조회 (비밀번호 재설정의 본인 확인용). */
|
||||
Optional<User> findByUsernameAndName(String username, String name);
|
||||
|
||||
/** 역할별 사용자 목록, 이름순 (멘토 페이지의 학생 진도 조회용). */
|
||||
List<User> findByRoleOrderByNameAsc(String role);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user