Skip to content

Commit fd6445e

Browse files
authored
Merge pull request #242 from pirogramming/feat/#240
[Feat] 커리큘럼 탭에 과제 MVP 명예의 전당 추가
2 parents 7c51187 + c023d20 commit fd6445e

11 files changed

Lines changed: 366 additions & 5 deletions

File tree

backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,17 @@ public ResponseEntity<Map<String, String>> deleteDay(@PathVariable LocalDate ses
4343
curriculumService.deleteDay(sessionDate);
4444
return ResponseEntity.ok(Map.of("message", "세션이 정상적으로 삭제되었습니다."));
4545
}
46-
}
46+
47+
// 과제 MVP 명예의 전당 조회 (로그인한 사용자 전체)
48+
@GetMapping("/mvp")
49+
public ResponseEntity<CurriculumResDTO.MvpRes> getMvp() {
50+
return ResponseEntity.ok(curriculumService.getMvp());
51+
}
52+
53+
// 과제 MVP 명예의 전당 수정 (운영진 전용, SecurityConfig에서 권한 제한)
54+
@PutMapping("/mvp")
55+
public ResponseEntity<CurriculumResDTO.MvpRes> updateMvp(
56+
@RequestBody CurriculumReqDTO.UpdateMvpReq req) {
57+
return ResponseEntity.ok(curriculumService.updateMvp(req));
58+
}
59+
}

backend/src/main/java/com/example/Piroin/project/domain/curriculum/converter/CurriculumConverter.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.example.Piroin.project.domain.curriculum.dto.CurriculumReqDTO;
44
import com.example.Piroin.project.domain.curriculum.dto.CurriculumResDTO;
55
import com.example.Piroin.project.domain.curriculum.entity.StudySession;
6+
import com.example.Piroin.project.domain.curriculum.entity.WeeklyMvp;
67
import com.example.Piroin.project.domain.curriculum.enums.SessionDayPart;
78
import com.example.Piroin.project.domain.curriculum.enums.SessionStatus;
89
import com.example.Piroin.project.domain.user.entity.User;
@@ -70,4 +71,15 @@ public static CurriculumResDTO.SessionInfo toSessionInfo(StudySession session) {
7071
);
7172
}
7273

73-
}
74+
public static CurriculumResDTO.MvpRes toMvpRes(WeeklyMvp mvp) {
75+
return new CurriculumResDTO.MvpRes(
76+
mvp.getWeek1Mvp(),
77+
mvp.getWeek2Mvp(),
78+
mvp.getWeek3Mvp(),
79+
mvp.getWeek4Mvp(),
80+
mvp.getWeek5Mvp(),
81+
mvp.getChallengeMvp()
82+
);
83+
}
84+
85+
}

backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,17 @@ public static class UpdateSessionItemReq {
8484
private String assignmentName;
8585
}
8686

87-
}
87+
// 과제 MVP 명예의 전당 수정 요청
88+
// 운영진이 한 번에 전체 필드를 저장
89+
@Getter
90+
@NoArgsConstructor
91+
public static class UpdateMvpReq {
92+
private String week1Mvp;
93+
private String week2Mvp;
94+
private String week3Mvp;
95+
private String week4Mvp;
96+
private String week5Mvp;
97+
private String challengeMvp;
98+
}
99+
100+
}

backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumResDTO.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,16 @@ public record PastSessionResponse(
5454
String title
5555
) {
5656
}
57-
}
57+
58+
// 과제 MVP 명예의 전당
59+
// 값이 없는 주차는 null로 내려가고, 프론트에서 null인 항목은 숨김
60+
public record MvpRes(
61+
String week1Mvp,
62+
String week2Mvp,
63+
String week3Mvp,
64+
String week4Mvp,
65+
String week5Mvp,
66+
String challengeMvp
67+
) {
68+
}
69+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.example.Piroin.project.domain.curriculum.entity;
2+
3+
import jakarta.persistence.*;
4+
import lombok.*;
5+
6+
import java.time.LocalDateTime;
7+
8+
/*
9+
과제 MVP 명예의 전당 데이터
10+
운영진 공지용으로만 쓰이는 단일 row 테이블이라 PK를 고정값(1L)으로 사용
11+
*/
12+
@Entity
13+
@Table(name = "weekly_mvp")
14+
@Getter
15+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
16+
@AllArgsConstructor
17+
@Builder
18+
public class WeeklyMvp {
19+
20+
@Id
21+
private Long id;
22+
23+
@Column(name = "week1_mvp", length = 100)
24+
private String week1Mvp;
25+
26+
@Column(name = "week2_mvp", length = 100)
27+
private String week2Mvp;
28+
29+
@Column(name = "week3_mvp", length = 100)
30+
private String week3Mvp;
31+
32+
@Column(name = "week4_mvp", length = 100)
33+
private String week4Mvp;
34+
35+
@Column(name = "week5_mvp", length = 100)
36+
private String week5Mvp;
37+
38+
@Column(name = "challenge_mvp", length = 100)
39+
private String challengeMvp;
40+
41+
private LocalDateTime updatedAt;
42+
43+
public void update(String week1Mvp, String week2Mvp, String week3Mvp,
44+
String week4Mvp, String week5Mvp, String challengeMvp) {
45+
this.week1Mvp = normalize(week1Mvp);
46+
this.week2Mvp = normalize(week2Mvp);
47+
this.week3Mvp = normalize(week3Mvp);
48+
this.week4Mvp = normalize(week4Mvp);
49+
this.week5Mvp = normalize(week5Mvp);
50+
this.challengeMvp = normalize(challengeMvp);
51+
this.updatedAt = LocalDateTime.now();
52+
}
53+
54+
// 빈 문자열은 '아직 미입력'으로 취급해서 null로 저장 (프론트에서 해당 주차를 숨기는 기준이 됨)
55+
private String normalize(String value) {
56+
return (value == null || value.isBlank()) ? null : value.trim();
57+
}
58+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.example.Piroin.project.domain.curriculum.repository;
2+
3+
import com.example.Piroin.project.domain.curriculum.entity.WeeklyMvp;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
6+
public interface WeeklyMvpRepository extends JpaRepository<WeeklyMvp, Long> {
7+
}

backend/src/main/java/com/example/Piroin/project/domain/curriculum/service/CurriculumService.java

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
import com.example.Piroin.project.domain.curriculum.dto.CurriculumReqDTO;
55
import com.example.Piroin.project.domain.curriculum.dto.CurriculumResDTO;
66
import com.example.Piroin.project.domain.curriculum.entity.StudySession;
7+
import com.example.Piroin.project.domain.curriculum.entity.WeeklyMvp;
78
import com.example.Piroin.project.domain.curriculum.enums.SessionStatus;
89
import com.example.Piroin.project.domain.curriculum.exception.CurriculumException;
910
import com.example.Piroin.project.domain.curriculum.repository.CurriculumRepository;
11+
import com.example.Piroin.project.domain.curriculum.repository.WeeklyMvpRepository;
1012
import com.example.Piroin.project.domain.user.entity.User;
1113
import com.example.Piroin.project.domain.user.repository.UserRepository;
1214
import com.example.Piroin.project.global.util.SecurityUtil;
@@ -25,8 +27,12 @@
2527
public class CurriculumService {
2628

2729
private final CurriculumRepository curriculumRepository;
30+
private final WeeklyMvpRepository weeklyMvpRepository;
2831
private final UserRepository userRepository;
2932

33+
// 명예의 전당은 단일 row(고정 id)로만 관리
34+
private static final Long MVP_ID = 1L;
35+
3036
@Transactional(readOnly = true)
3137
public List<CurriculumResDTO.CreateDayRes> getAllDays() {
3238
Map<LocalDate, List<StudySession>> grouped = curriculumRepository.findAllByOrderBySessionDateAscDayPartAsc()
@@ -138,4 +144,22 @@ private CurriculumResDTO.PastSessionResponse toPastSessionResponse(StudySession
138144
session.getTitle()
139145
);
140146
}
141-
}
147+
148+
@Transactional(readOnly = true)
149+
public CurriculumResDTO.MvpRes getMvp() {
150+
WeeklyMvp mvp = weeklyMvpRepository.findById(MVP_ID)
151+
.orElseGet(() -> WeeklyMvp.builder().id(MVP_ID).build());
152+
return CurriculumConverter.toMvpRes(mvp);
153+
}
154+
155+
@Transactional
156+
public CurriculumResDTO.MvpRes updateMvp(CurriculumReqDTO.UpdateMvpReq req) {
157+
WeeklyMvp mvp = weeklyMvpRepository.findById(MVP_ID)
158+
.orElseGet(() -> weeklyMvpRepository.save(WeeklyMvp.builder().id(MVP_ID).build()));
159+
160+
mvp.update(req.getWeek1Mvp(), req.getWeek2Mvp(), req.getWeek3Mvp(),
161+
req.getWeek4Mvp(), req.getWeek5Mvp(), req.getChallengeMvp());
162+
163+
return CurriculumConverter.toMvpRes(mvp);
164+
}
165+
}

backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
5656
.requestMatchers(HttpMethod.POST, "/api/curriculums").hasRole("ADMIN")
5757
.requestMatchers(HttpMethod.PATCH, "/api/curriculums/{sessionDate}").hasRole("ADMIN")
5858
.requestMatchers(HttpMethod.DELETE, "/api/curriculums/{sessionDate}").hasRole("ADMIN")
59+
.requestMatchers(HttpMethod.PUT, "/api/curriculums/mvp").hasRole("ADMIN")
5960

