feat: 멘토 주차별 체크리스트 진도 뷰 + 적대적 리뷰 반영

- GET /api/mentor/checklist-progress: 주차별 총계(분모) + 학생별 완료 수(분자)
- 멘토 페이지에 학생×주차 진도 표 (완료율 3단계 색, 첫 열 sticky)
- 리뷰 반영: Promise.allSettled로 섹션 독립 로딩(+에러 배너),
  table-static 클래스로 비클릭 표의 hover 하이라이트 제거, 시드 부재 빈 상태 구분

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-16 15:53:14 +09:00
parent f181aef30b
commit 6df3f308cc
5 changed files with 207 additions and 6 deletions

View File

@ -11,7 +11,9 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
/**
* 파일이 하는 :
@ -68,4 +70,32 @@ public class ProgressService {
.map(progress -> progress.getItem().getId())
.toList();
}
/**
* 사용자가 주차별로 개를 체크했는지 집계한다. (멘토 뷰용)
* 반환 : {1: 4, 2: 6} 1주차 4개, 2주차 6개 완료. 체크가 없는 주차는 자체가 없다.
*
* 학습 포인트: SQL의 GROUP BY로도 있지만(SQL 중급 코스 참고),
* 데이터가 수십 규모라 자바에서 Map으로 세는 쪽이 읽기 쉽다.
* TreeMap을 쓰면 (주차) 자동으로 정렬된다.
*/
@Transactional(readOnly = true)
public Map<Integer, Long> doneCountByWeek(User user) {
Map<Integer, Long> counts = new TreeMap<>();
for (Progress progress : progressRepository.findByUser(user)) {
Integer week = progress.getItem().getWeek();
counts.merge(week, 1L, Long::sum);
}
return counts;
}
/** 주차별 체크리스트 항목 총수. (멘토 뷰에서 분모로 쓴다) */
@Transactional(readOnly = true)
public Map<Integer, Long> totalCountByWeek() {
Map<Integer, Long> counts = new TreeMap<>();
for (ChecklistItem item : checklistItemRepository.findAll()) {
counts.merge(item.getWeek(), 1L, Long::sum);
}
return counts;
}
}

View File

