feat(mentor): 코호트(기수) 관리 + 기수 분석 대시보드
All checks were successful
CI / backend-test (push) Successful in 1m13s
CI / frontend-build (push) Successful in 40s
CI / backend-dep-scan (push) Successful in 29s

멘토 화면의 '분석·기수관리'를 개인 카드 나열에서 '묶어서 본 그림'으로 끌어올림.

기수(코호트):
- Flyway V2 마이그레이션으로 student_profile.cohort 추가(기존 학생 '2026' 백필)
- 가입 시 현재 기수(app.current-cohort, .env로 매년 변경) 자동 배정
- 명단에 기수 컬럼 + 배지 클릭으로 변경(POST /mentor/students/{id}/cohort)
- CSV 내보내기·StudentProfileResponse에 기수 포함

분석 대시보드(GET /mentor/analytics?cohort=):
- MentorAnalyticsService: 코호트별 KPI(진도·퀴즈·코딩·리뷰대기),
  학생별 마지막 활동 계산(4개 테이블 타임스탬프 max), 관심필요(7일+ 미활동),
  어려운 지점(평균 낮은 퀴즈·통과율 낮은 코딩 top5)
- MentorAnalytics 컴포넌트: 기수 선택·KPI 카드·관심필요·핫스팟, 슬러그→제목 해석

테스트: 백엔드 47→51(분석 4), 프론트 27→30(분석 3).
운영 검증: V2 빈DB→운영 무손실 적용(3명 백필), 분석 API·대시보드 브라우저 확인, 스모크 11/11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-20 13:29:49 +09:00
parent 9045303a7a
commit bc2807db79
16 changed files with 773 additions and 6 deletions

View File

