Compare commits

..

73 Commits

Author SHA1 Message Date
842a11a9ae refactor: API 엔드포인트 정리 및 프론트엔드 미사용 코드 제거 2025-09-13 19:01:30 +09:00
436418a12f refactor: DTO 명명 통일 2025-09-13 18:38:33 +09:00
85b1eb2f6f chore: 패키지명 수정 (member -> user) 2025-09-13 18:32:44 +09:00
e8ed273943 refactor: 예약 페이지에서의 일정 정보 조회는 비회원도 가능하도록 수정 2025-09-13 18:31:41 +09:00
0bcb90f812 feat: 회원 연락처 조회 API 및 테스트 추가 2025-09-13 18:31:20 +09:00
02d5c9e7ef test: 회원 예약 상세 정보 조회시 nullable 결제 정보 케이스 테스트 추가 2025-09-13 18:30:58 +09:00
375ec45078 refactor: 회원의 예약 목록 조회시 일부 상태만 조회하도록 수정 2025-09-13 18:18:00 +09:00
5724aaecb6 refactor: 회원의 예약 상세 조회시 결제 정보를 nullable로 수정 2025-09-13 18:16:02 +09:00
52230a5ba0 refactor: 새로운 API 명세에 맞춘 프론트엔드 코드 수정 2025-09-13 18:14:54 +09:00
24dd2c492f refactor: 새로운 API 명세에 맞춘 프론트엔드 코드 수정 2025-09-13 18:14:44 +09:00
041e8d157d chore: 로그인 요청 DTO에서 V2 접미사 제거 2025-09-13 16:55:52 +09:00
b0f67543be refactor: 회원가입 페이지 API 및 타입 수정 2025-09-13 16:54:17 +09:00
e1941052f9 refactor: 인기 테마 조회 프론트엔드 페이지 반영 2025-09-13 16:05:21 +09:00
1d41d517b1 refactor: 메인 페이지 & 예약 페이지에서 테마 상세 조회는 Public으로 권한 변경 2025-09-13 16:04:58 +09:00
905c4b7019 test: 가장 많이 예약된 테마 ID 조회 API 테스트 2025-09-13 15:40:42 +09:00
6eecd145cc feat: 가장 많이 예약된 테마 ID 조회 API 추가 2025-09-13 15:40:36 +09:00
c3b736b81f refactor: 테스트용 예약 데이터 삽입 시 발생할 수 있는 일정 / 테마 충돌 문제 수정 2025-09-13 15:38:53 +09:00
e4a18d0c79 feat: 입력된 날짜 기준 지난 주 일요일을 찾는 유틸 및 테스트 추가 2025-09-13 15:33:46 +09:00
854b3153e1 refactor: 테스트에서 기본 회원 엔티티 조회 시 존재하지 않으면 저장 후 반환하도록 수정 2025-09-13 15:33:12 +09:00
bf6b1b5cdc rename: 테마 DTO 이름 변경 및 컨벤션 통일 2025-09-13 13:57:20 +09:00
16ee7eecf3 chore: 테스트에서의 유틸 패키지명 수정(util -> supports) 2025-09-13 13:34:05 +09:00
40d687f7f2 remove: 회원 / 인증 기능 적용 완료로 인한 기존 기능 제거 2025-09-13 13:33:21 +09:00
f5192750c3 refactor: 테스트용 프로파일 / 로깅 설정 분리 2025-09-13 13:23:15 +09:00
dd4e022d6d refactor: 테스트에서의 region 테이블 FK 충돌 해결로 인한 값 재설정 2025-09-13 13:23:03 +09:00
8a7778ba19 refactor: DatabaseCleanerExtension에서 region 테이블 생명주기 명시 2025-09-13 13:22:31 +09:00
3283779720 test: 결제 테스트에 새로 추가된 회원 및 인증 권한 테스트 추가 2025-09-13 12:56:16 +09:00
97a84f1c61 test: 예약 테스트에 새로 추가된 회원 및 인증 권한 테스트 추가 2025-09-13 12:56:09 +09:00
45039b8e7c test: 로직 변경에 따른 테스트 수정 2025-09-13 12:55:31 +09:00
c832410160 refactor: Kotest 병렬 실행 + Region Sql 데이터 초기화에서 발생하는 FK 제약조건 해소를 위한 regionCode 임시 Null 처리 2025-09-13 12:55:17 +09:00
671243b9b1 chore: 미사용 메서드 제거 2025-09-13 12:54:33 +09:00
611508b358 refactor: 결제 기능에 신규 회원 및 인증 반영 2025-09-13 12:29:08 +09:00
53d82902ca refactor: 예약 기능에 신규 회원 및 인증 반영 2025-09-13 12:28:59 +09:00
e0972550d4 refactor: Reservation의 회원 ID 컬럼명 수정 (member_id -> user_id) 2025-09-13 12:28:34 +09:00
741888f156 test: 테마 테스트에 새로 추가된 회원 및 인증 권한 테스트 추가 2025-09-13 11:51:05 +09:00
3bed383218 test: 일정(schedule) 테스트에 새로 추가된 회원 및 인증 권한 테스트 추가 2025-09-13 11:50:58 +09:00
ee9d8cd9f0 refactor: 회원 / 관리자 테이블 분리에 따른 스키마에서의 createdBy, updatedBy 외래키 제약조건 삭제 2025-09-13 11:49:51 +09:00
1ddf812d1c feat: RestAssuredUtils에 예외 처리 전용 테스트 추가 2025-09-13 11:48:57 +09:00
8d86dd8a70 refactor: MDC에 넣는 회원 ID 상수명 수정 2025-09-13 11:47:47 +09:00
2fc1cabe0e refactor: JwtUtils에 Inerceptor / Resolver 공통 로직 생성 및 null claim 조회 시 로그 추가 2025-09-13 11:47:43 +09:00
26910f1d14 refactor: 테마 / 일정에서의 생성(수정)인 반환 타입을 OperatorInfo로 수정 2025-09-12 21:27:28 +09:00
3b6e7ba7a6 feat: 회원의 예약 페이지에서 본인의 정보(이름, 전화번호)를 조회할 때 사용할 별도의 DTO 및 로직 정의 2025-09-12 21:22:43 +09:00
87a273971e feat: 기존의 감사 정보 조회시 확장성을 고려해 이름 뿐만 아니라 id까지 포함하는 타입 및 관리자 로직 정의 2025-09-12 21:22:19 +09:00
af901770dd test: 인증 API 테스트 추가(전체 경우 / 로그인 이력 실패 케이스) 2025-09-12 20:57:45 +09:00
2e52785f7a feat: 테스트용 AuthUtil에 관리자 & 회원 생성 기능 추가 2025-09-12 20:57:11 +09:00
3f74206985 refactor: region 테이블로 인해 발생하는 테스트에서의 테이블 초기화 오류 수정 2025-09-12 20:56:57 +09:00
efa33a071f refactor: 로그인 로직 개선
- 인증 정보 및 비밀번호 검증 메서드 분리
- 로그인 성공 이력 저장에 실패하면 실패 이력에 저장하는 오류 수정
- 예외 타입별 처리 분리
2025-09-12 20:55:10 +09:00
81613562bc refactor: 로그인 이력 저장 실패시 AuthService로의 예외 전파 방지 로직 추가 2025-09-12 20:53:36 +09:00
77de425fc1 feat: LoginHistoryRepository에 회원 ID(PK)로 조회하는 메서드 추가 2025-09-12 20:52:58 +09:00
e4f6ffe53d refactor: JwtUtils에서의 공통 부분 메서드 분리 & 만료 조건 추가 및 테스트 2025-09-12 20:52:26 +09:00
ea45673ef4 refactor: ArgumentResolver에서의 오타 수정 2025-09-12 20:51:44 +09:00
4ae9aa5911 feat: 인증 에러코드에 예상치 못한 예외 발생 케이스 추가 2025-09-12 20:51:04 +09:00
a6a82d7fd9 refactor: 로그인 확인 API에서의 불필요한 중복 DB 요청 제거 2025-09-12 20:50:34 +09:00
a70a032946 refactor: 관리자 인증 정보 조회시 로그에 계정 정보 추가 2025-09-12 20:49:46 +09:00
b041df2167 feat: 회원가입 API 및 테스트 추가 2025-09-11 19:48:48 +09:00
c8377a3dde refactor: 기존 API에 새로 정의된 어노테이션 추가 2025-09-11 17:16:08 +09:00
1e9dbd87c3 feat: 새로운 스키마에 맞춘 테스트 AuthUtil 기능 추가 및 FK 오류 방지를 위한 테스트에서의 region 테이블 삭제 방지 2025-09-11 17:06:35 +09:00
0b5d91d301 feat: 새로운 스키마에 맞춘 phone 로그 마스킹 기능 추가 2025-09-11 17:05:55 +09:00
3c71562317 feat: 새로운 인증 API 구현 2025-09-11 17:05:34 +09:00
66ae7d7beb feat: 로그인 이력을 관리하는 별도 서비스 정의 2025-09-11 17:04:08 +09:00
797ee2c0d0 feat: 새로 도입된 Interceptor 및 Resolver 설정 등록 2025-09-11 17:02:07 +09:00
26c3c62b04 feat: 기존의 \@MemberId를 대체하여 사용할 CurrentUserContext에 맞춘 resolver 추가 2025-09-11 17:01:48 +09:00
7f1ab906b7 feat: 관리자 / 회원 구분없는 '로그인 상태' 확인 API와 로그인 여부와 상관없이 사용 가능한 API에 사용할 새로운 Interceptor 및 어노테이션 추가 2025-09-11 17:01:04 +09:00
c79a4bdd1f feat: 로그인 된 회원 전용 API에 사용할 새로운 Interceptor 및 어노테이션 추가 2025-09-11 16:58:29 +09:00
e02086680b feat: 관리자 전용 API에 사용할 새로운 Interceptor 및 어노테이션 추가 2025-09-11 16:58:05 +09:00
39da28d3f1 feat: 이전에 정의한 Credential과 Context를 가져오는 user 서비스 로직 추가 2025-09-11 16:52:56 +09:00
8c7bf2980f feat: 이전에 정의한 Credential과 Context를 가져오는 admin 서비스 로직 추가 2025-09-11 16:50:55 +09:00
da9c7953f4 feat: 새로 사용하게 될 subject + claim 기반의 JwtUtils 및 테스트 추가 2025-09-11 16:49:30 +09:00
c9b7c9d4f1 feat: user / admin / auth 및 전역적으로 사용할 인증 관련 타입 추가 2025-09-11 16:33:45 +09:00
f32613d6d9 feat: 테스트에서 사용할 새로운 user / admin Fixture 등록 2025-09-11 16:31:25 +09:00
573ab14aca feat: 새로운 user / admin 커스텀 예외 추가 2025-09-11 16:28:32 +09:00
c15e0f456e feat: 회원, 관리자 및 상태 / 로그인 변경 이력 Entity 정의 2025-09-11 16:26:16 +09:00
75acdc2c2f feat: 회원, 관리자 및 상태 / 로그인 변경 이력 스키마 정의 2025-09-11 16:24:25 +09:00
59907bd643 test: 테스트에서의 LoginUtil -> AuthUtil 이름 변경 2025-09-09 15:57:19 +09:00
403 changed files with 10954 additions and 18619 deletions