@ -3,7 +3,9 @@ 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.ProgressService;
import dev.awesomedev.mirim.service.SubmissionService;
import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse;
import dev.awesomedev.mirim.web.dto.FeedbackRequest;
import dev.awesomedev.mirim.web.dto.MentorSubmissionResponse;
import dev.awesomedev.mirim.web.dto.StudentCourseProgressResponse;
@ -27,13 +29,16 @@ public class MentorController {
private final SubmissionService submissionService;
private final CourseProgressService courseProgressService;
private final ProgressService progressService;
private final UserRepository userRepository;
public MentorController(SubmissionService submissionService,
CourseProgressService courseProgressService,
ProgressService progressService,
UserRepository userRepository) {
this.submissionService = submissionService;
this.courseProgressService = courseProgressService;
this.progressService = progressService;
this.userRepository = userRepository;
}
@ -63,6 +68,24 @@ public class MentorController {
.toList();
}
/**
* GET /api/mentor/checklist-progress 주차별 체크리스트 진도 현황.
* 주차별 항목 총수(분모) 학생별 주차 완료 (분자) 함께 반환한다.
*/
@GetMapping("/checklist-progress")
public ChecklistProgressOverviewResponse checklistProgress() {
List<ChecklistProgressOverviewResponse.StudentWeekly> students =
userRepository.findByRoleOrderByNameAsc("STUDENT").stream()
.map(student -> new ChecklistProgressOverviewResponse.StudentWeekly(
student.getId(),
student.getUsername(),
student.getName(),
student.getTrack(),
progressService.doneCountByWeek(student)))
.toList();
return new ChecklistProgressOverviewResponse(progressService.totalCountByWeek(), students);
}
/** POST /api/mentor/submissions/{id}/feedback — 피드백 등록, 상태는 REVIEWED로 변경 */
@PostMapping("/submissions/{id}/feedback")
public MentorSubmissionResponse giveFeedback(@PathVariable Long id,

View File

@ -0,0 +1,27 @@
package dev.awesomedev.mirim.web.dto;
import java.util.List;
import java.util.Map;
/**
* 파일이 하는 :
* 멘토 페이지의 "주차별 체크리스트 진도" 응답
* 주차별 항목 총수(분모) + 학생별 주차 완료 (분자) 번에 담는다.
*
* 학습 포인트: 총수는 모든 학생에게 같으니 학생마다 반복해서 보내지 않고
* 번만 보낸다(totalByWeek). 응답 크기를 줄이는 흔한 설계다.
*/
public record ChecklistProgressOverviewResponse(
Map<Integer, Long> totalByWeek,
List<StudentWeekly> students
) {
/** 학생 한 명의 주차별 완료 수. 체크가 없는 주차는 키가 없다(프론트에서 0으로 처리). */
public record StudentWeekly(
Long userId,
String username,
String name,
String track,
Map<Integer, Long> doneByWeek
) {
}
}

View File

@ -52,9 +52,80 @@ function StudentProgressCard({ student }) {
);
}
// .
// : (totalByWeek) ( doneByWeek) ,
// . 3(0 / / )
// .
function WeeklyChecklistTable({ overview }) {
// (totalByWeek) . JSON
// . ( : JSON !)
const weeks = Object.keys(overview.totalByWeek)
.map(Number)
.sort((a, b) => a - b);
function cellStyle(done, total) {
if (total === 0 || done === 0) return { color: 'var(--muted)' };
if (done >= total) return { color: 'var(--teal)', fontWeight: 800 };
return { color: 'var(--primary)', fontWeight: 700 };
}
return (
<Card style={{ padding: 0, overflowX: 'auto' }}>
<table className="table table-static" style={{ minWidth: 640 }}>
<thead>
<tr>
<th>학생</th>
{weeks.map((w) => (
<th key={w} style={{ textAlign: 'center' }}>{w}주차</th>
))}
<th style={{ textAlign: 'center' }}>전체</th>
</tr>
</thead>
<tbody>
{overview.students.map((student) => {
let doneSum = 0;
let totalSum = 0;
return (
<tr key={student.userId}>
<td style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>
{student.name}
<span className="muted" style={{ fontSize: 12, marginLeft: 6 }}>
{student.track === 'DESIGN' ? '디자인' : '개발'}
</span>
</td>
{weeks.map((w) => {
const total = overview.totalByWeek[w] ?? 0;
const done = student.doneByWeek[w] ?? 0; // 0
doneSum += done;
totalSum += total;
return (
<td
key={w}
style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums', ...cellStyle(done, total) }}
>
{done}/{total}
</td>
);
})}
<td
style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums', ...cellStyle(doneSum, totalSum) }}
>
{doneSum}/{totalSum}
</td>
</tr>
);
})}
</tbody>
</table>
</Card>
);
}
export default function MentorPage() {
const [submissions, setSubmissions] = useState([]);
const [students, setStudents] = useState([]); //
const [checklistOverview, setChecklistOverview] = useState(null); //
const [loadError, setLoadError] = useState(false); //
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState(null); // id
const [feedbackDraft, setFeedbackDraft] = useState('');
@ -62,14 +133,23 @@ export default function MentorPage() {
const [error, setError] = useState('');
function load() {
// .
return Promise.all([
// · · .
// : Promise.all ,
// . allSettled " "
// , . ( !)
return Promise.allSettled([
client.get('/mentor/submissions'),
client.get('/mentor/course-progress'),
client.get('/mentor/checklist-progress'),
])
.then(([subsRes, progressRes]) => {
setSubmissions(subsRes.data);
setStudents(progressRes.data);
.then(([subsRes, progressRes, checklistRes]) => {
if (subsRes.status === 'fulfilled') setSubmissions(subsRes.value.data);
if (progressRes.status === 'fulfilled') setStudents(progressRes.value.data);
if (checklistRes.status === 'fulfilled') setChecklistOverview(checklistRes.value.data);
const anyFailed = [subsRes, progressRes, checklistRes].some(
(r) => r.status === 'rejected',
);
setLoadError(anyFailed);
})
.finally(() => setLoading(false));
}
@ -124,6 +204,13 @@ export default function MentorPage() {
학생별 학습 진도를 확인하고, 제출물에 피드백을 남기세요.
</p>
{/* 일부 데이터 로딩 실패 시 — 빈 상태 문구와 구분되는 에러 안내 */}
{loadError && (
<p className="error-text" style={{ marginBottom: 14 }}>
일부 데이터를 불러오지 못했어요. 잠시 새로고침해 주세요.
</p>
)}
{/* ── 학생별 코스 진도 ── */}
<h2 className="section-title">학생별 코스 진도</h2>
{students.length === 0 ? (
@ -136,8 +223,19 @@ export default function MentorPage() {
</div>
)}
{/* ── 주차별 체크리스트 진도 ── */}
<h2 className="section-title">주차별 체크리스트 진도</h2>
{!checklistOverview || checklistOverview.students.length === 0 ? (
<p className="empty">아직 학생 계정이 없어요.</p>
) : Object.keys(checklistOverview.totalByWeek).length === 0 ? (
// " " " "
<p className="empty">등록된 체크리스트 항목이 없어요.</p>
) : (
<WeeklyChecklistTable overview={checklistOverview} />
)}
{/* ── 제출물 리뷰 ── */}
<h2 className="section-title">제출물</h2>
<h2 className="section-title" style={{ marginTop: 24 }}>제출물</h2>
{submissions.length === 0 ? (
<p className="empty">아직 제출물이 없어요.</p>
) : (

View File

@ -332,6 +332,29 @@ a {
background: var(--primary-soft);
}
/* 클릭되지 않는 정보 전역 hover 하이라이트를 끈다.
학습 포인트: hover 배경은 인라인 스타일로 덮을 없다(:hover는 CSS에만 있음).
그래서 "클릭 안 되는 표" 변형 클래스를 만들어 규칙 자체를 무효화한다. */
.table-static tbody tr {
cursor: default;
}
.table-static tbody tr:hover {
background: transparent;
}
/* 좁은 화면에서 가로 스크롤해도 첫 열(이름)이 고정되게 */
.table-static th:first-child,
.table-static td:first-child {
position: sticky;
left: 0;
background: var(--card);
}
.table-static thead th:first-child {
background: var(--paper);
}
/* ---------- 기타 ---------- */
.page-title {
font-size: 22px;