diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java index 8d17249..3b4a9e1 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/controller/CurriculumController.java @@ -43,4 +43,17 @@ public ResponseEntity> deleteDay(@PathVariable LocalDate ses curriculumService.deleteDay(sessionDate); return ResponseEntity.ok(Map.of("message", "세션이 정상적으로 삭제되었습니다.")); } -} + + // 과제 MVP 명예의 전당 조회 (로그인한 사용자 전체) + @GetMapping("/mvp") + public ResponseEntity getMvp() { + return ResponseEntity.ok(curriculumService.getMvp()); + } + + // 과제 MVP 명예의 전당 수정 (운영진 전용, SecurityConfig에서 권한 제한) + @PutMapping("/mvp") + public ResponseEntity updateMvp( + @RequestBody CurriculumReqDTO.UpdateMvpReq req) { + return ResponseEntity.ok(curriculumService.updateMvp(req)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/converter/CurriculumConverter.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/converter/CurriculumConverter.java index e4de760..1481c93 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/converter/CurriculumConverter.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/converter/CurriculumConverter.java @@ -3,6 +3,7 @@ import com.example.Piroin.project.domain.curriculum.dto.CurriculumReqDTO; import com.example.Piroin.project.domain.curriculum.dto.CurriculumResDTO; import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.curriculum.entity.WeeklyMvp; import com.example.Piroin.project.domain.curriculum.enums.SessionDayPart; import com.example.Piroin.project.domain.curriculum.enums.SessionStatus; import com.example.Piroin.project.domain.user.entity.User; @@ -70,4 +71,15 @@ public static CurriculumResDTO.SessionInfo toSessionInfo(StudySession session) { ); } -} + public static CurriculumResDTO.MvpRes toMvpRes(WeeklyMvp mvp) { + return new CurriculumResDTO.MvpRes( + mvp.getWeek1Mvp(), + mvp.getWeek2Mvp(), + mvp.getWeek3Mvp(), + mvp.getWeek4Mvp(), + mvp.getWeek5Mvp(), + mvp.getChallengeMvp() + ); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java index 631ef28..b8b6d40 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumReqDTO.java @@ -84,4 +84,17 @@ public static class UpdateSessionItemReq { private String assignmentName; } -} + // 과제 MVP 명예의 전당 수정 요청 + // 운영진이 한 번에 전체 필드를 저장 + @Getter + @NoArgsConstructor + public static class UpdateMvpReq { + private String week1Mvp; + private String week2Mvp; + private String week3Mvp; + private String week4Mvp; + private String week5Mvp; + private String challengeMvp; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumResDTO.java index e8ec7e2..26b66f8 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumResDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/dto/CurriculumResDTO.java @@ -54,4 +54,16 @@ public record PastSessionResponse( String title ) { } -} + + // 과제 MVP 명예의 전당 + // 값이 없는 주차는 null로 내려가고, 프론트에서 null인 항목은 숨김 + public record MvpRes( + String week1Mvp, + String week2Mvp, + String week3Mvp, + String week4Mvp, + String week5Mvp, + String challengeMvp + ) { + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/entity/WeeklyMvp.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/entity/WeeklyMvp.java new file mode 100644 index 0000000..51afcd2 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/entity/WeeklyMvp.java @@ -0,0 +1,58 @@ +package com.example.Piroin.project.domain.curriculum.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/* +과제 MVP 명예의 전당 데이터 +운영진 공지용으로만 쓰이는 단일 row 테이블이라 PK를 고정값(1L)으로 사용 +*/ +@Entity +@Table(name = "weekly_mvp") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class WeeklyMvp { + + @Id + private Long id; + + @Column(name = "week1_mvp", length = 100) + private String week1Mvp; + + @Column(name = "week2_mvp", length = 100) + private String week2Mvp; + + @Column(name = "week3_mvp", length = 100) + private String week3Mvp; + + @Column(name = "week4_mvp", length = 100) + private String week4Mvp; + + @Column(name = "week5_mvp", length = 100) + private String week5Mvp; + + @Column(name = "challenge_mvp", length = 100) + private String challengeMvp; + + private LocalDateTime updatedAt; + + public void update(String week1Mvp, String week2Mvp, String week3Mvp, + String week4Mvp, String week5Mvp, String challengeMvp) { + this.week1Mvp = normalize(week1Mvp); + this.week2Mvp = normalize(week2Mvp); + this.week3Mvp = normalize(week3Mvp); + this.week4Mvp = normalize(week4Mvp); + this.week5Mvp = normalize(week5Mvp); + this.challengeMvp = normalize(challengeMvp); + this.updatedAt = LocalDateTime.now(); + } + + // 빈 문자열은 '아직 미입력'으로 취급해서 null로 저장 (프론트에서 해당 주차를 숨기는 기준이 됨) + private String normalize(String value) { + return (value == null || value.isBlank()) ? null : value.trim(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/repository/WeeklyMvpRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/repository/WeeklyMvpRepository.java new file mode 100644 index 0000000..fe1087d --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/repository/WeeklyMvpRepository.java @@ -0,0 +1,7 @@ +package com.example.Piroin.project.domain.curriculum.repository; + +import com.example.Piroin.project.domain.curriculum.entity.WeeklyMvp; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WeeklyMvpRepository extends JpaRepository { +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/service/CurriculumService.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/service/CurriculumService.java index 1f41745..67575a7 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/service/CurriculumService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/service/CurriculumService.java @@ -4,9 +4,11 @@ import com.example.Piroin.project.domain.curriculum.dto.CurriculumReqDTO; import com.example.Piroin.project.domain.curriculum.dto.CurriculumResDTO; import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.curriculum.entity.WeeklyMvp; import com.example.Piroin.project.domain.curriculum.enums.SessionStatus; import com.example.Piroin.project.domain.curriculum.exception.CurriculumException; import com.example.Piroin.project.domain.curriculum.repository.CurriculumRepository; +import com.example.Piroin.project.domain.curriculum.repository.WeeklyMvpRepository; import com.example.Piroin.project.domain.user.entity.User; import com.example.Piroin.project.domain.user.repository.UserRepository; import com.example.Piroin.project.global.util.SecurityUtil; @@ -25,8 +27,12 @@ public class CurriculumService { private final CurriculumRepository curriculumRepository; + private final WeeklyMvpRepository weeklyMvpRepository; private final UserRepository userRepository; + // 명예의 전당은 단일 row(고정 id)로만 관리 + private static final Long MVP_ID = 1L; + @Transactional(readOnly = true) public List getAllDays() { Map> grouped = curriculumRepository.findAllByOrderBySessionDateAscDayPartAsc() @@ -138,4 +144,22 @@ private CurriculumResDTO.PastSessionResponse toPastSessionResponse(StudySession session.getTitle() ); } -} + + @Transactional(readOnly = true) + public CurriculumResDTO.MvpRes getMvp() { + WeeklyMvp mvp = weeklyMvpRepository.findById(MVP_ID) + .orElseGet(() -> WeeklyMvp.builder().id(MVP_ID).build()); + return CurriculumConverter.toMvpRes(mvp); + } + + @Transactional + public CurriculumResDTO.MvpRes updateMvp(CurriculumReqDTO.UpdateMvpReq req) { + WeeklyMvp mvp = weeklyMvpRepository.findById(MVP_ID) + .orElseGet(() -> weeklyMvpRepository.save(WeeklyMvp.builder().id(MVP_ID).build())); + + mvp.update(req.getWeek1Mvp(), req.getWeek2Mvp(), req.getWeek3Mvp(), + req.getWeek4Mvp(), req.getWeek5Mvp(), req.getChallengeMvp()); + + return CurriculumConverter.toMvpRes(mvp); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java b/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java index 083a9e9..1ecce0d 100644 --- a/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/example/Piroin/project/global/config/SecurityConfig.java @@ -56,6 +56,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.POST, "/api/curriculums").hasRole("ADMIN") .requestMatchers(HttpMethod.PATCH, "/api/curriculums/{sessionDate}").hasRole("ADMIN") .requestMatchers(HttpMethod.DELETE, "/api/curriculums/{sessionDate}").hasRole("ADMIN") + .requestMatchers(HttpMethod.PUT, "/api/curriculums/mvp").hasRole("ADMIN") .requestMatchers(HttpMethod.POST, "/api/assignments/create").hasRole("ADMIN") .requestMatchers(HttpMethod.PATCH, "/api/assignments/modify/{assignmentId}").hasRole("ADMIN") diff --git a/backend/src/main/resources/db/migration/V10__create_weekly_mvp.sql b/backend/src/main/resources/db/migration/V10__create_weekly_mvp.sql new file mode 100644 index 0000000..a0fdd75 --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__create_weekly_mvp.sql @@ -0,0 +1,14 @@ +CREATE TABLE weekly_mvp ( + id BIGINT NOT NULL, + week1_mvp VARCHAR(100), + week2_mvp VARCHAR(100), + week3_mvp VARCHAR(100), + week4_mvp VARCHAR(100), + week5_mvp VARCHAR(100), + challenge_mvp VARCHAR(100), + updated_at TIMESTAMP, + CONSTRAINT pk_weekly_mvp PRIMARY KEY (id) +); + +-- 단일 row(고정 id=1)로만 운영되는 명예의 전당 데이터, 미리 한 행을 만들어둠 +INSERT INTO weekly_mvp (id, updated_at) VALUES (1, CURRENT_TIMESTAMP); \ No newline at end of file diff --git a/frontend/src/pages/curriculum/CurriculumPage.js b/frontend/src/pages/curriculum/CurriculumPage.js index fc7fe89..3454589 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.js +++ b/frontend/src/pages/curriculum/CurriculumPage.js @@ -316,6 +316,131 @@ function SessionForm({ day, week, onClose, onSave }) { ); } +// ── 명예의 전당 (과제 MVP) ──────────────────────────── +const MVP_WEEKS = [1, 2, 3, 4, 5]; + +function CrownIcon() { + return ( + + ); +} + +function HonorOfFame({ isAdmin }) { + const [mvp, setMvp] = useState(null); + const [form, setForm] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [saving, setSaving] = useState(false); + + const fetchMvp = async () => { + try { + const res = await authFetch('/api/curriculums/mvp'); + const data = await res.json(); + setMvp(data); + setForm(data); + } catch (e) { } + }; + + useEffect(() => { fetchMvp(); }, []); + + if (!mvp || !form) return null; + + const entries = [ + ...MVP_WEEKS.map(w => ({ key: `week${w}Mvp`, label: `${w}주차 MVP` })), + { key: 'challengeMvp', label: '챌린지 MVP' }, + ]; + const filledEntries = entries.filter(e => mvp[e.key] && mvp[e.key].trim()); + + const handleEditStart = () => { + setForm(mvp); + setIsEditing(true); + }; + + const handleCancel = () => { + setForm(mvp); + setIsEditing(false); + }; + + const handleSave = async () => { + setSaving(true); + try { + await authFetch('/api/curriculums/mvp', { + method: 'PUT', + body: JSON.stringify(form), + }); + await fetchMvp(); + setIsEditing(false); + } catch (e) { + } finally { + setSaving(false); + } + }; + + return ( +
+
setIsOpen(p => !p)}> +
+ + 과제 MVP 명예의 전당 + +
+ toggle +
+
+ + {isOpen && ( +
+ {!isEditing && ( + <> + {filledEntries.length > 0 ? ( +
+ {filledEntries.map(e => ( +
+ {e.label}: {mvp[e.key]} +
+ ))} +
+ ) : ( +
아직 등록된 MVP가 없어요
+ )} + {isAdmin && ( + + )} + + )} + + {isAdmin && isEditing && ( +
+ {entries.map(e => ( +
+ + setForm({ ...form, [e.key]: ev.target.value })} + /> +
+ ))} +
+ + +
+
+ )} +
+ )} +
+ ); +} + // ── 메인 컴포넌트 ───────────────────────────────────── function CurriculumPage() { const role = localStorage.getItem('role') || 'MEMBER'; @@ -365,6 +490,9 @@ function CurriculumPage() { )} + + + {Object.entries(grouped).map(([week, weekDays]) => (
diff --git a/frontend/src/pages/curriculum/CurriculumPage.module.css b/frontend/src/pages/curriculum/CurriculumPage.module.css index e53fec9..430e1e1 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.module.css +++ b/frontend/src/pages/curriculum/CurriculumPage.module.css @@ -56,6 +56,179 @@ .createBtn:hover { background: var(--dark); color: var(--white); } +/* 명예의 전당 (과제 MVP) */ +.honorSection { + max-width: 640px; + margin: 0 auto 48px; + padding: 28px 32px; + background: var(--white); + border: 1px solid #eee; + border-radius: 20px; + box-shadow: 0 1px 4px rgba(0,0,0,0.06); + box-sizing: border-box; +} + +.honorHeader { + display: grid; + grid-template-columns: 14px 1fr 14px; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.honorTitleRow { + grid-column: 2; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} + +.honorHeader .toggleIcon { + grid-column: 3; + justify-self: end; +} + +.crownIcon { + width: 22px; + height: 22px; + color: var(--dark); + flex-shrink: 0; +} + +.honorTitle { + font-family: var(--font-main); + font-size: 1.3rem; + font-weight: 700; + color: var(--black); +} + +.honorBody { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.honorList { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.honorItem { + font-family: var(--font-main); + font-size: 1rem; + font-weight: 500; + color: var(--gray600); +} + +.honorName { + font-weight: 700; + color: var(--dark); +} + +.honorEmpty { + font-family: var(--font-main); + font-size: 0.9rem; + color: #aaa; +} + +.honorEditBtn { + margin-top: 14px; + padding: 6px 20px; + background: transparent; + border: 1.5px solid var(--dark); + border-radius: 10px; + color: var(--dark); + font-family: var(--font-main); + font-size: 0.85rem; + cursor: pointer; +} + +.honorEditBtn:hover { + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; +} + +.honorEditList { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + width: 100%; + max-width: 360px; +} + +.honorEditRow { + display: flex; + align-items: center; + gap: 10px; +} + +.honorEditLabel { + font-family: var(--font-main); + font-size: 0.85rem; + color: #666; + min-width: 92px; + text-align: left; + flex-shrink: 0; +} + +.honorEditInput { + flex: 1; + padding: 7px 10px; + border: 1px solid #ddd; + border-radius: 8px; + font-family: var(--font-main); + font-size: 0.9rem; + outline: none; + box-sizing: border-box; +} + +.honorEditInput:focus { border-color: var(--dark); } + +.honorEditBtns { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 10px; +} + +.honorSaveBtn { + padding: 6px 20px; + background: transparent; + border: 1.5px solid var(--dark); + border-radius: 10px; + color: var(--dark); + font-family: var(--font-main); + font-size: 0.85rem; + cursor: pointer; +} + +.honorSaveBtn:hover { + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; +} +.honorSaveBtn:disabled { opacity: 0.6; cursor: default; } + +.honorCancelBtn { + padding: 6px 20px; + background: transparent; + border: none; + color: #aaa; + font-family: var(--font-main); + font-size: 0.85rem; + cursor: pointer; +} + +.honorCancelBtn:hover { color: #777; } +.honorCancelBtn:disabled { opacity: 0.6; cursor: default; } + /* 카드 행 */ .cardsRow { margin: 0 auto; @@ -524,6 +697,8 @@ .cardTitle { font-size: 1.1rem; } .cardDate { margin-left: 0; } + .honorSection { padding: 20px 16px; } + .honorEditList { max-width: 100%; } .formOverlay { padding: 0; align-items: flex-start; } .formCard { width: 100%; min-height: 100vh; border-radius: 0; padding: 28px 20px; } .formGrid { grid-template-columns: 1fr; }