6
.gitignore vendored
View File

@ -36,8 +36,4 @@ out/
### VS Code ###
.vscode/
logs
.kotlin
### sql
data/*.sql
data/*.txt
.kotlin

View File

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

3
README.md Normal file
View File

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

View File

@ -1,58 +1,96 @@
import org.jetbrains.kotlin.gradle.plugin.KaptExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
val springBootVersion = "3.5.3"
val kotlinVersion = "2.2.0"
id("io.spring.dependency-management") version "1.1.7" apply false
id("org.springframework.boot") version springBootVersion apply false
kotlin("jvm") version kotlinVersion apply false
kotlin("kapt") version kotlinVersion apply false
kotlin("plugin.spring") version kotlinVersion apply false
kotlin("plugin.jpa") version kotlinVersion apply false
id("org.springframework.boot") version springBootVersion
id("io.spring.dependency-management") version "1.1.7"
kotlin("jvm") version kotlinVersion
kotlin("plugin.spring") version kotlinVersion
kotlin("plugin.jpa") version kotlinVersion
kotlin("kapt") version kotlinVersion
}
group = "com.sangdol"
version = "0.0.1-SNAPSHOT"
allprojects {
repositories {
mavenCentral()
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
subprojects {
apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "org.jetbrains.kotlin.kapt")
apply(plugin = "io.spring.dependency-management")
tasks.jar {
enabled = false
}
extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kapt {
keepJavacAnnotationProcessors = true
}
extensions.configure<KaptExtension> {
keepJavacAnnotationProcessors = true
}
repositories {
mavenCentral()
}
dependencies {
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 {
// 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")
tasks.withType<Test> {
useJUnitPlatform()
}
// API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
tasks.withType<KotlinCompile> {
compilerOptions {
freeCompilerArgs.addAll(
"-Xjsr305=strict",
"-Xannotation-default-target=param-property"
)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
// 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()
}
tasks.withType<KotlinCompile> {
compilerOptions {
freeCompilerArgs.addAll(
"-Xjsr305=strict",
"-Xannotation-default-target=param-property"
)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

View File

@ -1,6 +0,0 @@
#!/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

@ -1,11 +0,0 @@
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

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

View File

@ -1,20 +0,0 @@
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

@ -1,57 +0,0 @@
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,29 +0,0 @@
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,13 +0,0 @@
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

@ -1,25 +0,0 @@
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

@ -1,44 +0,0 @@
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,53 +0,0 @@
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

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

View File

@ -1,12 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,71 +0,0 @@
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

@ -1,18 +0,0 @@
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

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

View File

@ -1,24 +0,0 @@
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

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

View File

@ -1,21 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,25 +0,0 @@
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

@ -1,45 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,34 +0,0 @@
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

@ -1,28 +0,0 @@
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,26 +0,0 @@
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

@ -1,50 +0,0 @@
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

@ -1,41 +0,0 @@
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

@ -1,40 +0,0 @@
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,75 +0,0 @@
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

@ -1,68 +0,0 @@
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,233 +0,0 @@
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

@ -1,194 +0,0 @@
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
}
}
}
})

Binary file not shown.

View File

@ -1,14 +0,0 @@
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,18 +1,6 @@
.git
.gitignore
# Node.js
node_modules
npm-debug.log
# Build output
build
dist
# Editor/OS specific
.vscode
.idea
.git
.DS_Store
# Environment variables
.env*
npm-debug.log
dist
build

View File

@ -1,17 +1,18 @@
FROM node:24-alpine AS builder
# Stage 1: Build the React app
FROM node:24 AS builder
WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
COPY package.json package-lock.json ./
RUN npm ci
RUN npm install --frozen-lockfile
COPY . .
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 nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,12 +1,10 @@
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 { AdminAuthProvider } from './context/AdminAuthContext';
import { AuthProvider } from './context/AuthContext';
import {AuthProvider} from './context/AuthContext';
import AdminLayout from './pages/admin/AdminLayout';
import AdminLoginPage from './pages/admin/AdminLoginPage';
import AdminPage from './pages/admin/AdminPage';
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
import AdminStorePage from './pages/admin/AdminStorePage';
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
import AdminThemePage from './pages/admin/AdminThemePage';
import HomePage from '@_pages/HomePage';
@ -18,28 +16,26 @@ import ReservationStep2Page from '@_pages/ReservationStep2Page';
import ReservationSuccessPage from '@_pages/ReservationSuccessPage';
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() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/admin/*" element={
<AdminAuthProvider>
<Routes>
<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>
<AdminRoute>
<AdminRoutes />
</AdminRoute>
} />
<Route path="/*" element={
<Layout>
@ -61,4 +57,4 @@ function App() {
);
}
export default App;
export default App;

View File

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

View File

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

View File

@ -5,13 +5,6 @@ export const 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 {
account: string,
password: string;
@ -20,15 +13,6 @@ export interface LoginRequest {
export interface LoginSuccessResponse {
accessToken: string;
name: string;
}
export interface UserLoginSuccessResponse extends LoginSuccessResponse {
}
export interface AdminLoginSuccessResponse extends LoginSuccessResponse {
type: AdminType;
storeId: string | null;
}
export interface CurrentUserContext {

View File

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

View File

@ -1,12 +0,0 @@
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

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

View File

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

View File

@ -1,14 +0,0 @@
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

@ -1,27 +0,0 @@
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,
PendingReservationCreateResponse,
ReservationDetailRetrieveResponse,
ReservationOverviewListResponse
ReservationSummaryRetrieveListResponse
} from './reservationTypes';
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> => {
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason });
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason }, true);
};
export const fetchAllOverviewByUser = async (): Promise<ReservationOverviewListResponse> => {
return await apiClient.get<ReservationOverviewListResponse>('/reservations/overview');
export const fetchSummaryByMember = async (): Promise<ReservationSummaryRetrieveListResponse> => {
return await apiClient.get<ReservationSummaryRetrieveListResponse>('/reservations/summary');
}
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> => {
return await apiClient.get<MostReservedThemeIdListResponse>(`/reservations/popular-themes?count=${count}`);
return await apiClient.get<MostReservedThemeIdListResponse>(`/reservations/popular-themes?count=${count}`, false);
}

View File

@ -1,24 +1,6 @@
import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes";
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 = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED',
@ -46,38 +28,30 @@ export interface PendingReservationCreateResponse {
id: string
}
export interface ReservationOverviewResponse {
export interface ReservationSummaryRetrieveResponse {
id: string;
storeName: string;
themeName: string;
date: string;
startFrom: string;
endAt: string;
startAt: string;
status: ReservationStatus;
}
export interface ReservationOverviewListResponse {
reservations: ReservationOverviewResponse[];
}
export interface ReserverInfo {
name: string;
contact: string;
participantCount: number;
requirement: string;
export interface ReservationSummaryRetrieveListResponse {
reservations: ReservationSummaryRetrieveResponse[];
}
export interface ReservationDetailRetrieveResponse {
id: string;
reserver: ReserverInfo;
user: UserContactRetrieveResponse;
applicationDateTime: string;
payment: PaymentRetrieveResponse;
}
export interface ReservationDetail {
overview: ReservationOverviewResponse;
reserver: ReserverInfo;
id: string;
themeName: string;
date: string;
startAt: string;
user: UserContactRetrieveResponse;
applicationDateTime: string;
payment?: PaymentRetrieveResponse;
@ -85,4 +59,4 @@ export interface ReservationDetail {
export interface MostReservedThemeIdListResponse {
themeIds: string[];
}
}

View File

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

View File

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

View File

@ -1,48 +0,0 @@
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

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

View File

@ -1,9 +1,20 @@
import type { AuditInfo } from '@_api/common/commonTypes';
export interface AdminThemeDetailResponse {
theme: ThemeInfoResponse;
isActive: boolean;
audit: AuditInfo
id: string;
name: string;
description: string;
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 {
@ -17,7 +28,7 @@ export interface ThemeCreateRequest {
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
isActive: boolean;
isOpen: boolean;
}
export interface ThemeCreateResponse {
@ -35,19 +46,38 @@ export interface ThemeUpdateRequest {
availableMinutes?: number;
expectedMinutesFrom?: number;
expectedMinutesTo?: number;
isActive?: boolean;
isOpen?: boolean;
}
export interface AdminThemeSummaryResponse {
export interface AdminThemeSummaryRetrieveResponse {
id: string;
name: string;
difficulty: Difficulty;
price: number;
isActive: boolean;
isOpen: boolean;
}
export interface AdminThemeSummaryListResponse {
themes: AdminThemeSummaryResponse[];
export interface AdminThemeSummaryRetrieveListResponse {
themes: AdminThemeSummaryRetrieveResponse[];
}
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 {
@ -72,34 +102,18 @@ export interface ThemeIdListResponse {
themeIds: string[];
}
// @ts-ignore
export enum Difficulty {
VERY_EASY = 'VERY_EASY',
EASY = 'EASY',
NORMAL = 'NORMAL',
HARD = 'HARD',
VERY_HARD = 'VERY_HARD',
VERY_EASY = '매우 쉬움',
EASY = '쉬움',
NORMAL = '보통',
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 {
return {
...res,
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";
export const signup = async (data: UserCreateRequest): Promise<UserCreateResponse> => {
return await apiClient.post('/users', data);
return await apiClient.post('/users', data, false);
};
export const fetchContact = async (): Promise<UserContactRetrieveResponse> => {
return await apiClient.get<UserContactRetrieveResponse>('/users/contact');
return await apiClient.get<UserContactRetrieveResponse>('/users/contact', true);
}

View File

@ -0,0 +1,28 @@
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

@ -1,96 +0,0 @@
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,13 +1,15 @@
import { logout as apiLogout, userLogin as apiLogin } from '@_api/auth/authAPI';
import { type LoginRequest, type UserLoginSuccessResponse } from '@_api/auth/authTypes';
import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react';
import {checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout} from '@_api/auth/authAPI';
import {type LoginRequest, type LoginSuccessResponse, PrincipalType} from '@_api/auth/authTypes';
import React, {createContext, type ReactNode, useContext, useEffect, useState} from 'react';
interface AuthContextType {
loggedIn: boolean;
userName: string | null;
loading: boolean;
login: (data: Omit<LoginRequest, 'principalType'>) => Promise<UserLoginSuccessResponse>;
type: PrincipalType | null;
loading: boolean;
login: (data: LoginRequest) => Promise<LoginSuccessResponse>;
logout: () => Promise<void>;
checkLogin: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@ -15,33 +17,33 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [loggedIn, setLoggedIn] = useState(false);
const [userName, setUserName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [type, setType] = useState<PrincipalType | null>(null);
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(() => {
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);
}
checkLogin();
}, []);
const login = async (data: Omit<LoginRequest, 'principalType'>) => {
const response = await apiLogin(data);
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('userName', response.name);
const login = async (data: LoginRequest) => {
const response = await apiLogin({ ...data });
setLoggedIn(true);
setUserName(response.name);
setType(data.principalType);
return response;
};
@ -49,15 +51,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
try {
await apiLogout();
} finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('userName');
setLoggedIn(false);
setUserName(null);
setType(null);
localStorage.removeItem('accessToken');
}
};
return (
<AuthContext.Provider value={{ loggedIn, userName, loading, login, logout }}>
<AuthContext.Provider value={{ loggedIn, userName, type, loading, login, logout, checkLogin }}>
{children}
</AuthContext.Provider>
);

View File

@ -1,13 +1,11 @@
/* New CSS content */
.admin-schedule-container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
font-size: 0.95rem; /* Slightly smaller base font */
}
.page-title {
font-size: 1.8rem; /* smaller */
font-size: 2rem;
font-weight: bold;
margin-bottom: 2rem;
text-align: center;
@ -20,7 +18,7 @@
padding: 1.5rem;
background-color: #f9f9f9;
border-radius: 8px;
align-items: flex-end; /* Align to bottom */
align-items: center;
}
.schedule-controls .form-group {
@ -28,29 +26,18 @@
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 {
font-size: 0.85rem; /* smaller */
font-size: 0.9rem;
margin-bottom: 0.5rem;
color: #555;
}
.schedule-controls .form-input,
.schedule-controls .form-select {
padding: 0.6rem; /* smaller */
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem; /* smaller */
font-size: 1rem;
}
.section-card {
@ -76,11 +63,10 @@ table {
}
th, td {
padding: 0.8rem; /* smaller */
padding: 1rem;
text-align: left;
border-bottom: 1px solid #eee;
vertical-align: middle;
font-size: 0.9rem; /* smaller */
}
th {
@ -89,11 +75,11 @@ th {
}
.btn {
padding: 0.4rem 0.8rem; /* smaller */
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem; /* smaller */
font-size: 0.9rem;
transition: background-color 0.2s;
white-space: nowrap;
}
@ -188,8 +174,8 @@ th {
font-size: 1rem;
border: 1px solid #dee2e6;
border-radius: 4px;
height: auto; /* remove fixed height */
box-sizing: border-box;
height: 3rem;
box-sizing: border-box; /* Ensures padding/border are included in height */
}
.details-form-container .button-group {
@ -204,7 +190,7 @@ th {
border: 1px solid #dee2e6;
border-radius: 8px;
background-color: #fff;
margin-bottom: 1.5rem;
margin-bottom: 1.5rem; /* Add margin to separate from buttons */
}
.audit-title {
@ -225,96 +211,4 @@ th {
.audit-body p strong {
color: #212529;
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

@ -1,207 +0,0 @@
/* /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 {
background-color: #ffffff !important;
padding: 30px !important;
border-radius: 16px !important;
width: 90% !important;
max-width: 600px !important;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2) !important;
display: flex !important;
flex-direction: column !important;
gap: 20px !important;
background-color: #ffffff;
padding: 30px;
border-radius: 16px;
width: 90%;
max-width: 600px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
gap: 20px;
}
.modal-thumbnail {
@ -163,18 +163,3 @@
.modal-button.close:hover {
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,24 +49,10 @@
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 {
display: flex;
flex-direction: column;
gap: 10px;
gap: 4px;
}
.summary-theme-name-v2 {
@ -79,15 +65,15 @@
.summary-datetime-v2 {
font-size: 16px;
color: #505a67;
margin-bottom: 5px;
margin: 0;
}
/* --- Status Badge --- */
.card-status-badge {
position: absolute;
top: 30px;
right: 10px;
padding: 3px 10px;
top: 15px;
right: 15px;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
@ -191,16 +177,16 @@
}
.modal-content-v2 {
background: #ffffff !important;
padding: 30px !important;
border-radius: 16px !important;
width: 90% !important;
max-width: 500px !important;
position: relative !important;
box-shadow: 0 5px 15px rgba(0,0,0,0.3) !important;
animation: slide-up 0.3s ease-out !important;
max-height: 90vh !important; /* Prevent modal from being too tall */
overflow-y: auto !important; /* Allow scrolling for long content */
background: #ffffff;
padding: 30px;
border-radius: 16px;
width: 90%;
max-width: 500px;
position: relative;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
animation: slide-up 0.3s ease-out;
max-height: 90vh; /* Prevent modal from being too tall */
overflow-y: auto; /* Allow scrolling for long content */
}
@keyframes slide-up {
@ -254,6 +240,13 @@
color: #505a67;
}
.modal-section-v2 p strong {
color: #333d4b;
font-weight: 600;
min-width: 100px;
display: inline-block;
}
.cancellation-section-v2 {
background-color: #fcf2f2;
padding: 15px;
@ -353,18 +346,3 @@
border-color: #007bff;
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 */
.reservation-v21-container {
width: 100%;
padding: 40px;
max-width: 900px;
margin: 2rem auto;
padding: 2rem;
font-family: 'Pretendard', sans-serif;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin: 40px auto;
background-color: #ffffff;
border-radius: 16px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
font-family: 'Toss Product Sans', sans-serif;
color: #333D4B;
}
.page-title {
text-align: center;
font-size: 2rem;
font-size: 28px;
font-weight: 700;
margin-bottom: 2.5rem;
color: #212529;
margin-bottom: 40px;
color: #191F28;
text-align: center;
}
/* Step Section */
/* Step Sections */
.step-section {
margin-bottom: 3rem;
padding: 1.5rem;
border: 1px solid #f1f3f5;
border-radius: 8px;
background-color: #f8f9fa;
margin-bottom: 40px;
padding: 24px;
border: 1px solid #E5E8EB;
border-radius: 12px;
transition: all 0.3s ease;
}
.step-section.disabled {
opacity: 0.5;
pointer-events: none;
background-color: #F9FAFB;
}
.step-section h3 {
font-size: 1.5rem;
font-size: 20px;
font-weight: 600;
margin-top: 0;
margin-bottom: 1.5rem;
color: #343a40;
margin-bottom: 20px;
color: #191F28;
}
/* Date Carousel */
@ -45,241 +45,274 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.carousel-arrow {
background: none;
border: none;
font-size: 2rem;
color: #868e96;
cursor: pointer;
padding: 0 1rem;
gap: 10px;
margin: 20px 0;
}
.date-options-container {
display: flex;
gap: 10px;
overflow-x: auto;
-ms-overflow-style: none;
scrollbar-width: none;
gap: 8px;
overflow-x: hidden;
flex-grow: 1;
justify-content: space-between;
margin: 0px 15px;
}
.date-options-container::-webkit-scrollbar {
display: none;
}
.date-option {
text-align: center;
cursor: pointer;
padding: 10px;
.carousel-arrow, .today-button {
background-color: #F2F4F6;
border: 1px solid #E5E8EB;
border-radius: 50%;
width: 60px;
height: 60px;
width: 36px;
height: 36px;
font-size: 20px;
font-weight: bold;
color: #4E5968;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: background-color 0.3s, color 0.3s;
}
.date-option .day-of-week {
font-size: 0.8rem;
margin-bottom: 4px;
}
.date-option .day-circle {
font-weight: 600;
}
.date-option.active {
background-color: #0064FF;
color: white;
}
.date-option:not(.active):hover {
background-color: #f1f3f5;
}
.date-option.disabled {
color: #ced4da;
cursor: not-allowed;
justify-content: center;
flex-shrink: 0;
transition: background-color 0.2s;
}
.today-button {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 20px;
padding: 0.5rem 1rem;
cursor: pointer;
margin-left: 1rem;
font-weight: 500;
}
/* --- 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;
font-size: 1rem;
font-size: 14px;
font-weight: 600;
width: auto;
padding: 0 15px;
}
.carousel-arrow:hover, .today-button:hover {
background-color: #E5E8EB;
}
.date-option {
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 {
padding: 8px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.theme-schedule-group {
background-color: #fff;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1.5rem;
}
.theme-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid #f1f3f5;
justify-content: center;
border: 1px solid transparent;
transition: all 0.3s ease;
width: 60px;
flex-shrink: 0;
}
.theme-header h4 {
margin: 0;
font-size: 1.25rem;
.date-option:hover {
background-color: #f0f0f0;
}
.date-option.active {
border: 1px solid #007bff;
background-color: #e7f3ff;
}
.date-option .day-of-week {
font-size: 12px;
color: #888;
}
.date-option.active .day-of-week {
color: #007bff;
}
.date-option .day-circle {
font-size: 16px;
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 {
background-color: #007bff;
color: white;
}
.date-option.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.date-option.disabled .day-circle {
background-color: #E5E8EB;
color: #B0B8C1;
}
/* Theme List */
.theme-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.theme-card {
cursor: pointer;
border-radius: 12px;
overflow: hidden;
border: 2px solid #E5E8EB;
transition: all 0.2s ease-in-out;
background-color: #fff;
display: flex;
flex-direction: column;
}
.theme-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.theme-card.active {
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;
flex-direction: column;
flex-grow: 1;
}
.theme-info h4 {
font-size: 16px;
font-weight: 600;
color: #343a40;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.theme-info p {
font-size: 14px;
color: #6B7684;
margin: 0;
}
.theme-meta {
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 {
padding: 0.5rem 1rem;
font-size: 0.9rem;
background-color: transparent;
color: #0064FF;
border: 1px solid #0064FF;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: background-color 0.2s, color 0.2s;
}
.theme-detail-button:hover {
background-color: #0064FF;
color: #fff;
}
/* Time Slots */
.time-slots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.time-slot {
padding: 0.75rem;
border: 1px solid #dee2e6;
border-radius: 6px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background-color: #fff;
}
.time-slot:hover:not(.disabled) {
border-color: #0064FF;
color: #0064FF;
}
.time-slot.active {
background-color: #0064FF;
color: white;
border-color: #0064FF;
font-weight: 600;
}
.time-slot.disabled {
background-color: #f8f9fa;
color: #adb5bd;
cursor: not-allowed;
text-decoration: line-through;
}
.time-availability {
display: block;
font-size: 0.8rem;
margin-top: 4px;
}
.no-times {
color: #868e96;
padding: 2rem;
text-align: center;
background-color: #fff;
border-radius: 8px;
}
/* --- Next Step Button --- */
.next-step-button-container {
margin-top: 2rem;
text-align: center;
}
.next-step-button {
width: 100%;
max-width: 400px;
padding: 1rem;
font-size: 1.2rem;
font-weight: 700;
color: #fff;
background-color: #0064FF;
padding: 8px;
font-size: 14px;
font-weight: 600;
border: none;
background-color: #F2F4F6;
color: #4E5968;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s;
}
.next-step-button:hover:not(:disabled) {
background-color: #0053d1;
.theme-detail-button:hover {
background-color: #E5E8EB;
}
/* Time Slots */
.time-slots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
}
.time-slot {
cursor: pointer;
padding: 16px;
border-radius: 8px;
text-align: center;
background-color: #F2F4F6;
font-weight: 600;
transition: all 0.2s ease-in-out;
position: relative;
}
.time-slot:hover {
background-color: #E5E8EB;
}
.time-slot.active {
background-color: #3182F6;
color: #ffffff;
}
.time-slot.disabled {
background-color: #F9FAFB;
color: #B0B8C1;
cursor: not-allowed;
text-decoration: line-through;
}
.time-availability {
font-size: 12px;
display: block;
margin-top: 4px;
font-weight: 500;
}
.no-times {
text-align: center;
padding: 20px;
color: #8A94A2;
}
/* Next Step Button */
.next-step-button-container {
display: flex;
justify-content: flex-end;
margin-top: 30px;
}
.next-step-button {
padding: 14px 28px;
font-size: 18px;
font-weight: 700;
border: none;
background-color: #3182F6;
color: #ffffff;
border-radius: 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.next-step-button:disabled {
background-color: #a0a0a0;
background-color: #B0B8C1;
cursor: not-allowed;
}
.next-step-button:hover:not(:disabled) {
background-color: #1B64DA;
}
/* --- Modal Styles --- */
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
@ -295,158 +328,170 @@
.modal-content {
background-color: #ffffff !important;
padding: 2rem !important;
border-radius: 12px !important;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important;
padding: 32px !important;
border-radius: 16px !important;
width: 90% !important;
max-width: 500px !important;
position: relative !important;
max-height: 90vh !important;
overflow-y: auto !important;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1) !important;
}
.modal-close-button {
position: absolute;
top: 1rem;
right: 1rem;
top: 16px;
right: 16px;
background: none;
border: none;
font-size: 1.5rem;
color: #868e96;
font-size: 24px;
cursor: pointer;
color: #8A94A2;
}
.modal-theme-thumbnail {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 8px;
margin-bottom: 1.5rem;
border-radius: 12px;
margin-bottom: 24px;
}
.modal-content h2 {
margin-top: 0;
margin-bottom: 2rem;
text-align: center;
font-size: 24px;
font-weight: 700;
margin-bottom: 24px;
color: #191F28;
}
.modal-section {
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;
margin-bottom: 20px;
}
.modal-section h3 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.1rem;
color: #495057;
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
border-bottom: 1px solid #E5E8EB;
padding-bottom: 8px;
}
.modal-section p {
margin: 0.5rem 0;
color: #495057;
font-size: 16px;
line-height: 1.6;
margin-bottom: 8px;
color: #4E5968;
}
.modal-section p strong {
color: #333D4B;
margin-right: 8px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
gap: 12px;
margin-top: 30px;
}
.modal-actions .cancel-button,
.modal-actions .confirm-button {
padding: 0.75rem 1.5rem;
border-radius: 8px;
border: none;
font-size: 1rem;
.modal-actions button {
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
border: none;
transition: background-color 0.2s;
}
.modal-actions .cancel-button {
background-color: #f1f3f5;
color: #495057;
background-color: #E5E8EB;
color: #4E5968;
}
.modal-actions .cancel-button:hover {
background-color: #D1D6DB;
}
.modal-actions .confirm-button {
background-color: #0064FF;
color: #fff;
background-color: #3182F6;
color: #ffffff;
}
.modal-actions .confirm-button:hover {
background-color: #1B64DA;
}
/* --- Form Styles for ReservationFormPage --- */
/* Styles for ReservationFormPage */
.form-group {
margin-bottom: 1rem;
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #495057;
font-weight: bold;
margin-bottom: 8px;
color: #333;
}
.form-input {
.form-group input[type="text"],
.form-group input[type="tel"],
.form-group input[type="number"],
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
padding: 12px;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 1rem;
font-size: 16px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
/* Success Page */
.success-icon {
font-size: 4rem;
color: #0064FF;
text-align: center;
margin-bottom: 1.5rem;
.form-group input:focus, .form-group textarea:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.success-page-actions {
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.participant-control {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 2.5rem;
align-items: center;
}
.success-page-actions .action-button {
padding: 0.8rem 1.6rem;
border-radius: 8px;
text-decoration: none;
font-size: 1rem;
font-weight: 600;
.participant-control input {
text-align: center;
border-left: none;
border-right: none;
width: 60px;
border-radius: 0;
}
.participant-control button {
width: 44px;
height: 44px;
border: 1px solid #ccc;
background-color: #f0f0f0;
font-size: 20px;
cursor: pointer;
transition: background-color 0.2s;
}
.success-page-actions .action-button.secondary {
background-color: #f1f3f5;
color: #495057;
.participant-control button:hover:not(:disabled) {
background-color: #e0e0e0;
}
.success-page-actions .action-button:not(.secondary) {
background-color: #0064FF;
color: #fff;
.participant-control button:disabled {
background-color: #e9ecef;
cursor: not-allowed;
color: #aaa;
}
/* Added for modal info alignment */
.modal-info-grid p {
display: flex;
align-items: flex-start;
margin: 0.6rem 0;
line-height: 1.5;
.participant-control button:first-of-type {
border-radius: 8px 0 0 8px;
}
.modal-info-grid p strong {
flex: 0 0 130px; /* fixed width for labels */
font-weight: 600;
}
.modal-info-grid p span {
flex: 1;
.participant-control button:last-of-type {
border-radius: 0 8px 8px 0;
}

View File

@ -68,13 +68,4 @@
color: #E53E3E;
font-size: 12px;
margin-top: 4px;
}
.region-select-group {
display: flex;
gap: 10px;
}
.region-select-group select {
flex: 1;
}
}

View File

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

View File

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

View File

@ -1,18 +1,17 @@
import { cancelPayment } from '@_api/payment/paymentAPI';
import type { PaymentRetrieveResponse } from '@_api/payment/PaymentTypes';
import { cancelReservation, fetchDetailById, fetchAllOverviewByUser } from '@_api/reservation/reservationAPI';
import {cancelPayment} from '@_api/payment/paymentAPI';
import type {PaymentRetrieveResponse} from '@_api/payment/PaymentTypes';
import {cancelReservation, fetchDetailById, fetchSummaryByMember} from '@_api/reservation/reservationAPI';
import {
ReservationStatus,
type ReservationDetail,
type ReservationOverviewResponse
type ReservationDetail,
ReservationStatus,
type ReservationSummaryRetrieveResponse
} from '@_api/reservation/reservationTypes';
import React, {useEffect, useState} from 'react';
import '@_css/my-reservation-v2.css';
import { formatDate, formatDisplayDateTime, formatTime } from '@_util/DateTimeFormatter';
import React, { useEffect, useState } from 'react';
const getReservationStatus = (reservation: ReservationOverviewResponse): { className: string, text: string } => {
const getReservationStatus = (reservation: ReservationSummaryRetrieveResponse): { className: string, text: string } => {
const now = new Date();
const reservationDateTime = new Date(`${reservation.date}T${reservation.startFrom}`);
const reservationDateTime = new Date(`${reservation.date}T${reservation.startAt}`);
switch (reservation.status) {
case ReservationStatus.CANCELED:
@ -23,12 +22,81 @@ const getReservationStatus = (reservation: ReservationOverviewResponse): { class
}
return { className: 'status-confirmed', text: '예약확정' };
case ReservationStatus.PENDING:
return { className: 'status-pending', text: '입금대기' };
return { className: 'status-pending', text: '입금대기' };
default:
return { className: `status-${reservation.status.toLowerCase()}`, text: reservation.status };
}
};
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 ---
const CancellationView: React.FC<{
reservation: ReservationDetail;
@ -49,10 +117,10 @@ const CancellationView: React.FC<{
return (
<div className="cancellation-view-v2">
<h3> </h3>
<div className="cancellation-summary-v2 modal-info-grid">
<p><strong>:</strong><span>{reservation.overview.themeName}</span></p>
<p><strong> :</strong><span>{formatDisplayDateTime(reservation.applicationDateTime)}</span></p>
{reservation.payment && <p><strong> :</strong><span>{reservation.payment.totalAmount.toLocaleString()}</span></p>}
<div className="cancellation-summary-v2">
<p><strong>:</strong> {reservation.themeName}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
{reservation.payment && <p><strong> :</strong> {reservation.payment.totalAmount.toLocaleString()}</p>}
</div>
<textarea
value={reason}
@ -79,7 +147,7 @@ const ReservationDetailView: React.FC<{
const renderPaymentSubDetails = (payment: PaymentRetrieveResponse) => {
if (!payment.detail) {
return <p> . .</p>;
return <p> . .</p>;
}
const { detail } = payment;
@ -89,33 +157,33 @@ const ReservationDetailView: React.FC<{
<>
{payment.totalAmount !== detail.amount && (
<>
<p><strong> :</strong><span>{detail.amount.toLocaleString()}</span></p>
<p><strong>() :</strong> {detail.amount.toLocaleString()}</p>
{detail.easypayDiscountAmount && (
<p><strong> :</strong><span>{detail.easypayDiscountAmount.toLocaleString()}</span></p>
<p><strong>() :</strong> {detail.easypayDiscountAmount.toLocaleString()}</p>
)}
</>
)}
{detail.easypayProviderName && (
<p><strong>: </strong><span>{detail.easypayProviderName}</span></p>
<p><strong>: </strong> {detail.easypayProviderName}</p>
)}
<p><strong> / :</strong><span>{detail.issuerCode}({detail.ownerType}) / {detail.cardType}</span></p>
<p><strong> :</strong><span>{detail.cardNumber}</span></p>
<p><strong>:</strong><span>{detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</span></p>
<p><strong> :</strong><span>{detail.approvalNumber}</span></p>
<p><strong> / :</strong> {detail.issuerCode}({detail.ownerType}) / {detail.cardType}</p>
<p><strong> :</strong> {detail.cardNumber}</p>
<p><strong> :</strong> {detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</p>
<p><strong> :</strong> {detail.approvalNumber}</p>
</>
);
case 'BANK_TRANSFER':
return (
<>
<p><strong>:</strong><span>{detail.bankName}</span></p>
<p><strong> :</strong><span>{detail.settlementStatus}</span></p>
<p><strong>:</strong> {detail.bankName}</p>
<p><strong> :</strong> {detail.settlementStatus}</p>
</>
);
case 'EASYPAY_PREPAID':
return (
<>
<p><strong> :</strong><span>{detail.amount.toLocaleString()}</span></p>
{detail.discountAmount > 0 && <p><strong> :</strong><span>{detail.discountAmount.toLocaleString()}</span></p>}
<p><strong> :</strong> {detail.amount.toLocaleString()}</p>
{detail.discountAmount > 0 && <p><strong> :</strong> {detail.discountAmount.toLocaleString()}</p>}
</>
);
default:
@ -125,14 +193,13 @@ const ReservationDetailView: React.FC<{
return (
<>
<div className="modal-section-v2 modal-info-grid">
<div className="modal-section-v2">
<h3> </h3>
<p><strong>:</strong><span>{reservation.overview.storeName}</span></p>
<p><strong>:</strong><span>{reservation.overview.themeName}</span></p>
<p><strong>:</strong><span>{formatDate(reservation.overview.date)} {formatTime(reservation.overview.startFrom)} ~ {formatTime(reservation.overview.endAt)}</span></p>
<p><strong> :</strong><span>{reservation.reserver.name}</span></p>
<p><strong> :</strong><span>{reservation.reserver.contact}</span></p>
<p><strong> :</strong><span>{formatDisplayDateTime(reservation.applicationDateTime)}</span></p>
<p><strong> :</strong> {reservation.themeName}</p>
<p><strong> :</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p>
<p><strong> :</strong> {reservation.user.name}</p>
<p><strong> :</strong> {reservation.user.phone}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
</div>
{!reservation.payment ? (
@ -142,14 +209,14 @@ const ReservationDetailView: React.FC<{
</div>
) : (
<>
<div className="modal-section-v2 modal-info-grid">
<div className="modal-section-v2">
<h3> </h3>
<p><strong> ID:</strong><span>{reservation.payment.orderId}</span></p>
<p><strong> :</strong><span>{reservation.payment.totalAmount.toLocaleString()}</span></p>
<p><strong> :</strong><span>{reservation.payment.method}</span></p>
{reservation.payment.approvedAt && <p><strong> :</strong><span>{formatDisplayDateTime(reservation.payment.approvedAt)}</span></p>}
<p><strong> ID:</strong> {reservation.payment.orderId}</p>
<p><strong> :</strong> {reservation.payment.totalAmount.toLocaleString()}</p>
<p><strong> :</strong> {reservation.payment.method}</p>
{reservation.payment.approvedAt && <p><strong> :</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>}
</div>
<div className="modal-section-v2 modal-info-grid">
<div className="modal-section-v2">
<h3> </h3>
{renderPaymentSubDetails(reservation.payment)}
</div>
@ -157,12 +224,12 @@ const ReservationDetailView: React.FC<{
)}
{reservation.payment && reservation.payment.cancel && (
<div className="modal-section-v2 cancellation-section-v2 modal-info-grid">
<div className="modal-section-v2 cancellation-section-v2">
<h3> </h3>
<p><strong> :</strong><span>{formatDisplayDateTime(reservation.payment.cancel.cancellationRequestedAt)}</span></p>
<p><strong> :</strong><span>{formatDisplayDateTime(reservation.payment.cancel.cancellationApprovedAt)}</span></p>
<p><strong> :</strong><span>{reservation.payment.cancel.cancelReason}</span></p>
<p><strong> :</strong><span>{reservation.payment.cancel.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</span></p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.cancel.cancellationRequestedAt)}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.cancel.cancellationApprovedAt)}</p>
<p><strong> :</strong> {reservation.payment.cancel.cancelReason}</p>
<p><strong> :</strong> {reservation.payment.cancel.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</p>
</div>
)}
{reservation.payment && reservation.payment.status !== 'CANCELED' && (
@ -176,7 +243,7 @@ const ReservationDetailView: React.FC<{
// --- Main Page Component ---
const MyReservationPage: React.FC = () => {
const [reservations, setReservations] = useState<ReservationOverviewResponse[]>([]);
const [reservations, setReservations] = useState<ReservationSummaryRetrieveResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -191,7 +258,7 @@ const MyReservationPage: React.FC = () => {
const loadReservations = async () => {
try {
setIsLoading(true);
const data = await fetchAllOverviewByUser();
const data = await fetchSummaryByMember();
setReservations(data.reservations);
setError(null);
} catch (err) {
@ -205,15 +272,17 @@ const MyReservationPage: React.FC = () => {
loadReservations();
}, []);
const handleShowDetail = async (overview: ReservationOverviewResponse) => {
const handleShowDetail = async (id: string, themeName: string, date: string, time: string) => {
try {
setIsDetailLoading(true);
setDetailError(null);
setModalView('detail');
const detailData = await fetchDetailById(overview.id);
const detailData = await fetchDetailById(id);
setSelectedReservation({
overview: overview,
reserver: detailData.reserver,
id: detailData.id,
themeName: themeName,
date: date,
startAt: time,
user: detailData.user,
applicationDateTime: detailData.applicationDateTime,
payment: detailData.payment
@ -241,8 +310,8 @@ const MyReservationPage: React.FC = () => {
try {
setIsCancelling(true);
setDetailError(null);
await cancelPayment({ reservationId: selectedReservation.overview.id, cancelReason: reason });
await cancelReservation(selectedReservation.overview.id, reason);
await cancelReservation(selectedReservation.id, reason);
cancelPayment({ reservationId: selectedReservation.id, cancelReason: reason });
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
handleCloseModal();
await loadReservations(); // Refresh the list
@ -256,7 +325,7 @@ const MyReservationPage: React.FC = () => {
return (
<div className="my-reservation-container-v2">
<h1> </h1>
<h1> V2</h1>
{isLoading && <p> ...</p>}
{error && <p className="error-message-v2">{error}</p>}
@ -269,18 +338,15 @@ const MyReservationPage: React.FC = () => {
<div key={res.id} className={`reservation-summary-card-v2 ${status.className}`}>
<div className="card-status-badge">{status.text}</div>
<div className="summary-details-v2">
<div><h3 className="summary-theme-name-v2">{res.themeName}</h3></div>
<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>
<h3 className="summary-theme-name-v2">{res.themeName}</h3>
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p>
</div>
<button
onClick={() => handleShowDetail(res)}
onClick={() => handleShowDetail(res.id, res.themeName, res.date, res.startAt)}
disabled={isDetailLoading}
className="detail-button-v2"
>
{isDetailLoading && selectedReservation?.overview.id === res.id ? '로딩중...' : '상세보기'}
{isDetailLoading && selectedReservation?.id === res.id ? '로딩중...' : '상세보기'}
</button>
</div>
);

View File

@ -1,20 +1,19 @@
import { isLoginRequiredError } from '@_api/apiClient';
import { createPendingReservation } from '@_api/reservation/reservationAPI';
import type { ReservationData } from '@_api/reservation/reservationTypes';
import { fetchContact } from '@_api/user/userAPI';
import {isLoginRequiredError} from '@_api/apiClient';
import {createPendingReservation} from '@_api/reservation/reservationAPI';
import {fetchContact} from '@_api/user/userAPI';
import '@_css/reservation-v2-1.css';
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
import React, {useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
const ReservationFormPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const reservationData = location.state as ReservationData;
const { scheduleId, theme, date, time } = location.state || {};
const [reserverName, setReserverName] = useState('');
const [reserverContact, setReserverContact] = useState('');
const [participantCount, setParticipantCount] = useState(reservationData.theme.minParticipants || 2);
const [participantCount, setParticipantCount] = useState(theme.minParticipants || 1);
const [requirement, setRequirement] = useState('');
const [isLoadingUserInfo, setIsLoadingUserInfo] = useState(true);
@ -51,29 +50,30 @@ const ReservationFormPage: React.FC = () => {
return;
}
createPendingReservation({
scheduleId: reservationData.scheduleId,
const reservationData = {
scheduleId,
reserverName,
reserverContact,
participantCount,
requirement,
}).then(res => {
navigate('/reservation/payment', {
state: {
reservationId: res.id,
storeName: reservationData.store.name,
themeName: reservationData.theme.name,
date: reservationData.date,
time: formatTime(reservationData.startFrom) + ' ~ ' + formatTime(reservationData.endAt),
themePrice: reservationData.theme.price,
totalPrice: reservationData.theme.price * participantCount,
participantCount: participantCount,
}
});
}).catch(handleError);
};
createPendingReservation(reservationData)
.then(res => {
navigate('/reservation/payment', {
state: {
reservationId: res.id,
themeName: theme.name,
date: date,
startAt: time,
price: theme.price * participantCount,
}
});
})
.catch(handleError);
};
if (!reservationData) {
if (!scheduleId || !theme) {
return (
<div className="reservation-v21-container">
<h2 className="page-title"> </h2>
@ -86,23 +86,22 @@ const ReservationFormPage: React.FC = () => {
return (
<div className="reservation-v21-container">
<h2 className="page-title"> </h2>
<div className="step-section">
<h3> </h3>
<p><strong>:</strong> {reservationData.store.name}</p>
<p><strong>:</strong> {reservationData.theme.name}</p>
<p><strong>:</strong> {formatDate(reservationData.date)}</p>
<p><strong>:</strong> {formatTime(reservationData.startFrom)} ~ {formatTime(reservationData.endAt)}</p>
<p><strong>:</strong> {theme.name}</p>
<p><strong>:</strong> {formatDate(date)}</p>
<p><strong>:</strong> {formatTime(time)}</p>
</div>
<div className="step-section">
<h3> </h3>
<div className="form-group">
<label htmlFor="reserverName"></label>
<input
type="text"
id="reserverName"
value={reserverName}
<input
type="text"
id="reserverName"
value={reserverName}
onChange={e => setReserverName(e.target.value)}
disabled={isLoadingUserInfo}
placeholder={isLoadingUserInfo ? "로딩 중..." : "예약자명을 입력하세요"}
@ -110,11 +109,11 @@ const ReservationFormPage: React.FC = () => {
</div>
<div className="form-group">
<label htmlFor="reserverContact"></label>
<input
type="tel"
id="reserverContact"
value={reserverContact}
onChange={e => setReserverContact(e.target.value)}
<input
type="tel"
id="reserverContact"
value={reserverContact}
onChange={e => setReserverContact(e.target.value)}
disabled={isLoadingUserInfo}
placeholder={isLoadingUserInfo ? "로딩 중..." : "'-' 없이 입력"}
/>
@ -122,12 +121,12 @@ const ReservationFormPage: React.FC = () => {
<div className="form-group">
<label></label>
<div className="participant-control">
<input
type="number"
value={participantCount}
onChange={e => setParticipantCount(Math.max(reservationData.theme.minParticipants, Math.min(reservationData.theme.maxParticipants, Number(e.target.value))))}
min={reservationData.theme.minParticipants}
max={reservationData.theme.maxParticipants}
<input
type="number"
value={participantCount}
onChange={e => setParticipantCount(Math.max(theme.minParticipants, Math.min(theme.maxParticipants, Number(e.target.value))))}
min={theme.minParticipants}
max={theme.maxParticipants}
/>
</div>
</div>

View File

@ -1,44 +1,30 @@
import { isLoginRequiredError } from '@_api/apiClient';
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
import { type ReservationData } from '@_api/reservation/reservationTypes';
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 {isLoginRequiredError} from '@_api/apiClient';
import {findAvailableThemesByDate, findSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
import {type ScheduleRetrieveResponse, ScheduleStatus} from '@_api/schedule/scheduleTypes';
import {findThemesByIds} from '@_api/theme/themeAPI';
import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
import '@_css/reservation-v2-1.css';
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate } from 'src/util/DateTimeFormatter';
import React, {useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
const ReservationStep1Page: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [viewDate, setViewDate] = useState<Date>(new Date());
const [sidoList, setSidoList] = useState<SidoResponse[]>([]);
const [sigunguList, setSigunguList] = useState<SigunguResponse[]>([]);
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 [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel
const [themes, setThemes] = useState<ThemeInfoResponse[]>([]);
const [selectedTheme, setSelectedTheme] = useState<ThemeInfoResponse | null>(null);
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleRetrieveResponse | null>(null);
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
const [modalThemeDetails, setModalThemeDetails] = useState<ThemeInfoResponse | null>(null);
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', {state: {from: location}});
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
@ -47,48 +33,89 @@ const ReservationStep1Page: React.FC = () => {
};
useEffect(() => {
fetchSidoList().then(res => setSidoList(res.sidoList)).catch(handleError);
}, []);
useEffect(() => {
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');
fetchSchedules(selectedStore.id, dateStr)
if (selectedDate) {
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
findAvailableThemesByDate(dateStr)
.then(res => {
const grouped = res.schedules.reduce((acc, schedule) => {
const key = schedule.theme.name;
if (!acc[key]) acc[key] = [];
acc[key].push(schedule);
return acc;
}, {} as Record<string, ScheduleWithThemeResponse[]>);
setSchedulesByTheme(grouped);
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: [] });
}
})
.catch(handleError);
} else {
setSchedulesByTheme({});
.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);
});
}
setSelectedSchedule(null);
}, [selectedDate, selectedStore]);
}, [selectedDate]);
useEffect(() => {
if (selectedDate && selectedTheme) {
const dateStr = selectedDate.toLocaleDateString('en-CA');
findSchedules(dateStr, selectedTheme.id)
.then(res => {
setSchedules(res.schedules);
setSelectedSchedule(null);
})
.catch((err) => {
if (isLoginRequiredError(err)) {
setSchedules([]);
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
setSelectedSchedule(null);
});
}
}, [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 today = new Date();
@ -98,53 +125,7 @@ const ReservationStep1Page: React.FC = () => {
return;
}
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 dates = [];
@ -203,6 +184,11 @@ const ReservationStep1Page: React.FC = () => {
);
};
const openThemeModal = (theme: ThemeInfoResponse) => {
setSelectedTheme(theme);
setIsThemeModalOpen(true);
};
const getStatusText = (status: ScheduleStatus) => {
switch (status) {
case ScheduleStatus.AVAILABLE:
@ -214,6 +200,8 @@ const ReservationStep1Page: React.FC = () => {
}
};
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== ScheduleStatus.AVAILABLE;
return (
<div className="reservation-v21-container">
<h2 className="page-title"></h2>
@ -224,97 +212,82 @@ const ReservationStep1Page: React.FC = () => {
</div>
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
<h3>2. </h3>
<div className="region-store-selectors">
<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>
<h3>2. </h3>
<div className="theme-list">
{themes.map(theme => (
<div
key={theme.id}
className={`theme-card ${selectedTheme?.id === theme.id ? 'active' : ''}`}
onClick={() => setSelectedTheme(theme)}
>
<div className="theme-info">
<h4>{theme.name}</h4>
<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 className={`step-section ${!selectedStore ? 'disabled' : ''}`}>
<div className={`step-section ${!selectedTheme ? '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
key={schedule.schedule.id}
className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
>
{`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
<span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
</div>
))}
</div>
</div>
))
) : (
<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 className="next-step-button-container">
<button className="next-step-button" disabled={!selectedSchedule} onClick={handleNextStep}>
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}>
</button>
</div>
{isThemeModalOpen && modalThemeDetails && (
{isThemeModalOpen && selectedTheme && (
<div className="modal-overlay" onClick={() => setIsThemeModalOpen(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="modal-close-button" onClick={() => setIsThemeModalOpen(false)}>×</button>
<img src={modalThemeDetails.thumbnailUrl} alt={modalThemeDetails.name}
className="modal-theme-thumbnail"/>
<h2>{modalThemeDetails.name}</h2>
<div className="modal-section modal-info-grid">
<img src={selectedTheme.thumbnailUrl} alt={selectedTheme.name} className="modal-theme-thumbnail" />
<h2>{selectedTheme.name}</h2>
<div className="modal-section">
<h3> </h3>
<p><strong>:</strong><span>{DifficultyKoreanMap[modalThemeDetails.difficulty]}</span></p>
<p><strong> :</strong><span>{modalThemeDetails.minParticipants} ~ {modalThemeDetails.maxParticipants}</span></p>
<p><strong>1 :</strong><span>{modalThemeDetails.price.toLocaleString()}</span></p>
<p><strong> :</strong><span>{modalThemeDetails.expectedMinutesFrom} ~ {modalThemeDetails.expectedMinutesTo}</span></p>
<p><strong> :</strong><span>{modalThemeDetails.availableMinutes}</span></p>
<p><strong>:</strong> {selectedTheme.difficulty}</p>
<p><strong> :</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</p>
<p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p>
<p><strong>1 :</strong> {selectedTheme.price.toLocaleString()}</p>
</div>
<div className="modal-section">
<h3></h3>
<p>{modalThemeDetails.description}</p>
<p>{selectedTheme.description}</p>
</div>
</div>
</div>
)}
{isConfirmModalOpen && selectedSchedule && (
{isConfirmModalOpen && (
<div className="modal-overlay" onClick={() => setIsConfirmModalOpen(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="modal-close-button" onClick={() => setIsConfirmModalOpen(false)}>×</button>
<h2> </h2>
<div className="modal-section modal-info-grid">
<p><strong>:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
<p><strong>:</strong><span>{selectedStore?.name}</span></p>
<p><strong>:</strong><span>{selectedSchedule.theme.name}</span></p>
<p><strong>:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
<div className="modal-section">
<p><strong>:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
<p><strong>:</strong> {selectedTheme!!.name}</p>
<p><strong>:</strong> {formatTime(selectedSchedule!!.time)}</p>
</div>
<div className="modal-actions">
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}></button>
@ -327,4 +300,4 @@ const ReservationStep1Page: React.FC = () => {
);
};
export default ReservationStep1Page;
export default ReservationStep1Page;

View File

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

View File

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

View File

@ -1,17 +1,8 @@
import {
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 {signup} from '@_api/user/userAPI';
import type {UserCreateRequest, UserCreateResponse} from '@_api/user/userTypes';
import '@_css/signup-page-v2.css';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import React, {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
const MIN_PASSWORD_LENGTH = 8;
@ -23,43 +14,8 @@ const SignupPage: React.FC = () => {
const [errors, setErrors] = useState<Record<string, string>>({});
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();
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 newErrors: Record<string, string> = {};
@ -80,12 +36,6 @@ const SignupPage: React.FC = () => {
newErrors.phone = '올바른 휴대폰 번호 형식이 아닙니다. (예: 01012345678)';
}
if (selectedSidoCode || selectedSigunguCode) {
if (!selectedSidoCode || !selectedSigunguCode) {
newErrors.region = '모든 지역 정보를 선택해주세요.';
}
}
return newErrors;
};
@ -94,7 +44,7 @@ const SignupPage: React.FC = () => {
if (hasSubmitted) {
setErrors(validate());
}
}, [email, password, name, phone, hasSubmitted, selectedSidoCode, selectedSigunguCode]);
}, [email, password, name, phone, hasSubmitted]);
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
@ -105,22 +55,7 @@ const SignupPage: React.FC = () => {
if (Object.keys(newErrors).length > 0) return;
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 };
const request: UserCreateRequest = { email, password, name, phone, regionCode: null };
try {
const response: UserCreateResponse = await signup(request);
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
@ -198,40 +133,6 @@ const SignupPage: React.FC = () => {
)}
</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
type="submit"
className="btn-primary"

View File

@ -1,6 +1,4 @@
import { useAdminAuth } from '@_context/AdminAuthContext';
import React, { type ReactNode, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import React, {type ReactNode} from 'react';
import AdminNavbar from './AdminNavbar';
interface AdminLayoutProps {
@ -8,23 +6,6 @@ interface AdminLayoutProps {
}
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 (
<>
<AdminNavbar />

View File

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

View File

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

View File

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

View File

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

View File

@ -32,43 +32,4 @@ export const formatTime = (timeStr: string) => {
}
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,6 +19,7 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
@ -33,7 +34,6 @@
"@_hooks/*": ["src/hooks/*"],
"@_pages/*": ["src/pages/*"],
"@_types/*": ["/src/types/*"],
"@_util/*": ["src/util/*"]
}
},
"include": ["src"],

View File

@ -1,57 +0,0 @@
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
}

View File

@ -1,26 +0,0 @@
package com.sangdol.roomescape
import org.springframework.boot.Banner
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.cache.annotation.EnableCaching
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
import java.util.*
@EnableAsync
@EnableCaching
@EnableScheduling
@SpringBootApplication(
scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"]
)
class RoomescapeApplication
fun main(args: Array<String>) {
System.setProperty("user.timezone", "UTC")
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
val springApplication = SpringApplication(RoomescapeApplication::class.java)
springApplication.setBannerMode(Banner.Mode.OFF)
springApplication.run()
}

View File

@ -1,49 +0,0 @@
package com.sangdol.roomescape.admin.business
import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
import com.sangdol.roomescape.admin.mapper.toCredentials
import com.sangdol.roomescape.admin.exception.AdminErrorCode
import com.sangdol.roomescape.admin.exception.AdminException
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
import com.sangdol.roomescape.common.types.Auditor
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
private val log: KLogger = KotlinLogging.logger {}
@Service
class AdminService(
private val adminRepository: AdminRepository,
) {
@Transactional(readOnly = true)
fun findCredentialsByAccount(account: String): AdminLoginCredentials {
log.debug { "[findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
return adminRepository.findByAccount(account)
?.let {
log.info { "[findCredentialsByAccount] 관리자 조회 완료: account=${account}, id=${it.id}" }
it.toCredentials()
}
?: run {
log.debug { "[findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
}
}
@Transactional(readOnly = true)
fun findOperatorOrUnknown(id: Long): Auditor {
log.debug { "[findOperatorById] 작업자 정보 조회 시작: id=${id}" }
return adminRepository.findByIdOrNull(id)?.let { admin ->
Auditor(admin.id, admin.name).also {
log.info { "[findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" }
}
} ?: run {
log.warn { "[findOperatorById] 작업자 정보 조회 실패. id=${id}" }
Auditor.UNKNOWN
}
}
}

View File

@ -1,29 +0,0 @@
package com.sangdol.roomescape.admin.dto
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.auth.dto.LoginCredentials
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
data class AdminLoginCredentials(
override val id: Long,
override val password: String,
override val name: String,
val type: AdminType,
val storeId: Long?,
val permissionLevel: AdminPermissionLevel,
) : LoginCredentials() {
override fun toResponse(accessToken: String) = AdminLoginSuccessResponse(
accessToken = accessToken,
name = name,
type = type,
storeId = storeId
)
}
data class AdminLoginSuccessResponse(
override val accessToken: String,
override val name: String,
val type: AdminType,
val storeId: Long?,
) : LoginSuccessResponse()

View File

@ -1,13 +0,0 @@
package com.sangdol.roomescape.admin.mapper
import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
fun AdminEntity.toCredentials() = AdminLoginCredentials(
id = this.id,
password = this.password,
name = this.name,
type = this.type,
storeId = this.storeId,
permissionLevel = this.permissionLevel
)

View File

@ -1,97 +0,0 @@
package com.sangdol.roomescape.auth.business
import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.auth.dto.LoginContext
import com.sangdol.roomescape.auth.dto.LoginCredentials
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.user.business.UserService
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
private val log: KLogger = KotlinLogging.logger {}
const val CLAIM_ADMIN_TYPE_KEY = "admin_type"
const val CLAIM_PERMISSION_KEY = "permission"
const val CLAIM_STORE_ID_KEY = "store_id"
@Service
class AuthService(
private val adminService: AdminService,
private val userService: UserService,
private val jwtUtils: JwtUtils,
private val eventPublisher: ApplicationEventPublisher
) {
fun login(
request: LoginRequest,
context: LoginContext
): LoginSuccessResponse {
log.debug { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
val (credentials, extraClaims) = getCredentials(request)
val event = LoginHistoryEvent(
id = credentials.id,
type = request.principalType,
ipAddress = context.ipAddress,
userAgent = context.userAgent
)
try {
verifyPasswordOrThrow(request, credentials)
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
eventPublisher.publishEvent(event.onSuccess())
return credentials.toResponse(accessToken).also {
log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
}
} catch (e: Exception) {
eventPublisher.publishEvent(event.onFailure())
when (e) {
is AuthException -> { throw e }
else -> {
log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
}
}
}
}
private fun verifyPasswordOrThrow(
request: LoginRequest,
credentials: LoginCredentials
) {
if (credentials.password != request.password) {
log.debug { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
throw AuthException(AuthErrorCode.LOGIN_FAILED)
}
}
private fun getCredentials(request: LoginRequest): Pair<LoginCredentials, Map<String, Any>> {
val extraClaims: MutableMap<String, Any> = mutableMapOf()
val credentials: LoginCredentials = when (request.principalType) {
PrincipalType.ADMIN -> {
adminService.findCredentialsByAccount(request.account).also {
extraClaims.put(CLAIM_ADMIN_TYPE_KEY, it.type.name)
extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel.name)
it.storeId?.also { storeId -> extraClaims.put(CLAIM_STORE_ID_KEY, storeId.toString()) }
}
}
PrincipalType.USER -> {
userService.findCredentialsByAccount(request.account)
}
}
return credentials to extraClaims
}
}

View File

@ -1,92 +0,0 @@
package com.sangdol.roomescape.auth.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import com.sangdol.roomescape.auth.mapper.toEntity
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.annotation.PreDestroy
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
private val log: KLogger = KotlinLogging.logger {}
@Component
class LoginHistoryEventListener(
private val idGenerator: IDGenerator,
private val loginHistoryRepository: LoginHistoryRepository,
private val queue: ConcurrentLinkedQueue<LoginHistoryEntity> = ConcurrentLinkedQueue()
) {
@Value(value = "\${spring.jpa.properties.hibernate.jdbc.batch_size:100}")
private var batchSize: Int = 0
@Async
@EventListener(classes = [LoginHistoryEvent::class])
fun onLoginCompleted(event: LoginHistoryEvent) {
log.debug { "[onLoginCompleted] 로그인 이력 저장 이벤트 수신: id=${event.id}, type=${event.type}" }
queue.add(event.toEntity(idGenerator.create())).also {
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 큐 저장 완료: id=${event.id}, type=${event.type}" }
}
if (queue.size >= batchSize) {
flush()
}
}
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS)
fun flushScheduled() {
log.debug { "[flushScheduled] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
if (queue.isEmpty()) {
log.debug { "[flushScheduled] 큐에 있는 로그인 이력이 없음." }
return
}
flush()
log.info { "[flushScheduled] 큐에 저장된 로그인 이력 저장 완료: size=${queue.size}" }
}
@PreDestroy
fun flushAll() {
log.debug { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 시작: size=${queue.size}" }
while (!queue.isEmpty()) {
flush()
}
log.info { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 완료: size=${queue.size}" }
}
private fun flush() {
log.debug { "[flush] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
if (queue.isEmpty()) {
log.debug { "[flush] 큐에 있는 로그인 이력이 없음." }
return;
}
val batch = mutableListOf<LoginHistoryEntity>()
repeat(batchSize) {
val entity: LoginHistoryEntity? = queue.poll()
if (entity != null) {
batch.add(entity)
} else {
return@repeat
}
}
if (batch.isEmpty()) {
return
}
loginHistoryRepository.saveAll(batch).also {
log.info { "[flush] 큐에 저장된 로그인 이력 저장 완료: size=${batch.size}" }
}
}
}

View File

@ -1,19 +0,0 @@
package com.sangdol.roomescape.auth.business.domain
class LoginHistoryEvent(
val id: Long,
val type: PrincipalType,
var success: Boolean = true,
val ipAddress: String,
val userAgent: String
) {
fun onSuccess(): LoginHistoryEvent {
this.success = true
return this
}
fun onFailure(): LoginHistoryEvent {
this.success = false
return this
}
}

View File

@ -1,5 +0,0 @@
package com.sangdol.roomescape.auth.business.domain
enum class PrincipalType {
USER, ADMIN
}

View File

@ -1,34 +0,0 @@
package com.sangdol.roomescape.auth.docs
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody
interface AuthAPI {
@Public
@Operation(summary = "로그인")
@ApiResponses(ApiResponse(responseCode = "200"))
fun login(
@Valid @RequestBody loginRequest: LoginRequest,
servletRequest: HttpServletRequest
): ResponseEntity<CommonApiResponse<LoginSuccessResponse>>
@Operation(summary = "로그아웃")
@ApiResponses(ApiResponse(responseCode = "200"))
fun logout(
@User user: CurrentUserContext,
servletResponse: HttpServletResponse
): ResponseEntity<CommonApiResponse<Unit>>
}

View File

@ -1,27 +0,0 @@
package com.sangdol.roomescape.auth.dto
import com.sangdol.roomescape.auth.business.domain.PrincipalType
data class LoginContext(
val ipAddress: String,
val userAgent: String,
)
data class LoginRequest(
val account: String,
val password: String,
val principalType: PrincipalType
)
abstract class LoginSuccessResponse {
abstract val accessToken: String
abstract val name: String
}
abstract class LoginCredentials {
abstract val id: Long
abstract val password: String
abstract val name: String
abstract fun toResponse(accessToken: String): LoginSuccessResponse
}

View File

@ -1,13 +0,0 @@
package com.sangdol.roomescape.auth.mapper
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
fun LoginHistoryEvent.toEntity(id: Long) = LoginHistoryEntity(
id = id,
principalId = this.id,
principalType = this.type,
success = this.success,
ipAddress = this.ipAddress,
userAgent = this.userAgent
)

View File

@ -1,111 +0,0 @@
package com.sangdol.roomescape.auth.web.support.interceptors
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
import com.sangdol.roomescape.auth.business.CLAIM_PERMISSION_KEY
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.support.AdminOnly
import com.sangdol.roomescape.auth.web.support.accessToken
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor
private val log: KLogger = KotlinLogging.logger {}
@Component
class AdminInterceptor(
private val jwtUtils: JwtUtils
) : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any
): Boolean {
if (handler !is HandlerMethod) {
return true
}
val annotation: AdminOnly = handler.getMethodAnnotation(AdminOnly::class.java) ?: return true
val token: String? = request.accessToken()
try {
run {
val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalIdUtil.set(it) }
val type: AdminType = validateTypeAndGet(token, annotation.type)
val permission: AdminPermissionLevel = validatePermissionAndGet(token, annotation.privilege)
log.info { "[AdminInterceptor] 인증 완료. adminId=$id, type=${type}, permission=${permission}" }
}
return true
} catch (e: Exception) {
when (e) {
is AuthException -> {
throw e
}
else -> {
log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
}
}
}
}
private fun validateTypeAndGet(token: String?, requiredType: AdminType): AdminType {
val typeClaim: String? = jwtUtils.extractClaim(token, key = CLAIM_ADMIN_TYPE_KEY)
/**
* 이전의 id 추출 과정에서 토큰이 유효한지 검증했기 때문에 typeClaim null 이라는 것은
* 회원 토큰일 가능성이 . (관리자 토큰에는 CLAIM_ADMIN_TYPE_KEY 무조건 존재함)
*/
if (typeClaim == null) {
log.warn { "[AdminInterceptor] 관리자 타입 조회 실패: token=${token}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
val type = try {
AdminType.valueOf(typeClaim)
} catch (_: IllegalArgumentException) {
log.warn { "[AdminInterceptor] 관리자 타입 변환 실패: token=${token}, typeClaim=${typeClaim}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
if (requiredType != AdminType.ALL && type != requiredType) {
log.warn { "[AdminInterceptor] 관리자 권한 부족: requiredType=${requiredType} / current=${type}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
return type
}
private fun validatePermissionAndGet(token: String?, requiredPrivilege: Privilege): AdminPermissionLevel {
val permissionClaim: String? = jwtUtils.extractClaim(token, key = CLAIM_PERMISSION_KEY)
if (permissionClaim == null) {
log.warn { "[AdminInterceptor] 관리자 권한 조회 실패: token=${token}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
val permission = try {
AdminPermissionLevel.valueOf(permissionClaim)
} catch (_: IllegalArgumentException) {
log.warn { "[AdminInterceptor] 관리자 권한 변환 실패: token=${token}, permissionClaim=${permissionClaim}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
if (!permission.hasPrivilege(requiredPrivilege)) {
log.warn { "[AdminInterceptor] 관리자 권한 부족: required=${requiredPrivilege} / current=${permission.privileges}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
return permission
}
}

View File

@ -1,61 +0,0 @@
package com.sangdol.roomescape.auth.web.support.interceptors
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.support.UserOnly
import com.sangdol.roomescape.auth.web.support.accessToken
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor
private val log: KLogger = KotlinLogging.logger {}
@Component
class UserInterceptor(
private val jwtUtils: JwtUtils
) : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any
): Boolean {
if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(UserOnly::class.java) == null)) {
return true
}
val token: String? = request.accessToken()
try {
val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalIdUtil.set(it) }
/**
* CLAIM_ADMIN_TYPE_KEY 존재하면 관리자 토큰임
*/
jwtUtils.extractClaim(token, key = CLAIM_ADMIN_TYPE_KEY)?.also {
log.warn { "[UserInterceptor] 관리자 토큰으로 접근 시도. userId=$id, adminType=$it" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
log.info { "[UserInterceptor] 인증 완료. userId=$id" }
return true
} catch (e: Exception) {
when (e) {
is AuthException -> {
throw e
}
else -> {
log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
}
}
}
}
}

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