6061
.requestMatchers(HttpMethod.POST, "/api/assignments/create").hasRole("ADMIN")
6162
.requestMatchers(HttpMethod.PATCH, "/api/assignments/modify/{assignmentId}").hasRole("ADMIN")
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
CREATE TABLE weekly_mvp (
2+
id BIGINT NOT NULL,
3+
week1_mvp VARCHAR(100),
4+
week2_mvp VARCHAR(100),
5+
week3_mvp VARCHAR(100),
6+
week4_mvp VARCHAR(100),
7+
week5_mvp VARCHAR(100),
8+
challenge_mvp VARCHAR(100),
9+
updated_at TIMESTAMP,
10+
CONSTRAINT pk_weekly_mvp PRIMARY KEY (id)
11+
);
12+
13+
-- 단일 row(고정 id=1)로만 운영되는 명예의 전당 데이터, 미리 한 행을 만들어둠
14+
INSERT INTO weekly_mvp (id, updated_at) VALUES (1, CURRENT_TIMESTAMP);

frontend/src/pages/curriculum/CurriculumPage.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,96 @@ function SessionForm({ day, week, onClose, onSave }) {
316316
);
317317
}
318318

319+
// ── 명예의 전당 (과제 MVP) ────────────────────────────
320+
const MVP_WEEKS = [1, 2, 3, 4, 5];
321+
322+
function CrownIcon() {
323+
return (
324+
<svg className={styles.crownIcon} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
325+
<path d="M3 18.5L1.5 7L7 11L12 4L17 11L22.5 7L21 18.5H3Z" fill="currentColor" />
326+
<rect x="3" y="19.5" width="18" height="2" rx="1" fill="currentColor" />
327+
</svg>
328+
);
329+
}
330+
331+
function HonorOfFame({ isAdmin }) {
332+
const [mvp, setMvp] = useState(null);
333+
const [form, setForm] = useState(null);
334+
const [saving, setSaving] = useState(false);
335+
336+
const fetchMvp = async () => {
337+
try {
338+
const res = await authFetch('/api/curriculums/mvp');
339+
const data = await res.json();
340+
setMvp(data);
341+
setForm(data);
342+
} catch (e) { }
343+
};
344+
345+
useEffect(() => { fetchMvp(); }, []);
346+
347+
if (!mvp || !form) return null;
348+
349+
const entries = [
350+
...MVP_WEEKS.map(w => ({ key: `week${w}Mvp`, label: `${w}주차 MVP` })),
351+
{ key: 'challengeMvp', label: '챌린지 MVP' },
352+
];
353+
const filledEntries = entries.filter(e => mvp[e.key] && mvp[e.key].trim());
354+
355+
const handleSave = async () => {
356+
setSaving(true);
357+
try {
358+
await authFetch('/api/curriculums/mvp', {
359+
method: 'PUT',
360+
body: JSON.stringify(form),
361+
});
362+
await fetchMvp();
363+
} catch (e) {
364+
} finally {
365+
setSaving(false);
366+
}
367+
};
368+
369+
return (
370+
<div className={styles.honorSection}>
371+
<div className={styles.honorTitleRow}>
372+
<CrownIcon />
373+
<span className={styles.honorTitle}>과제 MVP 명예의 전당</span>
374+
<CrownIcon />
375+
</div>
376+
377+
{!isAdmin && filledEntries.length > 0 && (
378+
<div className={styles.honorList}>
379+
{filledEntries.map(e => (
380+
<div key={e.key} className={styles.honorItem}>
381+
{e.label}: <span className={styles.honorName}>{mvp[e.key]}</span>
382+
</div>
383+
))}
384+
</div>
385+
)}
386+
387+
{isAdmin && (
388+
<div className={styles.honorEditList}>
389+
{entries.map(e => (
390+
<div key={e.key} className={styles.honorEditRow}>
391+
<label className={styles.honorEditLabel}>{e.label}</label>
392+
<input
393+
className={styles.honorEditInput}
394+
value={form[e.key] || ''}
395+
placeholder="이름을 입력하세요"
396+
onChange={ev => setForm({ ...form, [e.key]: ev.target.value })}
397+
/>
398+
</div>
399+
))}
400+
<button className={styles.honorSaveBtn} onClick={handleSave} disabled={saving}>
401+
{saving ? '저장 중...' : '저장하기'}
402+
</button>
403+
</div>
404+
)}
405+
</div>
406+
);
407+
}
408+
319409
// ── 메인 컴포넌트 ─────────────────────────────────────
320410
function CurriculumPage() {
321411
const role = localStorage.getItem('role') || 'MEMBER';
@@ -365,6 +455,9 @@ function CurriculumPage() {
365455
</button>
366456
</div>
367457
)}
458+
459+
<HonorOfFame isAdmin={role === 'ADMIN'} />
460+
368461
{Object.entries(grouped).map(([week, weekDays]) => (
369462
<div key={week} className={styles.weekSection}>
370463
<div className={styles.weekHeader}>

0 commit comments

Comments
 (0)