diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceDayStatusRes.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceDayStatusRes.java new file mode 100644 index 0000000..8f25d7f --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceDayStatusRes.java @@ -0,0 +1,23 @@ +package com.example.Piroin.project.domain.attendance.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Setter +@Schema(description = "요일별 출석 상태") +public class AttendanceDayStatusRes { + + @Schema(description = "출석 날짜", example = "2026-06-23") + private LocalDate date; + + @Schema(description = "요일", example = "TUESDAY") + private String day; + + @Schema(description = "출석 차시별 상태 목록") + private List slots; +} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceReqDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceReqDTO.java deleted file mode 100644 index 47b76ec..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceReqDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.attendance.dto; - -public class AttendanceReqDTO { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceResDTO.java deleted file mode 100644 index bbafb19..0000000 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceResDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.Piroin.project.domain.attendance.dto; - -public class AttendanceResDTO { -} diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceStatusRes.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceStatusRes.java index 1bc1ed4..d7f990c 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceStatusRes.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/dto/AttendanceStatusRes.java @@ -9,14 +9,12 @@ @Getter @Setter -@Schema(description = "사용자 출석 상태") +@Schema(description = "사용자 주차별 출석 상태") public class AttendanceStatusRes { - @Schema(description = "출석 날짜", example = "2025-06-24") - private LocalDate date; @Schema(description = "주차", example = "1") private int week; - @Schema(description = "출석 차시별 상태 목록") - private List slots; -} + @Schema(description = "요일별 출석 상태 목록") + private List days; +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java index 9b83ead..5839457 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java @@ -1,6 +1,7 @@ package com.example.Piroin.project.domain.attendance.repository; import com.example.Piroin.project.domain.attendance.entity.AttendanceCode; +import com.example.Piroin.project.domain.curriculum.entity.StudySession; import com.example.Piroin.project.domain.user.entity.User; import com.example.Piroin.project.domain.attendance.entity.Attendance; import org.springframework.data.jpa.repository.JpaRepository; @@ -20,7 +21,6 @@ public interface AttendanceRepository extends JpaRepository { // 연관관계 필드명이 attendanceCode 라면 내부 ID인 Id를 조합하여 명명 Optional findByUserIdAndAttendanceCodeId(Long userId, Long attendanceCodeId); - //List findByUserIdAndStudySessionSessionDate(Integer userId, LocalDate date); int countByUserAndStatusFalse(User user); @@ -54,6 +54,7 @@ Optional findByUserIdAndAttendanceCodeId( List findByAttendanceCodeId(Integer id); + // 특정 날짜에 발급된 출석 코드의 개수를 세는 메서드 //long countByAttendanceDate(String attendanceDate); diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java index 15c211e..2840c00 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java @@ -1,6 +1,9 @@ package com.example.Piroin.project.domain.attendance.service; +import com.example.Piroin.project.domain.assignment.repository.AssignmentRepository; import com.example.Piroin.project.domain.curriculum.entity.StudySession; +import com.example.Piroin.project.domain.curriculum.exception.CurriculumException; +import com.example.Piroin.project.domain.curriculum.exception.code.CurriculumErrorCode; import com.example.Piroin.project.domain.curriculum.repository.CurriculumRepository; import com.example.Piroin.project.domain.deposit.entity.Deposit; import com.example.Piroin.project.domain.deposit.repository.DepositRepository; @@ -23,13 +26,11 @@ import com.example.Piroin.project.domain.assignment.repository.AssignmentItemRepository; import com.example.Piroin.project.domain.attendance.dto.UpdateUserStatusReq; import com.example.Piroin.project.domain.curriculum.enums.SessionDayPart; +import com.example.Piroin.project.domain.attendance.dto.AttendanceDayStatusRes; import java.time.LocalDate; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; @@ -41,27 +42,29 @@ public class AttendanceService { private final AttendanceCodeRepository attendanceCodeRepository; private final UserRepository userRepository; private final DepositService depositService; - private final CurriculumRepository curriculumRepository; - private final AssignmentItemRepository assignmentItemRepository; - // 1. 출석 시작 코드 (출석코드 생성 함수) @Transactional public AttendanceCode generateCodeAndCreateAttendances(LocalDate date) { // [수정] 세션 ID 대신 날짜를 직접 받음 - // 1. [삭제] 더 이상 세션을 조회해서 날짜를 파싱할 필요가 없습니다. (curriculumRepository 조회 제거) + // 1-1) 해당 날짜에 커리큘럼이 있는지 확인 + if (!curriculumRepository.existsBySessionDate(date)) { + throw new CurriculumException( + CurriculumErrorCode.ATTENDANCE_DATE_NOT_AVAILABLE + ); + } - // 2. 해당 날짜에 생성된 출석 코드 개수 조회 + // 1-2) 해당 날짜에 생성된 출석 코드 개수 조회. long codeCountOfDay = attendanceCodeRepository.countByAttendanceDate(date); if (codeCountOfDay >= 3) { throw new IllegalStateException("하루에 최대 3회까지만 출석 코드를 생성할 수 있습니다."); } - // 3. 기존 활성화된 코드들 만료 처리 + // 1-3) 기존 활성화된 코드들 만료 처리 List activeCodes = attendanceCodeRepository.findByIsExpiredFalse(); for (AttendanceCode activeCode : activeCodes) { activeCode.expire(); @@ -79,11 +82,11 @@ public AttendanceCode generateCodeAndCreateAttendances(LocalDate date) { // [수 } - // 4. 4자리 랜덤 코드 생성 및 차수(Order) 계산 + // 1-4) 4자리 랜덤 코드 생성 및 차수(Order) 계산 String code = String.valueOf(ThreadLocalRandom.current().nextInt(1000, 10000)); String attendanceOrder = String.valueOf(codeCountOfDay + 1); // 1회차, 2회차, 3회차 - // 5. 새로운 AttendanceCode 생성 및 저장 + // 1-5) 새로운 AttendanceCode 생성 및 저장 AttendanceCode attendanceCode = AttendanceCode.builder() .attendanceDate(date) // [수정] 파라미터로 받은 날짜 주입 .attendanceOrder(attendanceOrder) @@ -93,11 +96,10 @@ public AttendanceCode generateCodeAndCreateAttendances(LocalDate date) { // [수 attendanceCodeRepository.save(attendanceCode); - // 6. 모든 MEMBER 유저에 대해 '현재 생성된 출석 코드' 기준 초기 출석 데이터 생성 + // 1-6) 모든 MEMBER 유저에 대해 '현재 생성된 출석 코드' 기준 초기 출석 데이터 생성 List users = userRepository.findByRole(Role.MEMBER); for (User user : users) { - // [확인] 이미 완벽하게 studySession 대신 attendanceCode를 주입하도록 잘 짜두셨습니다! Attendance attendance = Attendance.builder() .user(user) .attendanceCode(attendanceCode) @@ -233,103 +235,92 @@ public List findByUserIdAndDate(Integer userId, LocalDate dat .toList(); } - // 6. 유저의 전체 출석 현황을 날짜별로 묶어서 조회하는 함수 + // 6. 나의 전체 출석 현황 조회 서비스 public List findByUserId(Integer userId) { List attendances = attendanceRepository.findByUserId(Long.valueOf(userId)); - // LocalDate 기준으로 그룹화 - Map> grouped = attendances.stream() - .collect(Collectors.groupingBy( - attendance -> attendance.getAttendanceCode().getAttendanceDate() - )); + // 날짜별 그룹화 + Map> dateGrouped = + attendances.stream() + .collect(Collectors.groupingBy( + attendance -> + attendance.getAttendanceCode().getAttendanceDate() + )); + + // 주차별 그룹화 + Map> weekGrouped = + new HashMap<>(); + + for (Map.Entry> entry : dateGrouped.entrySet()) { + + LocalDate date = entry.getKey(); + + StudySession studySession = + curriculumRepository + .findFirstBySessionDate(date) + .orElseThrow(() -> + new RuntimeException("세션이 존재하지 않습니다.") + ); + + int week = studySession.getWeek().intValue(); + + List slots = + entry.getValue().stream() + .map(attendance -> + new AttendanceSlotRes( + attendance.getAttendanceCode().getId(), + attendance.getStatus() + ) + ) + .sorted( + Comparator.comparing( + AttendanceSlotRes::getAttendanceCodeId + ) + ) + .toList(); + + AttendanceDayStatusRes dayRes = new AttendanceDayStatusRes(); + dayRes.setDate(date); + dayRes.setDay(date.getDayOfWeek().toString()); + dayRes.setSlots(slots); - return grouped.entrySet().stream() + + weekGrouped + .computeIfAbsent(week, k -> new ArrayList<>()) + .add(dayRes); + } + + return weekGrouped.entrySet().stream() .map(entry -> { - LocalDate date = entry.getKey(); + AttendanceStatusRes dto = + new AttendanceStatusRes(); - List slots = entry.getValue().stream() - .map(attendance -> new AttendanceSlotRes( - attendance.getAttendanceCode().getId(), - attendance.getStatus() - )) - .sorted(Comparator.comparing(AttendanceSlotRes::getAttendanceCodeId)) - .toList(); + dto.setWeek(entry.getKey()); - AttendanceStatusRes dto = new AttendanceStatusRes(); - dto.setDate(date); - dto.setSlots(slots); + dto.setDays( + entry.getValue().stream() + .sorted( + Comparator.comparing( + AttendanceDayStatusRes::getDate + ) + ) + .toList() + ); return dto; }) - .sorted(Comparator.comparing(AttendanceStatusRes::getDate).reversed()) + .sorted( + Comparator.comparing( + AttendanceStatusRes::getWeek + ) + ) .toList(); } -// -// // 6. 유저 상태 변경 (관리자) -// // 컨트롤러 부분은 출석만 받는데 여기는 출석&과제 둘 다 받아서 추후에 수정 예정 -// @Transactional -// public boolean updateUserStatus(Integer userId, UpdateUserStatusReq req) { -// boolean updated = false; -// -// // 출석 상태 변경 코드 -// if (req.getAttendanceId() != null && req.getAttendanceStatus() != null) { -// Attendance attendance = attendanceRepository.findById(req.getAttendanceId()) -// .orElseThrow(() -> new IllegalArgumentException("출석 기록을 찾을 수 없습니다.")); -// -// if (!attendance.getUser().getId().equals(userId)) { -// throw new IllegalArgumentException("요청된 사용자와 출석 기록의 사용자가 일치하지 않습니다."); -// } -// -// attendance.updateStatus(req.getAttendanceStatus()); -// updated = true; -// } -// -// // 과제 상태 변경 코드 -// if (req.getAssignmentItemId() != null && req.getAssignmentStatus() != null) { -// AssignmentItem assignmentItem = assignmentItemRepository.findById(Math.toIntExact(req.getAssignmentItemId())) -// .orElseThrow(() -> new IllegalArgumentException("과제 기록을 찾을 수 없습니다.")); -// -// if (!assignmentItem.getUser().getId().equals(userId)) { -// throw new IllegalArgumentException("요청된 사용자와 과제 기록의 사용자가 일치하지 않습니다."); -// } -// -// assignmentItem.updateSubmitted(req.getAssignmentStatus()); -// updated = true; -// } -// -// // 출석 변경 → 보증금 재계산 (과제 변경도 포함이 되어 있나..?) -// if (updated) { -// depositService.recalculateDeposit(Long.valueOf(userId)); -// } -// -// return updated; -// } -} - -/* - // 관리자가 유저의 출석 상태를 변경하는 함수(나중에 과제까지 같이 변경되도록 수정할 것) - @Transactional - public boolean updateAttendanceStatus(Long attendanceId, boolean status) { - Optional attendanceOpt = attendanceRepository.findById(attendanceId); - - if (attendanceOpt.isEmpty()) { - return false; - } - // 출석 상태 변경 - Attendance attendance = attendanceOpt.get(); - attendance.setStatus(status); - attendanceRepository.save(attendance); - - // 출석 변경 → 보증금 재계산 - depositService.recalculateDeposit(attendance.getUser().getId()); - - return true; - } +} - */ diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/code/CurriculumErrorCode.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/code/CurriculumErrorCode.java index 07258ef..21a760f 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/code/CurriculumErrorCode.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/exception/code/CurriculumErrorCode.java @@ -30,6 +30,12 @@ public enum CurriculumErrorCode { HttpStatus.BAD_REQUEST, "CURRICULUM405", "해당 주차/요일의 세션이 존재하지 않습니다. 세션을 먼저 생성해주세요." + ), + + ATTENDANCE_DATE_NOT_AVAILABLE( + HttpStatus.BAD_REQUEST, + "CURRICULUM406", + "해당 날짜는 세션 진행일이 아닙니다. 세션 일정 또는 커리큘럼을 확인해주세요." ); private final HttpStatus status; diff --git a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/repository/CurriculumRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/repository/CurriculumRepository.java index 35719a8..35e1501 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/curriculum/repository/CurriculumRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/curriculum/repository/CurriculumRepository.java @@ -6,6 +6,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; /* StudySession(세션) DB 접근 인터페이스 @@ -25,6 +26,11 @@ public interface CurriculumRepository extends JpaRepository List findByWeekOrderBySessionDateAsc(Long week); + boolean existsBySessionDate(LocalDate sessionDate); + + Optional findFirstBySessionDate(LocalDate sessionDate); + + // @Query(""" // SELECT s // FROM StudySession s diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionLikeRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionLikeRepository.java index 9fb38f3..2d3cb3a 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionLikeRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionLikeRepository.java @@ -4,7 +4,10 @@ import com.example.Piroin.project.domain.question.entity.QuestionLike; import com.example.Piroin.project.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface QuestionLikeRepository extends JpaRepository { @@ -19,4 +22,12 @@ public interface QuestionLikeRepository extends JpaRepository findLikedQuestionIdsByQuestionIdsAndUser( + @Param("questionIds") List questionIds, + @Param("user") User user + ); } \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java index a1aca36..af58000 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java @@ -27,7 +27,9 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; +import java.util.Set; import java.util.Map; +import java.util.HashSet; import java.util.stream.Collectors; @Service @@ -132,7 +134,7 @@ public QuestionResDTO.CommentCreateRes createComment( Question question = findQuestion(questionId); // 1. 대댓글 여부 확인: parentCommentId가 있으면 부모 댓글 조회 - QuestionComment parentComment = resolveParentComment(request.getParentCommentId()); + QuestionComment parentComment = resolveParentComment(request.getParentCommentId(), question); // 2. 댓글 엔티티 생성 및 저장 LocalDateTime now = LocalDateTime.now(); @@ -168,12 +170,27 @@ public QuestionResDTO.CommentCreateRes createComment( } // parentCommentId가 있으면 해당 댓글 조회, 없으면 null 반환 - private QuestionComment resolveParentComment(Long parentCommentId) { + private QuestionComment resolveParentComment(Long parentCommentId, Question question) { if (parentCommentId == null) { return null; } - return questionCommentRepository.findById(parentCommentId) + QuestionComment parent = questionCommentRepository.findById(parentCommentId) .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "부모 댓글을 찾을 수 없습니다.")); + + // 삭제된 댓글에는 대댓글을 달 수 없음 + if (parent.getDeletedAt() != null) { + throw new QuestionException(HttpStatus.BAD_REQUEST, "삭제된 댓글에는 대댓글을 달 수 없습니다."); + } + // 다른 질문의 댓글을 부모로 붙이는 것 방지 + if (!parent.getQuestion().getId().equals(question.getId())) { + throw new QuestionException(HttpStatus.BAD_REQUEST, "다른 질문의 댓글에는 대댓글을 달 수 없습니다."); + } + // 대댓글에 또 대댓글을 다는 것 방지 (2depth 제한) + if (parent.getParentComment() != null) { + throw new QuestionException(HttpStatus.BAD_REQUEST, "대댓글에는 대댓글을 달 수 없습니다."); + } + + return parent; } /* @@ -574,7 +591,7 @@ private QuestionResDTO.UnderstandingCheckResponse toUnderstandingCheckResponse( private QuestionResDTO.QuestionGroupsResponse getQuestionGroups(StudySession session, User loginUser) { List questions = questionRepository.findBySessionAndDeletedAtIsNull(session); - QuestionSummaryContext summaryContext = getQuestionSummaryContext(questions); + QuestionSummaryContext summaryContext = getQuestionSummaryContext(questions, loginUser); List popularQuestions = questions.stream() .filter(q -> !q.getIsResolved() && q.getLikeCount() >= POPULAR_LIKE_THRESHOLD) @@ -601,7 +618,7 @@ private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse ( User loginUser ) { Long questionId = question.getId(); - boolean isLiked = questionLikeRepository.existsByQuestionAndUser(question, loginUser); + boolean isLiked = summaryContext.likedQuestionIds().contains(questionId); boolean isMine = question.getUser().getId().equals(loginUser.getId()); return new QuestionResDTO.QuestionSummaryResponse( questionId, question.getContent(), question.getImageUrl(), @@ -617,9 +634,9 @@ private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse ( ); } - private QuestionSummaryContext getQuestionSummaryContext(List questions) { + private QuestionSummaryContext getQuestionSummaryContext(List questions, User loginUser) { if (questions.isEmpty()) { - return new QuestionSummaryContext(Map.of(), Map.of()); + return new QuestionSummaryContext(Map.of(), Map.of(), Set.of()); } List questionIds = questions.stream() @@ -636,14 +653,17 @@ private QuestionSummaryContext getQuestionSummaryContext(List question questionCommentRepository.findPreviewCommentsByQuestionIds(questionIds) .forEach(row -> { Question question = questionsById.get(row.getQuestionId()); - if (question == null) { - return; - } + if (question == null) return; previewComments.computeIfAbsent(row.getQuestionId(), key -> new ArrayList<>()) .add(toPreviewCommentResponse(question, row)); }); - return new QuestionSummaryContext(commentCounts, previewComments); + // 좋아요 여부를 질문마다 조회하는 대신 한 번에 배치 조회한다. + Set likedQuestionIds = new HashSet<>( + questionLikeRepository.findLikedQuestionIdsByQuestionIdsAndUser(questionIds, loginUser) + ); + + return new QuestionSummaryContext(commentCounts, previewComments, likedQuestionIds); } private QuestionResDTO.PreviewCommentResponse toPreviewCommentResponse( @@ -677,16 +697,26 @@ private String getPreviewDisplayName(Question question, QuestionCommentRepositor private void publishCommentCreatedEventAfterCommit(Question question) { Long sessionId = question.getSession().getId(); Long questionId = question.getId(); - QuestionSummaryContext summaryContext = getQuestionSummaryContext(List.of(question)); - // 프론트가 전체 목록을 다시 조회하지 않고 해당 질문만 갱신할 수 있는 최소 데이터만 보낸다. + // 이벤트 발행용이라 좋아요 여부가 필요 없으므로 댓글 수/미리보기만 직접 조회 + List questionIds = List.of(questionId); + + Map commentCounts = new HashMap<>(); + questionCommentRepository.countByQuestionIds(questionIds) + .forEach(row -> commentCounts.put(row.getQuestionId(), Math.toIntExact(row.getCommentCount()))); + + Map> previewComments = new HashMap<>(); + questionCommentRepository.findPreviewCommentsByQuestionIds(questionIds) + .forEach(row -> previewComments.computeIfAbsent(row.getQuestionId(), key -> new ArrayList<>()) + .add(toPreviewCommentResponse(question, row))); + QuestionResDTO.CommentCreatedEvent event = new QuestionResDTO.CommentCreatedEvent( "COMMENT_CREATED", sessionId, questionId, question.getIsResolved(), - summaryContext.commentCounts().getOrDefault(questionId, 0), - summaryContext.previewComments().getOrDefault(questionId, List.of()) + commentCounts.getOrDefault(questionId, 0), + previewComments.getOrDefault(questionId, List.of()) ); publishAfterCommit(() -> questionEventService.publishCommentCreated(sessionId, event)); @@ -759,7 +789,8 @@ public void afterCommit() { private record QuestionSummaryContext( Map commentCounts, - Map> previewComments + Map> previewComments, + Set likedQuestionIds ) { } } diff --git a/backend/src/main/java/com/example/Piroin/project/global/config/CorsConfig.java b/backend/src/main/java/com/example/Piroin/project/global/config/CorsConfig.java index ef95a6b..e56e761 100644 --- a/backend/src/main/java/com/example/Piroin/project/global/config/CorsConfig.java +++ b/backend/src/main/java/com/example/Piroin/project/global/config/CorsConfig.java @@ -14,12 +14,17 @@ public class CorsConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); + // 모든 출처(도메인) 허용 - Vercel 프론트엔드 등 다양한 도메인에서 요청 가능 config.setAllowedOriginPatterns(List.of("*")); + // preflight(OPTIONS) 포함 모든 HTTP 메서드 허용 config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + // Authorization 등 모든 요청 헤더 허용 config.setAllowedHeaders(List.of("*")); + // 쿠키/인증 정보 포함 요청 허용 config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + // 모든 경로에 위 CORS 설정 적용 source.registerCorsConfiguration("/**", config); return source; } 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 b05f584..083a9e9 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 @@ -27,24 +27,19 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http + // CorsConfig에서 등록한 CORS 설정을 Spring Security 필터 체인에 적용 + // 이 설정이 없으면 preflight(OPTIONS) 요청이 Security 단에서 차단되어 405 반환 .cors(cors -> cors.configurationSource(corsConfigurationSource)) .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - // 로그인 페이지는 로그인 안 된 상태에서 접근 가능 + // 로그인 .requestMatchers("/api/auth/login").permitAll() - // curriculum: GET은 로그인한 누구나, POST/PATCH/DELETE는 ADMIN만 -> 이중 보안 느낌 - .requestMatchers(HttpMethod.GET, "/api/curriculums").authenticated() - .requestMatchers(HttpMethod.POST, "/api/curriculums").hasRole("ADMIN") - .requestMatchers(HttpMethod.PATCH, "/api/curriculums/{sessionDate}").hasRole("ADMIN") - .requestMatchers(HttpMethod.DELETE, "/api/curriculums/{sessionDate}").hasRole("ADMIN") - - // understanding check: 생성은 ADMIN만 가능 - .requestMatchers(HttpMethod.POST, "/api/sessions/{sessionId}/understanding-checks").hasRole("ADMIN") - // Swagger .requestMatchers( "/swagger-ui/**", @@ -55,10 +50,25 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // Actuator health check .requestMatchers("/actuator/health").permitAll() - // 다른 도메인 권한 설정 필요 시 위 패턴 참고해서 추가 - // 단, 추가하지 않아도 무방함 - // 이유 1. anyRequest().authenticated()로 비로그인 접근 차단 - // 이유 2. 프론트에서 ADMIN 전용 버튼/기능을 UI 단에서 숨김 처리 + // ADMIN 전용 엔드포인트 + .requestMatchers("/api/admin/**").hasRole("ADMIN") + + .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.POST, "/api/assignments/create").hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/assignments/modify/{assignmentId}").hasRole("ADMIN") + .requestMatchers(HttpMethod.DELETE, "/api/assignments/{assignmentId}").hasRole("ADMIN") + .requestMatchers(HttpMethod.GET, "/api/assignments/{week}/view").hasRole("ADMIN") + + .requestMatchers(HttpMethod.GET, "/api/deposit/{userId}/deposit/view").hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/deposit/{userId}/deposit/defence").hasRole("ADMIN") + + .requestMatchers(HttpMethod.POST, "/api/sessions/{sessionId}/understanding-checks").hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/questions/{questionId}/status").hasRole("ADMIN") + + // 나머지는 로그인한 사용자면 접근 가능 .anyRequest().authenticated() ) diff --git a/backend/src/main/resources/db/migration/V6__add_unique_constraints_like_and_understanding_response.sql b/backend/src/main/resources/db/migration/V6__add_unique_constraints_like_and_understanding_response.sql new file mode 100644 index 0000000..6d711d8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__add_unique_constraints_like_and_understanding_response.sql @@ -0,0 +1,9 @@ +-- QuestionLike: 같은 유저가 같은 질문에 좋아요를 중복으로 누르는 것을 DB 레벨에서 차단 +ALTER TABLE question_like + ADD CONSTRAINT uq_question_like_question_user + UNIQUE (question_id, user_id); + +-- UnderstandingResponse: 같은 유저가 같은 이해도 체크에 중복 응답하는 것을 DB 레벨에서 차단 +ALTER TABLE understanding_response + ADD CONSTRAINT uq_understanding_response_check_user + UNIQUE (check_id, user_id); \ No newline at end of file diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js index d2c815c..ce10952 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -1,23 +1,81 @@ +import { useState, useEffect, useCallback } from 'react'; import { NavLink } from 'react-router-dom'; import styles from './Header.module.css'; function Header({ type }) { + const [menuOpen, setMenuOpen] = useState(false); + + const closeMenu = useCallback(() => setMenuOpen(false), []); + + useEffect(() => { + const mq = window.matchMedia('(min-width: 1025px)'); + const handler = (e) => { if (e.matches) closeMenu(); }; + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, [closeMenu]); + + useEffect(() => { + document.body.style.overflow = menuOpen ? 'hidden' : ''; + return () => { document.body.style.overflow = ''; }; + }, [menuOpen]); + + const handleLogout = () => { + localStorage.removeItem('token'); + localStorage.removeItem('role'); + window.location.href = '/login'; + }; + + const themeClass = type === 'dark' ? styles.dark : styles.light; + return ( -
- PIROIN -
+ ); } diff --git a/frontend/src/components/Header.module.css b/frontend/src/components/Header.module.css index fb275f0..9b3c98e 100644 --- a/frontend/src/components/Header.module.css +++ b/frontend/src/components/Header.module.css @@ -10,7 +10,7 @@ --header-bg: var(--black); --header-color: var(--white); --logo-color: var(--main); - border-bottom: none; /* 추가 */ + border-bottom: none; } .header { @@ -21,7 +21,6 @@ align-items: center; padding: 0 80px; box-sizing: border-box; - position: relative; position: sticky; top: 0; z-index: 100; @@ -33,8 +32,10 @@ font-size: 2.8rem; font-weight: 800; text-decoration: none; + flex-shrink: 0; } +/* ── 데스크탑 nav ── */ .nav { display: flex; gap: 6rem; @@ -51,6 +52,7 @@ font-size: 1.4rem; font-weight: 500; text-decoration: none; + white-space: nowrap; } .nav a:hover { @@ -72,6 +74,163 @@ font-weight: 500; cursor: pointer; opacity: 0.7; + flex-shrink: 0; + white-space: nowrap; } -.logoutBtn:hover { opacity: 1; transition: all ease-in-out 0.2s; } \ No newline at end of file +.logoutBtn:hover { + opacity: 1; + transition: all ease-in-out 0.2s; +} + +/* ── 햄버거 버튼 ── */ +.hamburger { + display: none; + flex-direction: column; + justify-content: center; + gap: 5px; + margin-left: auto; + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + z-index: 200; +} + +.hamburger span { + display: block; + width: 24px; + height: 2px; + background: var(--header-color); + border-radius: 2px; + transition: transform 0.3s ease, opacity 0.3s ease, width 0.3s ease; + transform-origin: center; +} + +.hamburgerOpen span:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} +.hamburgerOpen span:nth-child(2) { + opacity: 0; + width: 0; +} +.hamburgerOpen span:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); +} + +/* ── 오버레이 (항상 DOM에 존재, visibility로 제어) ── */ +.overlay { + position: fixed; + inset: 0; + z-index: 140; + opacity: 0; + visibility: hidden; + transition: opacity 0.35s ease, visibility 0.35s ease; + backdrop-filter: blur(2px); +} + +.overlayVisible { + opacity: 1; + visibility: visible; +} + +/* ── 드로어 (항상 DOM에 존재, transform으로 제어) ── */ +.drawer { + position: fixed; + top: 0; + right: 0; + width: 260px; + height: 100vh; + z-index: 150; + display: flex; + flex-direction: column; + padding: 90px 36px 40px; + box-sizing: border-box; + transform: translateX(100%); + visibility: hidden; + transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), + visibility 0.35s ease; + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); +} + +.drawerOpen { + transform: translateX(0); + visibility: visible; +} + +.drawer a { + color: var(--header-color); + font-family: var(--font-main); + font-size: 1.2rem; + font-weight: 500; + text-decoration: none; + padding: 16px 0; + border-bottom: 1px solid rgba(128, 128, 128, 0.15); + transition: color 0.2s ease; +} + +.drawer a:last-of-type { + border-bottom: none; +} + +.drawer a:hover { + color: var(--logo-color); +} + +.drawerLogoutBtn { + margin-top: auto; + background: transparent; + border: 1px solid rgba(128, 128, 128, 0.3); + border-radius: 8px; + color: var(--dark); + font-family: var(--font-main); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + opacity: 1; + padding: 10px 0; + transition: opacity 0.2s ease; +} + +.drawerLogoutBtn:hover { + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; +} + +/* ── 반응형: 1024px 이하에서 햄버거로 전환 ── */ +@media (max-width: 1024px) { + .header { + padding: 0 24px; + } + + .nav, + .logoutBtn { + display: none; + } + + .hamburger { + display: flex; + } +} + +/* ── 드로어 닫기 버튼 ── */ +.drawerCloseBtn { + position: absolute; + top: 20px; + right: 20px; + background: transparent; + border: none; + color: var(--header-color); + font-size: 1.8rem; + cursor: pointer; + opacity: 0.6; + line-height: 1; + padding: 4px 8px; + transition: opacity 0.2s ease; +} + +.drawerCloseBtn:hover { + opacity: 1; +} + \ No newline at end of file diff --git a/frontend/src/pages/OnboardingPage.module.css b/frontend/src/pages/OnboardingPage.module.css index 890b008..f25dab2 100644 --- a/frontend/src/pages/OnboardingPage.module.css +++ b/frontend/src/pages/OnboardingPage.module.css @@ -47,4 +47,34 @@ font-size: 18px; margin: 4px 0; font-weight: 550; + text-align: center; +} + +/* ── 모바일 ── */ +@media (max-width: 480px) { + .title { + font-size: 60px; + margin-bottom: 32px; + } + + .logoWrap { + width: 200px; + height: 200px; + margin-bottom: 32px; + } + + .logoWrap img { + width: 200px; + height: 200px; + } + + .circle { + width: 86px; + height: 86px; + } + + .sub { + font-size: 14px; + padding: 0 24px; + } } \ No newline at end of file diff --git a/frontend/src/pages/curriculum/CurriculumPage.js b/frontend/src/pages/curriculum/CurriculumPage.js index d369939..e65f1ce 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.js +++ b/frontend/src/pages/curriculum/CurriculumPage.js @@ -12,6 +12,15 @@ const DAY_LABEL = { TUESDAY: '화요일', THURSDAY: '목요일', SATURDAY: '토 const STATUS_OPTIONS = ['BEFORE', 'ONGOING', 'AFTER']; const STATUS_LABEL = { BEFORE: '세션 전', ONGOING: '세션 중', AFTER: '세션 후' }; +// sessionDate(yyyy-mm-dd)에서 요일 계산 +function getWeekDayFromDate(dateStr) { + if (!dateStr) return ''; + const [year, month, day] = dateStr.split('-').map(Number); + const date = new Date(year, month - 1, day); + const map = { 2: '화요일', 4: '목요일', 6: '토요일' }; + return map[date.getDay()] || ''; +} + // ── 세션 정보 렌더 (공통) ───────────────────────────── function SessionInfo({ session, isAdmin }) { const icon = session.dayPart === 'AM' ? AmImg : PmImg; @@ -47,7 +56,7 @@ function MemberSessionCard({ day }) { const [isOpen, setIsOpen] = useState(false); const amSession = day.sessions?.find(s => s.dayPart === 'AM'); const pmSession = day.sessions?.find(s => s.dayPart === 'PM'); - const weekDay = DAY_LABEL[day.dayOfWeek] || ''; + const weekDay = getWeekDayFromDate(day.sessionDate) || DAY_LABEL[day.dayOfWeek] || ''; return (
@@ -56,7 +65,7 @@ function MemberSessionCard({ day }) { {day.week}주차 {weekDay} 세션 {day.sessionDate}
- toggle + toggle
@@ -81,10 +90,10 @@ function MemberSessionCard({ day }) { // ── 운영진용 세션 카드 ──────────────────────────────── function AdminSessionCard({ day, onEdit, onDelete }) { - const [isOpen, setIsOpen] = useState(true); + const [isOpen, setIsOpen] = useState(false); const amSession = day.sessions?.find(s => s.dayPart === 'AM'); const pmSession = day.sessions?.find(s => s.dayPart === 'PM'); - const weekDay = DAY_LABEL[day.dayOfWeek] || ''; + const weekDay = getWeekDayFromDate(day.sessionDate) || DAY_LABEL[day.dayOfWeek] || ''; return (
diff --git a/frontend/src/pages/curriculum/CurriculumPage.module.css b/frontend/src/pages/curriculum/CurriculumPage.module.css index c899b66..57633ed 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.module.css +++ b/frontend/src/pages/curriculum/CurriculumPage.module.css @@ -31,7 +31,7 @@ .weekTitle { font-family: var(--font-main); font-size: 1.6rem; - font-weight: 700; + font-weight: 650; color: var(--black); } @@ -63,7 +63,7 @@ gap: 20px; flex-wrap: wrap; align-items: flex-start; - justify-content: flex-start; + justify-content: flex-start; } /* 세션 카드 */ @@ -71,10 +71,16 @@ background: var(--white); border: 1px solid #eee; border-radius: 20px; - padding: 30px; + padding: 30px 30px 24px 30px; min-width: 200px; width: calc(28% - 14px); + max-height: 380px; + display: flex; + flex-direction: column; box-shadow: 0 1px 4px rgba(0,0,0,0.06); + box-sizing: border-box; + overflow: hidden; + transition: max-height 0.3s ease; } .cardHeader { @@ -82,13 +88,14 @@ align-items: center; justify-content: space-between; cursor: pointer; - margin-bottom: 0; + margin-bottom: 0; } .cardHeaderLeft { display: flex; align-items: center; gap: 10px; + flex-wrap: wrap; } .cardTitle { @@ -114,12 +121,15 @@ display: flex; flex-direction: column; gap: 12px; + overflow-y: auto; + flex: 1; + padding-bottom: 4px; } .divider { - border: none; + border: none; border-top: 1px solid var(--gray200); - margin: 20px 0 20px 0; + margin: 20px 0; } /* 세션 정보 */ @@ -144,13 +154,17 @@ } .sessionTitleRow { - margin: 5px 0; + display: flex; + align-items: center; + gap: 8px; + margin: 5px 0; } .sessionIcon { width: 18px; height: 18px; object-fit: contain; + flex-shrink: 0; filter: brightness(0) saturate(100%) invert(44%) sepia(98%) saturate(500%) hue-rotate(90deg) brightness(95%) contrast(110%); } @@ -160,6 +174,11 @@ font-weight: 550; color: var(--black); padding: 5px 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; } .sessionHost { @@ -167,12 +186,16 @@ font-size: 0.9rem; color: var(--gray600); margin-left: auto; + flex-shrink: 0; + white-space: nowrap; } .sessionDetailRow { - display: flex; - justify-content: space-between; - padding: 3px 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 3px 0 3px 26px; } .sessionLink { @@ -180,12 +203,16 @@ font-size: 0.9rem; color: var(--black); text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; } .sessionLink:hover { - color: var(--dark); - transition: all ease-in-out 0.2s; - cursor: pointer; + color: var(--dark); + transition: all ease-in-out 0.2s; + cursor: pointer; } .sessionLinkName { @@ -206,6 +233,24 @@ color: var(--black); } +.sessionDetailLabel { + font-family: var(--font-main); + font-size: 0.9rem; + color: var(--black); + min-width: 50px; + flex-shrink: 0; +} + +.sessionDetailVal { + font-family: var(--font-main); + font-size: 0.9rem; + color: var(--black); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; +} + /* 과제 */ .assignmentRow { display: flex; @@ -214,6 +259,7 @@ gap: 8px; padding-top: 4px; margin-left: 22px; + flex-wrap: wrap; } .assignmentLabel { @@ -249,9 +295,9 @@ } .editBtn:hover { - background: var(--dark); - color: var(--white); - transition: all ease-in-out 0.2s; + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; } .deleteBtn { @@ -266,9 +312,9 @@ } .deleteBtn:hover { - background: var(--dark); - color: var(--white); - transition: all ease-in-out 0.2s; + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; } /* 세션 생성/수정 폼 */ @@ -289,9 +335,11 @@ border-radius: 16px; padding: 40px; width: 560px; + max-width: 100%; display: flex; flex-direction: column; gap: 16px; + box-sizing: border-box; } .formTitle { @@ -313,6 +361,7 @@ align-items: center; gap: 8px; margin-top: 8px; + flex-wrap: wrap; } .amLabel { @@ -333,6 +382,7 @@ display: flex; gap: 6px; margin-left: auto; + flex-wrap: wrap; } .statusBtn { @@ -347,9 +397,9 @@ } .statusBtn:hover { - background: var(--dark); - color: var(--white); - transition: all ease-in-out 0.2s; + background: var(--dark); + color: var(--white); + transition: all ease-in-out 0.2s; } .statusActive { @@ -412,10 +462,10 @@ text-align: center; } -/* 추가 스타일 */ .toggleIcon { width: 14px; height: 14px; + flex-shrink: 0; transition: transform 0.3s ease; filter: brightness(0) saturate(100%) invert(44%) sepia(60%) saturate(1693%) hue-rotate(89deg) brightness(107%) contrast(95%); } @@ -424,34 +474,83 @@ transform: rotate(180deg); } -.sessionTitleRow { - display: flex; - align-items: center; - gap: 8px; -} - -.sessionDetailRow { - display: flex; - align-items: center; - gap: 8px; - padding-left: 26px; -} - -.sessionDetailLabel { - font-family: var(--font-main); - font-size: 0.9rem; - color: var(--black); - min-width: 50px; -} - -.sessionDetailVal { - font-family: var(--font-main); - font-size: 0.9rem; - color: var(--black); -} - .formRow2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; +} + +/* ── 태블릿: 카드 2열 ── */ +@media (max-width: 1100px) { + .container { + padding: 32px 32px; + } + + .sessionCard { + width: calc(50% - 10px); + } +} + +/* ── 모바일: 카드 1열, 폼 풀스크린 ── */ +@media (max-width: 640px) { + .container { + padding: 24px 16px; + } + + .sessionCard { + width: 100%; + min-width: unset; + padding: 20px; + height: auto; + overflow: visible; + } + + .cardBody { + overflow-y: visible; + flex: none; + } + + .cardTitle { + font-size: 1.1rem; + } + + .cardDate { + margin-left: 0; + } + + /* 폼 */ + .formOverlay { + padding: 0; + align-items: flex-start; + } + + .formCard { + width: 100%; + min-height: 100vh; + border-radius: 0; + padding: 28px 20px; + } + + .formGrid { + grid-template-columns: 1fr; + } + + .formRow2 { + grid-template-columns: 1fr; + } + + .saveFormBtn { + width: 100%; + } + + .statusBtns { + margin-left: 0; + margin-top: 6px; + width: 100%; + } + + .formSectionTitle { + flex-direction: column; + align-items: flex-start; + } } \ No newline at end of file diff --git a/frontend/src/pages/login/LoginPage.js b/frontend/src/pages/login/LoginPage.js index 7fbd50d..6e7840d 100644 --- a/frontend/src/pages/login/LoginPage.js +++ b/frontend/src/pages/login/LoginPage.js @@ -1,6 +1,5 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { authFetch } from '../../utils/Api'; import styles from './LoginPage.module.css'; function LoginPage() { @@ -8,12 +7,10 @@ function LoginPage() { const [focused, setFocused] = useState(''); const [form, setForm] = useState({ name: '', password: '' }); - const handleChange = (e) => { setForm({ ...form, [e.target.name]: e.target.value }); }; - const handleLogin = async () => { try { const response = await fetch('/api/auth/login', { @@ -27,9 +24,8 @@ function LoginPage() { localStorage.setItem('token', data.token); localStorage.setItem('role', data.role); localStorage.setItem('name', data.name); - navigate('/sessions'); // 로그인 성공 시 이동할 페이지 + navigate('/sessions'); } else { - const errData = await response.json(); alert('이름 또는 비밀번호가 올바르지 않습니다.'); } } catch (error) { @@ -37,13 +33,18 @@ function LoginPage() { } }; - useEffect(() => { + // 엔터키 로그인 + const handleKeyDown = (e) => { + if (e.key === 'Enter') handleLogin(); + }; + + useEffect(() => { document.title = "로그인 | PIROIN"; }, []); return (
-

PIROIN

+

navigate('/')}>PIROIN

setFocused('name')} onBlur={() => setFocused('')} @@ -61,6 +63,7 @@ function LoginPage() { placeholder="비밀번호" value={form.password} onChange={handleChange} + onKeyDown={handleKeyDown} className={`${styles.input} ${focused === 'pw' ? styles.inputFocused : ''}`} onFocus={() => setFocused('pw')} onBlur={() => setFocused('')} diff --git a/frontend/src/pages/login/LoginPage.module.css b/frontend/src/pages/login/LoginPage.module.css index 3a146cd..bf710c8 100644 --- a/frontend/src/pages/login/LoginPage.module.css +++ b/frontend/src/pages/login/LoginPage.module.css @@ -13,6 +13,8 @@ font-size: 56px; font-weight: 900; margin-bottom: 48px; + cursor: pointer; + text-decoration: none; } .form { @@ -30,6 +32,7 @@ font-size: 16px; outline: none; transition: border 0.2s; + box-sizing: border-box; } .inputFocused { @@ -51,4 +54,18 @@ .button:hover { background-color: var(--main); transition: all 0.2s ease; +} + +/* ── 모바일 ── */ +@media (max-width: 480px) { + .title { + font-size: 40px; + margin-bottom: 32px; + } + + .form { + width: 100%; + padding: 0 24px; + box-sizing: border-box; + } } \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/PIroCheckMain.module.css b/frontend/src/pages/pirocheck/PIroCheckMain.module.css index 9a0663a..856186b 100644 --- a/frontend/src/pages/pirocheck/PIroCheckMain.module.css +++ b/frontend/src/pages/pirocheck/PIroCheckMain.module.css @@ -4,7 +4,7 @@ align-items: center; justify-content: center; gap: 20px; - height: calc(100vh - 100px); + height: calc(100vh - 100px); background: var(--black); } @@ -25,4 +25,13 @@ .menuBtn:hover { background: var(--dark); color: var(--white); +} + +/* ── 모바일 ── */ +@media (max-width: 480px) { + .menuBtn { + width: calc(100% - 48px); + font-size: 1.5rem; + padding: 20px 0; + } } \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/assignment/Assignment.js b/frontend/src/pages/pirocheck/assignment/Assignment.js index 81a0f13..27786c7 100644 --- a/frontend/src/pages/pirocheck/assignment/Assignment.js +++ b/frontend/src/pages/pirocheck/assignment/Assignment.js @@ -56,13 +56,14 @@ function AssignmentModal({ item, onClose, onSave }) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: form.title, week: form.week, day: form.day }), }); - onSave(); + onSave(form.title); onClose(); }; return (
+ logo
ASSIGNMENT
@@ -113,21 +114,21 @@ function WeekBlock({ weekData, role, onEdit, onDelete }) { {dayMap[session.day]} {session.sessionDate && {session.sessionDate}}
- {role === 'ADMIN' && ( -
- - -
- )}
{session.items.map((item, k) => (
{item.title} {role === 'MEMBER' && } + {role === 'ADMIN' && ( +
+ + +
+ )}
))} {j < grouped.length - 1 &&
} @@ -156,6 +157,18 @@ function Assignment() { setWeeks(results); }; + // 수정 시 로컬 state만 업데이트 (순서 유지) + const handleEditSave = (updatedItem) => { + setWeeks(prev => prev.map(w => ({ + ...w, + assignments: w.assignments.map(a => + a.assignmentId === updatedItem.assignmentId + ? { ...a, ...updatedItem } + : a + ) + }))); + }; + useEffect(() => { fetchAll(); }, []); const handleDelete = async (assignmentId) => { @@ -186,7 +199,7 @@ function Assignment() { setModalItem(undefined)} - onSave={fetchAll} + onSave={modalItem ? (updated) => handleEditSave({ ...modalItem, title: updated }) : fetchAll} /> )}
diff --git a/frontend/src/pages/pirocheck/assignment/Assignment.module.css b/frontend/src/pages/pirocheck/assignment/Assignment.module.css index 5ca20b9..8621bcf 100644 --- a/frontend/src/pages/pirocheck/assignment/Assignment.module.css +++ b/frontend/src/pages/pirocheck/assignment/Assignment.module.css @@ -36,6 +36,7 @@ border-radius: 16px; margin-bottom: 20px; overflow: hidden; + box-sizing: border-box; } .weekHeader { @@ -153,6 +154,7 @@ .statusIcon { width: 20px; height: 20px; + flex-shrink: 0; } .divider { @@ -195,25 +197,44 @@ } .modal { + position: relative; background: #3a3a3a; border-radius: 20px; padding: 40px 60px; - width: 420px; + width: clamp(360px, 40vw, 520px); display: flex; flex-direction: column; align-items: center; - gap: 16px; + gap: 16px; + box-sizing: border-box; } +/* X 닫기 버튼 */ +.modalCloseBtn { + position: absolute; + top: 16px; + right: 20px; + background: transparent; + border: none; + color: #aaa; + font-size: 1.4rem; + cursor: pointer; + line-height: 1; + padding: 4px; + transition: color 0.2s; +} + +.modalCloseBtn:hover { color: var(--white); } + .modalLogo { - width: 200px; - height: 200px; + width: 160px; + height: 160px; object-fit: contain; } .modalTitle { - font-family: var(--font-main); - font-size: 3rem; + font-family: var(--font-title); + font-size: 2.6rem; font-weight: 800; color: var(--main); letter-spacing: 0; @@ -223,36 +244,33 @@ display: flex; align-items: center; gap: 12px; - width: 85%; + width: 100%; margin-top: 10px; } .select { - padding: 10px 36px 10px 20px; + padding: 10px 36px 10px 20px; background-color: var(--pale); - -webkit-appearance: none; -moz-appearance: none; appearance: none; - background-image: url("data:image/svg+xml;utf8,"); background-repeat: no-repeat; background-position: right 16px center; - border: none; border-radius: 8px; font-family: var(--font-main); font-size: 1rem; cursor: pointer; flex: 1; + min-width: 0; } -.select::-ms-expand { - display: none; -} +.select::-ms-expand { display: none; } + .modalInput { - width: 85%; - padding: 12px 20px; + width: 100%; + padding: 12px 20px; background: var(--pale); border: none; border-radius: 8px; @@ -265,6 +283,7 @@ color: var(--white); font-family: var(--font-main); font-size: 1.2rem; + white-space: nowrap; } .saveBtn { @@ -284,4 +303,109 @@ .saveBtn:hover { background: var(--main); color: var(--black); +} + +/* ── 모바일 ── */ +@media (max-width: 640px) { + .container { + padding: 40px 24px; + } + + .title { + font-size: 3rem; + margin-bottom: 28px; + text-align: center; + word-break: keep-all; + line-height: 1.2; + } + + .mockBanner { + width: 100%; + } + + .weekBlock { + width: 100%; + } + + .weekHeader { + padding: 20px; + } + + .weekBody { + padding: 0 20px 20px; + } + + /* 모달 */ + .modalOverlay { + align-items: center; + padding: 24px; + box-sizing: border-box; + } + + .modal { + width: 100%; + max-width: 100%; + border-radius: 20px; + padding: 40px 24px 40px; + } + + .modalLogo { + width: 120px; + height: 120px; + } + + .modalTitle { + font-size: 2rem; + } + + .modalRow { + width: 100%; + } + + .modalInput { + width: 100%; + } + + .addBtn { + bottom: 24px; + right: 20px; + } + + .assignmentTitle { + font-size: 0.9rem; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 8px; + } + + .sessionTitle { + font-size: 0.85rem; + } + + .dayLabel { + font-size: 1.1rem; + } +} +/* ── 태블릿 ── */ +@media (min-width: 641px) and (max-width: 1024px) { + .container { + padding: 48px 32px; + } + + .weekBlock { + width: 100%; + max-width: 720px; + } + + .mockBanner { + width: 100%; + max-width: 720px; + } + + .modal { + width: clamp(400px, 70vw, 560px); + } } \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/attendance/Attendance.js b/frontend/src/pages/pirocheck/attendance/Attendance.js index fe8f350..77968ae 100644 --- a/frontend/src/pages/pirocheck/attendance/Attendance.js +++ b/frontend/src/pages/pirocheck/attendance/Attendance.js @@ -27,6 +27,7 @@ function historyIcon(slots) { function AdminView() { const [code, setCode] = useState(null); const [hasCode, setHasCode] = useState(false); + const [message, setMessage] = useState(''); useEffect(() => { const fetchActiveCode = async () => { @@ -48,7 +49,13 @@ function AdminView() { const res = await authFetch('/api/admin/attendance/start', { method: 'POST' }); const data = await res.json(); setCode(data.code); - setHasCode(true); + if (data.isSuccess) { + setCode(data.result.code); + setHasCode(true); + setMessage(''); + } else { + setMessage(data.message); + } }; const handleExpire = async () => { @@ -67,6 +74,13 @@ function AdminView() {
))}
+ + {message && ( +
+ {message} +
+ )} +