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:
parent
f181aef30b
commit
6df3f308cc
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user