Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,17 @@ public ResponseEntity<Map<String, String>> deleteDay(@PathVariable LocalDate ses
curriculumService.deleteDay(sessionDate);
return ResponseEntity.ok(Map.of("message", "세션이 정상적으로 삭제되었습니다."));
}
}

// 과제 MVP 명예의 전당 조회 (로그인한 사용자 전체)
@GetMapping("/mvp")
public ResponseEntity<CurriculumResDTO.MvpRes> getMvp() {
return ResponseEntity.ok(curriculumService.getMvp());
}

// 과제 MVP 명예의 전당 수정 (운영진 전용, SecurityConfig에서 권한 제한)
@PutMapping("/mvp")
public ResponseEntity<CurriculumResDTO.MvpRes> updateMvp(
@RequestBody CurriculumReqDTO.UpdateMvpReq req) {
return ResponseEntity.ok(curriculumService.updateMvp(req));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<WeeklyMvp, Long> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<CurriculumResDTO.CreateDayRes> getAllDays() {
Map<LocalDate, List<StudySession>> grouped = curriculumRepository.findAllByOrderBySessionDateAscDayPartAsc()
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 14 additions & 0 deletions backend/src/main/resources/db/migration/V10__create_weekly_mvp.sql
Original file line number Diff line number Diff line change
@@ -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);
128 changes: 128 additions & 0 deletions frontend/src/pages/curriculum/CurriculumPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,131 @@ function SessionForm({ day, week, onClose, onSave }) {
);
}

// ── 명예의 전당 (과제 MVP) ────────────────────────────
const MVP_WEEKS = [1, 2, 3, 4, 5];

function CrownIcon() {
return (
<svg className={styles.crownIcon} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M3 18.5L1.5 7L7 11L12 4L17 11L22.5 7L21 18.5H3Z" fill="currentColor" />
<rect x="3" y="19.5" width="18" height="2" rx="1" fill="currentColor" />
</svg>
);
}

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 (
<div className={styles.honorSection}>
<div className={styles.honorHeader} onClick={() => setIsOpen(p => !p)}>
<div className={styles.honorTitleRow}>
<CrownIcon />
<span className={styles.honorTitle}>과제 MVP 명예의 전당</span>
<CrownIcon />
</div>
<img src={Toggle1} className={`${styles.toggleIcon} ${isOpen ? styles.toggleOpen : ''}`} alt="toggle" />
</div>
<hr className={styles.divider} />

{isOpen && (
<div className={styles.honorBody}>
{!isEditing && (
<>
{filledEntries.length > 0 ? (
<div className={styles.honorList}>
{filledEntries.map(e => (
<div key={e.key} className={styles.honorItem}>
{e.label}: <span className={styles.honorName}>{mvp[e.key]}</span>
</div>
))}
</div>
) : (
<div className={styles.honorEmpty}>아직 등록된 MVP가 없어요</div>
)}
{isAdmin && (
<button className={styles.honorEditBtn} onClick={handleEditStart}>수정</button>
)}
</>
)}

{isAdmin && isEditing && (
<div className={styles.honorEditList}>
{entries.map(e => (
<div key={e.key} className={styles.honorEditRow}>
<label className={styles.honorEditLabel}>{e.label}</label>
<input
className={styles.honorEditInput}
value={form[e.key] || ''}
placeholder="이름을 입력하세요"
onChange={ev => setForm({ ...form, [e.key]: ev.target.value })}
/>
</div>
))}
<div className={styles.honorEditBtns}>
<button className={styles.honorSaveBtn} onClick={handleSave} disabled={saving}>
{saving ? '저장 중...' : '저장'}
</button>
<button className={styles.honorCancelBtn} onClick={handleCancel} disabled={saving}>
취소
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}

// ── 메인 컴포넌트 ─────────────────────────────────────
function CurriculumPage() {
const role = localStorage.getItem('role') || 'MEMBER';
Expand Down Expand Up @@ -365,6 +490,9 @@ function CurriculumPage() {
</button>
</div>
)}

<HonorOfFame isAdmin={role === 'ADMIN'} />

{Object.entries(grouped).map(([week, weekDays]) => (
<div key={week} className={styles.weekSection}>
<div className={styles.weekHeader}>
Expand Down
Loading
Loading