@ -63,6 +63,14 @@ public class StudentProfile {
@Column(nullable = false, length = 60) @Column(nullable = false, length = 60)
private String major; private String major;
/**
* 기수(코호트) : "2026". 같은 시기에 들어온 수습생 묶음.
* 학습 포인트: 멘토가 "기수 단위" 진도·분석을 있게 하는 축이다. 해마다 기수가
* 들어와도 같은 플랫폼에서 기수별로 나눠 관리한다. 값은 가입 앱이 현재 기수로 채운다.
*/
@Column(nullable = false, length = 20)
private String cohort;
/** 비상 연락처 — 이름 / 관계 / 전화번호 */ /** 비상 연락처 — 이름 / 관계 / 전화번호 */
@Column(nullable = false, length = 30) @Column(nullable = false, length = 30)
private String emergencyName; private String emergencyName;
@ -88,7 +96,7 @@ public class StudentProfile {
public StudentProfile(User user, String companyEmailLocal, LocalDate birthDate, String phone, public StudentProfile(User user, String companyEmailLocal, LocalDate birthDate, String phone,
String address, String school, String major, String address, String school, String major,
String emergencyName, String emergencyRelation, String emergencyPhone, String emergencyName, String emergencyRelation, String emergencyPhone,
Instant privacyConsentAt) { Instant privacyConsentAt, String cohort) {
this.user = user; this.user = user;
this.companyEmailLocal = companyEmailLocal; this.companyEmailLocal = companyEmailLocal;
this.birthDate = birthDate; this.birthDate = birthDate;
@ -100,6 +108,12 @@ public class StudentProfile {
this.emergencyRelation = emergencyRelation; this.emergencyRelation = emergencyRelation;
this.emergencyPhone = emergencyPhone; this.emergencyPhone = emergencyPhone;
this.privacyConsentAt = privacyConsentAt; this.privacyConsentAt = privacyConsentAt;
this.cohort = cohort;
}
/** 멘토가 이 학생의 기수를 바꾼다(예: 잘못 배정됐거나 재편성). */
public void changeCohort(String cohort) {
this.cohort = cohort;
} }
/** /**
@ -161,6 +175,10 @@ public class StudentProfile {
return major; return major;
} }
public String getCohort() {
return cohort;
}
public String getEmergencyName() { public String getEmergencyName() {
return emergencyName; return emergencyName;
} }

View File

@ -19,6 +19,9 @@ public interface StudentProfileRepository extends JpaRepository<StudentProfile,
/** 특정 사용자의 프로필. */ /** 특정 사용자의 프로필. */
Optional<StudentProfile> findByUser(User user); Optional<StudentProfile> findByUser(User user);
/** 학생 user_id로 프로필 조회 (멘토의 기수 변경 등). user.id 중첩 프로퍼티로 쿼리 생성. */
Optional<StudentProfile> findByUserId(Long userId);
/** /**
* 전체 프로필을 User까지 함께 가져온다 (멘토 명단·CSV용). * 전체 프로필을 User까지 함께 가져온다 (멘토 명단·CSV용).
* *

View File

@ -28,6 +28,10 @@ public class AuthService {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final TelegramNotifier telegramNotifier; private final TelegramNotifier telegramNotifier;
/** 가입하는 학생이 편입될 현재 기수(코호트). 운영에선 .env의 CURRENT_COHORT로 매년 바꾼다. */
@org.springframework.beans.factory.annotation.Value("${app.current-cohort:2026}")
private String currentCohort;
public AuthService(UserRepository userRepository, public AuthService(UserRepository userRepository,
StudentProfileRepository studentProfileRepository, StudentProfileRepository studentProfileRepository,
PasswordEncoder passwordEncoder, PasswordEncoder passwordEncoder,
@ -104,7 +108,7 @@ public class AuthService {
user, req.companyEmailLocal(), req.birthDate(), req.phone(), req.address(), user, req.companyEmailLocal(), req.birthDate(), req.phone(), req.address(),
req.school(), req.major(), req.school(), req.major(),
req.emergencyName(), req.emergencyRelation(), req.emergencyPhone(), req.emergencyName(), req.emergencyRelation(), req.emergencyPhone(),
java.time.Instant.now())); java.time.Instant.now(), currentCohort));
// 멘토에게 텔레그램 알림. 실패해도 가입은 성공한다(TelegramNotifier가 예외를 삼킨다). // 멘토에게 텔레그램 알림. 실패해도 가입은 성공한다(TelegramNotifier가 예외를 삼킨다).
telegramNotifier.send("🎓 새 수습생 가입\n" telegramNotifier.send("🎓 새 수습생 가입\n"

View File

@ -0,0 +1,218 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.CodingSubmission;
import dev.awesomedev.mirim.domain.CourseProgress;
import dev.awesomedev.mirim.domain.QuizResult;
import dev.awesomedev.mirim.domain.StudentProfile;
import dev.awesomedev.mirim.domain.Submission;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.ChecklistItemRepository;
import dev.awesomedev.mirim.repository.CodingProblemRepository;
import dev.awesomedev.mirim.repository.CodingSubmissionRepository;
import dev.awesomedev.mirim.repository.CourseProgressRepository;
import dev.awesomedev.mirim.repository.ProgressRepository;
import dev.awesomedev.mirim.repository.QuizResultRepository;
import dev.awesomedev.mirim.repository.StudentProfileRepository;
import dev.awesomedev.mirim.repository.SubmissionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* 파일이 하는 :
* 멘토용 "코호트(기수) 분석" 학생 개개인 카드로는 보이는 "묶어서 본 그림" 만든다.
* - 코호트별 KPI(진도·퀴즈·코딩·리뷰대기), 학생별 요약 + 마지막 활동/관심필요,
* - "어려운 지점"(평균 점수가 낮은 퀴즈·통과율이 낮은 코딩 문제) top 5.
*
* 학습 포인트 "데이터의 주인" 지킨다.
* 서버는 자기가 저장한 (퀴즈 점수·코딩 통과·활동 시각) 집계한다. "코스 총 몇 개"
* 프론트 카탈로그의 지식이라 여기서 % 만들지 않고 coursesDone(원자료) 준다 화면이 계산.
*
* 학습 포인트 지금 규모(학생 4명)에선 학생마다 조회(N+1)해도 충분히 빠르다.
* 수백 명이 되면 집계 쿼리(GROUP BY) 바꾼다. "규모가 설계를 정한다."
*/
@Service
public class MentorAnalyticsService {
/** 이 일수를 넘게 아무 활동이 없으면 '관심 필요'로 본다. */
public static final int STALE_DAYS = 7;
private final StudentProfileRepository studentProfileRepository;
private final QuizResultRepository quizResultRepository;
private final CodingSubmissionRepository codingSubmissionRepository;
private final CodingProblemRepository codingProblemRepository;
private final CourseProgressRepository courseProgressRepository;
private final ProgressRepository progressRepository;
private final ChecklistItemRepository checklistItemRepository;
private final SubmissionRepository submissionRepository;
private final Clock clock;
public MentorAnalyticsService(StudentProfileRepository studentProfileRepository,
QuizResultRepository quizResultRepository,
CodingSubmissionRepository codingSubmissionRepository,
CodingProblemRepository codingProblemRepository,
CourseProgressRepository courseProgressRepository,
ProgressRepository progressRepository,
ChecklistItemRepository checklistItemRepository,
SubmissionRepository submissionRepository,
Clock clock) {
this.studentProfileRepository = studentProfileRepository;
this.quizResultRepository = quizResultRepository;
this.codingSubmissionRepository = codingSubmissionRepository;
this.codingProblemRepository = codingProblemRepository;
this.courseProgressRepository = courseProgressRepository;
this.progressRepository = progressRepository;
this.checklistItemRepository = checklistItemRepository;
this.submissionRepository = submissionRepository;
this.clock = clock;
}
public record CohortCount(String cohort, int count) {
}
/** 학생 한 명의 분석 요약 한 줄. lastActiveAt이 null이면 아직 아무 활동도 없다. */
public record StudentRow(Long userId, String name, String track, String cohort,
int coursesDone, int quizAttempted, int quizAvgPct,
int codingPassed, int codingTotal,
int checklistDone, int checklistTotal,
String lastActiveAt, Long daysSinceActive) {
}
public record QuizHotspot(String courseSlug, int avgScorePct, int attempts) {
}
public record CodingHotspot(Long problemId, String title, int passRatePct, int attempts) {
}
public record Analytics(String cohort, int studentCount, int pendingReviews,
List<CohortCount> cohorts, List<StudentRow> students,
List<QuizHotspot> hardestQuizzes, List<CodingHotspot> hardestCoding) {
}
@Transactional(readOnly = true)
public Analytics analytics(String cohortFilter) {
List<StudentProfile> all = studentProfileRepository.findAllWithUser();
// 코호트 목록 + 인원수 (필터와 무관하게 전체 기준 화면의 기수 선택지가 된다)
Map<String, Integer> counts = new TreeMap<>();
for (StudentProfile p : all) {
counts.merge(p.getCohort(), 1, Integer::sum);
}
List<CohortCount> cohorts = counts.entrySet().stream()
.map(e -> new CohortCount(e.getKey(), e.getValue()))
.toList();
boolean allCohorts = cohortFilter == null || cohortFilter.isBlank() || "all".equals(cohortFilter);
List<StudentProfile> scoped = allCohorts ? all
: all.stream().filter(p -> cohortFilter.equals(p.getCohort())).toList();
int codingTotal = (int) codingProblemRepository.count();
int checklistTotal = (int) checklistItemRepository.count();
Instant now = clock.instant();
// 핫스팟 누적기: 코스별 [점수합, 응시자수] / 문제별 [통과수, 시도자수]
Map<String, int[]> quizAgg = new HashMap<>();
Map<Long, int[]> codeAgg = new HashMap<>();
Map<Long, String> codeTitle = new HashMap<>();
int pendingReviews = 0;
List<StudentRow> rows = new ArrayList<>();
for (StudentProfile p : scoped) {
User u = p.getUser();
Instant last = null;
// 퀴즈 코스별 최고 점수(%)
Map<String, Integer> bestPct = new HashMap<>();
for (QuizResult qr : quizResultRepository.findByUserOrderBySubmittedAtDesc(u)) {
int pct = qr.getTotal() > 0 ? qr.getScore() * 100 / qr.getTotal() : 0;
bestPct.merge(qr.getCourseSlug(), pct, Math::max);
last = later(last, qr.getSubmittedAt());
}
int quizAttempted = bestPct.size();
int quizAvgPct = quizAttempted == 0 ? 0
: (int) Math.round(bestPct.values().stream().mapToInt(Integer::intValue).average().orElse(0));
for (Map.Entry<String, Integer> e : bestPct.entrySet()) {
int[] agg = quizAgg.computeIfAbsent(e.getKey(), k -> new int[2]);
agg[0] += e.getValue();
agg[1] += 1;
}
// 코딩 통과 + 문제별 통과율 누적
int codingPassed = 0;
for (CodingSubmission cs : codingSubmissionRepository.findByUser(u)) {
boolean passed = "PASSED".equals(cs.getStatus());
if (passed) {
codingPassed++;
}
Long pid = cs.getProblem().getId();
int[] agg = codeAgg.computeIfAbsent(pid, k -> new int[2]);
if (passed) {
agg[0] += 1;
}
agg[1] += 1;
codeTitle.putIfAbsent(pid, cs.getProblem().getTitle());
last = later(last, cs.getSubmittedAt());
}
// 코스 진도 (완료한 slug ) 코스 수는 프론트가 카탈로그로 안다
List<CourseProgress> courses = courseProgressRepository.findByUser(u);
for (CourseProgress cp : courses) {
last = later(last, cp.getCompletedAt());
}
// 체크리스트 완료
int checklistDone = progressRepository.findByUser(u).size();
// 과제 리뷰 대기(SUBMITTED) 집계 + 활동 시각
for (Submission s : submissionRepository.findByUserOrderBySubmittedAtDesc(u)) {
if ("SUBMITTED".equals(s.getStatus())) {
pendingReviews++;
}
last = later(last, s.getSubmittedAt());
}
Long daysSince = last == null ? null : Duration.between(last, now).toDays();
rows.add(new StudentRow(u.getId(), u.getName(), u.getTrack(), p.getCohort(),
courses.size(), quizAttempted, quizAvgPct, codingPassed, codingTotal,
checklistDone, checklistTotal,
last == null ? null : last.toString(), daysSince));
}
// 어려운 지점 = 평균이 낮은 top 5 (응시/시도가 있는 것만)
List<QuizHotspot> hardestQuizzes = quizAgg.entrySet().stream()
.map(e -> new QuizHotspot(e.getKey(), e.getValue()[0] / e.getValue()[1], e.getValue()[1]))
.sorted(Comparator.comparingInt(QuizHotspot::avgScorePct))
.limit(5)
.toList();
List<CodingHotspot> hardestCoding = codeAgg.entrySet().stream()
.map(e -> new CodingHotspot(e.getKey(), codeTitle.get(e.getKey()),
e.getValue()[0] * 100 / e.getValue()[1], e.getValue()[1]))
.sorted(Comparator.comparingInt(CodingHotspot::passRatePct))
.limit(5)
.toList();
String label = allCohorts ? "전체" : cohortFilter;
return new Analytics(label, scoped.size(), pendingReviews, cohorts, rows,
hardestQuizzes, hardestCoding);
}
/** 두 시각 중 나중 것(둘 중 하나가 null이면 나머지). */
private static Instant later(Instant a, Instant b) {
if (a == null) {
return b;
}
if (b == null) {
return a;
}
return a.isAfter(b) ? a : b;
}
}

View File

@ -106,12 +106,13 @@ public class StudentProfileService {
StringBuilder sb = new StringBuilder(""); // BOM StringBuilder sb = new StringBuilder(""); // BOM
// 헤더 // 헤더
sb.append("이름,회사이메일,트랙,생년월일,연락처,주소,학교,전공,비상연락-이름,비상연락-관계,비상연락-전화,개인정보동의일,가입일\n"); sb.append("이름,회사이메일,트랙,기수,생년월일,연락처,주소,학교,전공,비상연락-이름,비상연락-관계,비상연락-전화,개인정보동의일,가입일\n");
for (StudentProfile p : allProfiles()) { for (StudentProfile p : allProfiles()) {
sb.append(csv(p.getUser().getName())).append(',') sb.append(csv(p.getUser().getName())).append(',')
.append(csv(p.fullEmail())).append(',') .append(csv(p.fullEmail())).append(',')
.append(csv("DESIGN".equals(p.getUser().getTrack()) ? "디자인" : "개발")).append(',') .append(csv("DESIGN".equals(p.getUser().getTrack()) ? "디자인" : "개발")).append(',')
.append(csv(p.getCohort())).append(',')
.append(csv(p.getBirthDate().toString())).append(',') .append(csv(p.getBirthDate().toString())).append(',')
.append(csv(p.getPhone())).append(',') .append(csv(p.getPhone())).append(',')
.append(csv(p.getAddress())).append(',') .append(csv(p.getAddress())).append(',')
@ -151,4 +152,13 @@ public class StudentProfileService {
} }
return "\"" + v.replace("\"", "\"\"") + "\""; return "\"" + v.replace("\"", "\"\"") + "\"";
} }
/** 멘토가 학생의 기수(코호트)를 바꾼다. */
@Transactional
public void changeCohort(Long userId, String cohort) {
StudentProfile p = studentProfileRepository.findByUserId(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "학생을 찾을 수 없습니다."));
p.changeCohort(cohort.trim());
// @Transactional 안에서 managed 엔티티를 바꾸면 dirty checking으로 UPDATE가 나간다.
}
} }

View File

@ -6,6 +6,7 @@ import dev.awesomedev.mirim.domain.Submission;
import dev.awesomedev.mirim.repository.UserRepository; import dev.awesomedev.mirim.repository.UserRepository;
import dev.awesomedev.mirim.service.AuditService; import dev.awesomedev.mirim.service.AuditService;
import dev.awesomedev.mirim.service.AuthService; import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.MentorAnalyticsService;
import dev.awesomedev.mirim.service.CodingService; import dev.awesomedev.mirim.service.CodingService;
import dev.awesomedev.mirim.service.CourseProgressService; import dev.awesomedev.mirim.service.CourseProgressService;
import dev.awesomedev.mirim.service.ProgressService; import dev.awesomedev.mirim.service.ProgressService;
@ -15,6 +16,7 @@ import dev.awesomedev.mirim.service.StudentPurgeService;
import dev.awesomedev.mirim.service.SubmissionService; import dev.awesomedev.mirim.service.SubmissionService;
import dev.awesomedev.mirim.web.dto.AuditLogResponse; import dev.awesomedev.mirim.web.dto.AuditLogResponse;
import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse; import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse;
import dev.awesomedev.mirim.web.dto.CohortRequest;
import dev.awesomedev.mirim.web.dto.FeedbackRequest; import dev.awesomedev.mirim.web.dto.FeedbackRequest;
import dev.awesomedev.mirim.web.dto.MentorResetPasswordRequest; import dev.awesomedev.mirim.web.dto.MentorResetPasswordRequest;
import dev.awesomedev.mirim.web.dto.MentorSubmissionResponse; import dev.awesomedev.mirim.web.dto.MentorSubmissionResponse;
@ -57,6 +59,7 @@ public class MentorController {
private final StudentPurgeService studentPurgeService; private final StudentPurgeService studentPurgeService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuditService auditService; private final AuditService auditService;
private final MentorAnalyticsService mentorAnalyticsService;
public MentorController(SubmissionService submissionService, public MentorController(SubmissionService submissionService,
CourseProgressService courseProgressService, CourseProgressService courseProgressService,
@ -67,7 +70,8 @@ public class MentorController {
StudentProfileService studentProfileService, StudentProfileService studentProfileService,
StudentPurgeService studentPurgeService, StudentPurgeService studentPurgeService,
UserRepository userRepository, UserRepository userRepository,
AuditService auditService) { AuditService auditService,
MentorAnalyticsService mentorAnalyticsService) {
this.submissionService = submissionService; this.submissionService = submissionService;
this.courseProgressService = courseProgressService; this.courseProgressService = courseProgressService;
this.progressService = progressService; this.progressService = progressService;
@ -78,6 +82,23 @@ public class MentorController {
this.studentPurgeService = studentPurgeService; this.studentPurgeService = studentPurgeService;
this.userRepository = userRepository; this.userRepository = userRepository;
this.auditService = auditService; this.auditService = auditService;
this.mentorAnalyticsService = mentorAnalyticsService;
}
/**
* GET /api/mentor/analytics?cohort=2026 코호트(기수) 단위 분석.
* cohort 없으면 전체. 코호트 목록·KPI·학생별 요약·어려운 지점을 번에 준다.
*/
@GetMapping("/analytics")
public MentorAnalyticsService.Analytics analytics(
@RequestParam(name = "cohort", required = false) String cohort) {
return mentorAnalyticsService.analytics(cohort);
}
/** POST /api/mentor/students/{id}/cohort — 학생의 기수를 바꾼다. */
@PostMapping("/students/{id}/cohort")
public void changeCohort(@PathVariable Long id, @Valid @RequestBody CohortRequest request) {
studentProfileService.changeCohort(id, request.cohort());
} }
/** /**

View File

@ -0,0 +1,15 @@
package dev.awesomedev.mirim.web.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 파일이 하는 : 멘토가 학생의 기수(코호트) 바꿀 보내는 본문 {cohort}.
*/
public record CohortRequest(
@NotBlank(message = "기수는 필수입니다.")
@Size(max = 20, message = "기수는 20자 이하입니다.")
String cohort
) {
}

View File

@ -15,6 +15,7 @@ public record StudentProfileResponse(
String name, String name,
String username, String username,
String track, String track,
String cohort,
String companyEmail, String companyEmail,
LocalDate birthDate, LocalDate birthDate,
String phone, String phone,
@ -31,6 +32,7 @@ public record StudentProfileResponse(
p.getUser().getName(), p.getUser().getName(),
p.getUser().getUsername(), p.getUser().getUsername(),
p.getUser().getTrack(), p.getUser().getTrack(),
p.getCohort(),
p.fullEmail(), p.fullEmail(),
p.getBirthDate(), p.getBirthDate(),
p.getPhone(), p.getPhone(),

View File

@ -32,6 +32,11 @@ spring:
baseline-on-migrate: true baseline-on-migrate: true
baseline-version: 1 baseline-version: 1
# 앱 도메인 설정
app:
# 지금 가입하는 학생이 편입될 기수(코호트). 새 기수를 받을 땐 운영 .env에서 이 값만 바꾼다.
current-cohort: ${CURRENT_COHORT:2026}
server: server:
port: 8080 port: 8080
error: error:

View File

@ -0,0 +1,10 @@
-- V2: 수습생 프로필에 코호트(기수) 컬럼 추가.
--
-- 학습 포인트: 이게 Flyway를 도입한 진짜 이유다 — 운영 DB의 데이터를 지키면서
-- 스키마를 "버전 관리하며" 바꾼다. V1(baseline) 이후의 첫 실제 마이그레이션.
-- 기존 학생들은 DEFAULT로 '2026' 기수에 자동 편입된다(백필).
ALTER TABLE student_profile ADD COLUMN cohort varchar(20) NOT NULL DEFAULT '2026';
-- 백필이 끝났으니 DB 기본값은 떼어 낸다 — 이후 값은 앱(Java)이 가입 시 항상 넣어 주므로,
-- "값의 출처는 한 곳(앱)"으로 두는 게 깔끔하다. (엔티티는 nullable=false varchar(20)와 일치)
ALTER TABLE student_profile ALTER COLUMN cohort DROP DEFAULT;

View File

@ -0,0 +1,180 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.CodingProblem;
import dev.awesomedev.mirim.domain.CodingSubmission;
import dev.awesomedev.mirim.domain.QuizResult;
import dev.awesomedev.mirim.domain.StudentProfile;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.ChecklistItemRepository;
import dev.awesomedev.mirim.repository.CodingProblemRepository;
import dev.awesomedev.mirim.repository.CodingSubmissionRepository;
import dev.awesomedev.mirim.repository.CourseProgressRepository;
import dev.awesomedev.mirim.repository.ProgressRepository;
import dev.awesomedev.mirim.repository.QuizResultRepository;
import dev.awesomedev.mirim.repository.StudentProfileRepository;
import dev.awesomedev.mirim.repository.SubmissionRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 파일이 하는 : 코호트 분석의 집계 로직을 검증한다 코호트 필터, 퀴즈 평균,
* '마지막 활동/관심필요' 일수 계산, 어려운 지점(핫스팟) 정렬.
*
* 학습 포인트 시각(Clock) 고정해 "며칠 전 활동" 결정적으로 검증한다.
* 학습 포인트 Mockito 함정: when(...).thenReturn(X) X를 만드는 도중에 when(...)
* 부르면 "UnfinishedStubbing" 오류가 난다. 그래서 객체는 먼저 변수에 만들어 두고 넘긴다.
*/
class MentorAnalyticsServiceTest {
private static final Instant NOW = Instant.parse("2026-09-10T09:00:00Z");
private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
private final StudentProfileRepository profileRepo = mock(StudentProfileRepository.class);
private final QuizResultRepository quizRepo = mock(QuizResultRepository.class);
private final CodingSubmissionRepository codeRepo = mock(CodingSubmissionRepository.class);
private final CodingProblemRepository problemRepo = mock(CodingProblemRepository.class);
private final CourseProgressRepository courseRepo = mock(CourseProgressRepository.class);
private final ProgressRepository progressRepo = mock(ProgressRepository.class);
private final ChecklistItemRepository checklistRepo = mock(ChecklistItemRepository.class);
private final SubmissionRepository submissionRepo = mock(SubmissionRepository.class);
private final MentorAnalyticsService service = new MentorAnalyticsService(
profileRepo, quizRepo, codeRepo, problemRepo, courseRepo, progressRepo,
checklistRepo, submissionRepo, clock);
private User student(String name, String track) {
return new User(name, "해시", name, "STUDENT", track);
}
private StudentProfile profile(User u, String cohort) {
StudentProfile p = mock(StudentProfile.class);
when(p.getUser()).thenReturn(u);
when(p.getCohort()).thenReturn(cohort);
return p;
}
@Test
@DisplayName("코호트 목록과 인원수를 집계하고, 필터로 해당 기수만 남긴다")
void cohorts_and_filter() {
StudentProfile pa = profile(student("", "DEV"), "2025");
StudentProfile pb = profile(student("", "DEV"), "2026");
StudentProfile pc = profile(student("", "DESIGN"), "2026");
emptyActivity();
when(profileRepo.findAllWithUser()).thenReturn(List.of(pa, pb, pc));
var all = service.analytics(null);
assertEquals(3, all.studentCount());
assertEquals(List.of(
new MentorAnalyticsService.CohortCount("2025", 1),
new MentorAnalyticsService.CohortCount("2026", 2)), all.cohorts());
var only2026 = service.analytics("2026");
assertEquals(2, only2026.studentCount());
assertTrue(only2026.students().stream().allMatch(s -> s.cohort().equals("2026")));
}
@Test
@DisplayName("퀴즈는 코스별 최고점의 평균(%)을 내고, 마지막 활동 일수를 계산한다")
void quiz_average_and_last_active() {
User a = student("", "DEV");
StudentProfile pa = profile(a, "2026");
// 같은 코스 2회 응시(최고점 채택: 5/5=100 vs 3/5=60 100), 다른 코스 1회(2/4=50)
QuizResult q1 = quiz("c1", 5, 5, NOW.minus(Duration.ofDays(3)));
QuizResult q2 = quiz("c1", 3, 5, NOW.minus(Duration.ofDays(10)));
QuizResult q3 = quiz("c2", 2, 4, NOW.minus(Duration.ofDays(5)));
emptyActivity();
when(profileRepo.findAllWithUser()).thenReturn(List.of(pa));
when(quizRepo.findByUserOrderBySubmittedAtDesc(a)).thenReturn(List.of(q1, q2, q3));
var row = service.analytics(null).students().get(0);
assertEquals(2, row.quizAttempted());
assertEquals(75, row.quizAvgPct()); // (100 + 50) / 2
assertEquals(3L, row.daysSinceActive()); // 가장 최근 활동이 3일
}
@Test
@DisplayName("활동이 없으면 lastActive는 null, daysSinceActive도 null이다")
void no_activity() {
StudentProfile pa = profile(student("", "DEV"), "2026");
emptyActivity();
when(profileRepo.findAllWithUser()).thenReturn(List.of(pa));
var row = service.analytics(null).students().get(0);
assertNull(row.lastActiveAt());
assertNull(row.daysSinceActive());
assertEquals(0, row.quizAvgPct());
}
@Test
@DisplayName("어려운 코딩 문제는 통과율 낮은 순으로 정렬된다")
void coding_hotspots_sorted_by_pass_rate() {
User a = student("", "DEV");
User b = student("", "DEV");
StudentProfile pa = profile(a, "2026");
StudentProfile pb = profile(b, "2026");
CodingProblem easy = problem(10L, "쉬운문제");
CodingProblem hard = problem(20L, "어려운문제");
// easy: 통과(100%). hard: 명만 통과(50%)
CodingSubmission a1 = sub(easy, "PASSED");
CodingSubmission a2 = sub(hard, "PASSED");
CodingSubmission b1 = sub(easy, "PASSED");
CodingSubmission b2 = sub(hard, "FAILED");
emptyActivity();
when(profileRepo.findAllWithUser()).thenReturn(List.of(pa, pb));
when(codeRepo.findByUser(a)).thenReturn(List.of(a1, a2));
when(codeRepo.findByUser(b)).thenReturn(List.of(b1, b2));
var hot = service.analytics(null).hardestCoding();
assertEquals(2, hot.size());
assertEquals("어려운문제", hot.get(0).title()); // 통과율 낮은 먼저
assertEquals(50, hot.get(0).passRatePct());
assertEquals(100, hot.get(1).passRatePct());
}
// 헬퍼: 객체는 먼저 만들어 두고, when()에는 완성된 객체만 넘긴다
private void emptyActivity() {
when(problemRepo.count()).thenReturn(25L);
when(checklistRepo.count()).thenReturn(30L);
when(quizRepo.findByUserOrderBySubmittedAtDesc(any())).thenReturn(List.of());
when(codeRepo.findByUser(any())).thenReturn(List.of());
when(courseRepo.findByUser(any())).thenReturn(List.of());
when(progressRepo.findByUser(any())).thenReturn(List.of());
when(submissionRepo.findByUserOrderBySubmittedAtDesc(any())).thenReturn(List.of());
}
private QuizResult quiz(String slug, int score, int total, Instant at) {
QuizResult qr = mock(QuizResult.class);
when(qr.getCourseSlug()).thenReturn(slug);
when(qr.getScore()).thenReturn(score);
when(qr.getTotal()).thenReturn(total);
when(qr.getSubmittedAt()).thenReturn(at);
return qr;
}
private CodingProblem problem(Long id, String title) {
CodingProblem p = mock(CodingProblem.class);
when(p.getId()).thenReturn(id);
when(p.getTitle()).thenReturn(title);
return p;
}
private CodingSubmission sub(CodingProblem problem, String status) {
CodingSubmission cs = mock(CodingSubmission.class);
when(cs.getProblem()).thenReturn(problem);
when(cs.getStatus()).thenReturn(status);
when(cs.getSubmittedAt()).thenReturn(NOW.minus(Duration.ofDays(2)));
return cs;
}
}

View File

@ -45,7 +45,7 @@ class StudentProfileServiceTest {
ReflectionTestUtils.setField(student, "id", 3L); ReflectionTestUtils.setField(student, "id", 3L);
profile = new StudentProfile(student, "donghyoek.lim", LocalDate.of(2008, 2, 27), profile = new StudentProfile(student, "donghyoek.lim", LocalDate.of(2008, 2, 27),
"010-6485-4121", "시흥시/정왕2동", "미림고", "뉴미디어SW", "010-6485-4121", "시흥시/정왕2동", "미림고", "뉴미디어SW",
"차정순", "", "010-4316-6756", Instant.now()); "차정순", "", "010-4316-6756", Instant.now(), "2026");
} }
private ProfileUpdateRequest request(String emailLocal, String address) { private ProfileUpdateRequest request(String emailLocal, String address) {
@ -106,7 +106,7 @@ class StudentProfileServiceTest {
User attacker = new User("evil", "해시", "=HYPERLINK(\"http://evil/\")", "STUDENT", "DEV"); User attacker = new User("evil", "해시", "=HYPERLINK(\"http://evil/\")", "STUDENT", "DEV");
ReflectionTestUtils.setField(attacker, "id", 9L); // createdAt은 생성자가 이미 채움 ReflectionTestUtils.setField(attacker, "id", 9L); // createdAt은 생성자가 이미 채움
StudentProfile evilProfile = new StudentProfile(attacker, "evil", LocalDate.of(2008, 1, 1), StudentProfile evilProfile = new StudentProfile(attacker, "evil", LocalDate.of(2008, 1, 1),
"010-0000-0000", "@SUM(A1)", "학교", "전공", "보호자", "", "010-1111-1111", Instant.now()); "010-0000-0000", "@SUM(A1)", "학교", "전공", "보호자", "", "010-1111-1111", Instant.now(), "2026");
when(studentProfileRepository.findAllWithUser()).thenReturn(List.of(evilProfile)); when(studentProfileRepository.findAllWithUser()).thenReturn(List.of(evilProfile));
String csv = service.exportCsv(); String csv = service.exportCsv();

View File

@ -8,6 +8,7 @@ import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.CodingService; import dev.awesomedev.mirim.service.CodingService;
import dev.awesomedev.mirim.service.CourseProgressService; import dev.awesomedev.mirim.service.CourseProgressService;
import dev.awesomedev.mirim.service.LoginAttemptService; import dev.awesomedev.mirim.service.LoginAttemptService;
import dev.awesomedev.mirim.service.MentorAnalyticsService;
import dev.awesomedev.mirim.service.ProgressService; import dev.awesomedev.mirim.service.ProgressService;
import dev.awesomedev.mirim.service.QuizService; import dev.awesomedev.mirim.service.QuizService;
import dev.awesomedev.mirim.service.RateLimiterService; import dev.awesomedev.mirim.service.RateLimiterService;
@ -78,6 +79,7 @@ class SecurityContractTest {
@MockitoBean private QuizService quizService; @MockitoBean private QuizService quizService;
@MockitoBean private StudentProfileService studentProfileService; @MockitoBean private StudentProfileService studentProfileService;
@MockitoBean private StudentPurgeService studentPurgeService; @MockitoBean private StudentPurgeService studentPurgeService;
@MockitoBean private MentorAnalyticsService mentorAnalyticsService;
@MockitoBean private UserRepository userRepository; @MockitoBean private UserRepository userRepository;
/** 실제 로그인과 똑같이, 주어진 역할의 인증 정보를 담은 세션을 만든다. */ /** 실제 로그인과 똑같이, 주어진 역할의 인증 정보를 담은 세션을 만든다. */

View File

@ -0,0 +1,192 @@
// : "() ".
// " "
// KPI(···), , (·).
//
// : " " % .
// ( ··) , % .
import { useEffect, useMemo, useState } from 'react';
import client from '../api/client';
import { ALL_COURSES } from '../courseCatalog';
import { LEVEL_COURSES } from '../levelCatalog';
const TOTAL_COURSES = ALL_COURSES.length + LEVEL_COURSES.length;
// slug , .
const SLUG_TITLE = new Map(
[...ALL_COURSES, ...LEVEL_COURSES].map((c) => [c.slug, c.title]),
);
function Kpi({ label, value, sub }) {
return (
<div className="card" style={{ textAlign: 'center' }}>
<div style={{ fontSize: 26, fontWeight: 800, color: 'var(--primary)' }}>{value}</div>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{label}</div>
{sub && <div className="muted" style={{ fontSize: 11.5, marginTop: 2 }}>{sub}</div>}
</div>
);
}
function daysLabel(d) {
if (d == null) return '활동 없음';
if (d === 0) return '오늘';
return `${d}일 전`;
}
export default function MentorAnalytics() {
const [cohort, setCohort] = useState('all');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
const q = cohort === 'all' ? '' : `?cohort=${encodeURIComponent(cohort)}`;
client
.get(`/mentor/analytics${q}`)
.then((res) => {
if (!cancelled) {
setData(res.data);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [cohort]);
const kpi = useMemo(() => {
if (!data) return null;
const rows = data.students;
const n = rows.length;
const avgCoursePct = n
? Math.round((rows.reduce((s, x) => s + x.coursesDone, 0) / n / TOTAL_COURSES) * 100)
: 0;
const quizzed = rows.filter((x) => x.quizAttempted > 0);
const avgQuiz = quizzed.length
? Math.round(quizzed.reduce((s, x) => s + x.quizAvgPct, 0) / quizzed.length)
: 0;
const codingRate = n
? Math.round(
(rows.reduce((s, x) => s + (x.codingTotal ? x.codingPassed / x.codingTotal : 0), 0) / n) * 100,
)
: 0;
return { n, avgCoursePct, avgQuiz, quizzedCount: quizzed.length, codingRate };
}, [data]);
if (loading && !data) return <p className="empty">기수 분석을 불러오는 ...</p>;
if (!data) return null;
// : 7 .
const atRisk = data.students
.filter((x) => x.daysSinceActive == null || x.daysSinceActive >= 7)
.sort((a, b) => (b.daysSinceActive ?? 99999) - (a.daysSinceActive ?? 99999));
const totalHead = data.cohorts.reduce((s, c) => s + c.count, 0);
return (
<div style={{ marginBottom: 26 }}>
<div
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
flexWrap: 'wrap', gap: 8, marginBottom: 12,
}}
>
<h2 className="section-title" style={{ margin: 0 }}>📊 기수 분석</h2>
<select
className="input"
style={{ width: 'auto' }}
value={cohort}
onChange={(e) => setCohort(e.target.value)}
aria-label="기수 선택"
>
<option value="all">전체 ({totalHead})</option>
{data.cohorts.map((c) => (
<option key={c.cohort} value={c.cohort}>{c.cohort} ({c.count})</option>
))}
</select>
</div>
{/* KPI 카드 */}
<div className="grid-cards" style={{ marginBottom: 18 }}>
<Kpi label="학생" value={`${kpi.n}`} />
<Kpi label="평균 코스 진도" value={`${kpi.avgCoursePct}%`} sub={`${TOTAL_COURSES}강좌 기준`} />
<Kpi label="평균 퀴즈 점수" value={`${kpi.avgQuiz}%`} sub={`응시 ${kpi.quizzedCount}`} />
<Kpi label="코딩 통과율" value={`${kpi.codingRate}%`} />
<Kpi label="리뷰 대기" value={`${data.pendingReviews}`} />
</div>
{/* 관심 필요 학생 */}
<h3 className="section-title" style={{ fontSize: 15 }}>🔔 관심 필요 (7 넘게 활동 없음)</h3>
{atRisk.length === 0 ? (
<p className="empty">모두 최근에 활동했어요 👍</p>
) : (
<div className="card" style={{ padding: 0, overflowX: 'auto', marginBottom: 18 }}>
<table className="table table--nowrap" style={{ minWidth: 420 }}>
<thead>
<tr>
<th>학생</th>
<th>트랙</th>
<th style={{ textAlign: 'center' }}>마지막 활동</th>
<th style={{ textAlign: 'center' }}>완료 코스</th>
</tr>
</thead>
<tbody>
{atRisk.map((s) => (
<tr key={s.userId}>
<td style={{ fontWeight: 600 }}>{s.name}</td>
<td>{s.track === 'DESIGN' ? '디자인' : '개발'}</td>
<td style={{ textAlign: 'center', color: s.daysSinceActive == null ? 'var(--rose)' : 'inherit' }}>
{daysLabel(s.daysSinceActive)}
</td>
<td style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
{s.coursesDone} / {TOTAL_COURSES}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 어려운 지점 — 퀴즈 / 코딩 */}
<div className="grid-cards" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
<div className="card">
<h3 className="section-title" style={{ fontSize: 15, marginTop: 0 }}>🧩 어려운 퀴즈 (평균 낮은 )</h3>
{data.hardestQuizzes.length === 0 ? (
<p className="empty">아직 퀴즈 응시 기록이 없어요.</p>
) : (
<ul style={{ margin: 0, paddingLeft: 18 }}>
{data.hardestQuizzes.map((q) => (
<li key={q.courseSlug} style={{ marginBottom: 4 }}>
<span>{SLUG_TITLE.get(q.courseSlug) || q.courseSlug}</span>
{' — '}
<b style={{ color: q.avgScorePct < 60 ? 'var(--rose)' : 'var(--ink)' }}>{q.avgScorePct}%</b>
<span className="muted" style={{ fontSize: 12 }}> (응시 {q.attempts})</span>
</li>
))}
</ul>
)}
</div>
<div className="card">
<h3 className="section-title" style={{ fontSize: 15, marginTop: 0 }}>💻 어려운 코딩 (통과율 낮은 )</h3>
{data.hardestCoding.length === 0 ? (
<p className="empty">아직 코딩 제출 기록이 없어요.</p>
) : (
<ul style={{ margin: 0, paddingLeft: 18 }}>
{data.hardestCoding.map((c) => (
<li key={c.problemId} style={{ marginBottom: 4 }}>
<span>{c.title}</span>
{' — '}
<b style={{ color: c.passRatePct < 60 ? 'var(--rose)' : 'var(--ink)' }}>{c.passRatePct}%</b>
<span className="muted" style={{ fontSize: 12 }}> (시도 {c.attempts})</span>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
// api .
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
import client from '../api/client'
import MentorAnalytics from './MentorAnalytics.jsx'
const SAMPLE = {
cohort: '전체',
studentCount: 2,
pendingReviews: 3,
cohorts: [{ cohort: '2026', count: 2 }],
students: [
{
userId: 1, name: '가영', track: 'DEV', cohort: '2026',
coursesDone: 10, quizAttempted: 2, quizAvgPct: 80,
codingPassed: 5, codingTotal: 10, checklistDone: 3, checklistTotal: 10,
lastActiveAt: '2026-09-08T00:00:00Z', daysSinceActive: 2,
},
{
userId: 2, name: '나윤', track: 'DEV', cohort: '2026',
coursesDone: 0, quizAttempted: 0, quizAvgPct: 0,
codingPassed: 0, codingTotal: 10, checklistDone: 0, checklistTotal: 10,
lastActiveAt: null, daysSinceActive: null,
},
],
hardestQuizzes: [{ courseSlug: 'lv1-loops', avgScorePct: 40, attempts: 2 }],
hardestCoding: [{ problemId: 1, title: '별 찍기', passRatePct: 25, attempts: 4 }],
}
describe('MentorAnalytics', () => {
beforeEach(() => {
client.get.mockReset()
client.get.mockResolvedValue({ data: SAMPLE })
})
it('KPI와 리뷰 대기 건수를 보여준다', async () => {
render(<MentorAnalytics />)
expect(await screen.findByText('2명')).toBeInTheDocument() //
expect(screen.getByText('3건')).toBeInTheDocument() //
})
it('활동 없는 학생을 "관심 필요"에 "활동 없음"으로 올린다', async () => {
render(<MentorAnalytics />)
await waitFor(() => expect(screen.getByText('나윤')).toBeInTheDocument())
expect(screen.getByText('활동 없음')).toBeInTheDocument()
})
it('어려운 퀴즈는 slug를 강좌 제목으로 바꿔 보여준다', async () => {
render(<MentorAnalytics />)
// lv1-loops ("...")
expect(await screen.findByText(/반복문/)).toBeInTheDocument()
expect(screen.getByText('별 찍기')).toBeInTheDocument() //
})
})

View File

@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import client from '../api/client'; import client from '../api/client';
import MentorCodingSection from '../components/MentorCodingSection'; import MentorCodingSection from '../components/MentorCodingSection';
import MentorAnalytics from '../components/MentorAnalytics';
import Card from '../components/Card'; import Card from '../components/Card';
import Badge from '../components/Badge'; import Badge from '../components/Badge';
import { COURSE_CATEGORIES, ALL_COURSES } from '../courseCatalog'; import { COURSE_CATEGORIES, ALL_COURSES } from '../courseCatalog';
@ -215,6 +216,20 @@ function StudentRoster({ students, onChanged }) {
} }
} }
// () .
async function changeCohort(s) {
const next = window.prompt(`${s.name} 님의 기수를 입력하세요 (현재: ${s.cohort})`, s.cohort);
if (next === null) return;
const v = next.trim();
if (!v || v === s.cohort) return;
try {
await client.post(`/mentor/students/${s.userId}/cohort`, { cohort: v });
onChanged();
} catch {
window.alert('기수 변경 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.');
}
}
return ( return (
<Card style={{ padding: 0, overflowX: 'auto' }}> <Card style={{ padding: 0, overflowX: 'auto' }}>
<div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}> <div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
@ -232,6 +247,7 @@ function StudentRoster({ students, onChanged }) {
<th>이름</th> <th>이름</th>
<th>회사 이메일</th> <th>회사 이메일</th>
<th>트랙</th> <th>트랙</th>
<th>기수</th>
<th>생년월일</th> <th>생년월일</th>
<th>연락처</th> <th>연락처</th>
<th>학교</th> <th>학교</th>
@ -245,6 +261,17 @@ function StudentRoster({ students, onChanged }) {
<td style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>{s.name}</td> <td style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>{s.name}</td>
<td style={{ whiteSpace: 'nowrap' }}>{s.companyEmail}</td> <td style={{ whiteSpace: 'nowrap' }}>{s.companyEmail}</td>
<td>{s.track === 'DESIGN' ? '디자인' : '개발'}</td> <td>{s.track === 'DESIGN' ? '디자인' : '개발'}</td>
<td>
{/* 기수 배지를 누르면 변경 — 클릭 가능함을 알 수 있게 button으로 감싼다 */}
<button
className="btn btn-ghost"
style={{ padding: '2px 6px' }}
onClick={() => changeCohort(s)}
title="기수 변경"
>
<span className="badge badge-primary">{s.cohort}</span>
</button>
</td>
<td style={{ whiteSpace: 'nowrap' }}>{s.birthDate}</td> <td style={{ whiteSpace: 'nowrap' }}>{s.birthDate}</td>
<td style={{ whiteSpace: 'nowrap' }}>{s.phone}</td> <td style={{ whiteSpace: 'nowrap' }}>{s.phone}</td>
<td style={{ whiteSpace: 'nowrap' }}>{s.school}</td> <td style={{ whiteSpace: 'nowrap' }}>{s.school}</td>
@ -470,6 +497,9 @@ export default function MentorPage() {
</p> </p>
)} )}
{/* ── 기수(코호트) 분석 대시보드 — 묶어서 본 그림 ── */}
<MentorAnalytics />
{/* ── 수습생 명단 (인적사항) ── */} {/* ── 수습생 명단 (인적사항) ── */}
<h2 className="section-title">수습생 명단</h2> <h2 className="section-title">수습생 명단</h2>
{roster.length === 0 ? ( {roster.length === 0 ? (