Compare commits

...

17 Commits

Author SHA1 Message Date
5e572c842c Merge pull request '[#72] 로그 레벨 재조정' (#73) from refactor/#72 into main
Reviewed-on: #73
2025-11-08 06:02:09 +00:00
7a236a8196 chore: 미사용 마크다운 제거 2025-10-21 09:44:34 +09:00
0756e21b63 refactor: 로그 레벨 재조정 2025-10-21 08:05:20 +09:00
162e5bbc79 [#70] 중복 조회 로직에 로컬 캐시 도입 (#71)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #70

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- Spring Cache + Caffeine 도입
- 테마 조회 및 수정 / 삭제 로직에 캐시 적용

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- P95 응답 시간 약 14% 개선
- Hikari Pool Connection & Tomcat Threads에서의 개선

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #71
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-17 10:47:15 +00:00
be2e6c606e [#68] ArgumentResolver에서의 불필요한 DB 요청 로직 제거 (#69)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #68

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- ArgumentResolver에서의 DB 조회 요청 제거

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 성능 테스트 결과 Hikari Pending 커넥션의 최대 개수가 80 -> 5로 대폭 감소
- Tomcat 스레드도 기존은 최대 200개까지 활성화 되었으나, 개선 후 최대 80까지만 처리됨.
- P95 응답 시간 235 -> 141ms로 40% 개선

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #69
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-17 06:18:50 +00:00
06f7faf7f9 [#66] 결제 & 예약 확정 로직 수정 (#67)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #66

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 이전 #64 의 작업 이후, 결제 & 예약 확정 API 로직은 총 3개의 독립된 트랜잭션을 사용함.
- 검증 & 배치 충돌 방지를 위한 첫 번째 트랜잭션 이외의 다른 트랜잭션은 불필요하다고 판단함. -> PG사 성공 응답이 오면 나머지 작업은 \@Async 처리 후 바로 성공 응답

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 변경된 로직에 대한 통합 테스트 완료
- 성능 테스트 결과 P95 응답 시간 327.01ms -> 235.52ms / 평균 응답 시간 77.85 -> 68.16ms /  최대 응답 시간 5.26 -> 4.08초 단축

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #67
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-17 04:59:12 +00:00
79de5c9c63 [#64] 결제 & 예약 확정 API에서의 트랜잭션 범위 수정 (#65)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #64

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 기존의 결제 시도 이력 테이블 조회 & 검증 -> 예약 / 일정 조회 및 검증을 하나의 트랜잭션으로 통합
- 예약 / 일정 LOCK 조회를 가장 먼저 수행 -> 배치와의 충돌을 방지하기 위함

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 동일 조건에서 테스트했을 때 P95 응답 시간 749 -> 327ms로 50% 가량 개선 확인
- 커넥션 대기로 길어진 최대 API 응답 시간 7.70 -> 2.88초로 대폭 감소

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #65
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-15 02:24:18 +00:00
5f2e44bb11 [#60] Trace Context의 초기화 오류로 발생하는 OOM 문제 해결 (#63)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #60

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- JVM Heapdump 분석 결과 Opentelemetry의 Context가 과도한 메모리를 사용하고 있었음.(Retain 925MB / 파드 메모리 할당 Limit 2GB 중)
- 원인은, 하나의 요청이 끝날 때 Trace Context가 초기화 되지 않았고, 동일한 Tomcat 스레드에 서로 다른 HTTP 요청의 SPAN이 계속 쌓인 것.
- Slow-query 를 기록하기 위한 Datasource-Proxy와 충돌이 그 이유였고, 슬로우쿼리 기록은 필요하기에 CurrentTraceContext를 이용하여 필터의 Finally 과정에서 컨텍스트를 정리하도록 수정

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- JVM의 Old, Survivor Space에서의 메모리 사용량 감소 확인
- 동일한 환경 테스트에서 OOM 발생하지 않음.

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #63
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-14 00:38:46 +00:00
bba3266f3f [#61] 커넥션 고갈 해결을 위한 로그인 이력 저장 비동기 처리 (#62)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #61

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 이력 저장을 비동기 + Batch Insert로 구현하여 기존의 '로그인 완료 - 이력 저장(동기)' 로직, 특히 이력 저장을 별도의 트랜잭션으로 진행하며 발생하던 커넥션 고갈 문제를 해결
- 이벤트를 수신하면 In-Memory Queue에 저장하게 되어, OOM 발생 가능성이 있다고 판단. => 100개가 넘어가는 순간 바로 Batch Insert를 수행하도록 함.

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 로컬 환경에서 Login API만 별도로 성능 테스트 => 기존 로직에서는 70VU에서 다운, 개선 후 1000VU, 초당 558번의 요청에서도 정상 동작
- 테스트 결과 메모리 사용량의 큰 변화 없이 커넥션 고갈 문제 해결

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #62
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-14 00:28:44 +00:00
135b13a9bf [#58] K6 성능 테스트 도입 (#59)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #58

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- K6 성능 테스트 스크립트 추가 및 배포 환경에서의 정상 동작 확인
- 정상 동작 과정 확인 중 발견된 slow-query 개선 => 커버링 인덱스를 생각했으나, 실제로 사용하지 않고 테이블 풀스캔을 하던 문제

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 스크립트는 크게 사용자가 예약할 수 있는 일정을 만드는 작업과 사용자가 예약하는 작업 두 가지로 구분
- 후자의 테스트는 40VU까지는 여유있게 처리 확인 => 다음 과정부터는 부하를 더 높여 진행할 예정

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #59
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-11 07:38:26 +00:00
047e4a395b [#56] 예약 & 결제 프로세스 및 패키지 구조 재정의 (#57)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #56

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
<img width="1163" alt="스크린샷 2025-10-09 18.26.43.png" src="attachments/b1651431-c1c4-4198-84c8-2019bde70dd6">
- '결제 요청 API가 호출된 이상 사용자는 결제를 시도한 것으로 간주한다' + 'PG사 정상 응답만 오면 사용자는 결제를 성공한 것이다' 의 관점으로 구현
- 결제 요청 API가 호출되면, 이미 예약이 완료, 취소, 만료된 것이 아니라면 검증 후 해당 예약을 배치가 처리하지 못하게 PAYMENT_IN_PROGRESS로 변경
- PG사 결제가 성공하면, 이후의 결제 & 예약 정보 저장의 성공 여부와 무관하게 일단 API는 성공 응답 전송
- 매 결제 시도는 성공 / 실패 여부와 무관하게 이력 저장 => 결제 시도 횟수가 N번 이상이면 프론트엔드에서 특정 처리(=> 현장결제 페이지 안내 예정. 현재 바로는 구현 계획 X)
- 기존 배치와의 경합 + 데드락 방지를 위해 배치 작업을 조회 -> 수정 두 단계로 변경했고, 조회 단계에서는 SKIP LOCKED 사용.

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 새로 통합한 Order 관련 API 테스트 및 기존 배치와의 경합 상황 테스트

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #57
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-09 09:33:29 +00:00
8215492eea [#54] 애플리케이션 배포 (#55)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #54

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 애플리케이션 배포
- 1차 배포에서 각 Service의 Trace가 구분이 되지 않아 XxxService 클래스에 \@Observation을 적용하는 AOP 추가
- 불필요하게 느껴지는 Prometheus Actuator 요청과 스케쥴링 작업 Tracing 제외
- 애플리케이션이 UTC로 배포됨에 따라 발생하는 문제 해결을 위해 LocalDateTime, OffsetDateTime -> Instant 타입 변경 및 LocalDate, LocalTime은 KST로 비교하도록 수정
- 기존 로그의 가독성이 좋지 않아, 로그 메시지가 가장 먼저 보이도록 형식 수정

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 실제 웹에 접속하여 전체적인 기능 점검
- 예약 처리 로직에서 미숙한 부분이 발견되어 다음 작업은 예약 처리 로직 개선 예정

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #55
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-06 02:42:13 +00:00
186d6e118c [#52] 만료 예약 / 일정 스케쥴링 작업 추가 및 동시성 처리를 위한 일부 코드 수정 (#53)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #52

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 예약 페이지에서 일정 조회시, 현 시간 이후부터 조회되도록 수정
- 사용자의 schedule 수정은 \@LastModified JPA Auditing이 적용되지 않도록 UPDATE 쿼리를 바로 전송하도록 수정
- 매 1분마다 Pending 예약이 되지 않은 일정, 결제가 되지 않은 Pending 예약 만료 처리 스케쥴링 작업 추가
- 스케쥴링 작업으로 인해 발생할 수 있는 'Pending 예약은 생성했으나 해당 일정이 재활성화' 되는 문제 해결을 위해 schedule 조회에 pessimistic lock 적용

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- LocalTime.plusHours()로 인해 특정 시간대 이후로는 실패하는 테스트 수정
- Pessimistic Lock 적용 후 해당 문제 상황 동시성 테스트 추가
- 하나의 일정에 대한 동시 HOLD 요청 상황 테스트 추가

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #53
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-04 08:40:37 +00:00
d056e12278 [#50] Tosspay API Mocking 서버 구현 (#51)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #50

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 입력된 paymentKey 등을 담아, 랜덤한 결제 정보를 반환하도록 구현
- 전체 중 50%는 간편결제 + 카드, 25%는 카드, 20%는 간편결제(선불 충전액), 나머지 5%는 계좌이체 결과를 반환
- micrometer 적용으로 Main Service와의 Tracing 처리
- 해당 서비스는 GCP에 배포 예정

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 전체 기능 테스트 완료

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #51
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-02 01:13:07 +00:00
97c3e1598c [#48] Tosspay mocking 서버 구현을 위한 멀티모듈 전환 (#49)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #48

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- Tosspay mocking 서버를 해당 프로젝트 내 구현할 때 각 서비스간 구분이 수월하도록 모듈 분리
- 분리하는 과정에서 추후 공통적으로 활용될 수 있다고 판단한 기능들은 common 모듈로 분리
- 일부 테스트 보완

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
<img width="612" alt="스크린샷 2025-09-29 22.05.25.png" src="attachments/d401a48a-3dd5-4ced-9315-b9d8aff16bf9">

- 전체 테스트 후 클래스 커버리지 기준 100% 달성 확인

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #49
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-09-30 00:39:13 +00:00
32b8019576 [#46] 더미 데이터 생성 및 1개의 슬로우쿼리 개선 (#47)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #46

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 전체 더미 데이터 추가(관리자 약 2,400건 / 회원 100만건 / 예약, 일정 약 197만건 / 결제 및 결제 상세 196만건(대략 충전식 간편결제 29.3만건, 카드 147만건, 계좌이체 19.6만건) / 테마 500건 / 매장 263건
- 로컬 애플리케이션 실행 후, 가장 병목이 되는 메인 인기 테마 쿼리만 성능 개선(5회 측정시 API 응답 시간 평균 3300 -> 90ms)

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
변경된 기능은 모두 테스트 반영

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->
취소 데이터 등이 들어가있지 않아, 일부 컬럼에서의 Cardinality가 훨씬 낮게 나오는 상황이긴 함. 예약을 예로 들면, 현재는 확정 예약인 데이터만 추가하여 확정 예약이 100%지만, 실제 도메인의 특성상 예약 데이터는 8~90%는 확정 예약일 것으로 생각하여 큰 차이가 없다고 판단하였음.

Reviewed-on: #47
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-09-27 06:38:44 +00:00
6bcec4c0ed [#44] 매장 기능 도입 (#45)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #44

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 매장 기능 도입 및 기존 기능에 적용
- 관리자 타입(본사, 매장, 전체) 분리 및 API별 적용

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 신규 기능 및 매장 기능 도입으로 수정된 기존 API 모두 통합 테스트 완료

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->
- 아직 미결제 예약 스케쥴링 작업 등 추가적인 작업이 필요하긴 하지만, 이 작업들은 배포 후 추가로 진행할 예정
- 다음 작업은 배포 + 초기 데이터 삽입

Reviewed-on: #45
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-09-20 03:15:06 +00:00
403 changed files with 18561 additions and 10896 deletions

4
.gitignore vendored
View File

@ -37,3 +37,7 @@ out/
.vscode/ .vscode/
logs logs
.kotlin .kotlin
### sql
data/*.sql
data/*.txt

View File

@ -1,10 +1,9 @@
FROM gradle:8-jdk17 AS builder
WORKDIR /app
COPY . .
RUN ./gradlew bootjar --no-daemon
FROM amazoncorretto:17 FROM amazoncorretto:17
WORKDIR /app WORKDIR /app
COPY service/build/libs/service.jar app.jar
EXPOSE 8080 EXPOSE 8080
COPY --from=builder /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -1,3 +0,0 @@
# issue-pr-template
공통으로 사용하게 될 이슈, PR 템플릿 저장소

View File

@ -1,91 +1,52 @@
import org.jetbrains.kotlin.gradle.plugin.KaptExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
val springBootVersion = "3.5.3" val springBootVersion = "3.5.3"
val kotlinVersion = "2.2.0" val kotlinVersion = "2.2.0"
id("org.springframework.boot") version springBootVersion id("io.spring.dependency-management") version "1.1.7" apply false
id("io.spring.dependency-management") version "1.1.7" id("org.springframework.boot") version springBootVersion apply false
kotlin("jvm") version kotlinVersion kotlin("jvm") version kotlinVersion apply false
kotlin("plugin.spring") version kotlinVersion kotlin("kapt") version kotlinVersion apply false
kotlin("plugin.jpa") version kotlinVersion kotlin("plugin.spring") version kotlinVersion apply false
kotlin("kapt") version kotlinVersion kotlin("plugin.jpa") version kotlinVersion apply false
} }
group = "com.sangdol" group = "com.sangdol"
version = "0.0.1-SNAPSHOT" version = "0.0.1-SNAPSHOT"
java { allprojects {
toolchain { repositories {
languageVersion = JavaLanguageVersion.of(17) mavenCentral()
} }
} }
tasks.jar { subprojects {
enabled = false apply(plugin = "org.jetbrains.kotlin.jvm")
} apply(plugin = "org.jetbrains.kotlin.kapt")
apply(plugin = "io.spring.dependency-management")
kapt { extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
extensions.configure<KaptExtension> {
keepJavacAnnotationProcessors = true keepJavacAnnotationProcessors = true
} }
repositories { dependencies {
mavenCentral() add("implementation", "io.github.oshai:kotlin-logging-jvm:7.0.3")
} add("implementation", "io.kotest:kotest-runner-junit5:5.9.1")
add("implementation", "ch.qos.logback:logback-classic:1.5.18")
}
dependencies { tasks.withType<Test> {
// Spring
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
// API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
// DB
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
// Jwt
implementation("io.jsonwebtoken:jjwt:0.12.6")
// Logging
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
implementation("com.github.loki4j:loki-logback-appender:2.0.0")
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
// Observability
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.mockk:mockk:1.14.4")
testImplementation("com.ninja-squad:springmockk:4.0.2")
// Kotest
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
// RestAssured
testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
}
tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
} }
tasks.withType<KotlinCompile> { tasks.withType<KotlinCompile> {
compilerOptions { compilerOptions {
freeCompilerArgs.addAll( freeCompilerArgs.addAll(
"-Xjsr305=strict", "-Xjsr305=strict",
@ -93,4 +54,5 @@ tasks.withType<KotlinCompile> {
) )
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
} }
}
} }

6
build.sh Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
IMAGE_NAME="roomescape-backend"
IMAGE_TAG=$1
./gradlew build -x test && docker buildx build --platform=linux/amd64 -t ${PRIVATE_REGISTRY}/$IMAGE_NAME:$IMAGE_TAG . --push

View File

@ -0,0 +1,11 @@
dependencies {
api("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.2")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.20.0")
implementation(project(":common:utils"))
testImplementation("io.mockk:mockk:1.14.4")
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -0,0 +1,9 @@
package com.sangdol.common.log.constant
enum class LogType {
INCOMING_HTTP_REQUEST,
CONTROLLER_INVOKED,
SUCCEED,
APPLICATION_FAILURE,
UNHANDLED_EXCEPTION
}

View File

@ -1,4 +1,4 @@
package roomescape.common.log package com.sangdol.common.log.message
import ch.qos.logback.classic.pattern.MessageConverter import ch.qos.logback.classic.pattern.MessageConverter
import ch.qos.logback.classic.spi.ILoggingEvent import ch.qos.logback.classic.spi.ILoggingEvent
@ -7,13 +7,14 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.databind.node.TextNode import com.fasterxml.jackson.databind.node.TextNode
import roomescape.common.config.JacksonConfig
private const val MASK: String = "****" abstract class AbstractLogMaskingConverter(
private val SENSITIVE_KEYS = setOf("password", "accessToken", "phone") val sensitiveKeys: Set<String>,
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper() val objectMapper: ObjectMapper
) : MessageConverter() {
val mask: String = "****"
class RoomescapeLogMaskingConverter : MessageConverter() {
override fun convert(event: ILoggingEvent): String { override fun convert(event: ILoggingEvent): String {
val message: String = event.formattedMessage val message: String = event.formattedMessage
@ -35,13 +36,13 @@ class RoomescapeLogMaskingConverter : MessageConverter() {
.toString() .toString()
private fun maskedPlainMessage(message: String): String { private fun maskedPlainMessage(message: String): String {
val keys: String = SENSITIVE_KEYS.joinToString("|") val keys: String = sensitiveKeys.joinToString("|")
val regex = Regex("(?i)($keys)(\\s*=\\s*)([^(,|\"|?)\\s]+)") val regex = Regex("(?i)($keys)(\\s*[:=]\\s*)([^(,|\"|?)]+)")
return regex.replace(message) { matchResult -> return regex.replace(message) { matchResult ->
val key = matchResult.groupValues[1] val key = matchResult.groupValues[1]
val delimiter = matchResult.groupValues[2] val delimiter = matchResult.groupValues[2]
val maskedValue = maskValue(matchResult.groupValues[3]) val maskedValue = maskValue(matchResult.groupValues[3].trim())
"${key}${delimiter}${maskedValue}" "${key}${delimiter}${maskedValue}"
} }
@ -51,7 +52,7 @@ class RoomescapeLogMaskingConverter : MessageConverter() {
node?.forEachEntry { key, childNode -> node?.forEachEntry { key, childNode ->
when { when {
childNode.isValueNode -> { childNode.isValueNode -> {
if (key in SENSITIVE_KEYS) (node as ObjectNode).put(key, maskValue(childNode.asText())) if (key in sensitiveKeys) (node as ObjectNode).put(key, maskValue(childNode.asText()))
} }
childNode.isObject -> maskRecursive(childNode) childNode.isObject -> maskRecursive(childNode)
@ -72,10 +73,6 @@ class RoomescapeLogMaskingConverter : MessageConverter() {
} }
private fun maskValue(value: String): String { private fun maskValue(value: String): String {
return if (value.length <= 2) { return "${value.first()}$mask${value.last()}"
MASK
} else {
"${value.first()}$MASK${value.last()}"
}
} }
} }

View File

@ -1,4 +1,4 @@
package roomescape.common.log package com.sangdol.common.log.sql
import net.ttddyy.dsproxy.ExecutionInfo import net.ttddyy.dsproxy.ExecutionInfo
import net.ttddyy.dsproxy.QueryInfo import net.ttddyy.dsproxy.QueryInfo

View File

@ -0,0 +1,20 @@
package com.sangdol.common.log.sql
import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder
import javax.sql.DataSource
object SlowQueryDataSourceFactory {
fun create(dataSource: DataSource, loggerName: String, logLevel: String, thresholdMs: Long): DataSource {
val mdcAwareListener = MDCAwareSlowQueryListenerWithoutParams(
logLevel = SLF4JLogLevel.nullSafeValueOf(logLevel.uppercase()),
thresholdMs = thresholdMs
)
return ProxyDataSourceBuilder.create(dataSource)
.name(loggerName)
.listener(mdcAwareListener)
.buildProxy()
}
}

View File

@ -0,0 +1,57 @@
package com.sangdol.common.log
import ch.qos.logback.classic.spi.ILoggingEvent
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.sangdol.common.log.message.AbstractLogMaskingConverter
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
class TestLogMaskingConverter : AbstractLogMaskingConverter(
sensitiveKeys = setOf("account", "address"),
objectMapper = jacksonObjectMapper()
)
class AbstractLogMaskingConverterTest : FunSpec({
val converter = TestLogMaskingConverter()
val event: ILoggingEvent = mockk()
val account = "sangdol@example.com"
val address = "서울특별시 강북구 수유1동 123-456"
context("sensitiveKeys=${converter.sensitiveKeys}에 있는 항목은 가린다.") {
context("평문 로그를 처리할 때, 여러 key / value가 있는 경우 서로 간의 구분자는 trim 처리한다.") {
listOf(":", "=", " : ", " = ").forEach { keyValueDelimiter ->
listOf(",", ", ").forEach { valueDelimiter ->
test("key1${keyValueDelimiter}value1${valueDelimiter}key2${keyValueDelimiter}value2 형식을 처리한다.") {
every {
event.formattedMessage
} returns "account$keyValueDelimiter$account${valueDelimiter}address$keyValueDelimiter$address"
assertSoftly(converter.convert(event)) {
this shouldBe "account${keyValueDelimiter}${account.first()}${converter.mask}${account.last()}${valueDelimiter}address${keyValueDelimiter}${address.first()}${converter.mask}${address.last()}"
}
}
}
}
}
context("JSON 로그") {
test("정상 처리") {
val json = "{\"request_body\":{\"account\":\"%s\",\"address\":\"%s\"}}"
every {
event.formattedMessage
} returns json.format(account, address)
converter.convert(event) shouldBeEqual json.format(
"${account.first()}${converter.mask}${account.last()}",
"${address.first()}${converter.mask}${address.last()}"
)
}
}
}
})

View File

@ -1,5 +1,7 @@
package roomescape.common.log package com.sangdol.common.log
import com.sangdol.common.log.sql.SlowQueryPredicate
import com.sangdol.common.log.sql.SqlLogFormatter
import io.kotest.assertions.assertSoftly import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.StringSpec import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
@ -18,9 +20,9 @@ class MDCAwareSlowQueryListenerWithoutParamsTest : StringSpec({
val slowQueryPredicate = SlowQueryPredicate(thresholdMs = slowQueryThreshold) val slowQueryPredicate = SlowQueryPredicate(thresholdMs = slowQueryThreshold)
assertSoftly(slowQueryPredicate) { assertSoftly(slowQueryPredicate) {
it.test(slowQueryThreshold) shouldBe true this.test(slowQueryThreshold) shouldBe true
it.test(slowQueryThreshold + 1) shouldBe true this.test(slowQueryThreshold + 1) shouldBe true
it.test(slowQueryThreshold - 1) shouldBe false this.test(slowQueryThreshold - 1) shouldBe false
} }
} }
}) })

View File

@ -0,0 +1,29 @@
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
kotlin("plugin.jpa")
}
dependencies {
api("org.springframework.boot:spring-boot-starter-data-jpa")
api("com.github.f4b6a3:tsid-creator:5.2.6")
implementation(project(":common:utils"))
implementation(project(":common:types"))
testRuntimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
testImplementation("io.mockk:mockk:1.14.4")
}
tasks.named<BootJar>("bootJar") {
enabled = false
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,14 +1,14 @@
package roomescape.common.entity package com.sangdol.common.persistence
import jakarta.persistence.* import jakarta.persistence.Column
import jakarta.persistence.EntityListeners
import jakarta.persistence.MappedSuperclass
import org.springframework.data.annotation.CreatedBy import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedBy import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.domain.Persistable
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime import java.time.Instant
import kotlin.jvm.Transient
@MappedSuperclass @MappedSuperclass
@EntityListeners(AuditingEntityListener::class) @EntityListeners(AuditingEntityListener::class)
@ -17,7 +17,7 @@ abstract class AuditingBaseEntity(
) : PersistableBaseEntity(id) { ) : PersistableBaseEntity(id) {
@Column(updatable = false) @Column(updatable = false)
@CreatedDate @CreatedDate
lateinit var createdAt: LocalDateTime lateinit var createdAt: Instant
@Column(updatable = false) @Column(updatable = false)
@CreatedBy @CreatedBy
@ -25,29 +25,9 @@ abstract class AuditingBaseEntity(
@Column @Column
@LastModifiedDate @LastModifiedDate
lateinit var updatedAt: LocalDateTime lateinit var updatedAt: Instant
@Column @Column
@LastModifiedBy @LastModifiedBy
var updatedBy: Long = 0L var updatedBy: Long = 0L
} }
@MappedSuperclass
abstract class PersistableBaseEntity(
@Id
@Column(name = "id")
private val _id: Long,
@Transient
private var isNewEntity: Boolean = true
) : Persistable<Long> {
@PostLoad
@PrePersist
fun markNotNew() {
isNewEntity = false
}
override fun getId(): Long = _id
override fun isNew(): Boolean = isNewEntity
}

View File

@ -0,0 +1,13 @@
package com.sangdol.common.persistence
import com.github.f4b6a3.tsid.TsidFactory
interface IDGenerator {
fun create(): Long
}
class TsidIDGenerator(
private val tsidFactory: TsidFactory
) : IDGenerator {
override fun create(): Long = tsidFactory.create().toLong()
}

View File

@ -0,0 +1,25 @@
package com.sangdol.common.persistence
import jakarta.persistence.*
import org.springframework.data.domain.Persistable
import kotlin.jvm.Transient
@MappedSuperclass
abstract class PersistableBaseEntity(
@Id
@Column(name = "id")
private val _id: Long,
@Transient
private var isNewEntity: Boolean = true
) : Persistable<Long> {
@PostLoad
@PrePersist
fun markNotNew() {
isNewEntity = false
}
override fun getId(): Long = _id
override fun isNew(): Boolean = isNewEntity
}

View File

@ -0,0 +1,44 @@
package com.sangdol.common.persistence
import com.github.f4b6a3.tsid.TsidFactory
import com.sangdol.common.utils.MdcPrincipalIdUtil
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.data.domain.AuditorAware
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.transaction.PlatformTransactionManager
import java.util.*
@Configuration
@EnableJpaAuditing
class PersistenceConfig {
@Value("\${POD_NAME:app-0}")
private lateinit var podName: String
@Bean
fun auditorAware(): AuditorAware<Long> = MdcAuditorAware()
@Bean
@Primary
fun idGenerator(): IDGenerator {
val node = podName.substringAfterLast("-").toInt()
val tsidFactory = TsidFactory.builder().withNode(node).build()
return TsidIDGenerator(tsidFactory)
}
@Bean
fun transactionExecutionUtil(
transactionManager: PlatformTransactionManager
): TransactionExecutionUtil {
return TransactionExecutionUtil(transactionManager)
}
}
class MdcAuditorAware : AuditorAware<Long> {
override fun getCurrentAuditor(): Optional<Long> = MdcPrincipalIdUtil.extractAsOptionalLongOrEmpty()
}

View File

@ -1,31 +1,19 @@
package roomescape.common.util package com.sangdol.common.persistence
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.support.TransactionTemplate import org.springframework.transaction.support.TransactionTemplate
import roomescape.common.exception.CommonErrorCode
import roomescape.common.exception.RoomescapeException
private val log: KLogger = KotlinLogging.logger {}
@Component
class TransactionExecutionUtil( class TransactionExecutionUtil(
private val transactionManager: PlatformTransactionManager private val transactionManager: PlatformTransactionManager
) { ) {
fun <T> withNewTransaction(isReadOnly: Boolean, action: () -> T): T { fun <T> withNewTransaction(isReadOnly: Boolean, action: () -> T?): T? {
val transactionTemplate = TransactionTemplate(transactionManager).apply { val transactionTemplate = TransactionTemplate(transactionManager).apply {
this.isReadOnly = isReadOnly this.isReadOnly = isReadOnly
this.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW this.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
} }
return transactionTemplate.execute { action() } return transactionTemplate.execute { action() }
?: run {
log.error { "[TransactionExecutionUtil.withNewTransaction] 트랜잭션 작업 중 예상치 못한 null 반환 " }
throw RoomescapeException(CommonErrorCode.UNEXPECTED_SERVER_ERROR)
}
} }
} }

View File

@ -0,0 +1,53 @@
package com.sangdol.common.persistence
import com.sangdol.common.utils.MdcPrincipalIdUtil
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.date.shouldBeBefore
import io.kotest.matchers.equality.shouldBeEqualUsingFields
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.springframework.boot.test.context.SpringBootTest
import java.time.LocalDateTime
@SpringBootTest
class BaseEntityTest(
private val testPersistableBaseEntityRepository: TestPersistableBaseEntityRepository,
private val testAuditingBaseEntityRepository: TestAuditingBaseEntityRepository,
private val idGenerator: IDGenerator
) : FunSpec({
context("TestPersistableBaseEntityRepository") {
test("PK를 지정하여 INSERT 쿼리를 한번만 전송한다.") {
val entity = TestPersistableBaseEntity(idGenerator.create(), "hello").also {
testPersistableBaseEntityRepository.saveAndFlush(it)
}
testPersistableBaseEntityRepository.findById(entity.id).also {
it.shouldNotBeNull()
it.get() shouldBeEqualUsingFields entity
}
}
}
context("TestAuditingBaseEntityRepository") {
test("Entity 저장 후 Audit 정보를 확인한다.") {
val id = idGenerator.create()
.also {
MdcPrincipalIdUtil.set(it.toString())
}.also {
testAuditingBaseEntityRepository.saveAndFlush(TestAuditingBaseEntity(it, "hello"))
}
testAuditingBaseEntityRepository.findById(id).also {
it.shouldNotBeNull()
assertSoftly(it.get()) {
this.createdAt shouldBeBefore LocalDateTime.now()
this.updatedAt shouldBeBefore LocalDateTime.now()
this.createdBy shouldBe id
this.updatedBy shouldBe id
}
}
}
}
})

View File

@ -0,0 +1,6 @@
package com.sangdol.common.persistence
import org.springframework.boot.autoconfigure.SpringBootApplication
@SpringBootApplication
class TestApplication

View File

@ -0,0 +1,12 @@
package com.sangdol.common.persistence
import jakarta.persistence.Entity
import org.springframework.data.jpa.repository.JpaRepository
@Entity
class TestAuditingBaseEntity(
id: Long,
val name: String
) : AuditingBaseEntity(id)
interface TestAuditingBaseEntityRepository : JpaRepository<TestAuditingBaseEntity, Long>

View File

@ -0,0 +1,12 @@
package com.sangdol.common.persistence
import jakarta.persistence.Entity
import org.springframework.data.jpa.repository.JpaRepository
@Entity
class TestPersistableBaseEntity(
id: Long,
val name: String
) : PersistableBaseEntity(id)
interface TestPersistableBaseEntityRepository : JpaRepository<TestPersistableBaseEntity, Long>

View File

@ -0,0 +1,71 @@
package com.sangdol.common.persistence
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equality.shouldBeEqualUsingFields
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.*
import org.junit.jupiter.api.assertThrows
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.TransactionStatus
import org.springframework.transaction.support.DefaultTransactionDefinition
class TransactionExecutionUtilTest() : FunSpec() {
private val transactionManager = mockk<PlatformTransactionManager>(relaxed = true)
private val transactionExecutionUtil = TransactionExecutionUtil(transactionManager)
init {
context("withNewTransaction") {
beforeTest {
clearMocks(transactionManager)
}
val body = TestPersistableBaseEntity(123458192L, "hello")
test("지정한 action이 성공하면, 해당 값을 반환하고 트랜잭션을 커밋한다.") {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
body
}.also {
it.shouldNotBeNull()
it shouldBeEqualUsingFields body
verify { transactionManager.commit(any()) }
verify(exactly = 0) { transactionManager.rollback(any()) }
}
}
test("지정한 action 실행 도중 예외가 발생하면, 예외를 던지고 트랜잭션을 롤백한다.") {
assertThrows<RuntimeException> {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
throw RuntimeException()
}
}.also {
verify { transactionManager.rollback(any()) }
verify(exactly = 0) { transactionManager.commit(any()) }
}
}
test("isReadOnly=true 지정시 읽기 전용 트랜잭션으로 실행한다.") {
val transactionStatus = mockk<TransactionStatus>(relaxed = true)
val transactionDefinitionSlot = slot<TransactionDefinition>()
every {
transactionManager.getTransaction(capture(transactionDefinitionSlot))
} returns transactionStatus
transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
"hello"
}.also {
assertSoftly(transactionDefinitionSlot.captured) {
this.isReadOnly shouldBe true
this.propagationBehavior shouldBe DefaultTransactionDefinition.PROPAGATION_REQUIRES_NEW
}
verify { transactionManager.commit(any()) }
}
}
}
}
}

View File

@ -0,0 +1,18 @@
spring:
jpa:
properties:
hibernate:
format_sql: true
hibernate:
ddl-auto: create-drop
show-sql: true
h2:
console:
enabled: true
path: /h2-console
datasource:
hikari:
jdbc-url: jdbc:h2:mem:database
driver-class-name: org.h2.Driver
username: sa
password:

View File

@ -0,0 +1,3 @@
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,6 +1,6 @@
package roomescape.common.exception package com.sangdol.common.types.exception
import org.springframework.http.HttpStatus import com.sangdol.common.types.web.HttpStatus
enum class CommonErrorCode( enum class CommonErrorCode(
override val httpStatus: HttpStatus, override val httpStatus: HttpStatus,

View File

@ -1,6 +1,6 @@
package roomescape.common.exception package com.sangdol.common.types.exception
import org.springframework.http.HttpStatus import com.sangdol.common.types.web.HttpStatus
interface ErrorCode { interface ErrorCode {
val httpStatus: HttpStatus val httpStatus: HttpStatus

View File

@ -1,4 +1,4 @@
package roomescape.common.exception package com.sangdol.common.types.exception
open class RoomescapeException( open class RoomescapeException(
open val errorCode: ErrorCode, open val errorCode: ErrorCode,

View File

@ -1,9 +1,7 @@
package roomescape.common.dto.response package com.sangdol.common.types.web
import com.fasterxml.jackson.annotation.JsonInclude import com.sangdol.common.types.exception.ErrorCode
import roomescape.common.exception.ErrorCode
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CommonApiResponse<T>( data class CommonApiResponse<T>(
val data: T? = null, val data: T? = null,
) )

View File

@ -0,0 +1,24 @@
package com.sangdol.common.types.web
enum class HttpStatus(
val code: Int
) {
OK(200),
CREATED(201),
NO_CONTENT(204),
BAD_REQUEST(400),
UNAUTHORIZED(401),
FORBIDDEN(403),
NOT_FOUND(404),
CONFLICT(409),
INTERNAL_SERVER_ERROR(500)
;
fun isClientError(): Boolean {
return code in 400..<500
}
fun value(): Int {
return code
}
}

View File

@ -0,0 +1,7 @@
dependencies {
implementation("org.slf4j:slf4j-api:2.0.17")
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -0,0 +1,21 @@
package com.sangdol.common.utils
import java.time.*
import java.time.temporal.ChronoUnit
private val KST_CLOCK = Clock.system(ZoneId.of("Asia/Seoul"))
object KoreaDate {
fun today(): LocalDate = LocalDate.now(KST_CLOCK)
}
object KoreaTime {
fun now(): LocalTime = LocalTime.now(KST_CLOCK).truncatedTo(ChronoUnit.MINUTES)
}
object KoreaDateTime {
fun now(): LocalDateTime = LocalDateTime.now(KST_CLOCK)
fun nowWithOffset(): OffsetDateTime = OffsetDateTime.now(KST_CLOCK)
}
fun Instant.toKoreaDateTime(): LocalDateTime = this.atZone(KST_CLOCK.zone).toLocalDateTime()

View File

@ -0,0 +1,26 @@
package com.sangdol.common.utils
import org.slf4j.MDC
import java.util.*
object MdcPrincipalIdUtil {
const val MDC_PRINCIPAL_ID_KEY = "principal_id"
fun extractAsLongOrNull(): Long? {
return MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLong()
}
fun extractAsOptionalLongOrEmpty(): Optional<Long> {
return MDC.get(MDC_PRINCIPAL_ID_KEY)?.let {
Optional.of(it.toLong())
} ?: Optional.empty()
}
fun set(id: String) {
MDC.put(MDC_PRINCIPAL_ID_KEY, id)
}
fun clear() {
MDC.remove(MDC_PRINCIPAL_ID_KEY)
}
}

View File

@ -0,0 +1,25 @@
package com.sangdol.common.utils
import org.slf4j.MDC
object MdcStartTimeUtil {
const val MDC_START_TIME_KEY = "start_time"
fun extractDurationMsOrNull(): Long? {
return extractOrNull()?.let { System.currentTimeMillis() - it }
}
fun setCurrentTime() {
extractOrNull() ?: run {
MDC.put(MDC_START_TIME_KEY, System.currentTimeMillis().toString())
}
}
fun clear() {
MDC.remove(MDC_START_TIME_KEY)
}
private fun extractOrNull(): Long? {
return MDC.get(MDC_START_TIME_KEY)?.toLong()
}
}

View File

@ -0,0 +1,45 @@
package com.sangdol.common.utils
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.*
class KoreaDateTimeExtensionsTest : FunSpec({
test("한국 시간 기준으로 현재 시간을 가져오며, 초 단위는 제외한다.") {
assertSoftly(KoreaTime.now()) {
val utcNow = LocalTime.now(ZoneId.of("UTC"))
this.hour shouldBe utcNow.hour.plus(9)
this.minute shouldBe utcNow.minute
this.second shouldBe 0
this.nano shouldBe 0
}
}
test("한국 시간 기준으로 현재 날짜 + 시간을 LocalDateTime 타입으로 가져온다.") {
assertSoftly(KoreaDateTime.now()) {
val utcNow = LocalDateTime.now(ZoneId.of("UTC"))
this.withSecond(0).withNano(0) shouldBe utcNow.plusHours(9).withSecond(0).withNano(0)
}
}
test("한국 시간 기준으로 현재 날짜 + 시간을 OffsetDateTime 타입으로 가져온다.") {
assertSoftly(KoreaDateTime.nowWithOffset()) {
val utcNow = OffsetDateTime.now(ZoneId.of("UTC"))
this.toLocalDateTime().withSecond(0).withNano(0) shouldBe utcNow.toLocalDateTime().plusHours(9)
.withSecond(0).withNano(0)
}
}
test("UTC 시간을 LocalDateTime 타입의 한국 시간으로 변환한다.") {
val now = Instant.now()
val kstConverted = now.toKoreaDateTime()
val utc = now.atZone(ZoneId.of("UTC")).toLocalDateTime()
kstConverted.withSecond(0).withNano(0) shouldBe utc.plusHours(9).withSecond(0).withNano(0)
}
})

View File

@ -0,0 +1,28 @@
package com.sangdol.common.utils
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.util.*
class MdcPrincipalIdUtilTest : StringSpec({
val id = 1872847943L
"값을 설정한다." {
MdcPrincipalIdUtil.set(id.toString()).also {
MdcPrincipalIdUtil.extractAsLongOrNull() shouldBe id
MdcPrincipalIdUtil.extractAsOptionalLongOrEmpty() shouldBe Optional.of(id)
}
}
"값을 제거한다." {
MdcPrincipalIdUtil.set(id.toString()).also {
MdcPrincipalIdUtil.extractAsLongOrNull() shouldBe id
}
MdcPrincipalIdUtil.clear().also {
MdcPrincipalIdUtil.extractAsLongOrNull() shouldBe null
MdcPrincipalIdUtil.extractAsOptionalLongOrEmpty() shouldBe Optional.empty()
}
}
})

View File

@ -0,0 +1,34 @@
package com.sangdol.common.utils
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
class MdcStartTimeUtilTest : FunSpec({
test("기존에 등록된 startTime 값을 기준으로 duration_ms를 구한다.") {
MdcStartTimeUtil.setCurrentTime()
MdcStartTimeUtil.extractDurationMsOrNull().shouldNotBeNull()
MdcStartTimeUtil.clear()
}
test("기존에 등록된 startTime 값이 없으면 duration_ms는 null이다.") {
MdcStartTimeUtil.extractDurationMsOrNull() shouldBe null
}
test("현재 시간을 등록한다.") {
MdcStartTimeUtil.setCurrentTime()
MdcStartTimeUtil.extractDurationMsOrNull().shouldNotBeNull()
MdcStartTimeUtil.clear()
}
test("등록된 시간을 지운다.") {
MdcStartTimeUtil.setCurrentTime().also {
MdcStartTimeUtil.extractDurationMsOrNull().shouldNotBeNull()
}
MdcStartTimeUtil.clear().also {
MdcStartTimeUtil.extractDurationMsOrNull() shouldBe null
}
}
})

View File

@ -0,0 +1,28 @@
import org.gradle.kotlin.dsl.named
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
}
dependencies {
api("org.springframework.boot:spring-boot-starter-web")
api("org.springframework.boot:spring-boot-starter-aop")
api("com.fasterxml.jackson.module:jackson-module-kotlin")
api(project(":common:log"))
api(project(":common:utils"))
api(project(":common:types"))
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.mockk:mockk:1.14.4")
}
tasks.named<BootJar>("bootJar") {
enabled = false
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,5 +1,7 @@
package roomescape.common.log package com.sangdol.common.web.asepct
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.web.support.log.WebLogMessageConverter
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
@ -9,7 +11,6 @@ import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.annotation.Pointcut import org.aspectj.lang.annotation.Pointcut
import org.aspectj.lang.reflect.MethodSignature import org.aspectj.lang.reflect.MethodSignature
import org.slf4j.MDC
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
@ -21,19 +22,17 @@ private val log: KLogger = KotlinLogging.logger {}
@Aspect @Aspect
class ControllerLoggingAspect( class ControllerLoggingAspect(
private val messageConverter: ApiLogMessageConverter, private val messageConverter: WebLogMessageConverter,
) { ) {
@Pointcut("execution(* roomescape..web..*Controller*.*(..))") @Pointcut("execution(* com.sangdol..web..*Controller*.*(..))")
fun allController() { fun allController() {
} }
@Around("allController()") @Around("allController()")
fun logAPICalls(joinPoint: ProceedingJoinPoint): Any? { fun logAPICalls(joinPoint: ProceedingJoinPoint): Any? {
val startTime: Long = MDC.get("startTime").toLongOrNull() ?: System.currentTimeMillis()
val controllerPayload: Map<String, Any> = parsePayload(joinPoint)
val servletRequest: HttpServletRequest = servletRequest() val servletRequest: HttpServletRequest = servletRequest()
val controllerPayload: Map<String, Any> = parseControllerPayload(joinPoint)
log.info { log.info {
messageConverter.convertToControllerInvokedMessage(servletRequest, controllerPayload) messageConverter.convertToControllerInvokedMessage(servletRequest, controllerPayload)
@ -41,28 +40,21 @@ class ControllerLoggingAspect(
try { try {
return joinPoint.proceed() return joinPoint.proceed()
.also { logSuccess(servletRequest.getEndpoint(), startTime, it) } .also { logSuccess(servletRequest, it as ResponseEntity<*>) }
} catch (e: Exception) { } catch (e: Exception) {
throw e throw e
} }
} }
private fun logSuccess(endpoint: String, startTime: Long, result: Any) { private fun logSuccess(servletRequest: HttpServletRequest, result: ResponseEntity<*>) {
val responseEntity = result as ResponseEntity<*> val body: Any? = if (log.isDebugEnabled()) result.body else null
var convertResponseMessageRequest = ConvertResponseMessageRequest(
type = LogType.CONTROLLER_SUCCESS,
endpoint = endpoint,
httpStatus = responseEntity.statusCode.value(),
startTime = startTime,
)
if (log.isDebugEnabled()) { val logMessage = messageConverter.convertToResponseMessage(
convertResponseMessageRequest = convertResponseMessageRequest.copy( type = LogType.SUCCEED,
body = responseEntity.body servletRequest = servletRequest,
httpStatusCode = result.statusCode.value(),
responseBody = body,
) )
}
val logMessage = messageConverter.convertToResponseMessage(convertResponseMessageRequest)
log.info { logMessage } log.info { logMessage }
} }
@ -71,14 +63,16 @@ class ControllerLoggingAspect(
return (RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes).request return (RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes).request
} }
private fun parsePayload(joinPoint: JoinPoint): Map<String, Any> { private fun parseControllerPayload(joinPoint: JoinPoint): Map<String, Any> {
val signature = joinPoint.signature as MethodSignature val signature = joinPoint.signature as MethodSignature
val args = joinPoint.args val args = joinPoint.args
val payload = mutableMapOf<String, Any>() val payload = mutableMapOf<String, Any>(
payload["controller_method"] = joinPoint.signature.toShortString() "controller_method" to joinPoint.signature.toShortString()
)
val requestParams: MutableMap<String, Any> = mutableMapOf() val requestParams: MutableMap<String, Any> = mutableMapOf()
val pathVariables: MutableMap<String, Any> = mutableMapOf() val pathVariables: MutableMap<String, Any> = mutableMapOf()
signature.method.parameters.forEachIndexed { index, parameter -> signature.method.parameters.forEachIndexed { index, parameter ->
val arg = args[index] val arg = args[index]
@ -93,9 +87,10 @@ class ControllerLoggingAspect(
parameter.getAnnotation(RequestParam::class.java)?.let { parameter.getAnnotation(RequestParam::class.java)?.let {
requestParams[parameter.name] = arg requestParams[parameter.name] = arg
} }
} }.also {
if (pathVariables.isNotEmpty()) payload["path_variable"] = pathVariables if (pathVariables.isNotEmpty()) payload["path_variable"] = pathVariables
if (requestParams.isNotEmpty()) payload["request_param"] = requestParams if (requestParams.isNotEmpty()) payload["request_param"] = requestParams
}
return payload return payload
} }

View File

@ -0,0 +1,26 @@
package com.sangdol.common.web.asepct
import io.micrometer.observation.Observation
import io.micrometer.observation.ObservationRegistry
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.annotation.Pointcut
@Aspect
class ServiceObservationAspect(
private val observationRegistry: ObservationRegistry
) {
@Pointcut("execution(* com.sangdol..business..*Service*.*(..))")
fun allServices() {
}
@Around("allServices()")
fun runWithObserve(joinPoint: ProceedingJoinPoint): Any? {
val methodName: String = joinPoint.signature.toShortString()
return Observation.createNotStarted(methodName, observationRegistry)
.observe<Any?> { joinPoint.proceed() }
}
}

View File

@ -0,0 +1,50 @@
package com.sangdol.common.web.config
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer
import com.fasterxml.jackson.module.kotlin.kotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
@Configuration
class JacksonConfig {
companion object {
private val LOCAL_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("HH:mm")
}
@Bean
fun objectMapper(): ObjectMapper = ObjectMapper()
.registerModule(javaTimeModule())
.registerModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
.addSerializer(
LocalDate::class.java,
LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE)
)
.addDeserializer(
LocalDate::class.java,
LocalDateDeserializer(DateTimeFormatter.ISO_LOCAL_DATE)
)
.addSerializer(
LocalTime::class.java,
LocalTimeSerializer(LOCAL_TIME_FORMATTER)
)
.addDeserializer(
LocalTime::class.java,
LocalTimeDeserializer(LOCAL_TIME_FORMATTER)
) as JavaTimeModule
}

View File

@ -0,0 +1,41 @@
package com.sangdol.common.web.config
import com.sangdol.common.web.asepct.ServiceObservationAspect
import io.micrometer.observation.ObservationPredicate
import io.micrometer.observation.ObservationRegistry
import io.micrometer.observation.aop.ObservedAspect
import jakarta.servlet.http.HttpServletRequest
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.server.observation.ServerRequestObservationContext
@Configuration
class ObservationConfig(
@Value("\${management.endpoints.web.base-path}") private val actuatorPath: String
) {
@Bean
fun observedAspect(observationRegistry: ObservationRegistry): ObservedAspect {
return ObservedAspect(observationRegistry)
}
@Bean
fun serviceObservationAspect(observationRegistry: ObservationRegistry): ServiceObservationAspect {
return ServiceObservationAspect(observationRegistry)
}
@Bean
fun excludeActuatorPredicate(): ObservationPredicate {
return ObservationPredicate { _, context ->
if (context !is ServerRequestObservationContext) {
return@ObservationPredicate true
}
val servletRequest: HttpServletRequest = context.carrier
val requestUri = servletRequest.requestURI
!requestUri.contains(actuatorPath)
}
}
}

View File

@ -0,0 +1,40 @@
package com.sangdol.common.web.config
import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.common.web.asepct.ControllerLoggingAspect
import com.sangdol.common.web.servlet.HttpRequestLoggingFilter
import com.sangdol.common.web.support.log.WebLogMessageConverter
import io.micrometer.tracing.CurrentTraceContext
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.DependsOn
import org.springframework.core.Ordered
import org.springframework.web.filter.OncePerRequestFilter
@Configuration
class WebLoggingConfig {
@Bean
@DependsOn(value = ["webLogMessageConverter"])
fun filterRegistrationBean(
webLogMessageConverter: WebLogMessageConverter,
currentTraceContext: CurrentTraceContext
): FilterRegistrationBean<OncePerRequestFilter> {
val filter = HttpRequestLoggingFilter(webLogMessageConverter, currentTraceContext)
return FilterRegistrationBean<OncePerRequestFilter>(filter)
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }
}
@Bean
@DependsOn(value = ["webLogMessageConverter"])
fun apiLoggingAspect(webLogMessageConverter: WebLogMessageConverter): ControllerLoggingAspect {
return ControllerLoggingAspect(webLogMessageConverter)
}
@Bean
fun webLogMessageConverter(objectMapper: ObjectMapper): WebLogMessageConverter {
return WebLogMessageConverter(objectMapper)
}
}

View File

@ -1,27 +1,25 @@
package roomescape.common.exception package com.sangdol.common.web.exception
import com.sangdol.common.types.exception.CommonErrorCode
import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.exception.RoomescapeException
import com.sangdol.common.types.web.CommonErrorResponse
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.web.support.log.WebLogMessageConverter
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.slf4j.MDC
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.bind.annotation.RestControllerAdvice
import roomescape.auth.exception.AuthException
import roomescape.common.dto.response.CommonErrorResponse
import roomescape.common.log.ApiLogMessageConverter
import roomescape.common.log.ConvertResponseMessageRequest
import roomescape.common.log.LogType
import roomescape.common.log.getEndpoint
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@RestControllerAdvice @RestControllerAdvice
class ExceptionControllerAdvice( class GlobalExceptionHandler(
private val messageConverter: ApiLogMessageConverter private val messageConverter: WebLogMessageConverter
) { ) {
@ExceptionHandler(value = [RoomescapeException::class]) @ExceptionHandler(value = [RoomescapeException::class])
fun handleRoomException( fun handleRoomException(
@ -32,17 +30,10 @@ class ExceptionControllerAdvice(
val httpStatus: HttpStatus = errorCode.httpStatus val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode) val errorResponse = CommonErrorResponse(errorCode)
val type = if (e is AuthException) LogType.AUTHENTICATION_FAILURE else LogType.APPLICATION_FAILURE log.info { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
logException(
type = type,
servletRequest = servletRequest,
httpStatus = httpStatus.value(),
errorResponse = errorResponse,
exception = e
)
return ResponseEntity return ResponseEntity
.status(httpStatus) .status(httpStatus.value())
.body(errorResponse) .body(errorResponse)
} }
@ -51,29 +42,24 @@ class ExceptionControllerAdvice(
servletRequest: HttpServletRequest, servletRequest: HttpServletRequest,
e: Exception e: Exception
): ResponseEntity<CommonErrorResponse> { ): ResponseEntity<CommonErrorResponse> {
val message: String = if (e is MethodArgumentNotValidException) { if (e is MethodArgumentNotValidException) {
e.bindingResult.allErrors e.bindingResult.allErrors
.mapNotNull { it.defaultMessage } .mapNotNull { it.defaultMessage }
.joinToString(", ") .joinToString(", ")
} else { } else {
e.message!! e.message!!
}.also {
log.warn { "[ExceptionControllerAdvice] Invalid Request Value Exception occurred: $it" }
} }
log.debug { "[ExceptionControllerAdvice] Invalid Request Value Exception occurred: $message" }
val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
val httpStatus: HttpStatus = errorCode.httpStatus val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode) val errorResponse = CommonErrorResponse(errorCode)
logException( log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
type = LogType.APPLICATION_FAILURE,
servletRequest = servletRequest,
httpStatus = httpStatus.value(),
errorResponse = errorResponse,
exception = e
)
return ResponseEntity return ResponseEntity
.status(httpStatus) .status(httpStatus.value())
.body(errorResponse) .body(errorResponse)
} }
@ -88,40 +74,26 @@ class ExceptionControllerAdvice(
val httpStatus: HttpStatus = errorCode.httpStatus val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode) val errorResponse = CommonErrorResponse(errorCode)
logException( log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
type = LogType.UNHANDLED_EXCEPTION,
servletRequest = servletRequest,
httpStatus = httpStatus.value(),
errorResponse = errorResponse,
exception = e
)
return ResponseEntity return ResponseEntity
.status(httpStatus) .status(httpStatus.value())
.body(errorResponse) .body(errorResponse)
} }
private fun logException( private fun convertExceptionLogMessage(
type: LogType,
servletRequest: HttpServletRequest, servletRequest: HttpServletRequest,
httpStatus: Int, httpStatus: HttpStatus,
errorResponse: CommonErrorResponse, errorResponse: CommonErrorResponse,
exception: Exception exception: Exception
) { ): String {
val commonRequest = ConvertResponseMessageRequest( val actualException: Exception? = if (errorResponse.message == exception.message) null else exception
type = type,
endpoint = servletRequest.getEndpoint(), return messageConverter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = httpStatus, httpStatus = httpStatus,
startTime = MDC.get("startTime")?.toLongOrNull(), responseBody = errorResponse,
body = errorResponse, exception = actualException
) )
val logMessage = if (errorResponse.message == exception.message) {
messageConverter.convertToResponseMessage(commonRequest)
} else {
messageConverter.convertToResponseMessage(commonRequest.copy(exception = exception))
}
log.warn { logMessage }
} }
} }

View File

@ -1,11 +1,14 @@
package roomescape.common.log package com.sangdol.common.web.servlet
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import com.sangdol.common.web.support.log.WebLogMessageConverter
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.micrometer.tracing.CurrentTraceContext
import jakarta.servlet.FilterChain import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import org.slf4j.MDC
import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.util.ContentCachingRequestWrapper import org.springframework.web.util.ContentCachingRequestWrapper
import org.springframework.web.util.ContentCachingResponseWrapper import org.springframework.web.util.ContentCachingResponseWrapper
@ -13,7 +16,8 @@ import org.springframework.web.util.ContentCachingResponseWrapper
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
class HttpRequestLoggingFilter( class HttpRequestLoggingFilter(
private val messageConverter: ApiLogMessageConverter private val messageConverter: WebLogMessageConverter,
private val currentTraceContext: CurrentTraceContext
) : OncePerRequestFilter() { ) : OncePerRequestFilter() {
override fun doFilterInternal( override fun doFilterInternal(
request: HttpServletRequest, request: HttpServletRequest,
@ -25,15 +29,17 @@ class HttpRequestLoggingFilter(
val cachedRequest = ContentCachingRequestWrapper(request) val cachedRequest = ContentCachingRequestWrapper(request)
val cachedResponse = ContentCachingResponseWrapper(response) val cachedResponse = ContentCachingResponseWrapper(response)
val startTime = System.currentTimeMillis() MdcStartTimeUtil.setCurrentTime()
MDC.put("startTime", startTime.toString())
try { try {
filterChain.doFilter(cachedRequest, cachedResponse) filterChain.doFilter(cachedRequest, cachedResponse)
cachedResponse.copyBodyToResponse() cachedResponse.copyBodyToResponse()
} catch (e: Exception) {
throw e
} finally { } finally {
MDC.remove("startTime") MdcStartTimeUtil.clear()
MDC.remove("member_id") MdcPrincipalIdUtil.clear()
currentTraceContext.maybeScope(null)
} }
} }
} }

View File

@ -0,0 +1,75 @@
package com.sangdol.common.web.support.log
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import jakarta.servlet.http.HttpServletRequest
class LogPayloadBuilder(
private val type: LogType,
private val servletRequest: HttpServletRequest,
private val payload: MutableMap<String, Any> = mutableMapOf("type" to type)
) {
fun endpoint(): LogPayloadBuilder {
payload["endpoint"] = "${servletRequest.method} ${servletRequest.requestURI}"
return this
}
fun clientIp(): LogPayloadBuilder {
servletRequest.remoteAddr?.let { payload["client_ip"] = it }
return this
}
fun userAgent(): LogPayloadBuilder {
servletRequest.getHeader("User-Agent")?.let { payload["user_agent"] = it }
return this
}
fun queryString(): LogPayloadBuilder {
servletRequest.queryString?.let { payload["query_params"] = it }
return this
}
fun httpStatus(statusCode: Int?): LogPayloadBuilder {
statusCode?.let { payload["status_code"] = it }
return this
}
fun responseBody(body: Any?): LogPayloadBuilder {
body?.let { payload["response_body"] = it }
return this
}
fun durationMs(): LogPayloadBuilder {
MdcStartTimeUtil.extractDurationMsOrNull()?.let { payload["duration_ms"] = it }
return this
}
fun principalId(): LogPayloadBuilder {
MdcPrincipalIdUtil.extractAsLongOrNull()
?.let { payload["principal_id"] = it }
?: run { payload["principal_id"] = "UNKNOWN" }
return this
}
fun exception(exception: Exception?): LogPayloadBuilder {
exception?.let {
payload["exception"] = mapOf(
"class" to it.javaClass.simpleName,
"message" to it.message
)
}
return this
}
fun additionalPayloads(payload: Map<String, Any>): LogPayloadBuilder {
this.payload.putAll(payload)
return this
}
fun build(): Map<String, Any> = payload
}

View File

@ -0,0 +1,68 @@
package com.sangdol.common.web.support.log
import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.types.web.HttpStatus
import jakarta.servlet.http.HttpServletRequest
class WebLogMessageConverter(
private val objectMapper: ObjectMapper
) {
fun convertToHttpRequestMessage(servletRequest: HttpServletRequest): String {
val payload = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.endpoint()
.queryString()
.clientIp()
.userAgent()
.build()
return objectMapper.writeValueAsString(payload)
}
fun convertToControllerInvokedMessage(
servletRequest: HttpServletRequest,
controllerPayload: Map<String, Any>
): String {
val payload = LogPayloadBuilder(type = LogType.CONTROLLER_INVOKED, servletRequest = servletRequest)
.endpoint()
.principalId()
.additionalPayloads(controllerPayload)
.build()
return objectMapper.writeValueAsString(payload)
}
fun convertToResponseMessage(
type: LogType,
servletRequest: HttpServletRequest,
httpStatusCode: Int,
responseBody: Any? = null,
exception: Exception? = null,
): String {
val payload = LogPayloadBuilder(type = type, servletRequest = servletRequest)
.endpoint()
.httpStatus(httpStatusCode)
.durationMs()
.principalId()
.responseBody(responseBody)
.exception(exception)
.build()
return objectMapper.writeValueAsString(payload)
}
fun convertToErrorResponseMessage(
servletRequest: HttpServletRequest,
httpStatus: HttpStatus,
responseBody: Any? = null,
exception: Exception? = null,
): String {
val type = if (httpStatus.isClientError()) {
LogType.APPLICATION_FAILURE
} else {
LogType.UNHANDLED_EXCEPTION
}
return convertToResponseMessage(type, servletRequest, httpStatus.value(), responseBody, exception)
}
}

View File

@ -1,4 +1,4 @@
package roomescape.common.config package com.sangdol.common.web.config
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.exc.InvalidFormatException import com.fasterxml.jackson.databind.exc.InvalidFormatException
@ -6,11 +6,12 @@ import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
import java.time.* import java.time.LocalDate
import java.time.LocalTime
class JacksonConfigTest( class JacksonConfigTest : FunSpec({
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
) : FunSpec({ val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
context("날짜는 yyyy-mm-dd 형식이다.") { context("날짜는 yyyy-mm-dd 형식이다.") {
val date = "2025-07-14" val date = "2025-07-14"
@ -51,38 +52,4 @@ class JacksonConfigTest(
}.message shouldContain "Text '$hour:$minute:$sec' could not be parsed" }.message shouldContain "Text '$hour:$minute:$sec' could not be parsed"
} }
} }
context("Long 타입은 문자열로 (역)직렬화된다.") {
val number = 1234567890L
val serialized: String = objectMapper.writeValueAsString(number)
val deserialized: Long = objectMapper.readValue(serialized, Long::class.java)
test("Long 직렬화") {
serialized shouldBe "$number"
}
test("Long 역직렬화") {
deserialized shouldBe number
}
}
context("OffsetDateTime은 ISO 8601 형식으로 직렬화된다.") {
val date = LocalDate.of(2025, 7, 14)
val time = LocalTime.of(12, 30, 0)
val dateTime = OffsetDateTime.of(date, time, ZoneOffset.ofHours(9))
val serialized: String = objectMapper.writeValueAsString(dateTime)
test("OffsetDateTime 직렬화") {
serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
}
}
context("LocalDateTime은 ISO 8601 형식으로 직렬화된다.") {
val dateTime = LocalDateTime.of(2025, 7, 14, 12, 30, 0)
val serialized: String = objectMapper.writeValueAsString(dateTime)
test("LocalDateTime 직렬화") {
serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
}
}
}) })

View File

@ -0,0 +1,233 @@
package com.sangdol.common.web.support.log
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import jakarta.servlet.http.HttpServletRequest
class LogPayloadBuilderTest : FunSpec({
val servletRequest: HttpServletRequest = mockk()
lateinit var method: String
lateinit var requestUri: String
lateinit var remoteAddr: String
lateinit var userAgent: String
lateinit var queryString: String
beforeTest {
method = "GET".also { every { servletRequest.method } returns it }
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
queryString = "key=value".also { every { servletRequest.queryString } returns it }
}
afterSpec {
clearMocks(servletRequest)
}
context("endpoint") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.endpoint()
.build()
result["endpoint"] shouldBe "$method $requestUri"
}
test("ServletRequest에서 null이 반환되면 그대로 들어간다.") {
every { servletRequest.method } returns null
every { servletRequest.requestURI } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.endpoint()
.build()
result["endpoint"] shouldBe "null null"
}
}
context("clientIp") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.clientIp()
.build()
result["client_ip"] shouldBe remoteAddr
}
test("ServletRequest에서 null이 반환되면 추가되지 않는다.") {
every { servletRequest.remoteAddr } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.clientIp()
.build()
result["client_ip"] shouldBe null
}
}
context("userAgent") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.userAgent()
.build()
result["user_agent"] shouldBe userAgent
}
test("ServletRequest에서 null이 반환되면 추가되지 않는다.") {
every { servletRequest.getHeader("User-Agent") } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.userAgent()
.build()
result["user_agent"] shouldBe null
}
}
context("queryString") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.queryString()
.build()
result["query_params"] shouldBe queryString
}
test("ServletRequest에서 null이 반환되면 추가되지 않는다.") {
every { servletRequest.queryString } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.queryString()
.build()
result["query_params"] shouldBe null
}
}
context("httpStatus") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.httpStatus(200)
.build()
result["status_code"] shouldBe 200
}
test("null을 입력하면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.httpStatus(null)
.build()
result["status_code"] shouldBe null
}
}
context("responseBody") {
test("정상 응답") {
val body = mapOf("key" to "value")
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.responseBody(body)
.build()
result["response_body"] shouldBe body
}
test("null을 입력하면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.responseBody(null)
.build()
result["response_body"] shouldBe null
}
}
context("durationMs") {
test("정상 응답") {
MdcStartTimeUtil.setCurrentTime()
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.durationMs()
.build()
result["duration_ms"].shouldNotBeNull()
MdcStartTimeUtil.clear()
}
test("MDC에서 값을 가져올 수 없으면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.durationMs()
.build()
result["duration_ms"] shouldBe null
}
}
context("principalId") {
test("정상 응답") {
val principalId = 759980174446956066L.also {
MdcPrincipalIdUtil.set(it.toString())
}
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.principalId()
.build()
result["principal_id"] shouldBe principalId
MdcPrincipalIdUtil.clear()
}
test("MDC에서 값을 가져올 수 없으면 UNKNOWN 으로 표기된다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.principalId()
.build()
result["principal_id"] shouldBe "UNKNOWN"
}
}
context("exception") {
test("정상 응답") {
val exception = RuntimeException("hello")
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.exception(exception)
.build()
result["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
}
test("null을 입력하면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.exception(null)
.build()
result["exception"] shouldBe null
}
}
context("additionalPayloads") {
test("정상 응답") {
val payload = mapOf(
"key1" to "value1",
"key2" to "value2"
)
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.additionalPayloads(payload)
.build()
result["key1"] shouldBe "value1"
result["key2"] shouldBe "value2"
}
}
})

View File

@ -0,0 +1,194 @@
package com.sangdol.common.web.support.log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import jakarta.servlet.http.HttpServletRequest
class WebLogMessageConverterTest : FunSpec({
val objectMapper = jacksonObjectMapper()
val converter = WebLogMessageConverter(objectMapper)
val servletRequest: HttpServletRequest = mockk()
lateinit var method: String
lateinit var requestUri: String
lateinit var remoteAddr: String
lateinit var userAgent: String
lateinit var queryString: String
beforeTest {
method = "GET".also { every { servletRequest.method } returns it }
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
queryString = "key=value".also { every { servletRequest.queryString } returns it }
}
afterSpec {
clearMocks(servletRequest)
}
context("Http 요청 메시지를 변환한다.") {
test("정상 응답") {
val result = converter.convertToHttpRequestMessage(servletRequest)
result shouldBe """
{"type":"${LogType.INCOMING_HTTP_REQUEST.name}","endpoint":"$method $requestUri","query_params":"$queryString","client_ip":"$remoteAddr","user_agent":"$userAgent"}
""".trimIndent()
}
}
context("Controller 요청 메시지를 변환한다") {
val principalId = 759980174446956066L.also {
MdcPrincipalIdUtil.set(it.toString())
}
test("정상 응답") {
val controllerPayload: Map<String, Any> = mapOf(
"controller_method" to "ThemeController.findThemeById(..)",
"path_variable" to mapOf("id" to "7599801744469560667")
)
val result = converter.convertToControllerInvokedMessage(servletRequest, controllerPayload)
result shouldBe """
{"type":"${LogType.CONTROLLER_INVOKED.name}","endpoint":"$method $requestUri","principal_id":$principalId,"controller_method":"${controllerPayload["controller_method"]}","path_variable":{"id":"${7599801744469560667}"}}
""".trimIndent()
}
}
context("응답 메시지를 변환한다.") {
val principalId = 7599801744469560666
val body = mapOf(
"id" to 7599801744469560667,
"name" to "sangdol"
)
val exception = RuntimeException("hello")
beforeTest {
MdcPrincipalIdUtil.set(principalId.toString())
MdcStartTimeUtil.setCurrentTime()
}
afterTest {
MdcPrincipalIdUtil.clear()
MdcStartTimeUtil.clear()
}
test("응답 본문을 포함한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
responseBody = body
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe body
this["exception"] shouldBe null
}
}
test("예외를 포함한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
exception = exception
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe null
this["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
}
}
test("예외 + 응답 본문을 모두 포함한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
responseBody = body,
exception = exception
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe body
this["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
}
}
test("예외, 응답 본문 모두 제외한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe null
this["exception"] shouldBe null
}
}
test("4xx 에러가 발생하면 ${LogType.APPLICATION_FAILURE} 타입으로 변환한다.") {
val result = converter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = HttpStatus.BAD_REQUEST,
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.APPLICATION_FAILURE.name
}
}
test("5xx 에러가 발생하면 ${LogType.UNHANDLED_EXCEPTION} 타입으로 변환한다.") {
val result = converter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.UNHANDLED_EXCEPTION.name
}
}
}
})

BIN
data/population.xlsx Normal file

Binary file not shown.

View File

@ -0,0 +1,14 @@
services:
mysql-local:
image: mysql:8.4
container_name: mysql-local
restart: always
ports:
- "23306:3306"
environment:
MYSQL_ROOT_PASSWORD: init
MYSQL_DATABASE: roomescape_local
TZ: UTC
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci

View File

@ -1,6 +1,18 @@
node_modules
.git .git
.DS_Store .gitignore
# Node.js
node_modules
npm-debug.log npm-debug.log
dist
# Build output
build build
dist
# Editor/OS specific
.vscode
.idea
.DS_Store
# Environment variables
.env*

View File

@ -1,18 +1,17 @@
# Stage 1: Build the React app FROM node:24-alpine AS builder
FROM node:24 AS builder
WORKDIR /app WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
RUN npm install --frozen-lockfile COPY package.json package-lock.json ./
RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM nginx:1.27-alpine
# Stage 2: Serve with Nginx
FROM nginx:latest
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,10 +1,12 @@
import {BrowserRouter as Router, Route, Routes} from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import AdminRoute from './components/AdminRoute';
import Layout from './components/Layout'; import Layout from './components/Layout';
import {AuthProvider} from './context/AuthContext'; import { AdminAuthProvider } from './context/AdminAuthContext';
import { AuthProvider } from './context/AuthContext';
import AdminLayout from './pages/admin/AdminLayout'; import AdminLayout from './pages/admin/AdminLayout';
import AdminLoginPage from './pages/admin/AdminLoginPage';
import AdminPage from './pages/admin/AdminPage'; import AdminPage from './pages/admin/AdminPage';
import AdminSchedulePage from './pages/admin/AdminSchedulePage'; import AdminSchedulePage from './pages/admin/AdminSchedulePage';
import AdminStorePage from './pages/admin/AdminStorePage';
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage'; import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
import AdminThemePage from './pages/admin/AdminThemePage'; import AdminThemePage from './pages/admin/AdminThemePage';
import HomePage from '@_pages/HomePage'; import HomePage from '@_pages/HomePage';
@ -16,26 +18,28 @@ import ReservationStep2Page from '@_pages/ReservationStep2Page';
import ReservationSuccessPage from '@_pages/ReservationSuccessPage'; import ReservationSuccessPage from '@_pages/ReservationSuccessPage';
import SignupPage from '@_pages/SignupPage'; import SignupPage from '@_pages/SignupPage';
const AdminRoutes = () => (
<AdminLayout>
<Routes>
<Route path="/" element={<AdminPage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/schedule" element={<AdminSchedulePage />} />
</Routes>
</AdminLayout>
);
function App() { function App() {
return ( return (
<AuthProvider> <AuthProvider>
<Router> <Router>
<Routes> <Routes>
<Route path="/admin/*" element={ <Route path="/admin/*" element={
<AdminRoute> <AdminAuthProvider>
<AdminRoutes /> <Routes>
</AdminRoute> <Route path="/login" element={<AdminLoginPage />} />
<Route path="/*" element={
<AdminLayout>
<Routes>
<Route path="/" element={<AdminPage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/store" element={<AdminStorePage />} />
<Route path="/schedule" element={<AdminSchedulePage />} />
</Routes>
</AdminLayout>
} />
</Routes>
</AdminAuthProvider>
} /> } />
<Route path="/*" element={ <Route path="/*" element={
<Layout> <Layout>

View File

@ -1,5 +1,6 @@
import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios'; import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios';
import JSONbig from 'json-bigint'; import JSONbig from 'json-bigint';
import { PrincipalType } from './auth/authTypes';
// Create a JSONbig instance that stores big integers as strings // Create a JSONbig instance that stores big integers as strings
const JSONbigString = JSONbig({ storeAsString: true }); const JSONbigString = JSONbig({ storeAsString: true });
@ -38,7 +39,7 @@ async function request<T>(
method: Method, method: Method,
endpoint: string, endpoint: string,
data: object = {}, data: object = {},
isRequiredAuth: boolean = false type: PrincipalType,
): Promise<T> { ): Promise<T> {
const config: AxiosRequestConfig = { const config: AxiosRequestConfig = {
method, method,
@ -48,8 +49,9 @@ async function request<T>(
}, },
}; };
const accessTokenKey = type === PrincipalType.ADMIN ? 'adminAccessToken' : 'accessToken';
const accessToken = localStorage.getItem(accessTokenKey);
const accessToken = localStorage.getItem('accessToken');
if (accessToken) { if (accessToken) {
if (!config.headers) { if (!config.headers) {
config.headers = {}; config.headers = {};
@ -57,7 +59,6 @@ async function request<T>(
config.headers['Authorization'] = `Bearer ${accessToken}`; config.headers['Authorization'] = `Bearer ${accessToken}`;
} }
if (method.toUpperCase() !== 'GET') { if (method.toUpperCase() !== 'GET') {
config.data = data; config.data = data;
} }
@ -72,30 +73,50 @@ async function request<T>(
} }
} }
async function get<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> { async function get<T>(endpoint: string): Promise<T> {
return request<T>('GET', endpoint, {}, isRequiredAuth); return request<T>('GET', endpoint, {}, PrincipalType.USER);
} }
async function post<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> { async function adminGet<T>(endpoint: string): Promise<T> {
return request<T>('POST', endpoint, data, isRequiredAuth); return request<T>('GET', endpoint, {}, PrincipalType.ADMIN);
} }
async function put<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> { async function post<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PUT', endpoint, data, isRequiredAuth); return request<T>('POST', endpoint, data, PrincipalType.USER);
} }
async function patch<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> { async function adminPost<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PATCH', endpoint, data, isRequiredAuth); return request<T>('POST', endpoint, data, PrincipalType.ADMIN);
} }
async function del<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> { async function put<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('DELETE', endpoint, {}, isRequiredAuth); return request<T>('PUT', endpoint, data, PrincipalType.USER);
}
async function adminPut<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PUT', endpoint, data, PrincipalType.ADMIN);
}
async function patch<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PATCH', endpoint, data, PrincipalType.USER);
}
async function adminPatch<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PATCH', endpoint, data, PrincipalType.ADMIN);
}
async function del<T>(endpoint: string): Promise<T> {
return request<T>('DELETE', endpoint, {}, PrincipalType.USER);
}
async function adminDel<T>(endpoint: string): Promise<T> {
return request<T>('DELETE', endpoint, {}, PrincipalType.ADMIN);
} }
export default { export default {
get, get, adminGet,
post, post, adminPost,
put, put, adminPut,
patch, patch, adminPatch,
del del, adminDel,
}; };

View File

@ -1,19 +1,33 @@
import apiClient from '@_api/apiClient'; import apiClient from '@_api/apiClient';
import type {CurrentUserContext, LoginRequest, LoginSuccessResponse} from './authTypes'; import {
type AdminLoginSuccessResponse,
type LoginRequest,
PrincipalType,
type UserLoginSuccessResponse,
} from './authTypes';
export const userLogin = async (
export const login = async (data: LoginRequest): Promise<LoginSuccessResponse> => { data: Omit<LoginRequest, 'principalType'>,
const response = await apiClient.post<LoginSuccessResponse>('/auth/login', data, false); ): Promise<UserLoginSuccessResponse> => {
localStorage.setItem('accessToken', response.accessToken); return await apiClient.post<UserLoginSuccessResponse>(
'/auth/login',
return response; { ...data, principalType: PrincipalType.USER },
);
}; };
export const checkLogin = async (): Promise<CurrentUserContext> => { export const adminLogin = async (
return await apiClient.get<CurrentUserContext>('/auth/login/check', true); data: Omit<LoginRequest, 'principalType'>,
): Promise<AdminLoginSuccessResponse> => {
return await apiClient.adminPost<AdminLoginSuccessResponse>(
'/auth/login',
{ ...data, principalType: PrincipalType.ADMIN },
);
}; };
export const logout = async (): Promise<void> => { export const logout = async (): Promise<void> => {
await apiClient.post('/auth/logout', {}, true); await apiClient.post('/auth/logout', {});
localStorage.removeItem('accessToken');
}; };
export const adminLogout = async (): Promise<void> => {
await apiClient.adminPost('/auth/logout', {});
}

View File

@ -5,6 +5,13 @@ export const PrincipalType = {
export type PrincipalType = typeof PrincipalType[keyof typeof PrincipalType]; export type PrincipalType = typeof PrincipalType[keyof typeof PrincipalType];
export const AdminType = {
HQ: 'HQ',
STORE: 'STORE',
} as const;
export type AdminType = typeof AdminType[keyof typeof AdminType];
export interface LoginRequest { export interface LoginRequest {
account: string, account: string,
password: string; password: string;
@ -13,6 +20,15 @@ export interface LoginRequest {
export interface LoginSuccessResponse { export interface LoginSuccessResponse {
accessToken: string; accessToken: string;
name: string;
}
export interface UserLoginSuccessResponse extends LoginSuccessResponse {
}
export interface AdminLoginSuccessResponse extends LoginSuccessResponse {
type: AdminType;
storeId: string | null;
} }
export interface CurrentUserContext { export interface CurrentUserContext {

View File

@ -0,0 +1,11 @@
export interface OperatorInfo {
id: string;
name: string;
}
export interface AuditInfo {
createdAt: string;
updatedAt: string;
createdBy: OperatorInfo;
updatedBy: OperatorInfo;
}

View File

@ -0,0 +1,12 @@
import apiClient from "@_api/apiClient";
import type { PaymentConfirmRequest } from "@_api/payment/PaymentTypes";
export const confirm = async (
reservationId: string,
data: PaymentConfirmRequest,
): Promise<void> => {
return await apiClient.post<void>(
`/orders/${reservationId}/confirm`,
data
);
};

View File

@ -0,0 +1,5 @@
export interface OrderErrorResponse {
code: string;
message: string;
trial: number;
}

View File

@ -2,7 +2,6 @@ export interface PaymentConfirmRequest {
paymentKey: string; paymentKey: string;
orderId: string; orderId: string;
amount: number; amount: number;
paymentType: PaymentType;
} }
export interface PaymentCancelRequest { export interface PaymentCancelRequest {

View File

@ -0,0 +1,14 @@
import apiClient from "@_api/apiClient";
import type { RegionCodeResponse, SidoListResponse, SigunguListResponse } from "./regionTypes";
export const fetchSidoList = async (): Promise<SidoListResponse> => {
return await apiClient.get(`/regions/sido`);
};
export const fetchSigunguList = async (sidoCode: string): Promise<SigunguListResponse> => {
return await apiClient.get(`/regions/sigungu?sidoCode=${sidoCode}`);
}
export const fetchRegionCode = async (sidoCode: string, sigunguCode: string): Promise<RegionCodeResponse> => {
return await apiClient.get(`/regions/code?sidoCode=${sidoCode}&sigunguCode=${sigunguCode}`);
}

View File

@ -0,0 +1,27 @@
export interface SidoResponse {
code: string,
name: string,
}
export interface SidoListResponse {
sidoList: SidoResponse[]
}
export interface SigunguResponse {
code: string,
name: string,
}
export interface SigunguListResponse {
sigunguList: SigunguResponse[]
}
export interface RegionCodeResponse {
code: string
}
export interface RegionInfoResponse {
code: string,
sidoName: string,
sigunguName: string,
}

View File

@ -4,7 +4,7 @@ import type {
PendingReservationCreateRequest, PendingReservationCreateRequest,
PendingReservationCreateResponse, PendingReservationCreateResponse,
ReservationDetailRetrieveResponse, ReservationDetailRetrieveResponse,
ReservationSummaryRetrieveListResponse ReservationOverviewListResponse
} from './reservationTypes'; } from './reservationTypes';
export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => { export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => {
@ -17,11 +17,11 @@ export const confirmReservation = async (reservationId: string): Promise<void> =
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => { export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason }, true); return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason });
}; };
export const fetchSummaryByMember = async (): Promise<ReservationSummaryRetrieveListResponse> => { export const fetchAllOverviewByUser = async (): Promise<ReservationOverviewListResponse> => {
return await apiClient.get<ReservationSummaryRetrieveListResponse>('/reservations/summary'); return await apiClient.get<ReservationOverviewListResponse>('/reservations/overview');
} }
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => { export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => {
@ -29,5 +29,5 @@ export const fetchDetailById = async (reservationId: string): Promise<Reservatio
} }
export const fetchMostReservedThemeIds = async (count: number = 10): Promise<MostReservedThemeIdListResponse> => { export const fetchMostReservedThemeIds = async (count: number = 10): Promise<MostReservedThemeIdListResponse> => {
return await apiClient.get<MostReservedThemeIdListResponse>(`/reservations/popular-themes?count=${count}`, false); return await apiClient.get<MostReservedThemeIdListResponse>(`/reservations/popular-themes?count=${count}`);
} }

View File

@ -1,6 +1,24 @@
import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes"; import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes";
import type {UserContactRetrieveResponse} from "@_api/user/userTypes"; import type {UserContactRetrieveResponse} from "@_api/user/userTypes";
export interface ReservationData {
scheduleId: string;
store: {
id: string;
name: string;
}
theme: {
id: string;
name: string;
price: number;
minParticipants: number;
maxParticipants: number;
}
date: string; // "yyyy-MM-dd"
startFrom: string; // "HH:mm ~ HH:mm"
endAt: string;
}
export const ReservationStatus = { export const ReservationStatus = {
PENDING: 'PENDING', PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED', CONFIRMED: 'CONFIRMED',
@ -28,30 +46,38 @@ export interface PendingReservationCreateResponse {
id: string id: string
} }
export interface ReservationSummaryRetrieveResponse { export interface ReservationOverviewResponse {
id: string; id: string;
storeName: string;
themeName: string; themeName: string;
date: string; date: string;
startAt: string; startFrom: string;
endAt: string;
status: ReservationStatus; status: ReservationStatus;
} }
export interface ReservationSummaryRetrieveListResponse { export interface ReservationOverviewListResponse {
reservations: ReservationSummaryRetrieveResponse[]; reservations: ReservationOverviewResponse[];
}
export interface ReserverInfo {
name: string;
contact: string;
participantCount: number;
requirement: string;
} }
export interface ReservationDetailRetrieveResponse { export interface ReservationDetailRetrieveResponse {
id: string; id: string;
reserver: ReserverInfo;
user: UserContactRetrieveResponse; user: UserContactRetrieveResponse;
applicationDateTime: string; applicationDateTime: string;
payment: PaymentRetrieveResponse; payment: PaymentRetrieveResponse;
} }
export interface ReservationDetail { export interface ReservationDetail {
id: string; overview: ReservationOverviewResponse;
themeName: string; reserver: ReserverInfo;
date: string;
startAt: string;
user: UserContactRetrieveResponse; user: UserContactRetrieveResponse;
applicationDateTime: string; applicationDateTime: string;
payment?: PaymentRetrieveResponse; payment?: PaymentRetrieveResponse;

View File

@ -1,37 +1,55 @@
import apiClient from '../apiClient'; import apiClient from "@_api/apiClient";
import type {AuditInfo} from "@_api/common/commonTypes";
import type { import type {
AvailableThemeIdListResponse, AdminScheduleSummaryListResponse,
ScheduleCreateRequest, ScheduleCreateRequest,
ScheduleCreateResponse, ScheduleCreateResponse,
ScheduleDetailRetrieveResponse, ScheduleUpdateRequest,
ScheduleRetrieveListResponse, ScheduleWithThemeListResponse
ScheduleUpdateRequest } from "./scheduleTypes";
} from './scheduleTypes';
export const findAvailableThemesByDate = async (date: string): Promise<AvailableThemeIdListResponse> => { // admin
return await apiClient.get<AvailableThemeIdListResponse>(`/schedules/themes?date=${date}`); export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise<AdminScheduleSummaryListResponse> => {
}; const queryParams: string[] = [];
export const findSchedules = async (date: string, themeId: string): Promise<ScheduleRetrieveListResponse> => { if (date && date.trim() !== '') {
return await apiClient.get<ScheduleRetrieveListResponse>(`/schedules?date=${date}&themeId=${themeId}`); queryParams.push(`date=${date}`);
}; }
export const findScheduleById = async (id: string): Promise<ScheduleDetailRetrieveResponse> => { if (themeId && themeId.trim() !== '') {
return await apiClient.get<ScheduleDetailRetrieveResponse>(`/schedules/${id}`); queryParams.push(`themeId=${themeId}`);
}
// 기본 URL에 쿼리 파라미터 추가
const baseUrl = `/admin/stores/${storeId}/schedules`;
const fullUrl = queryParams.length > 0
? `${baseUrl}?${queryParams.join('&')}`
: baseUrl;
return await apiClient.adminGet<AdminScheduleSummaryListResponse>(fullUrl);
} }
export const createSchedule = async (request: ScheduleCreateRequest): Promise<ScheduleCreateResponse> => { export const fetchScheduleAudit = async (scheduleId: string): Promise<AuditInfo> => {
return await apiClient.post<ScheduleCreateResponse>('/schedules', request); return await apiClient.adminGet<AuditInfo>(`/admin/schedules/${scheduleId}/audits`);
}
export const createSchedule = async (storeId: string, request: ScheduleCreateRequest): Promise<ScheduleCreateResponse> => {
return await apiClient.adminPost<ScheduleCreateResponse>(`/admin/stores/${storeId}/schedules`, request);
}; };
export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise<void> => { export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise<void> => {
await apiClient.patch(`/schedules/${id}`, request); return await apiClient.adminPatch<void>(`/admin/schedules/${id}`, request);
}; };
export const deleteSchedule = async (id: string): Promise<void> => { export const deleteSchedule = async (id: string): Promise<void> => {
await apiClient.del(`/schedules/${id}`); return await apiClient.adminDel<void>(`/admin/schedules/${id}`);
}; };
// public
export const holdSchedule = async (id: string): Promise<void> => { export const holdSchedule = async (id: string): Promise<void> => {
await apiClient.patch(`/schedules/${id}/hold`, {}); return await apiClient.post<void>(`/schedules/${id}/hold`);
};
export const fetchSchedules = async (storeId: string, date: string): Promise<ScheduleWithThemeListResponse> => {
return await apiClient.get<ScheduleWithThemeListResponse>(`/stores/${storeId}/schedules?date=${date}`);
}; };

View File

@ -7,24 +7,11 @@ export const ScheduleStatus = {
BLOCKED: 'BLOCKED' as ScheduleStatus, BLOCKED: 'BLOCKED' as ScheduleStatus,
}; };
export interface AvailableThemeIdListResponse { // Admin
themeIds: string[];
}
export interface ScheduleRetrieveResponse {
id: string;
time: string; // "HH:mm"
status: ScheduleStatus;
}
export interface ScheduleRetrieveListResponse {
schedules: ScheduleRetrieveResponse[];
}
export interface ScheduleCreateRequest { export interface ScheduleCreateRequest {
date: string; // "yyyy-MM-dd" date: string;
time: string; // "HH:mm"
themeId: string; themeId: string;
time: string;
} }
export interface ScheduleCreateResponse { export interface ScheduleCreateResponse {
@ -38,13 +25,48 @@ export interface ScheduleUpdateRequest {
status?: ScheduleStatus; status?: ScheduleStatus;
} }
export interface ScheduleDetailRetrieveResponse { export interface AdminScheduleSummaryResponse {
id: string; id: string,
date: string; // "yyyy-MM-dd" themeName: string,
time: string; // "HH:mm" startFrom: string,
status: ScheduleStatus; endAt: string,
createdAt: string; // or Date status: ScheduleStatus,
createdBy: string; }
updatedAt: string; // or Date
updatedBy: string; export interface AdminScheduleSummaryListResponse {
schedules: AdminScheduleSummaryResponse[];
}
// Public
export interface ScheduleResponse {
id: string;
date: string;
startFrom: string;
endAt: string;
status: ScheduleStatus;
}
export interface ScheduleThemeInfo {
id: string;
name: string;
}
export interface ScheduleStoreInfo {
id: string;
name: string;
}
export interface ScheduleWithStoreAndThemeResponse {
schedule: ScheduleResponse,
theme: ScheduleThemeInfo,
store: ScheduleStoreInfo,
}
export interface ScheduleWithThemeResponse {
schedule: ScheduleResponse,
theme: ScheduleThemeInfo
}
export interface ScheduleWithThemeListResponse {
schedules: ScheduleWithThemeResponse[];
} }

View File

@ -0,0 +1,48 @@
import apiClient from '@_api/apiClient';
import type {
SimpleStoreListResponse,
StoreCreateResponse,
StoreDetailResponse,
StoreInfoResponse,
StoreRegisterRequest,
UpdateStoreRequest
} from './storeTypes';
export const getStores = async (sidoCode?: string, sigunguCode?: string): Promise<SimpleStoreListResponse> => {
const queryParams: string[] = [];
if (sidoCode && sidoCode.trim() !== '') {
queryParams.push(`sido=${sidoCode}`);
}
if (sigunguCode && sigunguCode.trim() !== '') {
queryParams.push(`sigungu=${sigunguCode}`);
}
const baseUrl = `/stores`;
const fullUrl = queryParams.length > 0
? `${baseUrl}?${queryParams.join('&')}`
: baseUrl;
return await apiClient.get(fullUrl);
};
export const getStoreInfo = async (id: string): Promise<StoreInfoResponse> => {
return await apiClient.get(`/stores/${id}`);
}
export const getStoreDetail = async (id: string): Promise<StoreDetailResponse> => {
return await apiClient.adminGet(`/admin/stores/${id}/detail`);
};
export const createStore = async (request: StoreRegisterRequest): Promise<StoreCreateResponse> => {
return await apiClient.adminPost<StoreCreateResponse>('/admin/stores', request);
};
export const updateStore = async (id: string, request: UpdateStoreRequest): Promise<void> => {
await apiClient.adminPatch(`/admin/stores/${id}`, request);
};
export const deleteStore = async (id: string): Promise<void> => {
await apiClient.adminPost(`/admin/stores/${id}/disable`, {});
};

View File

@ -0,0 +1,48 @@
import {type AuditInfo} from '@_api/common/commonTypes';
import type {RegionInfoResponse} from '@_api/region/regionTypes';
export interface SimpleStoreResponse {
id: string;
name: string;
}
export interface SimpleStoreListResponse {
stores: SimpleStoreResponse[];
}
export interface StoreDetailResponse {
id: string;
name: string;
address: string;
contact: string;
businessRegNum: string;
region: RegionInfoResponse;
audit: AuditInfo;
}
export interface StoreRegisterRequest {
name: string;
address: string;
contact: string;
businessRegNum: string;
regionCode: string;
}
export interface UpdateStoreRequest {
name?: string;
address?: string;
contact?: string;
regionCode?: string;
}
export interface StoreInfoResponse {
id: string;
name: string;
address: string;
contact: string;
businessRegNum: string;
}
export interface StoreCreateResponse {
id: string;
}

View File

@ -1,38 +1,48 @@
import apiClient from '@_api/apiClient'; import apiClient from '@_api/apiClient';
import type { import type {
AdminThemeDetailRetrieveResponse, AdminThemeDetailResponse,
AdminThemeSummaryRetrieveListResponse, AdminThemeSummaryListResponse,
SimpleActiveThemeListResponse,
ThemeCreateRequest, ThemeCreateRequest,
ThemeCreateResponse, ThemeCreateResponse,
ThemeIdListResponse, ThemeIdListResponse,
ThemeInfoListResponse, ThemeInfoListResponse,
ThemeInfoResponse,
ThemeUpdateRequest ThemeUpdateRequest
} from './themeTypes'; } from './themeTypes';
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryRetrieveListResponse> => { export const fetchAdminThemes = async (): Promise<AdminThemeSummaryListResponse> => {
return await apiClient.get<AdminThemeSummaryRetrieveListResponse>('/admin/themes'); return await apiClient.adminGet<AdminThemeSummaryListResponse>('/admin/themes');
}; };
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailRetrieveResponse> => { export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailResponse> => {
return await apiClient.get<AdminThemeDetailRetrieveResponse>(`/admin/themes/${id}`); return await apiClient.adminGet<AdminThemeDetailResponse>(`/admin/themes/${id}`);
}; };
export const createTheme = async (themeData: ThemeCreateRequest): Promise<ThemeCreateResponse> => { export const createTheme = async (themeData: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
return await apiClient.post<ThemeCreateResponse>('/admin/themes', themeData); return await apiClient.adminPost<ThemeCreateResponse>('/admin/themes', themeData);
}; };
export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise<void> => { export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise<void> => {
await apiClient.patch<any>(`/admin/themes/${id}`, themeData); await apiClient.adminPatch<any>(`/admin/themes/${id}`, themeData);
}; };
export const deleteTheme = async (id: string): Promise<void> => { export const deleteTheme = async (id: string): Promise<void> => {
await apiClient.del<any>(`/admin/themes/${id}`); await apiClient.adminDel<any>(`/admin/themes/${id}`);
}; };
export const fetchUserThemes = async (): Promise<ThemeInfoListResponse> => { export const fetchActiveThemes = async (): Promise<SimpleActiveThemeListResponse> => {
return await apiClient.get<ThemeInfoListResponse>('/themes'); return await apiClient.adminGet<SimpleActiveThemeListResponse>('/admin/themes/active');
}; };
export const findThemesByIds = async (request: ThemeIdListResponse): Promise<ThemeInfoListResponse> => { export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<ThemeInfoListResponse> => {
return await apiClient.post<ThemeInfoListResponse>('/themes/retrieve', request); return await apiClient.post<ThemeInfoListResponse>('/themes/batch', request);
};
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
return await apiClient.get<ThemeInfoResponse>(`/themes/${id}`);
}
export const fetchMostReservedThemes = async (count: number): Promise<ThemeInfoListResponse> => {
return await apiClient.get<ThemeInfoListResponse>(`/themes/most-reserved?count=${count}`);
}; };

View File

@ -1,20 +1,9 @@
import type { AuditInfo } from '@_api/common/commonTypes';
export interface AdminThemeDetailResponse { export interface AdminThemeDetailResponse {
id: string; theme: ThemeInfoResponse;
name: string; isActive: boolean;
description: string; audit: AuditInfo
thumbnailUrl: string;
difficulty: Difficulty;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
isOpen: boolean;
createDate: string; // Assuming ISO string format
updatedDate: string; // Assuming ISO string format
createdBy: string;
updatedBy: string;
} }
export interface ThemeCreateRequest { export interface ThemeCreateRequest {
@ -28,7 +17,7 @@ export interface ThemeCreateRequest {
availableMinutes: number; availableMinutes: number;
expectedMinutesFrom: number; expectedMinutesFrom: number;
expectedMinutesTo: number; expectedMinutesTo: number;
isOpen: boolean; isActive: boolean;
} }
export interface ThemeCreateResponse { export interface ThemeCreateResponse {
@ -46,38 +35,19 @@ export interface ThemeUpdateRequest {
availableMinutes?: number; availableMinutes?: number;
expectedMinutesFrom?: number; expectedMinutesFrom?: number;
expectedMinutesTo?: number; expectedMinutesTo?: number;
isOpen?: boolean; isActive?: boolean;
} }
export interface AdminThemeSummaryRetrieveResponse { export interface AdminThemeSummaryResponse {
id: string; id: string;
name: string; name: string;
difficulty: Difficulty; difficulty: Difficulty;
price: number; price: number;
isOpen: boolean; isActive: boolean;
} }
export interface AdminThemeSummaryRetrieveListResponse { export interface AdminThemeSummaryListResponse {
themes: AdminThemeSummaryRetrieveResponse[]; themes: AdminThemeSummaryResponse[];
}
export interface AdminThemeDetailRetrieveResponse {
id: string;
name: string;
description: string;
thumbnailUrl: string;
difficulty: Difficulty;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
isOpen: boolean;
createdAt: string; // LocalDateTime in Kotlin, map to string (ISO format)
createdBy: string;
updatedAt: string; // LocalDateTime in Kotlin, map to string (ISO format)
updatedBy: string;
} }
export interface ThemeInfoResponse { export interface ThemeInfoResponse {
@ -102,18 +72,34 @@ export interface ThemeIdListResponse {
themeIds: string[]; themeIds: string[];
} }
// @ts-ignore
export enum Difficulty { export enum Difficulty {
VERY_EASY = '매우 쉬움', VERY_EASY = 'VERY_EASY',
EASY = '쉬움', EASY = 'EASY',
NORMAL = '보통', NORMAL = 'NORMAL',
HARD = '어려움', HARD = 'HARD',
VERY_HARD = '매우 어려움', VERY_HARD = 'VERY_HARD',
} }
export const DifficultyKoreanMap: Record<Difficulty, string> = {
[Difficulty.VERY_EASY]: '매우 쉬움',
[Difficulty.EASY]: '쉬움',
[Difficulty.NORMAL]: '보통',
[Difficulty.HARD]: '어려움',
[Difficulty.VERY_HARD]: '매우 어려움',
};
export function mapThemeResponse(res: any): ThemeInfoResponse { export function mapThemeResponse(res: any): ThemeInfoResponse {
return { return {
...res, ...res,
difficulty: Difficulty[res.difficulty as keyof typeof Difficulty], difficulty: Difficulty[res.difficulty as keyof typeof Difficulty],
} }
} }
export interface SimpleActiveThemeResponse {
id: string;
name: string;
}
export interface SimpleActiveThemeListResponse {
themes: SimpleActiveThemeResponse[];
}

View File

@ -2,9 +2,9 @@ import apiClient from "@_api/apiClient";
import type {UserContactRetrieveResponse, UserCreateRequest, UserCreateResponse} from "./userTypes"; import type {UserContactRetrieveResponse, UserCreateRequest, UserCreateResponse} from "./userTypes";
export const signup = async (data: UserCreateRequest): Promise<UserCreateResponse> => { export const signup = async (data: UserCreateRequest): Promise<UserCreateResponse> => {
return await apiClient.post('/users', data, false); return await apiClient.post('/users', data);
}; };
export const fetchContact = async (): Promise<UserContactRetrieveResponse> => { export const fetchContact = async (): Promise<UserContactRetrieveResponse> => {
return await apiClient.get<UserContactRetrieveResponse>('/users/contact', true); return await apiClient.get<UserContactRetrieveResponse>('/users/contact');
} }

View File

@ -1,28 +0,0 @@
import React from 'react';
import {Navigate, useLocation} from 'react-router-dom';
import {useAuth} from '../context/AuthContext';
const AdminRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { loggedIn, role, loading } = useAuth();
const location = useLocation();
if (loading) {
return <div>Loading...</div>; // Or a proper spinner component
}
if (!loggedIn) {
// Not logged in, redirect to login page. No alert needed here
// as the user is simply redirected.
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (role !== 'ADMIN') {
// Logged in but not an admin, show alert and redirect.
alert('접근 권한이 없어요. 관리자에게 문의해주세요.');
return <Navigate to="/" replace />;
}
return children;
};
export default AdminRoute;

View File

@ -0,0 +1,96 @@
import { adminLogin as apiLogin, adminLogout as apiLogout } from '@_api/auth/authAPI';
import {
type AdminLoginSuccessResponse,
type AdminType,
type LoginRequest,
} from '@_api/auth/authTypes';
import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react';
interface AdminAuthContextType {
isAdmin: boolean;
name: string | null;
type: AdminType | null;
storeId: string | null;
loading: boolean;
login: (data: Omit<LoginRequest, 'principalType'>) => Promise<AdminLoginSuccessResponse>;
logout: () => Promise<void>;
}
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [isAdmin, setIsAdmin] = useState(false);
const [name, setName] = useState<string | null>(null);
const [type, setType] = useState<AdminType | null>(null);
const [storeId, setStoreId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
try {
const token = localStorage.getItem('adminAccessToken');
const storedName = localStorage.getItem('adminName');
const storedType = localStorage.getItem('adminType') as AdminType | null;
const storedStoreId = localStorage.getItem('adminStoreId');
if (token && storedName && storedType) {
setIsAdmin(true);
setName(storedName);
setType(storedType);
setStoreId(storedStoreId ? storedStoreId : null);
}
} catch (error) {
console.error("Failed to load admin auth state from storage", error);
} finally {
setLoading(false);
}
}, []);
const login = async (data: Omit<LoginRequest, 'principalType'>) => {
const response = await apiLogin(data);
localStorage.setItem('adminAccessToken', response.accessToken);
localStorage.setItem('adminName', response.name);
localStorage.setItem('adminType', response.type);
if (response.storeId) {
localStorage.setItem('adminStoreId', response.storeId.toString());
} else {
localStorage.removeItem('adminStoreId');
}
setIsAdmin(true);
setName(response.name);
setType(response.type);
setStoreId(response.storeId);
return response;
};
const logout = async () => {
try {
await apiLogout();
} finally {
localStorage.removeItem('adminAccessToken');
localStorage.removeItem('adminName');
localStorage.removeItem('adminType');
localStorage.removeItem('adminStoreId');
setIsAdmin(false);
setName(null);
setType(null);
setStoreId(null);
}
};
return (
<AdminAuthContext.Provider value={{ isAdmin, name, type, storeId, loading, login, logout }}>
{children}
</AdminAuthContext.Provider>
);
};
export const useAdminAuth = (): AdminAuthContextType => {
const context = useContext(AdminAuthContext);
if (!context) {
throw new Error('useAdminAuth must be used within an AdminAuthProvider');
}
return context;
};

View File

@ -1,15 +1,13 @@
import {checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout} from '@_api/auth/authAPI'; import { logout as apiLogout, userLogin as apiLogin } from '@_api/auth/authAPI';
import {type LoginRequest, type LoginSuccessResponse, PrincipalType} from '@_api/auth/authTypes'; import { type LoginRequest, type UserLoginSuccessResponse } from '@_api/auth/authTypes';
import React, {createContext, type ReactNode, useContext, useEffect, useState} from 'react'; import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react';
interface AuthContextType { interface AuthContextType {
loggedIn: boolean; loggedIn: boolean;
userName: string | null; userName: string | null;
type: PrincipalType | null;
loading: boolean; loading: boolean;
login: (data: LoginRequest) => Promise<LoginSuccessResponse>; login: (data: Omit<LoginRequest, 'principalType'>) => Promise<UserLoginSuccessResponse>;
logout: () => Promise<void>; logout: () => Promise<void>;
checkLogin: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
@ -17,33 +15,33 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false);
const [userName, setUserName] = useState<string | null>(null); const [userName, setUserName] = useState<string | null>(null);
const [type, setType] = useState<PrincipalType | null>(null); const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(true); // Add loading state
const checkLogin = async () => {
try {
const response = await apiCheckLogin();
setLoggedIn(true);
setUserName(response.name);
setType(response.type);
} catch (error) {
setLoggedIn(false);
setUserName(null);
setType(null);
localStorage.removeItem('accessToken');
} finally {
setLoading(false); // Set loading to false after check is complete
}
};
useEffect(() => { useEffect(() => {
checkLogin(); try {
const token = localStorage.getItem('accessToken');
const storedUserName = localStorage.getItem('userName');
if (token && storedUserName) {
setLoggedIn(true);
setUserName(storedUserName);
}
} catch (error) {
console.error("Failed to load user auth state from storage", error);
} finally {
setLoading(false);
}
}, []); }, []);
const login = async (data: LoginRequest) => { const login = async (data: Omit<LoginRequest, 'principalType'>) => {
const response = await apiLogin({ ...data }); const response = await apiLogin(data);
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('userName', response.name);
setLoggedIn(true); setLoggedIn(true);
setType(data.principalType); setUserName(response.name);
return response; return response;
}; };
@ -51,15 +49,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
try { try {
await apiLogout(); await apiLogout();
} finally { } finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('userName');
setLoggedIn(false); setLoggedIn(false);
setUserName(null); setUserName(null);
setType(null);
localStorage.removeItem('accessToken');
} }
}; };
return ( return (
<AuthContext.Provider value={{ loggedIn, userName, type, loading, login, logout, checkLogin }}> <AuthContext.Provider value={{ loggedIn, userName, loading, login, logout }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@ -1,11 +1,13 @@
/* New CSS content */
.admin-schedule-container { .admin-schedule-container {
padding: 2rem; padding: 2rem;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
font-size: 0.95rem; /* Slightly smaller base font */
} }
.page-title { .page-title {
font-size: 2rem; font-size: 1.8rem; /* smaller */
font-weight: bold; font-weight: bold;
margin-bottom: 2rem; margin-bottom: 2rem;
text-align: center; text-align: center;
@ -18,7 +20,7 @@
padding: 1.5rem; padding: 1.5rem;
background-color: #f9f9f9; background-color: #f9f9f9;
border-radius: 8px; border-radius: 8px;
align-items: center; align-items: flex-end; /* Align to bottom */
} }
.schedule-controls .form-group { .schedule-controls .form-group {
@ -26,18 +28,29 @@
flex-direction: column; flex-direction: column;
} }
/* Width adjustments */
.schedule-controls .store-selector-group,
.schedule-controls .date-selector-group {
flex: 1 1 180px;
}
.schedule-controls .theme-selector-group {
flex: 2 1 300px;
}
.schedule-controls .form-label { .schedule-controls .form-label {
font-size: 0.9rem; font-size: 0.85rem; /* smaller */
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #555; color: #555;
} }
.schedule-controls .form-input, .schedule-controls .form-input,
.schedule-controls .form-select { .schedule-controls .form-select {
padding: 0.75rem; padding: 0.6rem; /* smaller */
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
font-size: 1rem; font-size: 0.9rem; /* smaller */
} }
.section-card { .section-card {
@ -63,10 +76,11 @@ table {
} }
th, td { th, td {
padding: 1rem; padding: 0.8rem; /* smaller */
text-align: left; text-align: left;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
vertical-align: middle; vertical-align: middle;
font-size: 0.9rem; /* smaller */
} }
th { th {
@ -75,11 +89,11 @@ th {
} }
.btn { .btn {
padding: 0.5rem 1rem; padding: 0.4rem 0.8rem; /* smaller */
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.85rem; /* smaller */
transition: background-color 0.2s; transition: background-color 0.2s;
white-space: nowrap; white-space: nowrap;
} }
@ -174,8 +188,8 @@ th {
font-size: 1rem; font-size: 1rem;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 4px; border-radius: 4px;
height: 3rem; height: auto; /* remove fixed height */
box-sizing: border-box; /* Ensures padding/border are included in height */ box-sizing: border-box;
} }
.details-form-container .button-group { .details-form-container .button-group {
@ -190,7 +204,7 @@ th {
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 8px; border-radius: 8px;
background-color: #fff; background-color: #fff;
margin-bottom: 1.5rem; /* Add margin to separate from buttons */ margin-bottom: 1.5rem;
} }
.audit-title { .audit-title {
@ -212,3 +226,95 @@ th {
color: #212529; color: #212529;
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.theme-selector-button-group {
display: flex;
flex-direction: row !important;
align-items: flex-end;
gap: 0.5rem;
}
.theme-selector-button-group .form-select {
flex-grow: 1;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: #ffffff !important;
padding: 2rem !important;
border-radius: 8px !important;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important;
width: 90% !important;
max-width: 600px !important;
position: relative !important;
}
.modal-close-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #888;
}
.modal-title {
font-size: 1.75rem;
font-weight: bold;
margin-top: 0;
margin-bottom: 1.5rem;
text-align: center;
}
.theme-modal-thumbnail {
width: 100%;
max-height: 300px;
object-fit: cover;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.theme-modal-description {
font-size: 1rem;
line-height: 1.6;
color: #555;
margin-bottom: 1.5rem;
}
.theme-details-button {
white-space: nowrap;
}
.view-mode-buttons {
justify-content: flex-end;
}
/* Added for modal info alignment */
.modal-info-grid p {
display: flex;
align-items: flex-start;
margin: 0.6rem 0;
line-height: 1.5;
}
.modal-info-grid p strong {
flex: 0 0 130px; /* fixed width for labels */
font-weight: 600;
}
.modal-info-grid p span {
flex: 1;
}

View File

@ -0,0 +1,207 @@
/* /src/css/admin-store-page.css */
.admin-store-container {
max-width: 1400px;
margin: 40px auto;
padding: 40px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f6f8;
border-radius: 16px;
}
.admin-store-container .page-title {
font-size: 32px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
text-align: center;
}
.filter-controls {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1.5rem;
background-color: #f9f9f9;
border-radius: 8px;
}
.filter-controls .form-group {
flex: 1;
}
.section-card {
background-color: #ffffff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.table-header {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.table-container table {
width: 100%;
border-collapse: collapse;
font-size: 15px;
}
.table-container th,
.table-container td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #e5e8eb;
vertical-align: middle;
}
.table-container th {
background-color: #f9fafb;
color: #505a67;
font-weight: 600;
}
.table-container tr:last-child td {
border-bottom: none;
}
.table-container tr:hover {
background-color: #f4f6f8;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #E5E8EB;
border-radius: 8px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.btn {
padding: 8px 16px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.btn-primary:hover {
background-color: #1B64DA;
}
.btn-secondary {
background-color: #F2F4F6;
color: #4E5968;
}
.btn-secondary:hover {
background-color: #E5E8EB;
}
.btn-danger {
background-color: #e53e3e;
color: white;
}
.btn-danger:hover {
background-color: #c53030;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.details-row td {
padding: 0;
background-color: #f8f9fa;
}
.details-container {
padding: 1.5rem;
}
.details-form-card {
background-color: #fff;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
margin-bottom: 1.5rem;
}
.form-row {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
}
.form-group {
flex: 1;
}
.form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: #4E5968;
}
.button-group {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.audit-info {
padding: 1.5rem;
border: 1px solid #dee2e6;
border-radius: 8px;
background-color: #fff;
margin-bottom: 1.5rem;
}
.audit-title {
font-size: 1.1rem;
font-weight: 600;
color: #343a40;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #dee2e6;
}
.audit-body p {
margin: 0.5rem 0;
font-size: 0.9rem;
color: #495057;
}
.audit-body p strong {
color: #212529;
margin-right: 0.5rem;
}
.add-store-form {
padding: 1.5rem;
background-color: #fdfdff;
border: 1px solid #e5e8eb;
border-radius: 8px;
margin-bottom: 2rem;
}

View File

@ -81,15 +81,15 @@
} }
.theme-modal-content { .theme-modal-content {
background-color: #ffffff; background-color: #ffffff !important;
padding: 30px; padding: 30px !important;
border-radius: 16px; border-radius: 16px !important;
width: 90%; width: 90% !important;
max-width: 600px; max-width: 600px !important;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2); box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2) !important;
display: flex; display: flex !important;
flex-direction: column; flex-direction: column !important;
gap: 20px; gap: 20px !important;
} }
.modal-thumbnail { .modal-thumbnail {
@ -163,3 +163,18 @@
.modal-button.close:hover { .modal-button.close:hover {
background-color: #5a6268; background-color: #5a6268;
} }
/* Added for modal info alignment */
.modal-info-grid p {
display: flex;
align-items: flex-start;
margin: 0.6rem 0;
line-height: 1.5;
}
.modal-info-grid p strong {
flex: 0 0 130px; /* fixed width for labels */
font-weight: 600;
}
.modal-info-grid p span {
flex: 1;
}

View File

@ -49,10 +49,24 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.summary-subdetails-v2 {
display: flex;
flex-direction: column;
margin: 0px;
gap: 0px;
}
.summary-store-name-v2 {
font-size: 16px;
font-weight: bold;
color: #505a67;
margin: 0 0 5px 0;
}
.summary-details-v2 { .summary-details-v2 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 10px;
} }
.summary-theme-name-v2 { .summary-theme-name-v2 {
@ -65,15 +79,15 @@
.summary-datetime-v2 { .summary-datetime-v2 {
font-size: 16px; font-size: 16px;
color: #505a67; color: #505a67;
margin: 0; margin-bottom: 5px;
} }
/* --- Status Badge --- */ /* --- Status Badge --- */
.card-status-badge { .card-status-badge {
position: absolute; position: absolute;
top: 15px; top: 30px;
right: 15px; right: 10px;
padding: 4px 10px; padding: 3px 10px;
border-radius: 12px; border-radius: 12px;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@ -177,16 +191,16 @@
} }
.modal-content-v2 { .modal-content-v2 {
background: #ffffff; background: #ffffff !important;
padding: 30px; padding: 30px !important;
border-radius: 16px; border-radius: 16px !important;
width: 90%; width: 90% !important;
max-width: 500px; max-width: 500px !important;
position: relative; position: relative !important;
box-shadow: 0 5px 15px rgba(0,0,0,0.3); box-shadow: 0 5px 15px rgba(0,0,0,0.3) !important;
animation: slide-up 0.3s ease-out; animation: slide-up 0.3s ease-out !important;
max-height: 90vh; /* Prevent modal from being too tall */ max-height: 90vh !important; /* Prevent modal from being too tall */
overflow-y: auto; /* Allow scrolling for long content */ overflow-y: auto !important; /* Allow scrolling for long content */
} }
@keyframes slide-up { @keyframes slide-up {
@ -240,13 +254,6 @@
color: #505a67; color: #505a67;
} }
.modal-section-v2 p strong {
color: #333d4b;
font-weight: 600;
min-width: 100px;
display: inline-block;
}
.cancellation-section-v2 { .cancellation-section-v2 {
background-color: #fcf2f2; background-color: #fcf2f2;
padding: 15px; padding: 15px;
@ -346,3 +353,18 @@
border-color: #007bff; border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
} }
/* Added for modal info alignment */
.modal-info-grid p {
display: flex;
align-items: flex-start;
margin: 0.6rem 0;
line-height: 1.5;
}
.modal-info-grid p strong {
flex: 0 0 130px; /* fixed width for labels */
font-weight: 600;
}
.modal-info-grid p span {
flex: 1;
}

View File

@ -1,43 +1,43 @@
/* General Container */ /* General Container */
.reservation-v21-container { .reservation-v21-container {
padding: 40px; width: 100%;
max-width: 900px; max-width: 900px;
margin: 40px auto; margin: 2rem auto;
background-color: #ffffff; padding: 2rem;
border-radius: 16px; font-family: 'Pretendard', sans-serif;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07); background-color: #fff;
font-family: 'Toss Product Sans', sans-serif; border-radius: 12px;
color: #333D4B; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
} }
.page-title { .page-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 40px;
color: #191F28;
text-align: center; text-align: center;
font-size: 2rem;
font-weight: 700;
margin-bottom: 2.5rem;
color: #212529;
} }
/* Step Sections */ /* Step Section */
.step-section { .step-section {
margin-bottom: 40px; margin-bottom: 3rem;
padding: 24px; padding: 1.5rem;
border: 1px solid #E5E8EB; border: 1px solid #f1f3f5;
border-radius: 12px; border-radius: 8px;
transition: all 0.3s ease; background-color: #f8f9fa;
} }
.step-section.disabled { .step-section.disabled {
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
background-color: #F9FAFB;
} }
.step-section h3 { .step-section h3 {
font-size: 20px; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
margin-bottom: 20px; margin-top: 0;
color: #191F28; margin-bottom: 1.5rem;
color: #343a40;
} }
/* Date Carousel */ /* Date Carousel */
@ -45,274 +45,241 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; margin-bottom: 1rem;
margin: 20px 0; }
.carousel-arrow {
background: none;
border: none;
font-size: 2rem;
color: #868e96;
cursor: pointer;
padding: 0 1rem;
} }
.date-options-container { .date-options-container {
display: flex; display: flex;
gap: 8px; gap: 10px;
overflow-x: hidden; overflow-x: auto;
flex-grow: 1; -ms-overflow-style: none;
justify-content: space-between; scrollbar-width: none;
margin: 0px 15px;
} }
.carousel-arrow, .today-button { .date-options-container::-webkit-scrollbar {
background-color: #F2F4F6; display: none;
border: 1px solid #E5E8EB;
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 20px;
font-weight: bold;
color: #4E5968;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background-color 0.2s;
}
.today-button {
border-radius: 8px;
font-size: 14px;
font-weight: 600;
width: auto;
padding: 0 15px;
}
.carousel-arrow:hover, .today-button:hover {
background-color: #E5E8EB;
} }
.date-option { .date-option {
text-align: center;
cursor: pointer; cursor: pointer;
padding: 8px; padding: 10px;
border-radius: 8px; border-radius: 50%;
width: 60px;
height: 60px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
justify-content: center; justify-content: center;
border: 1px solid transparent; align-items: center;
transition: all 0.3s ease; transition: background-color 0.3s, color 0.3s;
width: 60px;
flex-shrink: 0;
}
.date-option:hover {
background-color: #f0f0f0;
}
.date-option.active {
border: 1px solid #007bff;
background-color: #e7f3ff;
} }
.date-option .day-of-week { .date-option .day-of-week {
font-size: 12px; font-size: 0.8rem;
color: #888; margin-bottom: 4px;
}
.date-option.active .day-of-week {
color: #007bff;
} }
.date-option .day-circle { .date-option .day-circle {
font-size: 16px; font-weight: 600;
font-weight: bold;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-top: 4px;
background-color: #f0f0f0;
color: #333;
} }
.date-option.active .day-circle { .date-option.active {
background-color: #007bff; background-color: #0064FF;
color: white; color: white;
} }
.date-option:not(.active):hover {
background-color: #f1f3f5;
}
.date-option.disabled { .date-option.disabled {
opacity: 0.5; color: #ced4da;
cursor: not-allowed; cursor: not-allowed;
pointer-events: none;
} }
.date-option.disabled .day-circle { .today-button {
background-color: #E5E8EB; background-color: #f8f9fa;
color: #B0B8C1; border: 1px solid #dee2e6;
} border-radius: 20px;
padding: 0.5rem 1rem;
/* Theme List */
.theme-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.theme-card {
cursor: pointer; cursor: pointer;
border-radius: 12px; margin-left: 1rem;
overflow: hidden; font-weight: 500;
border: 2px solid #E5E8EB; }
transition: all 0.2s ease-in-out;
/* --- Region & Store Selectors --- */
.region-store-selectors {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.region-store-selectors select {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #fff; background-color: #fff;
font-size: 1rem;
cursor: pointer;
transition: border-color 0.2s;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23868e96%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E');
background-repeat: no-repeat;
background-position: right .7em top 50%;
background-size: .65em auto;
}
.region-store-selectors select:disabled {
background-color: #f8f9fa;
cursor: not-allowed;
color: #adb5bd;
}
.region-store-selectors select:focus {
outline: none;
border-color: #0064FF;
}
/* --- Schedule List --- */
.schedule-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem;
} }
.theme-card:hover { .theme-schedule-group {
transform: translateY(-4px); background-color: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1.5rem;
} }
.theme-card.active { .theme-header {
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.theme-thumbnail {
width: 100%;
height: 120px;
object-fit: cover;
}
.theme-info {
padding: 16px;
display: flex; display: flex;
flex-direction: column; justify-content: space-between;
flex-grow: 1; align-items: center;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid #f1f3f5;
} }
.theme-info h4 { .theme-header h4 {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.theme-info p {
font-size: 14px;
color: #6B7684;
margin: 0; margin: 0;
} font-size: 1.25rem;
font-weight: 600;
.theme-meta { color: #343a40;
font-size: 14px;
color: #4E5968;
margin-bottom: 12px;
flex-grow: 1;
}
.theme-meta p {
margin: 2px 0;
}
.theme-meta strong {
color: #333D4B;
} }
.theme-detail-button { .theme-detail-button {
width: 100%; padding: 0.5rem 1rem;
padding: 8px; font-size: 0.9rem;
font-size: 14px; background-color: transparent;
font-weight: 600; color: #0064FF;
border: none; border: 1px solid #0064FF;
background-color: #F2F4F6; border-radius: 6px;
color: #4E5968;
border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; font-weight: 600;
transition: background-color 0.2s, color 0.2s;
} }
.theme-detail-button:hover { .theme-detail-button:hover {
background-color: #E5E8EB; background-color: #0064FF;
color: #fff;
} }
/* Time Slots */ /* Time Slots */
.time-slots { .time-slots {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px; gap: 0.75rem;
} }
.time-slot { .time-slot {
cursor: pointer; padding: 0.75rem;
padding: 16px; border: 1px solid #dee2e6;
border-radius: 8px; border-radius: 6px;
text-align: center; text-align: center;
background-color: #F2F4F6; cursor: pointer;
font-weight: 600; transition: all 0.2s;
transition: all 0.2s ease-in-out; background-color: #fff;
position: relative;
} }
.time-slot:hover { .time-slot:hover:not(.disabled) {
background-color: #E5E8EB; border-color: #0064FF;
color: #0064FF;
} }
.time-slot.active { .time-slot.active {
background-color: #3182F6; background-color: #0064FF;
color: #ffffff; color: white;
border-color: #0064FF;
font-weight: 600;
} }
.time-slot.disabled { .time-slot.disabled {
background-color: #F9FAFB; background-color: #f8f9fa;
color: #B0B8C1; color: #adb5bd;
cursor: not-allowed; cursor: not-allowed;
text-decoration: line-through; text-decoration: line-through;
} }
.time-availability { .time-availability {
font-size: 12px;
display: block; display: block;
font-size: 0.8rem;
margin-top: 4px; margin-top: 4px;
font-weight: 500;
} }
.no-times { .no-times {
color: #868e96;
padding: 2rem;
text-align: center; text-align: center;
padding: 20px; background-color: #fff;
color: #8A94A2; border-radius: 8px;
} }
/* Next Step Button */ /* --- Next Step Button --- */
.next-step-button-container { .next-step-button-container {
display: flex; margin-top: 2rem;
justify-content: flex-end; text-align: center;
margin-top: 30px;
} }
.next-step-button { .next-step-button {
padding: 14px 28px; width: 100%;
font-size: 18px; max-width: 400px;
padding: 1rem;
font-size: 1.2rem;
font-weight: 700; font-weight: 700;
color: #fff;
background-color: #0064FF;
border: none; border: none;
background-color: #3182F6; border-radius: 8px;
color: #ffffff;
border-radius: 12px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.next-step-button:hover:not(:disabled) {
background-color: #0053d1;
}
.next-step-button:disabled { .next-step-button:disabled {
background-color: #B0B8C1; background-color: #a0a0a0;
cursor: not-allowed; cursor: not-allowed;
} }
.next-step-button:hover:not(:disabled) {
background-color: #1B64DA;
}
/* Modal Styles */ /* --- Modal Styles --- */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@ -328,170 +295,158 @@
.modal-content { .modal-content {
background-color: #ffffff !important; background-color: #ffffff !important;
padding: 32px !important; padding: 2rem !important;
border-radius: 16px !important; border-radius: 12px !important;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important;
width: 90% !important; width: 90% !important;
max-width: 500px !important; max-width: 500px !important;
position: relative !important; position: relative !important;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1) !important; max-height: 90vh !important;
overflow-y: auto !important;
} }
.modal-close-button { .modal-close-button {
position: absolute; position: absolute;
top: 16px; top: 1rem;
right: 16px; right: 1rem;
background: none; background: none;
border: none; border: none;
font-size: 24px; font-size: 1.5rem;
color: #868e96;
cursor: pointer; cursor: pointer;
color: #8A94A2;
} }
.modal-theme-thumbnail { .modal-theme-thumbnail {
width: 100%; width: 100%;
height: 200px; height: 200px;
object-fit: cover; object-fit: cover;
border-radius: 12px; border-radius: 8px;
margin-bottom: 24px; margin-bottom: 1.5rem;
} }
.modal-content h2 { .modal-content h2 {
font-size: 24px; margin-top: 0;
font-weight: 700; margin-bottom: 2rem;
margin-bottom: 24px; text-align: center;
color: #191F28;
} }
.modal-section { .modal-section {
margin-bottom: 20px; margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #f1f3f5;
}
.modal-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
} }
.modal-section h3 { .modal-section h3 {
font-size: 18px; margin-top: 0;
font-weight: 600; margin-bottom: 1rem;
margin-bottom: 12px; font-size: 1.1rem;
border-bottom: 1px solid #E5E8EB; color: #495057;
padding-bottom: 8px;
} }
.modal-section p { .modal-section p {
font-size: 16px; margin: 0.5rem 0;
color: #495057;
line-height: 1.6; line-height: 1.6;
margin-bottom: 8px;
color: #4E5968;
}
.modal-section p strong {
color: #333D4B;
margin-right: 8px;
} }
.modal-actions { .modal-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 12px; gap: 1rem;
margin-top: 30px; margin-top: 2rem;
} }
.modal-actions button { .modal-actions .cancel-button,
padding: 12px 24px; .modal-actions .confirm-button {
font-size: 16px; padding: 0.75rem 1.5rem;
font-weight: 600;
border-radius: 8px; border-radius: 8px;
cursor: pointer;
border: none; border: none;
transition: background-color 0.2s; font-size: 1rem;
font-weight: 600;
cursor: pointer;
} }
.modal-actions .cancel-button { .modal-actions .cancel-button {
background-color: #E5E8EB; background-color: #f1f3f5;
color: #4E5968; color: #495057;
}
.modal-actions .cancel-button:hover {
background-color: #D1D6DB;
} }
.modal-actions .confirm-button { .modal-actions .confirm-button {
background-color: #3182F6; background-color: #0064FF;
color: #ffffff; color: #fff;
}
.modal-actions .confirm-button:hover {
background-color: #1B64DA;
} }
/* Styles for ReservationFormPage */ /* --- Form Styles for ReservationFormPage --- */
.form-group { .form-group {
margin-bottom: 20px; margin-bottom: 1rem;
} }
.form-group label { .form-group label {
display: block; display: block;
font-weight: bold; margin-bottom: 0.5rem;
margin-bottom: 8px; font-weight: 600;
color: #333; color: #495057;
} }
.form-group input[type="text"], .form-input {
.form-group input[type="tel"],
.form-group input[type="number"],
.form-group textarea {
width: 100%; width: 100%;
padding: 12px; padding: 0.75rem;
border: 1px solid #ccc; border: 1px solid #ddd;
border-radius: 8px; border-radius: 8px;
font-size: 16px; font-size: 1rem;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
} }
.form-group input:focus, .form-group textarea:focus { /* Success Page */
outline: none; .success-icon {
border-color: #3182F6; font-size: 4rem;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2); color: #0064FF;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.participant-control {
display: flex;
align-items: center;
}
.participant-control input {
text-align: center; text-align: center;
border-left: none; margin-bottom: 1.5rem;
border-right: none;
width: 60px;
border-radius: 0;
} }
.participant-control button { .success-page-actions {
width: 44px; display: flex;
height: 44px; justify-content: center;
border: 1px solid #ccc; gap: 1rem;
background-color: #f0f0f0; margin-top: 2.5rem;
font-size: 20px; }
cursor: pointer;
.success-page-actions .action-button {
padding: 0.8rem 1.6rem;
border-radius: 8px;
text-decoration: none;
font-size: 1rem;
font-weight: 600;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.participant-control button:hover:not(:disabled) { .success-page-actions .action-button.secondary {
background-color: #e0e0e0; background-color: #f1f3f5;
color: #495057;
} }
.participant-control button:disabled { .success-page-actions .action-button:not(.secondary) {
background-color: #e9ecef; background-color: #0064FF;
cursor: not-allowed; color: #fff;
color: #aaa;
} }
.participant-control button:first-of-type { /* Added for modal info alignment */
border-radius: 8px 0 0 8px; .modal-info-grid p {
display: flex;
align-items: flex-start;
margin: 0.6rem 0;
line-height: 1.5;
} }
.modal-info-grid p strong {
.participant-control button:last-of-type { flex: 0 0 130px; /* fixed width for labels */
border-radius: 0 8px 8px 0; font-weight: 600;
}
.modal-info-grid p span {
flex: 1;
} }

View File

@ -69,3 +69,12 @@
font-size: 12px; font-size: 12px;
margin-top: 4px; margin-top: 4px;
} }
.region-select-group {
display: flex;
gap: 10px;
}
.region-select-group select {
flex: 1;
}

View File

@ -1,9 +1,8 @@
import {fetchMostReservedThemeIds} from '@_api/reservation/reservationAPI';
import '@_css/home-page-v2.css'; import '@_css/home-page-v2.css';
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom'; import {useNavigate} from 'react-router-dom';
import {findThemesByIds} from '@_api/theme/themeAPI'; import {fetchMostReservedThemes} from '@_api/theme/themeAPI';
import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes'; import {DifficultyKoreanMap, mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
const HomePage: React.FC = () => { const HomePage: React.FC = () => {
const [ranking, setRanking] = useState<ThemeInfoResponse[]>([]); const [ranking, setRanking] = useState<ThemeInfoResponse[]>([]);
@ -13,19 +12,8 @@ const HomePage: React.FC = () => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const themeIds = await fetchMostReservedThemeIds().then(res => { const themeFetchCount = 10;
const themeIds = res.themeIds; const response = await fetchMostReservedThemes(themeFetchCount);
if (themeIds.length === 0) {
setRanking([]);
return;
}
return themeIds;
})
if (themeIds === undefined) return;
if (themeIds.length === 0) return;
const response = await findThemesByIds({ themeIds: themeIds });
setRanking(response.themes.map(mapThemeResponse)); setRanking(response.themes.map(mapThemeResponse));
} catch (err) { } catch (err) {
console.error('Error fetching ranking:', err); console.error('Error fetching ranking:', err);
@ -71,11 +59,12 @@ const HomePage: React.FC = () => {
<div className="modal-theme-info"> <div className="modal-theme-info">
<h2>{selectedTheme.name}</h2> <h2>{selectedTheme.name}</h2>
<p>{selectedTheme.description}</p> <p>{selectedTheme.description}</p>
<div className="theme-details"> <div className="theme-details modal-info-grid">
<p><strong>:</strong> {selectedTheme.difficulty}</p> <p><strong>:</strong><span>{DifficultyKoreanMap[selectedTheme.difficulty]}</span></p>
<p><strong>:</strong> {selectedTheme.price.toLocaleString()}</p> <p><strong> :</strong><span>{selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</span></p>
<p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p> <p><strong>1 :</strong><span>{selectedTheme.price.toLocaleString()}</span></p>
<p><strong> :</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</p> <p><strong> :</strong><span>{selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</span></p>
<p><strong> :</strong><span>{selectedTheme.availableMinutes}</span></p>
</div> </div>
</div> </div>
<div className="modal-buttons"> <div className="modal-buttons">

View File

@ -15,11 +15,11 @@ const LoginPage: React.FC = () => {
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const principalType = from.startsWith('/admin') ? 'ADMIN' : 'USER'; await login({ account: email, password: password });
await login({ account: email, password: password, principalType: principalType });
alert('로그인에 성공했어요!'); alert('로그인에 성공했어요!');
navigate(from, { replace: true }); const redirectTo = from.startsWith('/admin') ? '/' : from;
navigate(redirectTo, { replace: true });
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.message || '로그인에 실패했어요. 이메일과 비밀번호를 확인해주세요.'; const message = error.response?.data?.message || '로그인에 실패했어요. 이메일과 비밀번호를 확인해주세요.';
alert(message); alert(message);

View File

@ -1,17 +1,18 @@
import {cancelPayment} from '@_api/payment/paymentAPI'; import { cancelPayment } from '@_api/payment/paymentAPI';
import type {PaymentRetrieveResponse} from '@_api/payment/PaymentTypes'; import type { PaymentRetrieveResponse } from '@_api/payment/PaymentTypes';
import {cancelReservation, fetchDetailById, fetchSummaryByMember} from '@_api/reservation/reservationAPI'; import { cancelReservation, fetchDetailById, fetchAllOverviewByUser } from '@_api/reservation/reservationAPI';
import { import {
type ReservationDetail,
ReservationStatus, ReservationStatus,
type ReservationSummaryRetrieveResponse type ReservationDetail,
type ReservationOverviewResponse
} from '@_api/reservation/reservationTypes'; } from '@_api/reservation/reservationTypes';
import React, {useEffect, useState} from 'react';
import '@_css/my-reservation-v2.css'; import '@_css/my-reservation-v2.css';
import { formatDate, formatDisplayDateTime, formatTime } from '@_util/DateTimeFormatter';
import React, { useEffect, useState } from 'react';
const getReservationStatus = (reservation: ReservationSummaryRetrieveResponse): { className: string, text: string } => { const getReservationStatus = (reservation: ReservationOverviewResponse): { className: string, text: string } => {
const now = new Date(); const now = new Date();
const reservationDateTime = new Date(`${reservation.date}T${reservation.startAt}`); const reservationDateTime = new Date(`${reservation.date}T${reservation.startFrom}`);
switch (reservation.status) { switch (reservation.status) {
case ReservationStatus.CANCELED: case ReservationStatus.CANCELED:
@ -28,75 +29,6 @@ const getReservationStatus = (reservation: ReservationSummaryRetrieveResponse):
} }
}; };
const formatDisplayDateTime = (dateTime: any): string => {
let date: Date;
if (typeof dateTime === 'string') {
// ISO 문자열 형식 처리 (LocalDateTime, OffsetDateTime 모두 포함)
date = new Date(dateTime);
} else if (typeof dateTime === 'number') {
// Unix 타임스탬프(초) 형식 처리
date = new Date(dateTime * 1000);
} else if (Array.isArray(dateTime) && dateTime.length >= 6) {
// 배열 형식 처리: [year, month, day, hour, minute, second, nanosecond?]
const year = dateTime[0];
const month = dateTime[1] - 1; // JS Date의 월은 0부터 시작
const day = dateTime[2];
const hour = dateTime[3];
const minute = dateTime[4];
const second = dateTime[5];
const millisecond = dateTime.length > 6 ? Math.floor(dateTime[6] / 1000000) : 0;
date = new Date(year, month, day, hour, minute, second, millisecond);
} else {
return '유효하지 않은 날짜 형식';
}
if (isNaN(date.getTime())) {
return '유효하지 않은 날짜';
}
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
second: 'numeric'
};
return new Intl.DateTimeFormat('ko-KR', options).format(date);
};
const formatCardDateTime = (dateStr: string, timeStr: string): string => {
const date = new Date(`${dateStr}T${timeStr}`);
const currentYear = new Date().getFullYear();
const reservationYear = date.getFullYear();
const days = ['일', '월', '화', '수', '목', '금', '토'];
const dayOfWeek = days[date.getDay()];
const month = date.getMonth() + 1;
const day = date.getDate();
let hours = date.getHours();
const minutes = date.getMinutes();
const ampm = hours >= 12 ? '오후' : '오전';
hours = hours % 12;
hours = hours ? hours : 12;
let datePart = '';
if (currentYear === reservationYear) {
datePart = `${month}${day}일(${dayOfWeek})`;
} else {
datePart = `${reservationYear}${month}${day}일(${dayOfWeek})`;
}
let timePart = `${ampm} ${hours}`;
if (minutes !== 0) {
timePart += ` ${minutes}`;
}
return `${datePart} ${timePart}`;
};
// --- Cancellation View Component --- // --- Cancellation View Component ---
const CancellationView: React.FC<{ const CancellationView: React.FC<{
reservation: ReservationDetail; reservation: ReservationDetail;
@ -117,10 +49,10 @@ const CancellationView: React.FC<{
return ( return (
<div className="cancellation-view-v2"> <div className="cancellation-view-v2">
<h3> </h3> <h3> </h3>
<div className="cancellation-summary-v2"> <div className="cancellation-summary-v2 modal-info-grid">
<p><strong>:</strong> {reservation.themeName}</p> <p><strong>:</strong><span>{reservation.overview.themeName}</span></p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p> <p><strong> :</strong><span>{formatDisplayDateTime(reservation.applicationDateTime)}</span></p>
{reservation.payment && <p><strong> :</strong> {reservation.payment.totalAmount.toLocaleString()}</p>} {reservation.payment && <p><strong> :</strong><span>{reservation.payment.totalAmount.toLocaleString()}</span></p>}
</div> </div>
<textarea <textarea
value={reason} value={reason}
@ -157,33 +89,33 @@ const ReservationDetailView: React.FC<{
<> <>
{payment.totalAmount !== detail.amount && ( {payment.totalAmount !== detail.amount && (
<> <>
<p><strong>() :</strong> {detail.amount.toLocaleString()}</p> <p><strong> :</strong><span>{detail.amount.toLocaleString()}</span></p>
{detail.easypayDiscountAmount && ( {detail.easypayDiscountAmount && (
<p><strong>() :</strong> {detail.easypayDiscountAmount.toLocaleString()}</p> <p><strong> :</strong><span>{detail.easypayDiscountAmount.toLocaleString()}</span></p>
)} )}
</> </>
)} )}
{detail.easypayProviderName && ( {detail.easypayProviderName && (
<p><strong>: </strong> {detail.easypayProviderName}</p> <p><strong>: </strong><span>{detail.easypayProviderName}</span></p>
)} )}
<p><strong> / :</strong> {detail.issuerCode}({detail.ownerType}) / {detail.cardType}</p> <p><strong> / :</strong><span>{detail.issuerCode}({detail.ownerType}) / {detail.cardType}</span></p>
<p><strong> :</strong> {detail.cardNumber}</p> <p><strong> :</strong><span>{detail.cardNumber}</span></p>
<p><strong> :</strong> {detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</p> <p><strong>:</strong><span>{detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</span></p>
<p><strong> :</strong> {detail.approvalNumber}</p> <p><strong> :</strong><span>{detail.approvalNumber}</span></p>
</> </>
); );
case 'BANK_TRANSFER': case 'BANK_TRANSFER':
return ( return (
<> <>
<p><strong>:</strong> {detail.bankName}</p> <p><strong>:</strong><span>{detail.bankName}</span></p>
<p><strong> :</strong> {detail.settlementStatus}</p> <p><strong> :</strong><span>{detail.settlementStatus}</span></p>
</> </>
); );
case 'EASYPAY_PREPAID': case 'EASYPAY_PREPAID':
return ( return (
<> <>
<p><strong> :</strong> {detail.amount.toLocaleString()}</p> <p><strong> :</strong><span>{detail.amount.toLocaleString()}</span></p>
{detail.discountAmount > 0 && <p><strong> :</strong> {detail.discountAmount.toLocaleString()}</p>} {detail.discountAmount > 0 && <p><strong> :</strong><span>{detail.discountAmount.toLocaleString()}</span></p>}
</> </>
); );
default: default:
@ -193,13 +125,14 @@ const ReservationDetailView: React.FC<{
return ( return (
<> <>
<div className="modal-section-v2"> <div className="modal-section-v2 modal-info-grid">
<h3> </h3> <h3> </h3>
<p><strong> :</strong> {reservation.themeName}</p> <p><strong>:</strong><span>{reservation.overview.storeName}</span></p>
<p><strong> :</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p> <p><strong>:</strong><span>{reservation.overview.themeName}</span></p>
<p><strong> :</strong> {reservation.user.name}</p> <p><strong>:</strong><span>{formatDate(reservation.overview.date)} {formatTime(reservation.overview.startFrom)} ~ {formatTime(reservation.overview.endAt)}</span></p>
<p><strong> :</strong> {reservation.user.phone}</p> <p><strong> :</strong><span>{reservation.reserver.name}</span></p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p> <p><strong> :</strong><span>{reservation.reserver.contact}</span></p>
<p><strong> :</strong><span>{formatDisplayDateTime(reservation.applicationDateTime)}</span></p>
</div> </div>
{!reservation.payment ? ( {!reservation.payment ? (
@ -209,14 +142,14 @@ const ReservationDetailView: React.FC<{
</div> </div>
) : ( ) : (
<> <>
<div className="modal-section-v2"> <div className="modal-section-v2 modal-info-grid">
<h3> </h3> <h3> </h3>
<p><strong> ID:</strong> {reservation.payment.orderId}</p> <p><strong> ID:</strong><span>{reservation.payment.orderId}</span></p>
<p><strong> :</strong> {reservation.payment.totalAmount.toLocaleString()}</p> <p><strong> :</strong><span>{reservation.payment.totalAmount.toLocaleString()}</span></p>
<p><strong> :</strong> {reservation.payment.method}</p> <p><strong> :</strong><span>{reservation.payment.method}</span></p>
{reservation.payment.approvedAt && <p><strong> :</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>} {reservation.payment.approvedAt && <p><strong> :</strong><span>{formatDisplayDateTime(reservation.payment.approvedAt)}</span></p>}
</div> </div>
<div className="modal-section-v2"> <div className="modal-section-v2 modal-info-grid">
<h3> </h3> <h3> </h3>
{renderPaymentSubDetails(reservation.payment)} {renderPaymentSubDetails(reservation.payment)}
</div> </div>
@ -224,12 +157,12 @@ const ReservationDetailView: React.FC<{
)} )}
{reservation.payment && reservation.payment.cancel && ( {reservation.payment && reservation.payment.cancel && (
<div className="modal-section-v2 cancellation-section-v2"> <div className="modal-section-v2 cancellation-section-v2 modal-info-grid">
<h3> </h3> <h3> </h3>
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.cancel.cancellationRequestedAt)}</p> <p><strong> :</strong><span>{formatDisplayDateTime(reservation.payment.cancel.cancellationRequestedAt)}</span></p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.cancel.cancellationApprovedAt)}</p> <p><strong> :</strong><span>{formatDisplayDateTime(reservation.payment.cancel.cancellationApprovedAt)}</span></p>
<p><strong> :</strong> {reservation.payment.cancel.cancelReason}</p> <p><strong> :</strong><span>{reservation.payment.cancel.cancelReason}</span></p>
<p><strong> :</strong> {reservation.payment.cancel.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</p> <p><strong> :</strong><span>{reservation.payment.cancel.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</span></p>
</div> </div>
)} )}
{reservation.payment && reservation.payment.status !== 'CANCELED' && ( {reservation.payment && reservation.payment.status !== 'CANCELED' && (
@ -243,7 +176,7 @@ const ReservationDetailView: React.FC<{
// --- Main Page Component --- // --- Main Page Component ---
const MyReservationPage: React.FC = () => { const MyReservationPage: React.FC = () => {
const [reservations, setReservations] = useState<ReservationSummaryRetrieveResponse[]>([]); const [reservations, setReservations] = useState<ReservationOverviewResponse[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -258,7 +191,7 @@ const MyReservationPage: React.FC = () => {
const loadReservations = async () => { const loadReservations = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const data = await fetchSummaryByMember(); const data = await fetchAllOverviewByUser();
setReservations(data.reservations); setReservations(data.reservations);
setError(null); setError(null);
} catch (err) { } catch (err) {
@ -272,17 +205,15 @@ const MyReservationPage: React.FC = () => {
loadReservations(); loadReservations();
}, []); }, []);
const handleShowDetail = async (id: string, themeName: string, date: string, time: string) => { const handleShowDetail = async (overview: ReservationOverviewResponse) => {
try { try {
setIsDetailLoading(true); setIsDetailLoading(true);
setDetailError(null); setDetailError(null);
setModalView('detail'); setModalView('detail');
const detailData = await fetchDetailById(id); const detailData = await fetchDetailById(overview.id);
setSelectedReservation({ setSelectedReservation({
id: detailData.id, overview: overview,
themeName: themeName, reserver: detailData.reserver,
date: date,
startAt: time,
user: detailData.user, user: detailData.user,
applicationDateTime: detailData.applicationDateTime, applicationDateTime: detailData.applicationDateTime,
payment: detailData.payment payment: detailData.payment
@ -310,8 +241,8 @@ const MyReservationPage: React.FC = () => {
try { try {
setIsCancelling(true); setIsCancelling(true);
setDetailError(null); setDetailError(null);
await cancelReservation(selectedReservation.id, reason); await cancelPayment({ reservationId: selectedReservation.overview.id, cancelReason: reason });
cancelPayment({ reservationId: selectedReservation.id, cancelReason: reason }); await cancelReservation(selectedReservation.overview.id, reason);
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.'); alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
handleCloseModal(); handleCloseModal();
await loadReservations(); // Refresh the list await loadReservations(); // Refresh the list
@ -325,7 +256,7 @@ const MyReservationPage: React.FC = () => {
return ( return (
<div className="my-reservation-container-v2"> <div className="my-reservation-container-v2">
<h1> V2</h1> <h1> </h1>
{isLoading && <p> ...</p>} {isLoading && <p> ...</p>}
{error && <p className="error-message-v2">{error}</p>} {error && <p className="error-message-v2">{error}</p>}
@ -338,15 +269,18 @@ const MyReservationPage: React.FC = () => {
<div key={res.id} className={`reservation-summary-card-v2 ${status.className}`}> <div key={res.id} className={`reservation-summary-card-v2 ${status.className}`}>
<div className="card-status-badge">{status.text}</div> <div className="card-status-badge">{status.text}</div>
<div className="summary-details-v2"> <div className="summary-details-v2">
<h3 className="summary-theme-name-v2">{res.themeName}</h3> <div><h3 className="summary-theme-name-v2">{res.themeName}</h3></div>
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p> <div className="summary-subdetails-v2">
<p className="summary-store-name-v2">{res.storeName}</p>
<p className="summary-datetime-v2">{formatDate(res.date)} {formatTime(res.startFrom)} ~ {formatTime(res.endAt)}</p>
</div>
</div> </div>
<button <button
onClick={() => handleShowDetail(res.id, res.themeName, res.date, res.startAt)} onClick={() => handleShowDetail(res)}
disabled={isDetailLoading} disabled={isDetailLoading}
className="detail-button-v2" className="detail-button-v2"
> >
{isDetailLoading && selectedReservation?.id === res.id ? '로딩중...' : '상세보기'} {isDetailLoading && selectedReservation?.overview.id === res.id ? '로딩중...' : '상세보기'}
</button> </button>
</div> </div>
); );

View File

@ -1,19 +1,20 @@
import {isLoginRequiredError} from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import {createPendingReservation} from '@_api/reservation/reservationAPI'; import { createPendingReservation } from '@_api/reservation/reservationAPI';
import {fetchContact} from '@_api/user/userAPI'; import type { ReservationData } from '@_api/reservation/reservationTypes';
import { fetchContact } from '@_api/user/userAPI';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from 'react';
import {useLocation, useNavigate} from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import {formatDate, formatTime} from 'src/util/DateTimeFormatter'; import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
const ReservationFormPage: React.FC = () => { const ReservationFormPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { scheduleId, theme, date, time } = location.state || {}; const reservationData = location.state as ReservationData;
const [reserverName, setReserverName] = useState(''); const [reserverName, setReserverName] = useState('');
const [reserverContact, setReserverContact] = useState(''); const [reserverContact, setReserverContact] = useState('');
const [participantCount, setParticipantCount] = useState(theme.minParticipants || 1); const [participantCount, setParticipantCount] = useState(reservationData.theme.minParticipants || 2);
const [requirement, setRequirement] = useState(''); const [requirement, setRequirement] = useState('');
const [isLoadingUserInfo, setIsLoadingUserInfo] = useState(true); const [isLoadingUserInfo, setIsLoadingUserInfo] = useState(true);
@ -50,30 +51,29 @@ const ReservationFormPage: React.FC = () => {
return; return;
} }
const reservationData = { createPendingReservation({
scheduleId, scheduleId: reservationData.scheduleId,
reserverName, reserverName,
reserverContact, reserverContact,
participantCount, participantCount,
requirement, requirement,
}; }).then(res => {
createPendingReservation(reservationData)
.then(res => {
navigate('/reservation/payment', { navigate('/reservation/payment', {
state: { state: {
reservationId: res.id, reservationId: res.id,
themeName: theme.name, storeName: reservationData.store.name,
date: date, themeName: reservationData.theme.name,
startAt: time, date: reservationData.date,
price: theme.price * participantCount, time: formatTime(reservationData.startFrom) + ' ~ ' + formatTime(reservationData.endAt),
themePrice: reservationData.theme.price,
totalPrice: reservationData.theme.price * participantCount,
participantCount: participantCount,
} }
}); });
}) }).catch(handleError);
.catch(handleError);
}; };
if (!scheduleId || !theme) { if (!reservationData) {
return ( return (
<div className="reservation-v21-container"> <div className="reservation-v21-container">
<h2 className="page-title"> </h2> <h2 className="page-title"> </h2>
@ -89,9 +89,10 @@ const ReservationFormPage: React.FC = () => {
<div className="step-section"> <div className="step-section">
<h3> </h3> <h3> </h3>
<p><strong>:</strong> {theme.name}</p> <p><strong>:</strong> {reservationData.store.name}</p>
<p><strong>:</strong> {formatDate(date)}</p> <p><strong>:</strong> {reservationData.theme.name}</p>
<p><strong>:</strong> {formatTime(time)}</p> <p><strong>:</strong> {formatDate(reservationData.date)}</p>
<p><strong>:</strong> {formatTime(reservationData.startFrom)} ~ {formatTime(reservationData.endAt)}</p>
</div> </div>
<div className="step-section"> <div className="step-section">
@ -124,9 +125,9 @@ const ReservationFormPage: React.FC = () => {
<input <input
type="number" type="number"
value={participantCount} value={participantCount}
onChange={e => setParticipantCount(Math.max(theme.minParticipants, Math.min(theme.maxParticipants, Number(e.target.value))))} onChange={e => setParticipantCount(Math.max(reservationData.theme.minParticipants, Math.min(reservationData.theme.maxParticipants, Number(e.target.value))))}
min={theme.minParticipants} min={reservationData.theme.minParticipants}
max={theme.maxParticipants} max={reservationData.theme.maxParticipants}
/> />
</div> </div>
</div> </div>

View File

@ -1,30 +1,44 @@
import {isLoginRequiredError} from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import {findAvailableThemesByDate, findSchedules, holdSchedule} from '@_api/schedule/scheduleAPI'; import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
import {type ScheduleRetrieveResponse, ScheduleStatus} from '@_api/schedule/scheduleTypes'; import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
import {findThemesByIds} from '@_api/theme/themeAPI'; import { type ReservationData } from '@_api/reservation/reservationTypes';
import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes'; import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
import { getStores } from '@_api/store/storeAPI';
import { type SimpleStoreResponse } from '@_api/store/storeTypes';
import { fetchThemeById } from '@_api/theme/themeAPI';
import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from 'react';
import {useLocation, useNavigate} from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import {formatDate, formatTime} from 'src/util/DateTimeFormatter'; import { formatDate } from 'src/util/DateTimeFormatter';
const ReservationStep1Page: React.FC = () => { const ReservationStep1Page: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel const [viewDate, setViewDate] = useState<Date>(new Date());
const [themes, setThemes] = useState<ThemeInfoResponse[]>([]);
const [selectedTheme, setSelectedTheme] = useState<ThemeInfoResponse | null>(null); const [sidoList, setSidoList] = useState<SidoResponse[]>([]);
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]); const [sigunguList, setSigunguList] = useState<SigunguResponse[]>([]);
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleRetrieveResponse | null>(null); const [storeList, setStoreList] = useState<SimpleStoreResponse[]>([]);
const [selectedSido, setSelectedSido] = useState('');
const [selectedSigungu, setSelectedSigungu] = useState('');
const [selectedStore, setSelectedStore] = useState<SimpleStoreResponse | null>(null);
const [schedulesByTheme, setSchedulesByTheme] = useState<Record<string, ScheduleWithThemeResponse[]>>({});
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleWithThemeResponse | null>(null);
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false); const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
const [modalThemeDetails, setModalThemeDetails] = useState<ThemeInfoResponse | null>(null);
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const handleError = (err: any) => { const handleError = (err: any) => {
if (isLoginRequiredError(err)) { if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.'); alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } }); navigate('/login', {state: {from: location}});
} else { } else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message); alert(message);
@ -33,89 +47,48 @@ const ReservationStep1Page: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
if (selectedDate) { fetchSidoList().then(res => setSidoList(res.sidoList)).catch(handleError);
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd }, []);
findAvailableThemesByDate(dateStr)
.then(res => {
console.log('Available themes response:', res);
const themeIds: string[] = res.themeIds;
console.log('Available theme IDs:', themeIds);
if (themeIds.length > 0) {
return findThemesByIds({ themeIds });
} else {
return Promise.resolve({ themes: [] });
}
})
.then(themeResponse => {
setThemes(themeResponse.themes.map(mapThemeResponse));
})
.catch((err) => {
if (isLoginRequiredError(err)) {
setThemes([]);
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
})
.finally(() => {
setSelectedTheme(null);
setSchedules([]);
setSelectedSchedule(null);
});
}
}, [selectedDate]);
useEffect(() => { useEffect(() => {
if (selectedDate && selectedTheme) { if (selectedSido) {
fetchSigunguList(selectedSido).then(res => setSigunguList(res.sigunguList)).catch(handleError);
} else {
setSigunguList([]);
}
setSelectedSigungu('');
}, [selectedSido]);
useEffect(() => {
if (selectedSido) {
getStores(selectedSido, selectedSigungu)
.then(res => setStoreList(res.stores))
.catch(handleError);
} else {
setStoreList([]);
}
setSelectedStore(null);
}, [selectedSido, selectedSigungu]);
useEffect(() => {
if (selectedDate && selectedStore) {
const dateStr = selectedDate.toLocaleDateString('en-CA'); const dateStr = selectedDate.toLocaleDateString('en-CA');
findSchedules(dateStr, selectedTheme.id) fetchSchedules(selectedStore.id, dateStr)
.then(res => { .then(res => {
setSchedules(res.schedules); const grouped = res.schedules.reduce((acc, schedule) => {
setSelectedSchedule(null); const key = schedule.theme.name;
if (!acc[key]) acc[key] = [];
acc[key].push(schedule);
return acc;
}, {} as Record<string, ScheduleWithThemeResponse[]>);
setSchedulesByTheme(grouped);
}) })
.catch((err) => { .catch(handleError);
if (isLoginRequiredError(err)) {
setSchedules([]);
} else { } else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; setSchedulesByTheme({});
alert(message);
console.error(err);
} }
setSelectedSchedule(null); setSelectedSchedule(null);
}); }, [selectedDate, selectedStore]);
}
}, [selectedDate, selectedTheme]);
const handleNextStep = () => {
if (!selectedDate || !selectedTheme || !selectedSchedule) {
alert('날짜, 테마, 시간을 모두 선택해주세요.');
return;
}
if (selectedSchedule.status !== ScheduleStatus.AVAILABLE) {
alert('예약할 수 없는 시간입니다.');
return;
}
setIsConfirmModalOpen(true);
};
const handleConfirmReservation = () => {
if (!selectedSchedule) return;
holdSchedule(selectedSchedule.id)
.then(() => {
navigate('/reservation/form', {
state: {
scheduleId: selectedSchedule.id,
theme: selectedTheme,
date: selectedDate.toLocaleDateString('en-CA'),
time: selectedSchedule.time,
}
});
})
.catch(handleError)
.finally(() => setIsConfirmModalOpen(false));
};
const handleDateSelect = (date: Date) => { const handleDateSelect = (date: Date) => {
const today = new Date(); const today = new Date();
@ -125,7 +98,53 @@ const ReservationStep1Page: React.FC = () => {
return; return;
} }
setSelectedDate(date); setSelectedDate(date);
};
const handleNextStep = () => {
if (!selectedSchedule) {
alert('예약할 시간을 선택해주세요.');
return;
} }
setIsConfirmModalOpen(true);
};
const handleConfirmReservation = () => {
if (!selectedSchedule) return;
holdSchedule(selectedSchedule.schedule.id)
.then(() => {
fetchThemeById(selectedSchedule.theme.id).then(res => {
const reservationData: ReservationData = {
scheduleId: selectedSchedule.schedule.id,
store: {
id: selectedStore!.id,
name: selectedStore!.name,
},
theme: {
id: res.id,
name: res.name,
price: res.price,
minParticipants: res.minParticipants,
maxParticipants: res.maxParticipants,
},
date: selectedDate.toLocaleDateString('en-CA'),
startFrom: selectedSchedule.schedule.startFrom,
endAt: selectedSchedule.schedule.endAt,
};
navigate('/reservation/form', {state: reservationData});
}).catch(handleError);
})
.catch(handleError);
};
const openThemeModal = (themeId: string) => {
fetchThemeById(themeId)
.then(themeDetails => {
setModalThemeDetails(themeDetails);
setIsThemeModalOpen(true);
})
.catch(handleError);
};
const renderDateCarousel = () => { const renderDateCarousel = () => {
const dates = []; const dates = [];
@ -184,11 +203,6 @@ const ReservationStep1Page: React.FC = () => {
); );
}; };
const openThemeModal = (theme: ThemeInfoResponse) => {
setSelectedTheme(theme);
setIsThemeModalOpen(true);
};
const getStatusText = (status: ScheduleStatus) => { const getStatusText = (status: ScheduleStatus) => {
switch (status) { switch (status) {
case ScheduleStatus.AVAILABLE: case ScheduleStatus.AVAILABLE:
@ -200,8 +214,6 @@ const ReservationStep1Page: React.FC = () => {
} }
}; };
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== ScheduleStatus.AVAILABLE;
return ( return (
<div className="reservation-v21-container"> <div className="reservation-v21-container">
<h2 className="page-title"></h2> <h2 className="page-title"></h2>
@ -212,82 +224,97 @@ const ReservationStep1Page: React.FC = () => {
</div> </div>
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}> <div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
<h3>2. </h3> <h3>2. </h3>
<div className="theme-list"> <div className="region-store-selectors">
{themes.map(theme => ( <select value={selectedSido} onChange={e => setSelectedSido(e.target.value)}>
<option value="">/</option>
{sidoList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
</select>
<select value={selectedSigungu} onChange={e => setSelectedSigungu(e.target.value)}
disabled={!selectedSido}>
<option value="">// ()</option>
{sigunguList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
</select>
<select value={selectedStore?.id || ''}
onChange={e => setSelectedStore(storeList.find(s => s.id === e.target.value) || null)}
disabled={storeList.length === 0}>
<option value=""> </option>
{storeList.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
</div>
<div className={`step-section ${!selectedStore ? 'disabled' : ''}`}>
<h3>3. </h3>
<div className="schedule-list">
{Object.keys(schedulesByTheme).length > 0 ? (
Object.entries(schedulesByTheme).map(([themeName, scheduleAndTheme]) => (
<div key={themeName} className="theme-schedule-group">
<div className="theme-header">
<h4>{themeName}</h4>
<button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
className="theme-detail-button">
</button>
</div>
<div className="time-slots">
{scheduleAndTheme.map(schedule => (
<div <div
key={theme.id} key={schedule.schedule.id}
className={`theme-card ${selectedTheme?.id === theme.id ? 'active' : ''}`} className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => setSelectedTheme(theme)} onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
> >
<div className="theme-info"> {`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
<h4>{theme.name}</h4> <span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
<div className="theme-meta">
<p><strong>1 :</strong> {theme.price.toLocaleString()}</p>
<p><strong>:</strong> {theme.difficulty}</p>
<p><strong> :</strong> {theme.minParticipants} ~ {theme.maxParticipants}</p>
<p><strong> :</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}</p>
<p><strong> :</strong> {theme.availableMinutes}</p>
</div>
<button className="theme-detail-button" onClick={(e) => { e.stopPropagation(); openThemeModal(theme); }}></button>
</div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
))
<div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}> ) : (
<h3>3. </h3> <div className="no-times"> .</div>
<div className="time-slots"> )}
{schedules.length > 0 ? schedules.map(schedule => (
<div
key={schedule.id}
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
>
{schedule.time}
<span className="time-availability">{getStatusText(schedule.status)}</span>
</div>
)) : <div className="no-times"> .</div>}
</div> </div>
</div> </div>
<div className="next-step-button-container"> <div className="next-step-button-container">
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}> <button className="next-step-button" disabled={!selectedSchedule} onClick={handleNextStep}>
</button> </button>
</div> </div>
{isThemeModalOpen && selectedTheme && ( {isThemeModalOpen && modalThemeDetails && (
<div className="modal-overlay" onClick={() => setIsThemeModalOpen(false)}> <div className="modal-overlay" onClick={() => setIsThemeModalOpen(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}> <div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="modal-close-button" onClick={() => setIsThemeModalOpen(false)}>×</button> <button className="modal-close-button" onClick={() => setIsThemeModalOpen(false)}>×</button>
<img src={selectedTheme.thumbnailUrl} alt={selectedTheme.name} className="modal-theme-thumbnail" /> <img src={modalThemeDetails.thumbnailUrl} alt={modalThemeDetails.name}
<h2>{selectedTheme.name}</h2> className="modal-theme-thumbnail"/>
<div className="modal-section"> <h2>{modalThemeDetails.name}</h2>
<div className="modal-section modal-info-grid">
<h3> </h3> <h3> </h3>
<p><strong>:</strong> {selectedTheme.difficulty}</p> <p><strong>:</strong><span>{DifficultyKoreanMap[modalThemeDetails.difficulty]}</span></p>
<p><strong> :</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</p> <p><strong> :</strong><span>{modalThemeDetails.minParticipants} ~ {modalThemeDetails.maxParticipants}</span></p>
<p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p> <p><strong>1 :</strong><span>{modalThemeDetails.price.toLocaleString()}</span></p>
<p><strong>1 :</strong> {selectedTheme.price.toLocaleString()}</p> <p><strong> :</strong><span>{modalThemeDetails.expectedMinutesFrom} ~ {modalThemeDetails.expectedMinutesTo}</span></p>
<p><strong> :</strong><span>{modalThemeDetails.availableMinutes}</span></p>
</div> </div>
<div className="modal-section"> <div className="modal-section">
<h3></h3> <h3></h3>
<p>{selectedTheme.description}</p> <p>{modalThemeDetails.description}</p>
</div> </div>
</div> </div>
</div> </div>
)} )}
{isConfirmModalOpen && ( {isConfirmModalOpen && selectedSchedule && (
<div className="modal-overlay" onClick={() => setIsConfirmModalOpen(false)}> <div className="modal-overlay" onClick={() => setIsConfirmModalOpen(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}> <div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="modal-close-button" onClick={() => setIsConfirmModalOpen(false)}>×</button> <button className="modal-close-button" onClick={() => setIsConfirmModalOpen(false)}>×</button>
<h2> </h2> <h2> </h2>
<div className="modal-section"> <div className="modal-section modal-info-grid">
<p><strong>:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p> <p><strong>:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
<p><strong>:</strong> {selectedTheme!!.name}</p> <p><strong>:</strong><span>{selectedStore?.name}</span></p>
<p><strong>:</strong> {formatTime(selectedSchedule!!.time)}</p> <p><strong>:</strong><span>{selectedSchedule.theme.name}</span></p>
<p><strong>:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}></button> <button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}></button>

View File

@ -1,11 +1,12 @@
import {isLoginRequiredError} from '@_api/apiClient'; import { confirm } from '@_api/order/orderAPI';
import {confirmPayment} from '@_api/payment/paymentAPI'; import type { OrderErrorResponse } from '@_api/order/orderTypes';
import {type PaymentConfirmRequest, PaymentType} from '@_api/payment/PaymentTypes'; import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
import {confirmReservation} from '@_api/reservation/reservationAPI'; import { confirmReservation } from '@_api/reservation/reservationAPI';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import React, {useEffect, useRef} from 'react'; import type { AxiosError } from 'axios';
import {useLocation, useNavigate} from 'react-router-dom'; import React, { useEffect, useRef } from 'react';
import {formatDate, formatTime} from 'src/util/DateTimeFormatter'; import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate } from 'src/util/DateTimeFormatter';
declare global { declare global {
interface Window { interface Window {
@ -19,18 +20,7 @@ const ReservationStep2Page: React.FC = () => {
const paymentWidgetRef = useRef<any>(null); const paymentWidgetRef = useRef<any>(null);
const paymentMethodsRef = useRef<any>(null); const paymentMethodsRef = useRef<any>(null);
const { reservationId, themeName, date, startAt, price } = location.state || {}; const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
useEffect(() => { useEffect(() => {
if (!reservationId) { if (!reservationId) {
@ -51,12 +41,12 @@ const ReservationStep2Page: React.FC = () => {
const paymentMethods = paymentWidget.renderPaymentMethods( const paymentMethods = paymentWidget.renderPaymentMethods(
"#payment-method", "#payment-method",
{ value: price }, { value: totalPrice, currency: "KRW" },
{ variantKey: "DEFAULT" } { variantKey: "DEFAULT" }
); );
paymentMethodsRef.current = paymentMethods; paymentMethodsRef.current = paymentMethods;
}; };
}, [reservationId, price, navigate]); }, [reservationId, totalPrice, navigate]);
const handlePayment = () => { const handlePayment = () => {
if (!paymentWidgetRef.current || !reservationId) { if (!paymentWidgetRef.current || !reservationId) {
@ -67,36 +57,75 @@ const ReservationStep2Page: React.FC = () => {
const generateRandomString = () => const generateRandomString = () =>
crypto.randomUUID().replace(/-/g, ''); crypto.randomUUID().replace(/-/g, '');
paymentWidgetRef.current.requestPayment({ paymentWidgetRef.current.requestPayment({
orderId: generateRandomString(), orderId: generateRandomString(),
orderName: `${themeName} 예약 결제`, orderName: `${themeName} 예약 결제`,
amount: price, amount: totalPrice,
}).then((data: any) => { }).then((data: any) => {
const paymentData: PaymentConfirmRequest = { const paymentData: PaymentConfirmRequest = {
paymentKey: data.paymentKey, paymentKey: data.paymentKey,
orderId: data.orderId, orderId: data.orderId,
amount: price, // Use the price from component state instead of widget response amount: totalPrice,
paymentType: data.paymentType || PaymentType.NORMAL,
}; };
confirm(reservationId, paymentData)
confirmPayment(reservationId, paymentData)
.then(() => {
return confirmReservation(reservationId);
})
.then(() => { .then(() => {
alert('결제가 완료되었어요!'); alert('결제가 완료되었어요!');
navigate('/reservation/success', { navigate('/reservation/success', {
state: { state: {
themeName, storeName: storeName,
date, themeName: themeName,
startAt, date: date,
time: time,
participantCount: participantCount,
totalPrice: totalPrice,
} }
}); });
}) })
.catch(handleError); .catch(err => {
const error = err as AxiosError<OrderErrorResponse>;
const errorCode = error.response?.data?.code;
const errorMessage = error.response?.data?.message;
if (errorCode === 'B000') {
alert(`예약을 완료할 수 없어요.(${errorMessage})`);
navigate('/reservation');
return;
}
const trial = error.response?.data?.trial || 0;
if (trial < 2) {
alert(errorMessage);
return;
}
alert(errorMessage);
setTimeout(() => {
const agreeToOnsitePayment = window.confirm('재시도 횟수를 초과했어요. 현장결제를 하시겠어요?');
if (agreeToOnsitePayment) {
confirmReservation(reservationId)
.then(() => {
navigate('/reservation/success', {
state: {
storeName,
themeName,
date,
time,
participantCount,
totalPrice,
},
});
});
} else {
alert('다음에 다시 시도해주세요. 메인 페이지로 이동할게요.');
navigate('/');
}
}, 100);
});
}).catch((error: any) => { }).catch((error: any) => {
console.error("Payment request error:", error); console.error("Payment request error:", error);
alert("결제 요청 중 오류가 발생했습니다."); alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
}); });
}; };
@ -109,10 +138,13 @@ const ReservationStep2Page: React.FC = () => {
<h2 className="page-title"></h2> <h2 className="page-title"></h2>
<div className="step-section"> <div className="step-section">
<h3> </h3> <h3> </h3>
<p><strong>:</strong> {themeName}</p>
<p><strong>:</strong> {formatDate(date)}</p> <p><strong>:</strong> {formatDate(date)}</p>
<p><strong>:</strong> {formatTime(startAt)}</p> <p><strong>:</strong> {time}</p>
<p><strong>:</strong> {price.toLocaleString()}</p> <p><strong>:</strong> {themeName}</p>
<p><strong>:</strong> {storeName}</p>
<p><strong>:</strong> {participantCount}</p>
<p><strong>1 :</strong> {themePrice.toLocaleString()}</p>
<p><strong> :</strong> {totalPrice.toLocaleString()}</p>
</div> </div>
<div className="step-section"> <div className="step-section">
<h3> </h3> <h3> </h3>
@ -121,7 +153,7 @@ const ReservationStep2Page: React.FC = () => {
</div> </div>
<div className="next-step-button-container"> <div className="next-step-button-container">
<button onClick={handlePayment} className="next-step-button"> <button onClick={handlePayment} className="next-step-button">
{price.toLocaleString()} {totalPrice.toLocaleString()}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,18 +1,13 @@
import '@_css/reservation-v2-1.css'; // Reuse the new CSS import '@_css/reservation-v2-1.css';
import React from 'react'; import React from 'react';
import {Link, useLocation} from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import {formatDate, formatTime} from 'src/util/DateTimeFormatter'; import { formatDate } from 'src/util/DateTimeFormatter';
const ReservationSuccessPage: React.FC = () => { const ReservationSuccessPage: React.FC = () => {
const location = useLocation(); const location = useLocation();
const { themeName, date, startAt } = (location.state as { const { storeName, themeName, date, time, participantCount, totalPrice } = location.state || {};
themeName: string;
date: string;
startAt: string;
}) || {};
const formattedDate = formatDate(date) const formattedDate = date ? formatDate(date) : '';
const formattedTime = formatTime(startAt);
return ( return (
<div className="reservation-v21-container"> <div className="reservation-v21-container">
@ -20,9 +15,12 @@ const ReservationSuccessPage: React.FC = () => {
<h2 className="page-title"> !</h2> <h2 className="page-title"> !</h2>
<div className="step-section"> <div className="step-section">
<h3> </h3> <h3> </h3>
<p><strong>:</strong> {storeName}</p>
<p><strong>:</strong> {themeName}</p> <p><strong>:</strong> {themeName}</p>
<p><strong>:</strong> {formattedDate}</p> <p><strong>:</strong> {formattedDate}</p>
<p><strong>:</strong> {formattedTime}</p> <p><strong>:</strong> {time}</p>
<p><strong>:</strong> {participantCount}</p>
<p><strong> :</strong> {totalPrice?.toLocaleString()}</p>
</div> </div>
<div className="success-page-actions"> <div className="success-page-actions">
<Link to="/my-reservation" className="action-button"> <Link to="/my-reservation" className="action-button">

View File

@ -1,8 +1,17 @@
import {signup} from '@_api/user/userAPI'; import {
import type {UserCreateRequest, UserCreateResponse} from '@_api/user/userTypes'; fetchRegionCode,
fetchSidoList,
fetchSigunguList,
} from '@_api/region/regionAPI';
import type {
SidoResponse,
SigunguResponse,
} from '@_api/region/regionTypes';
import { signup } from '@_api/user/userAPI';
import type { UserCreateRequest, UserCreateResponse } from '@_api/user/userTypes';
import '@_css/signup-page-v2.css'; import '@_css/signup-page-v2.css';
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from 'react';
import {useNavigate} from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
const MIN_PASSWORD_LENGTH = 8; const MIN_PASSWORD_LENGTH = 8;
@ -14,8 +23,43 @@ const SignupPage: React.FC = () => {
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [hasSubmitted, setHasSubmitted] = useState(false); const [hasSubmitted, setHasSubmitted] = useState(false);
const [sidoList, setSidoList] = useState<SidoResponse[]>([]);
const [sigunguList, setSigunguList] = useState<SigunguResponse[]>([]);
const [selectedSidoCode, setSelectedSidoCode] = useState('');
const [selectedSigunguCode, setSelectedSigunguCode] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
const fetchSido = async () => {
try {
const response = await fetchSidoList();
setSidoList(response.sidoList);
} catch (error) {
console.error('시/도 목록을 불러오는 데 실패했습니다.', error);
}
};
fetchSido();
}, []);
useEffect(() => {
if (selectedSidoCode) {
const fetchSigungu = async () => {
try {
const response = await fetchSigunguList(selectedSidoCode);
setSigunguList(response.sigunguList);
setSelectedSigunguCode('');
} catch (error) {
console.error('시/군/구 목록을 불러오는 데 실패했습니다.', error);
}
};
fetchSigungu();
} else {
setSigunguList([]);
setSelectedSigunguCode('');
}
}, [selectedSidoCode]);
const validate = () => { const validate = () => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
@ -36,6 +80,12 @@ const SignupPage: React.FC = () => {
newErrors.phone = '올바른 휴대폰 번호 형식이 아닙니다. (예: 01012345678)'; newErrors.phone = '올바른 휴대폰 번호 형식이 아닙니다. (예: 01012345678)';
} }
if (selectedSidoCode || selectedSigunguCode) {
if (!selectedSidoCode || !selectedSigunguCode) {
newErrors.region = '모든 지역 정보를 선택해주세요.';
}
}
return newErrors; return newErrors;
}; };
@ -44,7 +94,7 @@ const SignupPage: React.FC = () => {
if (hasSubmitted) { if (hasSubmitted) {
setErrors(validate()); setErrors(validate());
} }
}, [email, password, name, phone, hasSubmitted]); }, [email, password, name, phone, hasSubmitted, selectedSidoCode, selectedSigunguCode]);
const handleSignup = async (e: React.FormEvent) => { const handleSignup = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -55,7 +105,22 @@ const SignupPage: React.FC = () => {
if (Object.keys(newErrors).length > 0) return; if (Object.keys(newErrors).length > 0) return;
const request: UserCreateRequest = { email, password, name, phone, regionCode: null }; let regionCode: string | null = null;
if (selectedSidoCode && selectedSigunguCode) {
try {
const response = await fetchRegionCode(
selectedSidoCode,
selectedSigunguCode,
);
regionCode = response.code;
} catch (error) {
alert('지역 코드를 가져오는 데 실패했습니다.');
console.error(error);
return;
}
}
const request: UserCreateRequest = { email, password, name, phone, regionCode };
try { try {
const response: UserCreateResponse = await signup(request); const response: UserCreateResponse = await signup(request);
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`); alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
@ -133,6 +198,40 @@ const SignupPage: React.FC = () => {
)} )}
</div> </div>
<div className="form-group">
<label className="form-label"> ()</label>
<div className="region-select-group">
<select
className="form-input"
value={selectedSidoCode}
onChange={e => setSelectedSidoCode(e.target.value)}
>
<option value="">/</option>
{sidoList.map(s => (
<option key={s.code} value={s.code}>
{s.name}
</option>
))}
</select>
<select
className="form-input"
value={selectedSigunguCode}
onChange={e => setSelectedSigunguCode(e.target.value)}
disabled={!selectedSidoCode}
>
<option value="">//</option>
{sigunguList.map(s => (
<option key={s.code} value={s.code}>
{s.name}
</option>
))}
</select>
</div>
{hasSubmitted && errors.region && (
<p className="error-text">{errors.region}</p>
)}
</div>
<button <button
type="submit" type="submit"
className="btn-primary" className="btn-primary"

View File

@ -1,4 +1,6 @@
import React, {type ReactNode} from 'react'; import { useAdminAuth } from '@_context/AdminAuthContext';
import React, { type ReactNode, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import AdminNavbar from './AdminNavbar'; import AdminNavbar from './AdminNavbar';
interface AdminLayoutProps { interface AdminLayoutProps {
@ -6,6 +8,23 @@ interface AdminLayoutProps {
} }
const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => { const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => {
const { isAdmin, loading } = useAdminAuth();
const navigate = useNavigate();
useEffect(() => {
if (!loading && !isAdmin) {
navigate('/admin/login');
}
}, [isAdmin, loading, navigate]);
if (loading) {
return <div>Loading...</div>;
}
if (!isAdmin) {
return null;
}
return ( return (
<> <>
<AdminNavbar /> <AdminNavbar />

View File

@ -0,0 +1,61 @@
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAdminAuth } from '@_context/AdminAuthContext';
import '@_css/login-page-v2.css';
const AdminLoginPage: React.FC = () => {
const [account, setAccount] = useState('');
const [password, setPassword] = useState('');
const { login } = useAdminAuth();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || '/admin';
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
await login({ account: account, password: password });
alert('관리자 로그인에 성공했어요!');
navigate(from, { replace: true });
} catch (error: any) {
const message = error.response?.data?.message || '로그인에 실패했어요. 계정과 비밀번호를 확인해주세요.';
alert(message);
console.error('관리자 로그인 실패:', error);
setPassword('');
}
};
return (
<div className="login-container-v2">
<h2 className="page-title"> </h2>
<form className="login-form-v2" onSubmit={handleLogin}>
<div className="form-group">
<input
type="text"
className="form-input"
placeholder="계정"
value={account}
onChange={e => setAccount(e.target.value)}
required
/>
</div>
<div className="form-group">
<input
type="password"
className="form-input"
placeholder="비밀번호"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div>
<div className="button-group">
<button type="submit" className="btn btn-primary"></button>
</div>
</form>
</div>
);
};
export default AdminLoginPage;

View File

@ -1,10 +1,10 @@
import { useAdminAuth } from '@_context/AdminAuthContext';
import React from 'react'; import React from 'react';
import {Link, useNavigate} from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import {useAuth} from '@_context/AuthContext';
import '@_css/navbar.css'; import '@_css/navbar.css';
const AdminNavbar: React.FC = () => { const AdminNavbar: React.FC = () => {
const { loggedIn, userName, logout } = useAuth(); const { isAdmin, name, type, logout } = useAdminAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const handleLogout = async (e: React.MouseEvent) => { const handleLogout = async (e: React.MouseEvent) => {
@ -21,16 +21,17 @@ const AdminNavbar: React.FC = () => {
<nav className="navbar-container"> <nav className="navbar-container">
<div className="nav-links"> <div className="nav-links">
<Link className="nav-link" to="/admin"></Link> <Link className="nav-link" to="/admin"></Link>
<Link className="nav-link" to="/admin/theme"></Link> {type === 'HQ' && <Link className="nav-link" to="/admin/theme"></Link>}
{type === 'HQ' && <Link className="nav-link" to="/admin/store"></Link>}
<Link className="nav-link" to="/admin/schedule"></Link> <Link className="nav-link" to="/admin/schedule"></Link>
</div> </div>
<div className="nav-actions"> <div className="nav-actions">
{!loggedIn ? ( {!isAdmin ? (
<button className="btn btn-primary" onClick={() => navigate('/v2/login')}>Login</button> <button className="btn btn-primary" onClick={() => navigate('/admin/login')}>Login</button>
) : ( ) : (
<div className="profile-info"> <div className="profile-info">
<img className="profile-image" src="/image/default-profile.png" alt="Profile" /> <img className="profile-image" src="/image/default-profile.png" alt="Profile" />
<span>{userName || 'Profile'}</span> <span>{name || 'Profile'}</span>
<div className="dropdown-menu"> <div className="dropdown-menu">
<a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a> <a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a>
</div> </div>

View File

@ -1,19 +1,20 @@
import {isLoginRequiredError} from '@_api/apiClient'; import {isLoginRequiredError} from '@_api/apiClient';
import type {AuditInfo} from '@_api/common/commonTypes';
import { import {
createSchedule, createSchedule,
deleteSchedule, deleteSchedule,
findScheduleById, fetchAdminSchedules,
findSchedules, fetchScheduleAudit,
updateSchedule updateSchedule
} from '@_api/schedule/scheduleAPI'; } from '@_api/schedule/scheduleAPI';
import { import {type AdminScheduleSummaryResponse, ScheduleStatus,} from '@_api/schedule/scheduleTypes';
type ScheduleDetailRetrieveResponse, import {getStores} from '@_api/store/storeAPI';
type ScheduleRetrieveResponse, import {type SimpleStoreResponse} from '@_api/store/storeTypes';
ScheduleStatus import {fetchActiveThemes} from '@_api/theme/themeAPI';
} from '@_api/schedule/scheduleTypes'; import {DifficultyKoreanMap, type SimpleActiveThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
import {fetchAdminThemes} from '@_api/theme/themeAPI'; import {useAdminAuth} from '@_context/AdminAuthContext';
import type {AdminThemeSummaryRetrieveResponse} from '@_api/theme/themeTypes';
import '@_css/admin-schedule-page.css'; import '@_css/admin-schedule-page.css';
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
import React, {Fragment, useEffect, useState} from 'react'; import React, {Fragment, useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
@ -32,28 +33,41 @@ const getScheduleStatusText = (status: ScheduleStatus): string => {
} }
}; };
type ScheduleDetail = AdminScheduleSummaryResponse & { audit?: AuditInfo };
type EditingSchedule = ScheduleDetail & { time: string };
const AdminSchedulePage: React.FC = () => { const AdminSchedulePage: React.FC = () => {
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]); const [schedules, setSchedules] = useState<AdminScheduleSummaryResponse[]>([]);
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]); const [themes, setThemes] = useState<SimpleActiveThemeResponse[]>([]);
const [selectedThemeId, setSelectedThemeId] = useState<string>(''); const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
const [selectedStoreId, setSelectedStoreId] = useState<string>('');
const [selectedTheme, setSelectedTheme] = useState<SimpleActiveThemeResponse | null>(null);
const [selectedDate, setSelectedDate] = useState<string>(new Date().toLocaleDateString('en-CA')); const [selectedDate, setSelectedDate] = useState<string>(new Date().toLocaleDateString('en-CA'));
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [newScheduleTime, setNewScheduleTime] = useState(''); const [newScheduleTime, setNewScheduleTime] = useState('');
const [expandedScheduleId, setExpandedScheduleId] = useState<string | null>(null); const [expandedScheduleId, setExpandedScheduleId] = useState<string | null>(null);
const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetailRetrieveResponse }>({}); const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetail }>({});
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false); const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<ScheduleDetailRetrieveResponse | null>(null); const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
const [isLoadingThemeDetails] = useState<boolean>(false);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const {type: adminType, storeId: adminStoreId} = useAdminAuth();
const storeIdForFetch = adminType === 'HQ' ? selectedStoreId : adminStoreId;
const showThemeColumn = !selectedTheme?.id;
const handleError = (err: any) => { const handleError = (err: any) => {
if (isLoginRequiredError(err)) { if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.'); alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } }); navigate('/admin/login', {state: {from: location}});
} else { } else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message); alert(message);
@ -62,19 +76,36 @@ const AdminSchedulePage: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
fetchAdminThemes() if (!adminType) return;
.then(res => {
setThemes(res.themes); const fetchPrerequisites = async () => {
if (res.themes.length > 0) { try {
setSelectedThemeId(String(res.themes[0].id)); // Fetch themes
const themeRes = await fetchActiveThemes();
const themeData = themeRes.themes.map(t => ({id: String(t.id), name: t.name}));
const allThemesOption = {id: '', name: '전체'};
setThemes([allThemesOption, ...themeData]);
setSelectedTheme(allThemesOption);
// Fetch stores for HQ admin
if (adminType === 'HQ') {
const storeRes = (await getStores()).stores;
setStores(storeRes);
if (storeRes.length > 0) {
setSelectedStoreId(String(storeRes[0].id));
} }
}) }
.catch(handleError); } catch (error) {
}, []); handleError(error);
}
};
fetchPrerequisites();
}, [adminType]);
const fetchSchedules = () => { const fetchSchedules = () => {
if (selectedDate && selectedThemeId) { if (storeIdForFetch) {
findSchedules(selectedDate, selectedThemeId) fetchAdminSchedules(storeIdForFetch, selectedDate, selectedTheme?.id === '' ? undefined : selectedTheme?.id)
.then(res => setSchedules(res.schedules)) .then(res => setSchedules(res.schedules))
.catch(err => { .catch(err => {
setSchedules([]); setSchedules([]);
@ -82,12 +113,14 @@ const AdminSchedulePage: React.FC = () => {
handleError(err); handleError(err);
} }
}); });
} else {
setSchedules([]);
} }
} }
useEffect(() => { useEffect(() => {
fetchSchedules(); fetchSchedules();
}, [selectedDate, selectedThemeId]); }, [selectedDate, selectedTheme, storeIdForFetch]);
const handleAddSchedule = async () => { const handleAddSchedule = async () => {
if (!newScheduleTime) { if (!newScheduleTime) {
@ -98,10 +131,18 @@ const AdminSchedulePage: React.FC = () => {
alert('시간 형식이 올바르지 않습니다. HH:MM 형식으로 입력해주세요.'); alert('시간 형식이 올바르지 않습니다. HH:MM 형식으로 입력해주세요.');
return; return;
} }
if (adminType !== 'STORE' || !adminStoreId) {
alert('매장 관리자만 일정을 추가할 수 있습니다.');
return;
}
if (!selectedDate || !selectedTheme?.id) {
alert('날짜와 특정 테마를 선택해주세요.');
return;
}
try { try {
await createSchedule({ await createSchedule(adminStoreId, {
date: selectedDate, date: selectedDate,
themeId: selectedThemeId, themeId: selectedTheme.id,
time: newScheduleTime, time: newScheduleTime,
}); });
fetchSchedules(); fetchSchedules();
@ -116,7 +157,7 @@ const AdminSchedulePage: React.FC = () => {
if (window.confirm('정말 이 일정을 삭제하시겠습니까?')) { if (window.confirm('정말 이 일정을 삭제하시겠습니까?')) {
try { try {
await deleteSchedule(scheduleId); await deleteSchedule(scheduleId);
setSchedules(schedules.filter(s => s.id !== scheduleId)); fetchSchedules();
setExpandedScheduleId(null); // Close the details view after deletion setExpandedScheduleId(null); // Close the details view after deletion
} catch (error) { } catch (error) {
handleError(error); handleError(error);
@ -126,16 +167,22 @@ const AdminSchedulePage: React.FC = () => {
const handleToggleDetails = async (scheduleId: string) => { const handleToggleDetails = async (scheduleId: string) => {
const isAlreadyExpanded = expandedScheduleId === scheduleId; const isAlreadyExpanded = expandedScheduleId === scheduleId;
setIsEditing(false); // Reset editing state whenever toggling setIsEditing(false);
if (isAlreadyExpanded) { if (isAlreadyExpanded) {
setExpandedScheduleId(null); setExpandedScheduleId(null);
} else { } else {
setExpandedScheduleId(scheduleId); setExpandedScheduleId(scheduleId);
if (!detailedSchedules[scheduleId]) { const scheduleInList = schedules.find(s => s.id === scheduleId);
if (!scheduleInList) return;
if (!detailedSchedules[scheduleId]?.audit) {
setIsLoadingDetails(true); setIsLoadingDetails(true);
try { try {
const details = await findScheduleById(scheduleId); const auditInfo = await fetchScheduleAudit(scheduleId);
setDetailedSchedules(prev => ({ ...prev, [scheduleId]: details })); setDetailedSchedules(prev => ({
...prev,
[scheduleId]: {...scheduleInList, audit: auditInfo}
}));
} catch (error) { } catch (error) {
handleError(error); handleError(error);
} finally { } finally {
@ -147,7 +194,15 @@ const AdminSchedulePage: React.FC = () => {
const handleEditClick = () => { const handleEditClick = () => {
if (expandedScheduleId && detailedSchedules[expandedScheduleId]) { if (expandedScheduleId && detailedSchedules[expandedScheduleId]) {
setEditingSchedule({ ...detailedSchedules[expandedScheduleId] }); const scheduleToEdit = detailedSchedules[expandedScheduleId];
setEditingSchedule({
...scheduleToEdit,
time: new Date(scheduleToEdit.startFrom).toLocaleTimeString('en-CA', {
hour: '2-digit',
minute: '2-digit',
hour12: false
}),
});
setIsEditing(true); setIsEditing(true);
} }
}; };
@ -158,9 +213,9 @@ const AdminSchedulePage: React.FC = () => {
}; };
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => { const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target; const {name, value} = e.target;
if (editingSchedule) { if (editingSchedule) {
setEditingSchedule({ ...editingSchedule, [name]: value }); setEditingSchedule({...editingSchedule, [name]: value});
} }
}; };
@ -172,24 +227,39 @@ const AdminSchedulePage: React.FC = () => {
time: editingSchedule.time, time: editingSchedule.time,
status: editingSchedule.status, status: editingSchedule.status,
}); });
// Refresh data fetchSchedules();
const details = await findScheduleById(editingSchedule.id); setExpandedScheduleId(null);
setDetailedSchedules(prev => ({ ...prev, [editingSchedule.id]: details }));
setSchedules(schedules.map(s => s.id === editingSchedule.id ? { ...s, time: details.time, status: details.status } : s));
alert('일정이 성공적으로 업데이트되었습니다.');
setIsEditing(false); setIsEditing(false);
setEditingSchedule(null);
alert('일정이 성공적으로 업데이트되었습니다.');
} catch (error) { } catch (error) {
handleError(error); handleError(error);
} }
}; };
const canModify = adminType === 'STORE';
return ( return (
<div className="admin-schedule-container"> <div className="admin-schedule-container">
<h2 className="page-title"> </h2> <h2 className="page-title"> </h2>
<div className="schedule-controls"> <div className="schedule-controls">
<div className="form-group"> {adminType === 'HQ' && (
<div className="form-group store-selector-group">
<label className="form-label" htmlFor="store-filter"></label>
<select
id="store-filter"
className="form-select"
value={selectedStoreId}
onChange={e => setSelectedStoreId(e.target.value)}
>
{stores.map(store => (
<option key={store.id} value={store.id}>{store.name}</option>
))}
</select>
</div>
)}
<div className="form-group date-selector-group">
<label className="form-label" htmlFor="date-filter"></label> <label className="form-label" htmlFor="date-filter"></label>
<input <input
id="date-filter" id="date-filter"
@ -199,13 +269,17 @@ const AdminSchedulePage: React.FC = () => {
onChange={e => setSelectedDate(e.target.value)} onChange={e => setSelectedDate(e.target.value)}
/> />
</div> </div>
<div className="form-group"> <div className="form-group theme-selector-group">
<label className="form-label" htmlFor="theme-filter"></label> <label className="form-label" htmlFor="theme-filter"></label>
<div className='theme-selector-button-group'>
<select <select
id="theme-filter" id="theme-filter"
className="form-select" className="form-select"
value={selectedThemeId} value={selectedTheme?.id || ''}
onChange={e => setSelectedThemeId(e.target.value)} onChange={e => {
const theme = themes.find(t => t.id === e.target.value);
setSelectedTheme(theme || null);
}}
> >
{themes.map(theme => ( {themes.map(theme => (
<option key={theme.id} value={theme.id}>{theme.name}</option> <option key={theme.id} value={theme.id}>{theme.name}</option>
@ -213,15 +287,19 @@ const AdminSchedulePage: React.FC = () => {
</select> </select>
</div> </div>
</div> </div>
</div>
<div className="section-card"> <div className="section-card">
{canModify && (
<div className="table-header"> <div className="table-header">
<button className="btn btn-primary" onClick={() => setIsAdding(true)}> </button> <button className="btn btn-primary" onClick={() => setIsAdding(true)}> </button>
</div> </div>
)}
<div className="table-container"> <div className="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
{showThemeColumn && <th></th>}
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
@ -231,7 +309,8 @@ const AdminSchedulePage: React.FC = () => {
{schedules.map(schedule => ( {schedules.map(schedule => (
<Fragment key={schedule.id}> <Fragment key={schedule.id}>
<tr> <tr>
<td>{schedule.time}</td> {showThemeColumn && <td>{schedule.themeName}</td>}
<td>{schedule.startFrom}</td>
<td>{getScheduleStatusText(schedule.status)}</td> <td>{getScheduleStatusText(schedule.status)}</td>
<td className="action-buttons"> <td className="action-buttons">
<button <button
@ -244,49 +323,76 @@ const AdminSchedulePage: React.FC = () => {
</tr> </tr>
{expandedScheduleId === schedule.id && ( {expandedScheduleId === schedule.id && (
<tr className="schedule-details-row"> <tr className="schedule-details-row">
<td colSpan={3}> <td colSpan={showThemeColumn ? 4 : 3}>
{isLoadingDetails ? ( {isLoadingDetails ? (
<p> ...</p> <p> ...</p>
) : detailedSchedules[schedule.id] ? ( ) : detailedSchedules[schedule.id] ? (
<div className="details-form-container"> <div className="details-form-container">
{detailedSchedules[schedule.id].audit ? (
<div className="audit-info"> <div className="audit-info">
<h4 className="audit-title"> </h4> <h4 className="audit-title"> </h4>
<div className="audit-body"> <div className="audit-body">
<p><strong>:</strong> {new Date(detailedSchedules[schedule.id].createdAt).toLocaleString()}</p> <p>
<p><strong>:</strong> {new Date(detailedSchedules[schedule.id].updatedAt).toLocaleString()}</p> <strong>:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.createdAt)}
<p><strong>:</strong> {detailedSchedules[schedule.id].createdBy}</p> </p>
<p><strong>:</strong> {detailedSchedules[schedule.id].updatedBy}</p> <p>
<strong>:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.updatedAt)}
</p>
<p>
<strong>:</strong> {detailedSchedules[schedule.id].audit!.createdBy.name}({detailedSchedules[schedule.id].audit!.createdBy.id})
</p>
<p>
<strong>:</strong> {detailedSchedules[schedule.id].audit!.updatedBy.name}({detailedSchedules[schedule.id].audit!.updatedBy.id})
</p>
</div> </div>
</div> </div>
) : <p> ...</p>}
{isEditing && editingSchedule ? ( {isEditing && editingSchedule?.id === schedule.id ? (
// --- EDIT MODE --- // --- EDIT MODE ---
<div className="form-card"> <div className="form-card">
<div className="form-section"> <div className="form-section">
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="form-label"></label> <label className="form-label"></label>
<input type="time" name="time" className="form-input" value={editingSchedule.time} onChange={handleEditChange} /> <input type="time" name="time"
className="form-input"
value={editingSchedule.time}
onChange={handleEditChange}/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label"></label> <label className="form-label"></label>
<select name="status" className="form-select" value={editingSchedule.status} onChange={handleEditChange}> <select name="status" className="form-select"
{Object.values(ScheduleStatus).map(s => <option key={s} value={s}>{getScheduleStatusText(s)}</option>)} value={editingSchedule.status}
onChange={handleEditChange}>
{Object.values(ScheduleStatus).map(s =>
<option key={s}
value={s}>{getScheduleStatusText(s)}</option>)}
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div className="button-group"> <div className="button-group">
<button type="button" className="btn btn-secondary" onClick={handleCancelEdit}></button> <button type="button" className="btn btn-secondary"
<button type="button" className="btn btn-primary" onClick={handleSave}></button> onClick={handleCancelEdit}>
</button>
<button type="button" className="btn btn-primary"
onClick={handleSave}>
</button>
</div> </div>
</div> </div>
) : ( ) : (
// --- VIEW MODE --- // --- VIEW MODE ---
canModify && (
<div className="button-group view-mode-buttons"> <div className="button-group view-mode-buttons">
<button type="button" className="btn btn-danger" onClick={() => handleDeleteSchedule(schedule.id)}></button> <button type="button" className="btn btn-danger"
<button type="button" className="btn btn-primary" onClick={handleEditClick}></button> onClick={() => handleDeleteSchedule(schedule.id)}>
</button>
<button type="button" className="btn btn-primary"
onClick={handleEditClick}>
</button>
</div> </div>
)
)} )}
</div> </div>
) : ( ) : (
@ -297,8 +403,9 @@ const AdminSchedulePage: React.FC = () => {
)} )}
</Fragment> </Fragment>
))} ))}
{isAdding && ( {isAdding && canModify && (
<tr className="editing-row"> <tr className="editing-row">
{showThemeColumn && <td></td>}
<td> <td>
<input <input
type="time" type="time"
@ -318,6 +425,33 @@ const AdminSchedulePage: React.FC = () => {
</table> </table>
</div> </div>
</div> </div>
{isModalOpen && (
<div className="modal-overlay">
<div className="modal-content">
<button className="modal-close-btn" onClick={() => setIsModalOpen(false)}>×</button>
{isLoadingThemeDetails ? (
<p> ...</p>
) : selectedThemeDetails ? (
<div className="theme-details-modal">
<h3 className="modal-title">{selectedThemeDetails.name}</h3>
<img src={selectedThemeDetails.thumbnailUrl} alt={selectedThemeDetails.name}
className="theme-modal-thumbnail"/>
<p className="theme-modal-description">{selectedThemeDetails.description}</p>
<div className="modal-info-grid">
<p><strong></strong><span>{DifficultyKoreanMap[selectedThemeDetails.difficulty]}</span></p>
<p><strong> </strong><span>{selectedThemeDetails.minParticipants} ~ {selectedThemeDetails.maxParticipants}</span></p>
<p><strong>1 </strong><span>{selectedThemeDetails.price.toLocaleString()}</span></p>
<p><strong> </strong><span>{selectedThemeDetails.expectedMinutesFrom} ~ {selectedThemeDetails.expectedMinutesTo}</span></p>
<p><strong> </strong><span>{selectedThemeDetails.availableMinutes}</span></p>
</div>
</div>
) : (
<p> .</p>
)}
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@ -0,0 +1,371 @@
import {isLoginRequiredError} from '@_api/apiClient';
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
import type {SidoResponse, SigunguResponse} from '@_api/region/regionTypes';
import {createStore, deleteStore, getStoreDetail, getStores, updateStore} from '@_api/store/storeAPI';
import {
type SimpleStoreResponse,
type StoreDetailResponse,
type StoreRegisterRequest,
type UpdateStoreRequest
} from '@_api/store/storeTypes';
import {useAdminAuth} from '@_context/AdminAuthContext';
import '@_css/admin-store-page.css';
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
import React, {Fragment, useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
const AdminStorePage: React.FC = () => {
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
const [isAdding, setIsAdding] = useState(false);
const [newStore, setNewStore] = useState<StoreRegisterRequest>({
name: '',
address: '',
contact: '',
businessRegNum: '',
regionCode: ''
});
const [expandedStoreId, setExpandedStoreId] = useState<string | null>(null);
const [detailedStores, setDetailedStores] = useState<{ [key: string]: StoreDetailResponse }>({});
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState(false);
const [editingStore, setEditingStore] = useState<UpdateStoreRequest | null>(null);
const [sidoList, setSidoList] = useState<SidoResponse[]>([]);
const [sigunguList, setSigunguList] = useState<SigunguResponse[]>([]);
const [selectedSido, setSelectedSido] = useState('');
const [selectedSigungu, setSelectedSigungu] = useState('');
const navigate = useNavigate();
const location = useLocation();
const { type: adminType } = useAdminAuth();
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요합니다.');
navigate('/admin/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
const fetchStores = async () => {
try {
const storesData = (await getStores(selectedSido || undefined, selectedSigungu || undefined)).stores;
setStores(storesData);
} catch (error) {
handleError(error);
};
}
useEffect(() => {
if (adminType !== 'HQ') {
alert('접근 권한이 없습니다.');
navigate('/admin');
return;
}
const fetchInitialData = async () => {
try {
const sidoRes = await fetchSidoList();
setSidoList(sidoRes.sidoList);
} catch (error) {
handleError(error);
}
};
fetchInitialData();
}, [adminType, navigate]);
useEffect(() => {
const fetchSigungu = async () => {
if (selectedSido) {
try {
const sigunguRes = await fetchSigunguList(selectedSido);
setSigunguList(sigunguRes.sigunguList);
} catch (error) {
handleError(error);
}
} else {
setSigunguList([]);
}
setSelectedSigungu('');
};
fetchSigungu();
}, [selectedSido]);
useEffect(() => { fetchStores();}, [selectedSido, selectedSigungu]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setNewStore(prev => ({ ...prev, [name]: value }));
};
const handleAddStore = async () => {
if (Object.values(newStore).some(val => val === '')) {
alert('모든 필드를 입력해주세요.');
return;
}
try {
await createStore(newStore);
const storesData = (await getStores(selectedSido || undefined, selectedSigungu || undefined)).stores;
setStores(storesData);
setIsAdding(false);
setNewStore({ name: '', address: '', contact: '', businessRegNum: '', regionCode: '' });
} catch (error) {
handleError(error);
}
};
const handleToggleDetails = async (storeId: string) => {
const isAlreadyExpanded = expandedStoreId === storeId;
setIsEditing(false);
if (isAlreadyExpanded) {
setExpandedStoreId(null);
} else {
setExpandedStoreId(storeId);
if (!detailedStores[storeId]) {
setIsLoadingDetails(true);
try {
const details = await getStoreDetail(storeId);
setDetailedStores(prev => ({ ...prev, [storeId]: details }));
} catch (error) {
handleError(error);
} finally {
setIsLoadingDetails(false);
}
}
}
};
const handleDeleteStore = async (storeId: string) => {
if (window.confirm('정말 이 매장을 삭제하시겠습니까? 관련 데이터가 모두 삭제될 수 있습니다.')) {
try {
await deleteStore(storeId);
fetchStores();
setExpandedStoreId(null);
} catch (error) {
handleError(error);
}
}
};
const handleEditClick = (store: StoreDetailResponse) => {
setEditingStore({ name: store.name, address: store.address, contact: store.contact });
setIsEditing(true);
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditingStore(null);
};
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
if (editingStore) {
setEditingStore(prev => ({ ...prev!, [name]: value }));
}
};
const handleSave = async (storeId: string) => {
if (!editingStore) return;
try {
await updateStore(storeId, editingStore);
const updatedStore = await getStoreDetail(storeId);
setDetailedStores(prev => ({ ...prev, [storeId]: updatedStore }));
setStores(prev => prev.map(s => s.id === String(storeId) ? { ...s, name: updatedStore.name } : s));
setIsEditing(false);
setEditingStore(null);
alert('매장 정보가 성공적으로 업데이트되었습니다.');
} catch (error) {
handleError(error);
}
};
return (
<div className="admin-store-container">
<h2 className="page-title"> </h2>
<div className="filter-controls">
<div className="form-group">
<label className="form-label">/</label>
<select className="form-select" value={selectedSido} onChange={e => setSelectedSido(e.target.value)}>
<option value=""></option>
{sidoList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">//</label>
<select className="form-select" value={selectedSigungu} onChange={e => setSelectedSigungu(e.target.value)} disabled={!selectedSido}>
<option value=""></option>
{sigunguList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
</select>
</div>
</div>
<div className="section-card">
<div className="table-header">
<button className="btn btn-primary" onClick={() => setIsAdding(!isAdding)}>
{isAdding ? '취소' : '매장 추가'}
</button>
</div>
{isAdding && (
<div className="add-store-form">
<div className="form-row">
<div className="form-group"><label className="form-label"></label><input type="text"
name="name"
className="form-input"
value={newStore.name}
onChange={handleInputChange} />
</div>
<div className="form-group"><label className="form-label"></label><input type="text"
name="address"
className="form-input"
value={newStore.address}
onChange={handleInputChange} />
</div>
</div>
<div className="form-row">
<div className="form-group"><label className="form-label"></label><input type="text"
name="contact"
className="form-input"
value={newStore.contact}
onChange={handleInputChange} />
</div>
<div className="form-group"><label className="form-label"></label><input type="text"
name="businessRegNum"
className="form-input"
value={newStore.businessRegNum}
onChange={handleInputChange} />
</div>
<div className="form-group"><label className="form-label"> </label><input type="text"
name="regionCode"
className="form-input"
value={newStore.regionCode}
onChange={handleInputChange} />
</div>
</div>
<div className="button-group">
<button className="btn btn-primary" onClick={handleAddStore}></button>
</div>
</div>
)}
<div className="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{stores.map(store => (
<Fragment key={store.id}>
<tr>
<td>{store.id}</td>
<td>{store.name}</td>
<td className="action-buttons">
<button className="btn btn-secondary"
onClick={() => handleToggleDetails(store.id)}>
{expandedStoreId === store.id ? '닫기' : '상세'}
</button>
</td>
</tr>
{expandedStoreId === store.id && (
<tr className="details-row">
<td colSpan={3}>
<div className="details-container">
{isLoadingDetails ? <p> ...</p> : detailedStores[store.id] ? (
<div>
<div className="audit-info">
<h4 className="audit-title"> </h4>
<div className="audit-body">
<p>
<strong>:</strong> {detailedStores[store.id].address}
</p>
<p>
<strong>:</strong> {detailedStores[store.id].contact}
</p>
<p>
<strong>:</strong> {detailedStores[store.id].businessRegNum}
</p>
<p><strong>
:</strong> {detailedStores[store.id].region.code}
</p>
<p>
<strong>:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.createdAt)}
</p>
<p>
<strong>:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.updatedAt)}
</p>
<p>
<strong>:</strong> {detailedStores[store.id].audit.createdBy.name}({detailedStores[store.id].audit.createdBy.id})
</p>
<p>
<strong>:</strong> {detailedStores[store.id].audit.updatedBy.name}({detailedStores[store.id].audit.updatedBy.id})
</p>
</div>
</div>
{isEditing && editingStore ? (
<div className="details-form-card">
<div className="form-row">
<div className="form-group"><label
className="form-label"></label><input
type="text" name="name" className="form-input"
value={editingStore.name}
onChange={handleEditChange} /></div>
<div className="form-group"><label
className="form-label"></label><input
type="text" name="address"
className="form-input"
value={editingStore.address}
onChange={handleEditChange} /></div>
<div className="form-group"><label
className="form-label"></label><input
type="text" name="contact"
className="form-input"
value={editingStore.contact}
onChange={handleEditChange} /></div>
</div>
<div className="button-group">
<button className="btn btn-secondary"
onClick={handleCancelEdit}>
</button>
<button className="btn btn-primary"
onClick={() => handleSave(store.id)}>
</button>
</div>
</div>
) : (
<div className="button-group">
<button className="btn btn-danger"
onClick={() => handleDeleteStore(store.id)}>
</button>
<button className="btn btn-primary"
onClick={() => handleEditClick(detailedStores[store.id])}>
</button>
</div>
)}
</div>
) : <p> .</p>}
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default AdminStorePage;

View File

@ -1,14 +1,30 @@
import {isLoginRequiredError} from '@_api/apiClient'; import {isLoginRequiredError} from '@_api/apiClient';
import {createTheme, deleteTheme, fetchAdminThemeDetail, updateTheme} from '@_api/theme/themeAPI'; import {createTheme, deleteTheme, fetchAdminThemeDetail, updateTheme} from '@_api/theme/themeAPI';
import { import {
type AdminThemeDetailResponse,
Difficulty, Difficulty,
DifficultyKoreanMap,
type ThemeCreateRequest, type ThemeCreateRequest,
type ThemeUpdateRequest type ThemeUpdateRequest
} from '@_api/theme/themeTypes'; } from '@_api/theme/themeTypes';
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import {useLocation, useNavigate, useParams} from 'react-router-dom'; import {useLocation, useNavigate, useParams} from 'react-router-dom';
import '@_css/admin-theme-edit-page.css'; import '@_css/admin-theme-edit-page.css';
import type {AuditInfo} from '@_api/common/commonTypes';
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
interface ThemeFormData {
name: string;
description: string;
thumbnailUrl: string;
difficulty: Difficulty;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
isActive: boolean;
}
const AdminThemeEditPage: React.FC = () => { const AdminThemeEditPage: React.FC = () => {
const { themeId } = useParams<{ themeId: string }>(); const { themeId } = useParams<{ themeId: string }>();
@ -17,15 +33,16 @@ const AdminThemeEditPage: React.FC = () => {
const isNew = themeId === 'new'; const isNew = themeId === 'new';
const [theme, setTheme] = useState<AdminThemeDetailResponse | ThemeCreateRequest | null>(null); const [formData, setFormData] = useState<ThemeFormData | null>(null);
const [originalTheme, setOriginalTheme] = useState<AdminThemeDetailResponse | ThemeCreateRequest | null>(null); const [originalFormData, setOriginalFormData] = useState<ThemeFormData | null>(null);
const [auditInfo, setAuditInfo] = useState<AuditInfo | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(isNew); const [isEditing, setIsEditing] = useState(isNew);
const handleError = (err: any) => { const handleError = (err: any) => {
if (isLoginRequiredError(err)) { if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.'); alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } }); navigate('/admin/login', { state: { from: location } });
} else { } else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message); alert(message);
@ -35,7 +52,7 @@ const AdminThemeEditPage: React.FC = () => {
useEffect(() => { useEffect(() => {
if (isNew) { if (isNew) {
const newTheme: ThemeCreateRequest = { const newTheme: ThemeFormData = {
name: '', name: '',
description: '', description: '',
thumbnailUrl: '', thumbnailUrl: '',
@ -43,38 +60,34 @@ const AdminThemeEditPage: React.FC = () => {
price: 0, price: 0,
minParticipants: 2, minParticipants: 2,
maxParticipants: 4, maxParticipants: 4,
availableMinutes: 60, availableMinutes: 80,
expectedMinutesFrom: 50, expectedMinutesFrom: 50,
expectedMinutesTo: 70, expectedMinutesTo: 60,
isOpen: true, isActive: true,
}; };
setTheme(newTheme); setFormData(newTheme);
setOriginalTheme(newTheme); setOriginalFormData(newTheme);
setIsLoading(false); setIsLoading(false);
} else if (themeId) { } else if (themeId) {
fetchAdminThemeDetail(themeId) fetchAdminThemeDetail(themeId)
.then(data => { .then(data => {
// Map AdminThemeDetailRetrieveResponse to ThemeV2 const { theme, isActive, audit } = data;
const fetchedTheme: AdminThemeDetailResponse = { const themeData: ThemeFormData = {
id: data.id, name: theme.name,
name: data.name, description: theme.description,
description: data.description, thumbnailUrl: theme.thumbnailUrl,
thumbnailUrl: data.thumbnailUrl, difficulty: theme.difficulty,
difficulty: data.difficulty, price: theme.price,
price: data.price, minParticipants: theme.minParticipants,
minParticipants: data.minParticipants, maxParticipants: theme.maxParticipants,
maxParticipants: data.maxParticipants, availableMinutes: theme.availableMinutes,
availableMinutes: data.availableMinutes, expectedMinutesFrom: theme.expectedMinutesFrom,
expectedMinutesFrom: data.expectedMinutesFrom, expectedMinutesTo: theme.expectedMinutesTo,
expectedMinutesTo: data.expectedMinutesTo, isActive: isActive,
isOpen: data.isOpen,
createDate: data.createdAt, // Map createdAt to createDate
updatedDate: data.updatedAt, // Map updatedAt to updatedDate
createdBy: data.createdBy,
updatedBy: data.updatedBy,
}; };
setTheme(fetchedTheme); setFormData(themeData);
setOriginalTheme(fetchedTheme); setOriginalFormData(themeData);
setAuditInfo(audit);
}) })
.catch(handleError) .catch(handleError)
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
@ -85,20 +98,20 @@ const AdminThemeEditPage: React.FC = () => {
const { name, value, type } = e.target; const { name, value, type } = e.target;
let processedValue: string | number | boolean = value; let processedValue: string | number | boolean = value;
if (name === 'isOpen') { if (name === 'isActive') {
processedValue = value === 'true'; processedValue = value === 'true';
} else if (type === 'checkbox') { } else if (type === 'checkbox') {
processedValue = (e.target as HTMLInputElement).checked; processedValue = (e.target as HTMLInputElement).checked;
} else if (type === 'number') { } else if (type === 'number') {
processedValue = value === '' ? '' : Number(value); processedValue = value === '' ? 0 : Number(value);
} }
setTheme(prev => prev ? { ...prev, [name]: processedValue } : null); setFormData(prev => prev ? { ...prev, [name]: processedValue } : null);
}; };
const handleCancelEdit = () => { const handleCancelEdit = () => {
if (!isNew) { if (!isNew) {
setTheme(originalTheme); setFormData(originalFormData);
setIsEditing(false); setIsEditing(false);
} else { } else {
navigate('/admin/theme'); navigate('/admin/theme');
@ -106,22 +119,21 @@ const AdminThemeEditPage: React.FC = () => {
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
console.log('handleSubmit called');
e.preventDefault(); e.preventDefault();
if (!theme) return; if (!formData) return;
try { try {
if (isNew) { if (isNew) {
await createTheme(theme as ThemeCreateRequest); await createTheme(formData as ThemeCreateRequest);
alert('테마가 성공적으로 생성되었습니다.'); alert('테마가 성공적으로 생성되었습니다.');
navigate(`/admin/theme`); navigate(`/admin/theme`);
} else { } else {
if (!themeId) { if (!themeId) {
throw new Error('themeId is undefined'); throw new Error('themeId is undefined');
} }
await updateTheme(themeId, theme as ThemeUpdateRequest); await updateTheme(themeId, formData as ThemeUpdateRequest);
alert('테마가 성공적으로 업데이트되었습니다.'); alert('테마가 성공적으로 업데이트되었습니다.');
setOriginalTheme(theme); setOriginalFormData(formData);
setIsEditing(false); setIsEditing(false);
navigate(`/admin/theme`); navigate(`/admin/theme`);
} }
@ -147,7 +159,7 @@ const AdminThemeEditPage: React.FC = () => {
return <div className="admin-theme-edit-container"><p> ...</p></div>; return <div className="admin-theme-edit-container"><p> ...</p></div>;
} }
if (!theme) { if (!formData) {
return <div className="admin-theme-edit-container"><p> .</p></div>; return <div className="admin-theme-edit-container"><p> .</p></div>;
} }
@ -161,15 +173,15 @@ const AdminThemeEditPage: React.FC = () => {
<div className="form-section"> <div className="form-section">
<div className="form-group full-width"> <div className="form-group full-width">
<label className="form-label" htmlFor="name"> </label> <label className="form-label" htmlFor="name"> </label>
<input id="name" name="name" type="text" className="form-input" value={theme.name} onChange={handleChange} required disabled={!isEditing} /> <input id="name" name="name" type="text" className="form-input" value={formData.name} onChange={handleChange} required disabled={!isEditing} />
</div> </div>
<div className="form-group full-width"> <div className="form-group full-width">
<label className="form-label" htmlFor="description"></label> <label className="form-label" htmlFor="description"></label>
<textarea id="description" name="description" className="form-textarea" value={theme.description} onChange={handleChange} required disabled={!isEditing} /> <textarea id="description" name="description" className="form-textarea" value={formData.description} onChange={handleChange} required disabled={!isEditing} />
</div> </div>
<div className="form-group full-width"> <div className="form-group full-width">
<label className="form-label" htmlFor="thumbnailUrl"> URL</label> <label className="form-label" htmlFor="thumbnailUrl"> URL</label>
<input id="thumbnailUrl" name="thumbnailUrl" type="text" className="form-input" value={theme.thumbnailUrl} onChange={handleChange} required disabled={!isEditing} /> <input id="thumbnailUrl" name="thumbnailUrl" type="text" className="form-input" value={formData.thumbnailUrl} onChange={handleChange} required disabled={!isEditing} />
</div> </div>
</div> </div>
@ -177,13 +189,13 @@ const AdminThemeEditPage: React.FC = () => {
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="difficulty"></label> <label className="form-label" htmlFor="difficulty"></label>
<select id="difficulty" name="difficulty" className="form-select" value={theme.difficulty} onChange={handleChange} disabled={!isEditing}> <select id="difficulty" name="difficulty" className="form-select" value={formData.difficulty} onChange={handleChange} disabled={!isEditing}>
{Object.values(Difficulty).map(d => <option key={d} value={d}>{d}</option>)} {Object.values(Difficulty).map(d => <option key={d} value={d}>{DifficultyKoreanMap[d]}</option>)}
</select> </select>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="isOpen"> </label> <label className="form-label" htmlFor="isActive"> </label>
<select id="isOpen" name="isOpen" className="form-select" value={String(theme.isOpen)} onChange={handleChange} disabled={!isEditing}> <select id="isActive" name="isActive" className="form-select" value={String(formData.isActive)} onChange={handleChange} disabled={!isEditing}>
<option value="true"></option> <option value="true"></option>
<option value="false"></option> <option value="false"></option>
</select> </select>
@ -194,11 +206,11 @@ const AdminThemeEditPage: React.FC = () => {
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="price">1 ()</label> <label className="form-label" htmlFor="price">1 ()</label>
<input id="price" name="price" type="number" className="form-input" value={theme.price} onChange={handleChange} required disabled={!isEditing} /> <input id="price" name="price" type="number" className="form-input" value={formData.price} onChange={handleChange} required disabled={!isEditing} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="availableMinutes"> ()</label> <label className="form-label" htmlFor="availableMinutes"> ()</label>
<input id="availableMinutes" name="availableMinutes" type="number" className="form-input" value={theme.availableMinutes} onChange={handleChange} required disabled={!isEditing} /> <input id="availableMinutes" name="availableMinutes" type="number" className="form-input" value={formData.availableMinutes} onChange={handleChange} required disabled={!isEditing} />
</div> </div>
</div> </div>
@ -206,22 +218,22 @@ const AdminThemeEditPage: React.FC = () => {
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="expectedMinutesFrom"> ()</label> <label className="form-label" htmlFor="expectedMinutesFrom"> ()</label>
<input id="expectedMinutesFrom" name="expectedMinutesFrom" type="number" className="form-input" value={theme.expectedMinutesFrom} onChange={handleChange} required disabled={!isEditing} /> <input id="expectedMinutesFrom" name="expectedMinutesFrom" type="number" className="form-input" value={formData.expectedMinutesFrom} onChange={handleChange} required disabled={!isEditing} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="expectedMinutesTo"> ()</label> <label className="form-label" htmlFor="expectedMinutesTo"> ()</label>
<input id="expectedMinutesTo" name="expectedMinutesTo" type="number" className="form-input" value={theme.expectedMinutesTo} onChange={handleChange} required disabled={!isEditing} /> <input id="expectedMinutesTo" name="expectedMinutesTo" type="number" className="form-input" value={formData.expectedMinutesTo} onChange={handleChange} required disabled={!isEditing} />
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="minParticipants"> ()</label> <label className="form-label" htmlFor="minParticipants"> ()</label>
<input id="minParticipants" name="minParticipants" type="number" className="form-input" value={theme.minParticipants} onChange={handleChange} required disabled={!isEditing} /> <input id="minParticipants" name="minParticipants" type="number" className="form-input" value={formData.minParticipants} onChange={handleChange} required disabled={!isEditing} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="maxParticipants"> ()</label> <label className="form-label" htmlFor="maxParticipants"> ()</label>
<input id="maxParticipants" name="maxParticipants" type="number" className="form-input" value={theme.maxParticipants} onChange={handleChange} required disabled={!isEditing} /> <input id="maxParticipants" name="maxParticipants" type="number" className="form-input" value={formData.maxParticipants} onChange={handleChange} required disabled={!isEditing} />
</div> </div>
</div> </div>
</div> </div>
@ -235,20 +247,20 @@ const AdminThemeEditPage: React.FC = () => {
) : ( ) : (
<div className="main-actions"> <div className="main-actions">
<button type="button" className="btn btn-secondary" onClick={() => navigate('/admin/theme')}></button> <button type="button" className="btn btn-secondary" onClick={() => navigate('/admin/theme')}></button>
<button type="button" className="btn btn-primary" onClick={(e) => { e.preventDefault(); console.log('setIsEditing(true) called'); setIsEditing(true); }}></button> <button type="button" className="btn btn-primary" onClick={(e) => { e.preventDefault(); setIsEditing(true); }}></button>
</div> </div>
)} )}
</div> </div>
</form> </form>
{!isNew && 'id' in theme && ( {!isNew && auditInfo && (
<div className="audit-info"> <div className="audit-info">
<h4 className="audit-title"> </h4> <h4 className="audit-title"> </h4>
<div className="audit-body"> <div className="audit-body">
<p><strong>:</strong> {new Date(theme.createDate).toLocaleString()}</p> <p><strong>:</strong> {formatDisplayDateTime(auditInfo.createdAt)}</p>
<p><strong>:</strong> {new Date(theme.updatedDate).toLocaleString()}</p> <p><strong>:</strong> {formatDisplayDateTime(auditInfo.updatedAt)}</p>
<p><strong>:</strong> {theme.createdBy}</p> <p><strong>:</strong> {auditInfo.createdBy.name}</p>
<p><strong>:</strong> {theme.updatedBy}</p> <p><strong>:</strong> {auditInfo.updatedBy.name}</p>
</div> </div>
</div> </div>
)} )}

View File

@ -1,19 +1,19 @@
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
import {fetchAdminThemes} from '@_api/theme/themeAPI'; import {fetchAdminThemes} from '@_api/theme/themeAPI';
import type {AdminThemeSummaryRetrieveResponse} from '@_api/theme/themeTypes'; import {DifficultyKoreanMap, type AdminThemeSummaryResponse} from '@_api/theme/themeTypes';
import {isLoginRequiredError} from '@_api/apiClient'; import {isLoginRequiredError} from '@_api/apiClient';
import '@_css/admin-theme-page.css'; import '@_css/admin-theme-page.css';
const AdminThemePage: React.FC = () => { const AdminThemePage: React.FC = () => {
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]); const [themes, setThemes] = useState<AdminThemeSummaryResponse[]>([]);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const handleError = (err: any) => { const handleError = (err: any) => {
if (isLoginRequiredError(err)) { if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.'); alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } }); navigate('/admin/login', { state: { from: location } });
} else { } else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message); alert(message);
@ -63,9 +63,9 @@ const AdminThemePage: React.FC = () => {
{themes.map(theme => ( {themes.map(theme => (
<tr key={theme.id}> <tr key={theme.id}>
<td>{theme.name}</td> <td>{theme.name}</td>
<td>{theme.difficulty}</td> <td>{DifficultyKoreanMap[theme.difficulty]}</td>
<td>{theme.price.toLocaleString()}</td> <td>{theme.price.toLocaleString()}</td>
<td>{theme.isOpen ? '공개' : '비공개'}</td> <td>{theme.isActive ? '공개' : '비공개'}</td>
<td> <td>
<button className="btn btn-secondary" onClick={() => handleManageClick(theme.id)}></button> <button className="btn btn-secondary" onClick={() => handleManageClick(theme.id)}></button>
</td> </td>

View File

@ -33,3 +33,42 @@ export const formatTime = (timeStr: string) => {
return timePart; return timePart;
} }
export const formatDisplayDateTime = (dateTime: any): string => {
let date: Date;
if (typeof dateTime === 'string') {
// ISO 문자열 형식 처리 (LocalDateTime, OffsetDateTime 모두 포함)
date = new Date(dateTime);
} else if (typeof dateTime === 'number') {
// Unix 타임스탬프(초) 형식 처리
date = new Date(dateTime * 1000);
} else if (Array.isArray(dateTime) && dateTime.length >= 6) {
// 배열 형식 처리: [year, month, day, hour, minute, second, nanosecond?]
const year = dateTime[0];
const month = dateTime[1] - 1; // JS Date의 월은 0부터 시작
const day = dateTime[2];
const hour = dateTime[3];
const minute = dateTime[4];
const second = dateTime[5];
const millisecond = dateTime.length > 6 ? Math.floor(dateTime[6] / 1000000) : 0;
date = new Date(year, month, day, hour, minute, second, millisecond);
} else {
return '유효하지 않은 날짜 형식';
}
if (isNaN(date.getTime())) {
return '유효하지 않은 날짜';
}
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
second: 'numeric'
};
return new Intl.DateTimeFormat('ko-KR', options).format(date);
};

View File

@ -19,7 +19,6 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
@ -34,6 +33,7 @@
"@_hooks/*": ["src/hooks/*"], "@_hooks/*": ["src/hooks/*"],
"@_pages/*": ["src/pages/*"], "@_pages/*": ["src/pages/*"],
"@_types/*": ["/src/types/*"], "@_types/*": ["/src/types/*"],
"@_util/*": ["src/util/*"]
} }
}, },
"include": ["src"], "include": ["src"],

57
service/build.gradle.kts Normal file
View File

@ -0,0 +1,57 @@
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
kotlin("plugin.jpa")
}
dependencies {
// API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
// Cache
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("com.github.ben-manes.caffeine:caffeine")
// DB
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
// Jwt
implementation("io.jsonwebtoken:jjwt:0.12.6")
// Logging
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
implementation("com.github.loki4j:loki-logback-appender:2.0.0")
// Observability
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.mockk:mockk:1.14.4")
testImplementation("com.ninja-squad:springmockk:4.0.2")
// Kotest
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
// RestAssured
testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
// etc
implementation("org.apache.poi:poi-ooxml:5.2.3")
// submodules
implementation(project(":common:persistence"))
implementation(project(":common:web"))
}
tasks.named<Jar>("jar") {
enabled = false
}

Some files were not shown because too many files have changed in this diff Show More