feat(mentor): 코호트(기수) 관리 + 기수 분석 대시보드
멘토 화면의 '분석·기수관리'를 개인 카드 나열에서 '묶어서 본 그림'으로 끌어올림.
기수(코호트):
- 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:
parent
9045303a7a
commit
bc2807db79
@ -63,6 +63,14 @@ public class StudentProfile {
|
||||
@Column(nullable = false, length = 60)
|
||||
private String major;
|
||||
|
||||
/**
|
||||
* 기수(코호트) — 예: "2026". 같은 시기에 들어온 수습생 묶음.
|
||||
* 학습 포인트: 멘토가 "기수 단위"로 진도·분석을 볼 수 있게 하는 축이다. 해마다 새 기수가
|
||||
* 들어와도 같은 플랫폼에서 기수별로 나눠 관리한다. 값은 가입 시 앱이 현재 기수로 채운다.
|
||||
*/
|
||||
@Column(nullable = false, length = 20)
|
||||
private String cohort;
|
||||
|
||||
/** 비상 연락처 — 이름 / 관계 / 전화번호 */
|
||||
@Column(nullable = false, length = 30)
|
||||
private String emergencyName;
|
||||
@ -88,7 +96,7 @@ public class StudentProfile {
|
||||
public StudentProfile(User user, String companyEmailLocal, LocalDate birthDate, String phone,
|
||||
String address, String school, String major,
|
||||
String emergencyName, String emergencyRelation, String emergencyPhone,
|
||||
Instant privacyConsentAt) {
|
||||
Instant privacyConsentAt, String cohort) {
|
||||
this.user = user;
|
||||
this.companyEmailLocal = companyEmailLocal;
|
||||
this.birthDate = birthDate;
|
||||
@ -100,6 +108,12 @@ public class StudentProfile {
|
||||
this.emergencyRelation = emergencyRelation;
|
||||
this.emergencyPhone = emergencyPhone;
|
||||
this.privacyConsentAt = privacyConsentAt;
|
||||
this.cohort = cohort;
|
||||
}
|
||||
|
||||
/** 멘토가 이 학생의 기수를 바꾼다(예: 잘못 배정됐거나 재편성). */
|
||||
public void changeCohort(String cohort) {
|
||||
this.cohort = cohort;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -161,6 +175,10 @@ public class StudentProfile {
|
||||
return major;
|
||||
}
|
||||
|
||||
public String getCohort() {
|
||||
return cohort;
|
||||
}
|
||||
|
||||
public String getEmergencyName() {
|
||||
return emergencyName;
|
||||
}
|
||||
|
||||
@ -19,6 +19,9 @@ public interface StudentProfileRepository extends JpaRepository<StudentProfile,
|
||||
/** 특정 사용자의 프로필. */
|
||||
Optional<StudentProfile> findByUser(User user);
|
||||
|
||||
/** 학생 user_id로 프로필 조회 (멘토의 기수 변경 등). user.id 중첩 프로퍼티로 쿼리 생성. */
|
||||
Optional<StudentProfile> findByUserId(Long userId);
|
||||
|
||||
/**
|
||||
* 전체 프로필을 User까지 함께 가져온다 (멘토 명단·CSV용).
|
||||
*
|
||||
|
||||
@ -28,6 +28,10 @@ public class AuthService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final TelegramNotifier telegramNotifier;
|
||||
|
||||
/** 가입하는 학생이 편입될 현재 기수(코호트). 운영에선 .env의 CURRENT_COHORT로 매년 바꾼다. */
|
||||
@org.springframework.beans.factory.annotation.Value("${app.current-cohort:2026}")
|
||||
private String currentCohort;
|
||||
|
||||
public AuthService(UserRepository userRepository,
|
||||
StudentProfileRepository studentProfileRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
@ -104,7 +108,7 @@ public class AuthService {
|
||||
user, req.companyEmailLocal(), req.birthDate(), req.phone(), req.address(),
|
||||
req.school(), req.major(),
|
||||
req.emergencyName(), req.emergencyRelation(), req.emergencyPhone(),
|
||||
java.time.Instant.now()));
|
||||
java.time.Instant.now(), currentCohort));
|
||||
|
||||
// 멘토에게 텔레그램 알림. 실패해도 가입은 성공한다(TelegramNotifier가 예외를 삼킨다).
|
||||
telegramNotifier.send("🎓 새 수습생 가입\n"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -106,12 +106,13 @@ public class StudentProfileService {
|
||||
StringBuilder sb = new StringBuilder(""); // BOM
|
||||
|
||||
// 헤더
|
||||
sb.append("이름,회사이메일,트랙,생년월일,연락처,주소,학교,전공,비상연락-이름,비상연락-관계,비상연락-전화,개인정보동의일,가입일\n");
|
||||
sb.append("이름,회사이메일,트랙,기수,생년월일,연락처,주소,학교,전공,비상연락-이름,비상연락-관계,비상연락-전화,개인정보동의일,가입일\n");
|
||||
|
||||
for (StudentProfile p : allProfiles()) {
|
||||
sb.append(csv(p.getUser().getName())).append(',')
|
||||
.append(csv(p.fullEmail())).append(',')
|
||||
.append(csv("DESIGN".equals(p.getUser().getTrack()) ? "디자인" : "개발")).append(',')
|
||||
.append(csv(p.getCohort())).append(',')
|
||||
.append(csv(p.getBirthDate().toString())).append(',')
|
||||
.append(csv(p.getPhone())).append(',')
|
||||
.append(csv(p.getAddress())).append(',')
|
||||
@ -151,4 +152,13 @@ public class StudentProfileService {
|
||||
}
|
||||
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가 나간다.
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import dev.awesomedev.mirim.domain.Submission;
|
||||
import dev.awesomedev.mirim.repository.UserRepository;
|
||||
import dev.awesomedev.mirim.service.AuditService;
|
||||
import dev.awesomedev.mirim.service.AuthService;
|
||||
import dev.awesomedev.mirim.service.MentorAnalyticsService;
|
||||
import dev.awesomedev.mirim.service.CodingService;
|
||||
import dev.awesomedev.mirim.service.CourseProgressService;
|
||||
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.web.dto.AuditLogResponse;
|
||||
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.MentorResetPasswordRequest;
|
||||
import dev.awesomedev.mirim.web.dto.MentorSubmissionResponse;
|
||||
@ -57,6 +59,7 @@ public class MentorController {
|
||||
private final StudentPurgeService studentPurgeService;
|
||||
private final UserRepository userRepository;
|
||||
private final AuditService auditService;
|
||||
private final MentorAnalyticsService mentorAnalyticsService;
|
||||
|
||||
public MentorController(SubmissionService submissionService,
|
||||
CourseProgressService courseProgressService,
|
||||
@ -67,7 +70,8 @@ public class MentorController {
|
||||
StudentProfileService studentProfileService,
|
||||
StudentPurgeService studentPurgeService,
|
||||
UserRepository userRepository,
|
||||
AuditService auditService) {
|
||||
AuditService auditService,
|
||||
MentorAnalyticsService mentorAnalyticsService) {
|
||||
this.submissionService = submissionService;
|
||||
this.courseProgressService = courseProgressService;
|
||||
this.progressService = progressService;
|
||||
@ -78,6 +82,23 @@ public class MentorController {
|
||||
this.studentPurgeService = studentPurgeService;
|
||||
this.userRepository = userRepository;
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -15,6 +15,7 @@ public record StudentProfileResponse(
|
||||
String name,
|
||||
String username,
|
||||
String track,
|
||||
String cohort,
|
||||
String companyEmail,
|
||||
LocalDate birthDate,
|
||||
String phone,
|
||||
@ -31,6 +32,7 @@ public record StudentProfileResponse(
|
||||
p.getUser().getName(),
|
||||
p.getUser().getUsername(),
|
||||
p.getUser().getTrack(),
|
||||
p.getCohort(),
|
||||
p.fullEmail(),
|
||||
p.getBirthDate(),
|
||||
p.getPhone(),
|
||||
|
||||
@ -32,6 +32,11 @@ spring:
|
||||
baseline-on-migrate: true
|
||||
baseline-version: 1
|
||||
|
||||
# 앱 도메인 설정
|
||||
app:
|
||||
# 지금 가입하는 학생이 편입될 기수(코호트). 새 기수를 받을 땐 운영 .env에서 이 값만 바꾼다.
|
||||
current-cohort: ${CURRENT_COHORT:2026}
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
error:
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -45,7 +45,7 @@ class StudentProfileServiceTest {
|
||||
ReflectionTestUtils.setField(student, "id", 3L);
|
||||
profile = new StudentProfile(student, "donghyoek.lim", LocalDate.of(2008, 2, 27),
|
||||
"010-6485-4121", "시흥시/정왕2동", "미림고", "뉴미디어SW",
|
||||
"차정순", "모", "010-4316-6756", Instant.now());
|
||||
"차정순", "모", "010-4316-6756", Instant.now(), "2026");
|
||||
}
|
||||
|
||||
private ProfileUpdateRequest request(String emailLocal, String address) {
|
||||
@ -106,7 +106,7 @@ class StudentProfileServiceTest {
|
||||
User attacker = new User("evil", "해시", "=HYPERLINK(\"http://evil/\")", "STUDENT", "DEV");
|
||||
ReflectionTestUtils.setField(attacker, "id", 9L); // createdAt은 생성자가 이미 채움
|
||||
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));
|
||||
|
||||
String csv = service.exportCsv();
|
||||
|
||||
@ -8,6 +8,7 @@ import dev.awesomedev.mirim.service.AuthService;
|
||||
import dev.awesomedev.mirim.service.CodingService;
|
||||
import dev.awesomedev.mirim.service.CourseProgressService;
|
||||
import dev.awesomedev.mirim.service.LoginAttemptService;
|
||||
import dev.awesomedev.mirim.service.MentorAnalyticsService;
|
||||
import dev.awesomedev.mirim.service.ProgressService;
|
||||
import dev.awesomedev.mirim.service.QuizService;
|
||||
import dev.awesomedev.mirim.service.RateLimiterService;
|
||||
@ -78,6 +79,7 @@ class SecurityContractTest {
|
||||
@MockitoBean private QuizService quizService;
|
||||
@MockitoBean private StudentProfileService studentProfileService;
|
||||
@MockitoBean private StudentPurgeService studentPurgeService;
|
||||
@MockitoBean private MentorAnalyticsService mentorAnalyticsService;
|
||||
@MockitoBean private UserRepository userRepository;
|
||||
|
||||
/** 실제 로그인과 똑같이, 주어진 역할의 인증 정보를 담은 세션을 만든다. */
|
||||
|
||||
192
frontend/src/components/MentorAnalytics.jsx
Normal file
192
frontend/src/components/MentorAnalytics.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/MentorAnalytics.test.jsx
Normal file
57
frontend/src/components/MentorAnalytics.test.jsx
Normal 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() // 어려운 코딩
|
||||
})
|
||||
})
|
||||
@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import client from '../api/client';
|
||||
import MentorCodingSection from '../components/MentorCodingSection';
|
||||
import MentorAnalytics from '../components/MentorAnalytics';
|
||||
import Card from '../components/Card';
|
||||
import Badge from '../components/Badge';
|
||||
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 (
|
||||
<Card style={{ padding: 0, overflowX: 'auto' }}>
|
||||
<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>
|
||||
@ -245,6 +261,17 @@ function StudentRoster({ students, onChanged }) {
|
||||
<td style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>{s.name}</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{s.companyEmail}</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.phone}</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{s.school}</td>
|
||||
@ -470,6 +497,9 @@ export default function MentorPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── 기수(코호트) 분석 대시보드 — 묶어서 본 그림 ── */}
|
||||
<MentorAnalytics />
|
||||
|
||||
{/* ── 수습생 명단 (인적사항) ── */}
|
||||
<h2 className="section-title">수습생 명단</h2>
|
||||
{roster.length === 0 ? (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user