generated from pricelees/issue-pr-template
Compare commits
73 Commits
main
...
refactor/#
| Author | SHA1 | Date | |
|---|---|---|---|
| 842a11a9ae | |||
| 436418a12f | |||
| 85b1eb2f6f | |||
| e8ed273943 | |||
| 0bcb90f812 | |||
| 02d5c9e7ef | |||
| 375ec45078 | |||
| 5724aaecb6 | |||
| 52230a5ba0 | |||
| 24dd2c492f | |||
| 041e8d157d | |||
| b0f67543be | |||
| e1941052f9 | |||
| 1d41d517b1 | |||
| 905c4b7019 | |||
| 6eecd145cc | |||
| c3b736b81f | |||
| e4a18d0c79 | |||
| 854b3153e1 | |||
| bf6b1b5cdc | |||
| 16ee7eecf3 | |||
| 40d687f7f2 | |||
| f5192750c3 | |||
| dd4e022d6d | |||
| 8a7778ba19 | |||
| 3283779720 | |||
| 97a84f1c61 | |||
| 45039b8e7c | |||
| c832410160 | |||
| 671243b9b1 | |||
| 611508b358 | |||
| 53d82902ca | |||
| e0972550d4 | |||
| 741888f156 | |||
| 3bed383218 | |||
| ee9d8cd9f0 | |||
| 1ddf812d1c | |||
| 8d86dd8a70 | |||
| 2fc1cabe0e | |||
| 26910f1d14 | |||
| 3b6e7ba7a6 | |||
| 87a273971e | |||
| af901770dd | |||
| 2e52785f7a | |||
| 3f74206985 | |||
| efa33a071f | |||
| 81613562bc | |||
| 77de425fc1 | |||
| e4f6ffe53d | |||
| ea45673ef4 | |||
| 4ae9aa5911 | |||
| a6a82d7fd9 | |||
| a70a032946 | |||
| b041df2167 | |||
| c8377a3dde | |||
| 1e9dbd87c3 | |||
| 0b5d91d301 | |||
| 3c71562317 | |||
| 66ae7d7beb | |||
| 797ee2c0d0 | |||
| 26c3c62b04 | |||
| 7f1ab906b7 | |||
| c79a4bdd1f | |||
| e02086680b | |||
| 39da28d3f1 | |||
| 8c7bf2980f | |||
| da9c7953f4 | |||
| c9b7c9d4f1 | |||
| f32613d6d9 | |||
| 573ab14aca | |||
| c15e0f456e | |||
| 75acdc2c2f | |||
| 59907bd643 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -37,7 +37,3 @@ out/
|
|||||||
.vscode/
|
.vscode/
|
||||||
logs
|
logs
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
### sql
|
|
||||||
data/*.sql
|
|
||||||
data/*.txt
|
|
||||||
11
Dockerfile
11
Dockerfile
@ -1,9 +1,10 @@
|
|||||||
FROM amazoncorretto:17
|
FROM gradle:8-jdk17 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN ./gradlew bootjar --no-daemon
|
||||||
|
|
||||||
COPY service/build/libs/service.jar app.jar
|
FROM amazoncorretto:17
|
||||||
|
WORKDIR /app
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
COPY --from=builder /app/build/libs/*.jar app.jar
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
@ -1,52 +1,91 @@
|
|||||||
import org.jetbrains.kotlin.gradle.plugin.KaptExtension
|
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
val springBootVersion = "3.5.3"
|
val springBootVersion = "3.5.3"
|
||||||
val kotlinVersion = "2.2.0"
|
val kotlinVersion = "2.2.0"
|
||||||
|
|
||||||
id("io.spring.dependency-management") version "1.1.7" apply false
|
id("org.springframework.boot") version springBootVersion
|
||||||
id("org.springframework.boot") version springBootVersion apply false
|
id("io.spring.dependency-management") version "1.1.7"
|
||||||
kotlin("jvm") version kotlinVersion apply false
|
kotlin("jvm") version kotlinVersion
|
||||||
kotlin("kapt") version kotlinVersion apply false
|
kotlin("plugin.spring") version kotlinVersion
|
||||||
kotlin("plugin.spring") version kotlinVersion apply false
|
kotlin("plugin.jpa") version kotlinVersion
|
||||||
kotlin("plugin.jpa") version kotlinVersion apply false
|
kotlin("kapt") version kotlinVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "com.sangdol"
|
group = "com.sangdol"
|
||||||
version = "0.0.1-SNAPSHOT"
|
version = "0.0.1-SNAPSHOT"
|
||||||
|
|
||||||
allprojects {
|
java {
|
||||||
repositories {
|
toolchain {
|
||||||
mavenCentral()
|
languageVersion = JavaLanguageVersion.of(17)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subprojects {
|
tasks.jar {
|
||||||
apply(plugin = "org.jetbrains.kotlin.jvm")
|
enabled = false
|
||||||
apply(plugin = "org.jetbrains.kotlin.kapt")
|
}
|
||||||
apply(plugin = "io.spring.dependency-management")
|
|
||||||
|
|
||||||
extensions.configure<JavaPluginExtension> {
|
kapt {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
extensions.configure<KaptExtension> {
|
|
||||||
keepJavacAnnotationProcessors = true
|
keepJavacAnnotationProcessors = true
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
repositories {
|
||||||
add("implementation", "io.github.oshai:kotlin-logging-jvm:7.0.3")
|
mavenCentral()
|
||||||
add("implementation", "io.kotest:kotest-runner-junit5:5.9.1")
|
}
|
||||||
add("implementation", "ch.qos.logback:logback-classic:1.5.18")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<Test> {
|
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")
|
||||||
|
|
||||||
|
// API docs
|
||||||
|
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
|
||||||
|
|
||||||
|
// DB
|
||||||
|
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
|
||||||
|
runtimeOnly("com.h2database:h2")
|
||||||
|
runtimeOnly("com.mysql:mysql-connector-j")
|
||||||
|
|
||||||
|
// Jwt
|
||||||
|
implementation("io.jsonwebtoken:jjwt:0.12.6")
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
|
||||||
|
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
|
||||||
|
implementation("com.github.loki4j:loki-logback-appender:2.0.0")
|
||||||
|
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
|
||||||
|
|
||||||
|
// Observability
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||||
|
implementation("io.micrometer:micrometer-tracing-bridge-otel")
|
||||||
|
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
|
||||||
|
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
|
||||||
|
|
||||||
|
// Kotlin
|
||||||
|
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||||
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||||
|
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
|
||||||
|
|
||||||
|
// Test
|
||||||
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
|
testImplementation("io.mockk:mockk:1.14.4")
|
||||||
|
testImplementation("com.ninja-squad:springmockk:4.0.2")
|
||||||
|
|
||||||
|
// Kotest
|
||||||
|
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
|
||||||
|
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
|
||||||
|
|
||||||
|
// RestAssured
|
||||||
|
testImplementation("io.rest-assured:rest-assured:5.5.5")
|
||||||
|
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test> {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<KotlinCompile> {
|
tasks.withType<KotlinCompile> {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
freeCompilerArgs.addAll(
|
freeCompilerArgs.addAll(
|
||||||
"-Xjsr305=strict",
|
"-Xjsr305=strict",
|
||||||
@ -54,5 +93,4 @@ subprojects {
|
|||||||
)
|
)
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
6
build.sh
6
build.sh
@ -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
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package com.sangdol.common.log.constant
|
|
||||||
|
|
||||||
enum class LogType {
|
|
||||||
INCOMING_HTTP_REQUEST,
|
|
||||||
CONTROLLER_INVOKED,
|
|
||||||
SUCCEED,
|
|
||||||
APPLICATION_FAILURE,
|
|
||||||
UNHANDLED_EXCEPTION
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
package com.sangdol.common.persistence
|
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
class TestApplication
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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:
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
tasks.named<Jar>("jar") {
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
dependencies {
|
|
||||||
implementation("org.slf4j:slf4j-api:2.0.17")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.named<Jar>("jar") {
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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.
@ -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
|
|
||||||
@ -1,18 +1,6 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Node.js
|
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
.git
|
||||||
|
|
||||||
# Build output
|
|
||||||
build
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Editor/OS specific
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
npm-debug.log
|
||||||
# Environment variables
|
dist
|
||||||
.env*
|
build
|
||||||
@ -1,17 +1,18 @@
|
|||||||
FROM node:24-alpine AS builder
|
# Stage 1: Build the React app
|
||||||
|
FROM node:24 AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
COPY package-lock.json ./
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
RUN npm install --frozen-lockfile
|
||||||
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
FROM nginx:1.27-alpine
|
|
||||||
|
|
||||||
|
# Stage 2: Serve with Nginx
|
||||||
|
FROM nginx:latest
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@ -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 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 AdminLayout from './pages/admin/AdminLayout';
|
||||||
import AdminLoginPage from './pages/admin/AdminLoginPage';
|
|
||||||
import AdminPage from './pages/admin/AdminPage';
|
import AdminPage from './pages/admin/AdminPage';
|
||||||
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
|
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
|
||||||
import AdminStorePage from './pages/admin/AdminStorePage';
|
|
||||||
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
|
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
|
||||||
import AdminThemePage from './pages/admin/AdminThemePage';
|
import AdminThemePage from './pages/admin/AdminThemePage';
|
||||||
import HomePage from '@_pages/HomePage';
|
import HomePage from '@_pages/HomePage';
|
||||||
@ -18,28 +16,26 @@ import ReservationStep2Page from '@_pages/ReservationStep2Page';
|
|||||||
import ReservationSuccessPage from '@_pages/ReservationSuccessPage';
|
import ReservationSuccessPage from '@_pages/ReservationSuccessPage';
|
||||||
import SignupPage from '@_pages/SignupPage';
|
import SignupPage from '@_pages/SignupPage';
|
||||||
|
|
||||||
|
const AdminRoutes = () => (
|
||||||
|
<AdminLayout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AdminPage />} />
|
||||||
|
<Route path="/theme" element={<AdminThemePage />} />
|
||||||
|
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
|
||||||
|
<Route path="/schedule" element={<AdminSchedulePage />} />
|
||||||
|
</Routes>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/admin/*" element={
|
<Route path="/admin/*" element={
|
||||||
<AdminAuthProvider>
|
<AdminRoute>
|
||||||
<Routes>
|
<AdminRoutes />
|
||||||
<Route path="/login" element={<AdminLoginPage />} />
|
</AdminRoute>
|
||||||
<Route path="/*" element={
|
|
||||||
<AdminLayout>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<AdminPage />} />
|
|
||||||
<Route path="/theme" element={<AdminThemePage />} />
|
|
||||||
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
|
|
||||||
<Route path="/store" element={<AdminStorePage />} />
|
|
||||||
<Route path="/schedule" element={<AdminSchedulePage />} />
|
|
||||||
</Routes>
|
|
||||||
</AdminLayout>
|
|
||||||
} />
|
|
||||||
</Routes>
|
|
||||||
</AdminAuthProvider>
|
|
||||||
} />
|
} />
|
||||||
<Route path="/*" element={
|
<Route path="/*" element={
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios';
|
import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios';
|
||||||
import JSONbig from 'json-bigint';
|
import JSONbig from 'json-bigint';
|
||||||
import { PrincipalType } from './auth/authTypes';
|
|
||||||
|
|
||||||
// Create a JSONbig instance that stores big integers as strings
|
// Create a JSONbig instance that stores big integers as strings
|
||||||
const JSONbigString = JSONbig({ storeAsString: true });
|
const JSONbigString = JSONbig({ storeAsString: true });
|
||||||
@ -39,7 +38,7 @@ async function request<T>(
|
|||||||
method: Method,
|
method: Method,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
data: object = {},
|
data: object = {},
|
||||||
type: PrincipalType,
|
isRequiredAuth: boolean = false
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
method,
|
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 (accessToken) {
|
||||||
if (!config.headers) {
|
if (!config.headers) {
|
||||||
config.headers = {};
|
config.headers = {};
|
||||||
@ -59,6 +57,7 @@ async function request<T>(
|
|||||||
config.headers['Authorization'] = `Bearer ${accessToken}`;
|
config.headers['Authorization'] = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (method.toUpperCase() !== 'GET') {
|
if (method.toUpperCase() !== 'GET') {
|
||||||
config.data = data;
|
config.data = data;
|
||||||
}
|
}
|
||||||
@ -73,50 +72,30 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get<T>(endpoint: string): Promise<T> {
|
async function get<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
|
||||||
return request<T>('GET', endpoint, {}, PrincipalType.USER);
|
return request<T>('GET', endpoint, {}, isRequiredAuth);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function adminGet<T>(endpoint: string): Promise<T> {
|
async function post<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
|
||||||
return request<T>('GET', endpoint, {}, PrincipalType.ADMIN);
|
return request<T>('POST', endpoint, data, isRequiredAuth);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post<T>(endpoint: string, data: object = {}): Promise<T> {
|
async function put<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
|
||||||
return request<T>('POST', endpoint, data, PrincipalType.USER);
|
return request<T>('PUT', endpoint, data, isRequiredAuth);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function adminPost<T>(endpoint: string, data: object = {}): Promise<T> {
|
async function patch<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
|
||||||
return request<T>('POST', endpoint, data, PrincipalType.ADMIN);
|
return request<T>('PATCH', endpoint, data, isRequiredAuth);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function put<T>(endpoint: string, data: object = {}): Promise<T> {
|
async function del<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
|
||||||
return request<T>('PUT', endpoint, data, PrincipalType.USER);
|
return request<T>('DELETE', endpoint, {}, isRequiredAuth);
|
||||||
}
|
|
||||||
|
|
||||||
async function adminPut<T>(endpoint: string, data: object = {}): Promise<T> {
|
|
||||||
return request<T>('PUT', endpoint, data, PrincipalType.ADMIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function patch<T>(endpoint: string, data: object = {}): Promise<T> {
|
|
||||||
return request<T>('PATCH', endpoint, data, PrincipalType.USER);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function adminPatch<T>(endpoint: string, data: object = {}): Promise<T> {
|
|
||||||
return request<T>('PATCH', endpoint, data, PrincipalType.ADMIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function del<T>(endpoint: string): Promise<T> {
|
|
||||||
return request<T>('DELETE', endpoint, {}, PrincipalType.USER);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function adminDel<T>(endpoint: string): Promise<T> {
|
|
||||||
return request<T>('DELETE', endpoint, {}, PrincipalType.ADMIN);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
get, adminGet,
|
get,
|
||||||
post, adminPost,
|
post,
|
||||||
put, adminPut,
|
put,
|
||||||
patch, adminPatch,
|
patch,
|
||||||
del, adminDel,
|
del
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,33 +1,19 @@
|
|||||||
import apiClient from '@_api/apiClient';
|
import apiClient from '@_api/apiClient';
|
||||||
import {
|
import type {CurrentUserContext, LoginRequest, LoginSuccessResponse} from './authTypes';
|
||||||
type AdminLoginSuccessResponse,
|
|
||||||
type LoginRequest,
|
|
||||||
PrincipalType,
|
|
||||||
type UserLoginSuccessResponse,
|
|
||||||
} from './authTypes';
|
|
||||||
|
|
||||||
export const userLogin = async (
|
|
||||||
data: Omit<LoginRequest, 'principalType'>,
|
export const login = async (data: LoginRequest): Promise<LoginSuccessResponse> => {
|
||||||
): Promise<UserLoginSuccessResponse> => {
|
const response = await apiClient.post<LoginSuccessResponse>('/auth/login', data, false);
|
||||||
return await apiClient.post<UserLoginSuccessResponse>(
|
localStorage.setItem('accessToken', response.accessToken);
|
||||||
'/auth/login',
|
|
||||||
{ ...data, principalType: PrincipalType.USER },
|
return response;
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminLogin = async (
|
export const checkLogin = async (): Promise<CurrentUserContext> => {
|
||||||
data: Omit<LoginRequest, 'principalType'>,
|
return await apiClient.get<CurrentUserContext>('/auth/login/check', true);
|
||||||
): Promise<AdminLoginSuccessResponse> => {
|
|
||||||
return await apiClient.adminPost<AdminLoginSuccessResponse>(
|
|
||||||
'/auth/login',
|
|
||||||
{ ...data, principalType: PrincipalType.ADMIN },
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logout = async (): Promise<void> => {
|
export const logout = async (): Promise<void> => {
|
||||||
await apiClient.post('/auth/logout', {});
|
await apiClient.post('/auth/logout', {}, true);
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminLogout = async (): Promise<void> => {
|
|
||||||
await apiClient.adminPost('/auth/logout', {});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -5,13 +5,6 @@ export const PrincipalType = {
|
|||||||
|
|
||||||
export type PrincipalType = typeof PrincipalType[keyof typeof PrincipalType];
|
export type PrincipalType = typeof PrincipalType[keyof typeof PrincipalType];
|
||||||
|
|
||||||
export const AdminType = {
|
|
||||||
HQ: 'HQ',
|
|
||||||
STORE: 'STORE',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type AdminType = typeof AdminType[keyof typeof AdminType];
|
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
account: string,
|
account: string,
|
||||||
password: string;
|
password: string;
|
||||||
@ -20,15 +13,6 @@ export interface LoginRequest {
|
|||||||
|
|
||||||
export interface LoginSuccessResponse {
|
export interface LoginSuccessResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserLoginSuccessResponse extends LoginSuccessResponse {
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminLoginSuccessResponse extends LoginSuccessResponse {
|
|
||||||
type: AdminType;
|
|
||||||
storeId: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentUserContext {
|
export interface CurrentUserContext {
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
export interface OperatorInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditInfo {
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
createdBy: OperatorInfo;
|
|
||||||
updatedBy: OperatorInfo;
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export interface OrderErrorResponse {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
trial: number;
|
|
||||||
}
|
|
||||||
@ -2,6 +2,7 @@ export interface PaymentConfirmRequest {
|
|||||||
paymentKey: string;
|
paymentKey: string;
|
||||||
orderId: string;
|
orderId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
paymentType: PaymentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentCancelRequest {
|
export interface PaymentCancelRequest {
|
||||||
|
|||||||
@ -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}`);
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
@ -4,7 +4,7 @@ import type {
|
|||||||
PendingReservationCreateRequest,
|
PendingReservationCreateRequest,
|
||||||
PendingReservationCreateResponse,
|
PendingReservationCreateResponse,
|
||||||
ReservationDetailRetrieveResponse,
|
ReservationDetailRetrieveResponse,
|
||||||
ReservationOverviewListResponse
|
ReservationSummaryRetrieveListResponse
|
||||||
} from './reservationTypes';
|
} from './reservationTypes';
|
||||||
|
|
||||||
export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => {
|
export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => {
|
||||||
@ -17,11 +17,11 @@ export const confirmReservation = async (reservationId: string): Promise<void> =
|
|||||||
|
|
||||||
|
|
||||||
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
|
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
|
||||||
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason });
|
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason }, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAllOverviewByUser = async (): Promise<ReservationOverviewListResponse> => {
|
export const fetchSummaryByMember = async (): Promise<ReservationSummaryRetrieveListResponse> => {
|
||||||
return await apiClient.get<ReservationOverviewListResponse>('/reservations/overview');
|
return await apiClient.get<ReservationSummaryRetrieveListResponse>('/reservations/summary');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => {
|
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => {
|
||||||
@ -29,5 +29,5 @@ export const fetchDetailById = async (reservationId: string): Promise<Reservatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const fetchMostReservedThemeIds = async (count: number = 10): Promise<MostReservedThemeIdListResponse> => {
|
export const fetchMostReservedThemeIds = async (count: number = 10): Promise<MostReservedThemeIdListResponse> => {
|
||||||
return await apiClient.get<MostReservedThemeIdListResponse>(`/reservations/popular-themes?count=${count}`);
|
return await apiClient.get<MostReservedThemeIdListResponse>(`/reservations/popular-themes?count=${count}`, false);
|
||||||
}
|
}
|
||||||
@ -1,24 +1,6 @@
|
|||||||
import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes";
|
import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes";
|
||||||
import type {UserContactRetrieveResponse} from "@_api/user/userTypes";
|
import type {UserContactRetrieveResponse} from "@_api/user/userTypes";
|
||||||
|
|
||||||
export interface ReservationData {
|
|
||||||
scheduleId: string;
|
|
||||||
store: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
theme: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
price: number;
|
|
||||||
minParticipants: number;
|
|
||||||
maxParticipants: number;
|
|
||||||
}
|
|
||||||
date: string; // "yyyy-MM-dd"
|
|
||||||
startFrom: string; // "HH:mm ~ HH:mm"
|
|
||||||
endAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReservationStatus = {
|
export const ReservationStatus = {
|
||||||
PENDING: 'PENDING',
|
PENDING: 'PENDING',
|
||||||
CONFIRMED: 'CONFIRMED',
|
CONFIRMED: 'CONFIRMED',
|
||||||
@ -46,38 +28,30 @@ export interface PendingReservationCreateResponse {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservationOverviewResponse {
|
export interface ReservationSummaryRetrieveResponse {
|
||||||
id: string;
|
id: string;
|
||||||
storeName: string;
|
|
||||||
themeName: string;
|
themeName: string;
|
||||||
date: string;
|
date: string;
|
||||||
startFrom: string;
|
startAt: string;
|
||||||
endAt: string;
|
|
||||||
status: ReservationStatus;
|
status: ReservationStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservationOverviewListResponse {
|
export interface ReservationSummaryRetrieveListResponse {
|
||||||
reservations: ReservationOverviewResponse[];
|
reservations: ReservationSummaryRetrieveResponse[];
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReserverInfo {
|
|
||||||
name: string;
|
|
||||||
contact: string;
|
|
||||||
participantCount: number;
|
|
||||||
requirement: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservationDetailRetrieveResponse {
|
export interface ReservationDetailRetrieveResponse {
|
||||||
id: string;
|
id: string;
|
||||||
reserver: ReserverInfo;
|
|
||||||
user: UserContactRetrieveResponse;
|
user: UserContactRetrieveResponse;
|
||||||
applicationDateTime: string;
|
applicationDateTime: string;
|
||||||
payment: PaymentRetrieveResponse;
|
payment: PaymentRetrieveResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservationDetail {
|
export interface ReservationDetail {
|
||||||
overview: ReservationOverviewResponse;
|
id: string;
|
||||||
reserver: ReserverInfo;
|
themeName: string;
|
||||||
|
date: string;
|
||||||
|
startAt: string;
|
||||||
user: UserContactRetrieveResponse;
|
user: UserContactRetrieveResponse;
|
||||||
applicationDateTime: string;
|
applicationDateTime: string;
|
||||||
payment?: PaymentRetrieveResponse;
|
payment?: PaymentRetrieveResponse;
|
||||||
|
|||||||
@ -1,55 +1,37 @@
|
|||||||
import apiClient from "@_api/apiClient";
|
import apiClient from '../apiClient';
|
||||||
import type {AuditInfo} from "@_api/common/commonTypes";
|
|
||||||
import type {
|
import type {
|
||||||
AdminScheduleSummaryListResponse,
|
AvailableThemeIdListResponse,
|
||||||
ScheduleCreateRequest,
|
ScheduleCreateRequest,
|
||||||
ScheduleCreateResponse,
|
ScheduleCreateResponse,
|
||||||
ScheduleUpdateRequest,
|
ScheduleDetailRetrieveResponse,
|
||||||
ScheduleWithThemeListResponse
|
ScheduleRetrieveListResponse,
|
||||||
} from "./scheduleTypes";
|
ScheduleUpdateRequest
|
||||||
|
} from './scheduleTypes';
|
||||||
|
|
||||||
// admin
|
export const findAvailableThemesByDate = async (date: string): Promise<AvailableThemeIdListResponse> => {
|
||||||
export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise<AdminScheduleSummaryListResponse> => {
|
return await apiClient.get<AvailableThemeIdListResponse>(`/schedules/themes?date=${date}`);
|
||||||
const queryParams: string[] = [];
|
};
|
||||||
|
|
||||||
if (date && date.trim() !== '') {
|
export const findSchedules = async (date: string, themeId: string): Promise<ScheduleRetrieveListResponse> => {
|
||||||
queryParams.push(`date=${date}`);
|
return await apiClient.get<ScheduleRetrieveListResponse>(`/schedules?date=${date}&themeId=${themeId}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
if (themeId && themeId.trim() !== '') {
|
export const findScheduleById = async (id: string): Promise<ScheduleDetailRetrieveResponse> => {
|
||||||
queryParams.push(`themeId=${themeId}`);
|
return await apiClient.get<ScheduleDetailRetrieveResponse>(`/schedules/${id}`);
|
||||||
}
|
|
||||||
|
|
||||||
// 기본 URL에 쿼리 파라미터 추가
|
|
||||||
const baseUrl = `/admin/stores/${storeId}/schedules`;
|
|
||||||
const fullUrl = queryParams.length > 0
|
|
||||||
? `${baseUrl}?${queryParams.join('&')}`
|
|
||||||
: baseUrl;
|
|
||||||
|
|
||||||
return await apiClient.adminGet<AdminScheduleSummaryListResponse>(fullUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchScheduleAudit = async (scheduleId: string): Promise<AuditInfo> => {
|
export const createSchedule = async (request: ScheduleCreateRequest): Promise<ScheduleCreateResponse> => {
|
||||||
return await apiClient.adminGet<AuditInfo>(`/admin/schedules/${scheduleId}/audits`);
|
return await apiClient.post<ScheduleCreateResponse>('/schedules', request);
|
||||||
}
|
|
||||||
|
|
||||||
export const createSchedule = async (storeId: string, request: ScheduleCreateRequest): Promise<ScheduleCreateResponse> => {
|
|
||||||
return await apiClient.adminPost<ScheduleCreateResponse>(`/admin/stores/${storeId}/schedules`, request);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise<void> => {
|
export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise<void> => {
|
||||||
return await apiClient.adminPatch<void>(`/admin/schedules/${id}`, request);
|
await apiClient.patch(`/schedules/${id}`, request);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteSchedule = async (id: string): Promise<void> => {
|
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> => {
|
export const holdSchedule = async (id: string): Promise<void> => {
|
||||||
return await apiClient.post<void>(`/schedules/${id}/hold`);
|
await apiClient.patch(`/schedules/${id}/hold`, {});
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchSchedules = async (storeId: string, date: string): Promise<ScheduleWithThemeListResponse> => {
|
|
||||||
return await apiClient.get<ScheduleWithThemeListResponse>(`/stores/${storeId}/schedules?date=${date}`);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,11 +7,24 @@ export const ScheduleStatus = {
|
|||||||
BLOCKED: 'BLOCKED' as 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 {
|
export interface ScheduleCreateRequest {
|
||||||
date: string;
|
date: string; // "yyyy-MM-dd"
|
||||||
|
time: string; // "HH:mm"
|
||||||
themeId: string;
|
themeId: string;
|
||||||
time: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleCreateResponse {
|
export interface ScheduleCreateResponse {
|
||||||
@ -25,48 +38,13 @@ export interface ScheduleUpdateRequest {
|
|||||||
status?: ScheduleStatus;
|
status?: ScheduleStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminScheduleSummaryResponse {
|
export interface ScheduleDetailRetrieveResponse {
|
||||||
id: string,
|
|
||||||
themeName: string,
|
|
||||||
startFrom: string,
|
|
||||||
endAt: string,
|
|
||||||
status: ScheduleStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminScheduleSummaryListResponse {
|
|
||||||
schedules: AdminScheduleSummaryResponse[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public
|
|
||||||
export interface ScheduleResponse {
|
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
date: string; // "yyyy-MM-dd"
|
||||||
startFrom: string;
|
time: string; // "HH:mm"
|
||||||
endAt: string;
|
|
||||||
status: ScheduleStatus;
|
status: ScheduleStatus;
|
||||||
}
|
createdAt: string; // or Date
|
||||||
|
createdBy: string;
|
||||||
export interface ScheduleThemeInfo {
|
updatedAt: string; // or Date
|
||||||
id: string;
|
updatedBy: 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[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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`, {});
|
|
||||||
};
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,48 +1,38 @@
|
|||||||
import apiClient from '@_api/apiClient';
|
import apiClient from '@_api/apiClient';
|
||||||
import type {
|
import type {
|
||||||
AdminThemeDetailResponse,
|
AdminThemeDetailRetrieveResponse,
|
||||||
AdminThemeSummaryListResponse,
|
AdminThemeSummaryRetrieveListResponse,
|
||||||
SimpleActiveThemeListResponse,
|
|
||||||
ThemeCreateRequest,
|
ThemeCreateRequest,
|
||||||
ThemeCreateResponse,
|
ThemeCreateResponse,
|
||||||
ThemeIdListResponse,
|
ThemeIdListResponse,
|
||||||
ThemeInfoListResponse,
|
ThemeInfoListResponse,
|
||||||
ThemeInfoResponse,
|
|
||||||
ThemeUpdateRequest
|
ThemeUpdateRequest
|
||||||
} from './themeTypes';
|
} from './themeTypes';
|
||||||
|
|
||||||
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryListResponse> => {
|
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryRetrieveListResponse> => {
|
||||||
return await apiClient.adminGet<AdminThemeSummaryListResponse>('/admin/themes');
|
return await apiClient.get<AdminThemeSummaryRetrieveListResponse>('/admin/themes');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailResponse> => {
|
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailRetrieveResponse> => {
|
||||||
return await apiClient.adminGet<AdminThemeDetailResponse>(`/admin/themes/${id}`);
|
return await apiClient.get<AdminThemeDetailRetrieveResponse>(`/admin/themes/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createTheme = async (themeData: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
|
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> => {
|
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> => {
|
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> => {
|
export const fetchUserThemes = async (): Promise<ThemeInfoListResponse> => {
|
||||||
return await apiClient.adminGet<SimpleActiveThemeListResponse>('/admin/themes/active');
|
return await apiClient.get<ThemeInfoListResponse>('/themes');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<ThemeInfoListResponse> => {
|
export const findThemesByIds = async (request: ThemeIdListResponse): Promise<ThemeInfoListResponse> => {
|
||||||
return await apiClient.post<ThemeInfoListResponse>('/themes/batch', request);
|
return await apiClient.post<ThemeInfoListResponse>('/themes/retrieve', 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}`);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,9 +1,20 @@
|
|||||||
import type { AuditInfo } from '@_api/common/commonTypes';
|
|
||||||
|
|
||||||
export interface AdminThemeDetailResponse {
|
export interface AdminThemeDetailResponse {
|
||||||
theme: ThemeInfoResponse;
|
id: string;
|
||||||
isActive: boolean;
|
name: string;
|
||||||
audit: AuditInfo
|
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 {
|
export interface ThemeCreateRequest {
|
||||||
@ -17,7 +28,7 @@ export interface ThemeCreateRequest {
|
|||||||
availableMinutes: number;
|
availableMinutes: number;
|
||||||
expectedMinutesFrom: number;
|
expectedMinutesFrom: number;
|
||||||
expectedMinutesTo: number;
|
expectedMinutesTo: number;
|
||||||
isActive: boolean;
|
isOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThemeCreateResponse {
|
export interface ThemeCreateResponse {
|
||||||
@ -35,19 +46,38 @@ export interface ThemeUpdateRequest {
|
|||||||
availableMinutes?: number;
|
availableMinutes?: number;
|
||||||
expectedMinutesFrom?: number;
|
expectedMinutesFrom?: number;
|
||||||
expectedMinutesTo?: number;
|
expectedMinutesTo?: number;
|
||||||
isActive?: boolean;
|
isOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminThemeSummaryResponse {
|
export interface AdminThemeSummaryRetrieveResponse {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
difficulty: Difficulty;
|
difficulty: Difficulty;
|
||||||
price: number;
|
price: number;
|
||||||
isActive: boolean;
|
isOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminThemeSummaryListResponse {
|
export interface AdminThemeSummaryRetrieveListResponse {
|
||||||
themes: AdminThemeSummaryResponse[];
|
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 {
|
export interface ThemeInfoResponse {
|
||||||
@ -72,34 +102,18 @@ export interface ThemeIdListResponse {
|
|||||||
themeIds: string[];
|
themeIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
export enum Difficulty {
|
export enum Difficulty {
|
||||||
VERY_EASY = 'VERY_EASY',
|
VERY_EASY = '매우 쉬움',
|
||||||
EASY = 'EASY',
|
EASY = '쉬움',
|
||||||
NORMAL = 'NORMAL',
|
NORMAL = '보통',
|
||||||
HARD = 'HARD',
|
HARD = '어려움',
|
||||||
VERY_HARD = 'VERY_HARD',
|
VERY_HARD = '매우 어려움',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DifficultyKoreanMap: Record<Difficulty, string> = {
|
|
||||||
[Difficulty.VERY_EASY]: '매우 쉬움',
|
|
||||||
[Difficulty.EASY]: '쉬움',
|
|
||||||
[Difficulty.NORMAL]: '보통',
|
|
||||||
[Difficulty.HARD]: '어려움',
|
|
||||||
[Difficulty.VERY_HARD]: '매우 어려움',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function mapThemeResponse(res: any): ThemeInfoResponse {
|
export function mapThemeResponse(res: any): ThemeInfoResponse {
|
||||||
return {
|
return {
|
||||||
...res,
|
...res,
|
||||||
difficulty: Difficulty[res.difficulty as keyof typeof Difficulty],
|
difficulty: Difficulty[res.difficulty as keyof typeof Difficulty],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimpleActiveThemeResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SimpleActiveThemeListResponse {
|
|
||||||
themes: SimpleActiveThemeResponse[];
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import apiClient from "@_api/apiClient";
|
|||||||
import type {UserContactRetrieveResponse, UserCreateRequest, UserCreateResponse} from "./userTypes";
|
import type {UserContactRetrieveResponse, UserCreateRequest, UserCreateResponse} from "./userTypes";
|
||||||
|
|
||||||
export const signup = async (data: UserCreateRequest): Promise<UserCreateResponse> => {
|
export const signup = async (data: UserCreateRequest): Promise<UserCreateResponse> => {
|
||||||
return await apiClient.post('/users', data);
|
return await apiClient.post('/users', data, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchContact = async (): Promise<UserContactRetrieveResponse> => {
|
export const fetchContact = async (): Promise<UserContactRetrieveResponse> => {
|
||||||
return await apiClient.get<UserContactRetrieveResponse>('/users/contact');
|
return await apiClient.get<UserContactRetrieveResponse>('/users/contact', true);
|
||||||
}
|
}
|
||||||
|
|||||||
28
frontend/src/components/AdminRoute.tsx
Normal file
28
frontend/src/components/AdminRoute.tsx
Normal 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;
|
||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -1,13 +1,15 @@
|
|||||||
import { logout as apiLogout, userLogin as apiLogin } from '@_api/auth/authAPI';
|
import {checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout} from '@_api/auth/authAPI';
|
||||||
import { type LoginRequest, type UserLoginSuccessResponse } from '@_api/auth/authTypes';
|
import {type LoginRequest, type LoginSuccessResponse, PrincipalType} from '@_api/auth/authTypes';
|
||||||
import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react';
|
import React, {createContext, type ReactNode, useContext, useEffect, useState} from 'react';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
userName: string | null;
|
userName: string | null;
|
||||||
|
type: PrincipalType | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (data: Omit<LoginRequest, 'principalType'>) => Promise<UserLoginSuccessResponse>;
|
login: (data: LoginRequest) => Promise<LoginSuccessResponse>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
checkLogin: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
@ -15,33 +17,33 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const [loggedIn, setLoggedIn] = useState(false);
|
const [loggedIn, setLoggedIn] = useState(false);
|
||||||
const [userName, setUserName] = useState<string | null>(null);
|
const [userName, setUserName] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [type, setType] = useState<PrincipalType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true); // Add loading state
|
||||||
|
|
||||||
useEffect(() => {
|
const checkLogin = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken');
|
const response = await apiCheckLogin();
|
||||||
const storedUserName = localStorage.getItem('userName');
|
|
||||||
|
|
||||||
if (token && storedUserName) {
|
|
||||||
setLoggedIn(true);
|
|
||||||
setUserName(storedUserName);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load user auth state from storage", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = async (data: Omit<LoginRequest, 'principalType'>) => {
|
|
||||||
const response = await apiLogin(data);
|
|
||||||
|
|
||||||
localStorage.setItem('accessToken', response.accessToken);
|
|
||||||
localStorage.setItem('userName', response.name);
|
|
||||||
|
|
||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
setUserName(response.name);
|
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(() => {
|
||||||
|
checkLogin();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (data: LoginRequest) => {
|
||||||
|
const response = await apiLogin({ ...data });
|
||||||
|
setLoggedIn(true);
|
||||||
|
setType(data.principalType);
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -49,15 +51,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
try {
|
try {
|
||||||
await apiLogout();
|
await apiLogout();
|
||||||
} finally {
|
} finally {
|
||||||
localStorage.removeItem('accessToken');
|
|
||||||
localStorage.removeItem('userName');
|
|
||||||
setLoggedIn(false);
|
setLoggedIn(false);
|
||||||
setUserName(null);
|
setUserName(null);
|
||||||
|
setType(null);
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ loggedIn, userName, loading, login, logout }}>
|
<AuthContext.Provider value={{ loggedIn, userName, type, loading, login, logout, checkLogin }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
/* New CSS content */
|
|
||||||
.admin-schedule-container {
|
.admin-schedule-container {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
font-size: 0.95rem; /* Slightly smaller base font */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 1.8rem; /* smaller */
|
font-size: 2rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -20,7 +18,7 @@
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
align-items: flex-end; /* Align to bottom */
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-controls .form-group {
|
.schedule-controls .form-group {
|
||||||
@ -28,29 +26,18 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Width adjustments */
|
|
||||||
.schedule-controls .store-selector-group,
|
|
||||||
.schedule-controls .date-selector-group {
|
|
||||||
flex: 1 1 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-controls .theme-selector-group {
|
|
||||||
flex: 2 1 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.schedule-controls .form-label {
|
.schedule-controls .form-label {
|
||||||
font-size: 0.85rem; /* smaller */
|
font-size: 0.9rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-controls .form-input,
|
.schedule-controls .form-input,
|
||||||
.schedule-controls .form-select {
|
.schedule-controls .form-select {
|
||||||
padding: 0.6rem; /* smaller */
|
padding: 0.75rem;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.9rem; /* smaller */
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-card {
|
.section-card {
|
||||||
@ -76,11 +63,10 @@ table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
padding: 0.8rem; /* smaller */
|
padding: 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eee;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
font-size: 0.9rem; /* smaller */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
@ -89,11 +75,11 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.4rem 0.8rem; /* smaller */
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem; /* smaller */
|
font-size: 0.9rem;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@ -188,8 +174,8 @@ th {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
height: auto; /* remove fixed height */
|
height: 3rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box; /* Ensures padding/border are included in height */
|
||||||
}
|
}
|
||||||
|
|
||||||
.details-form-container .button-group {
|
.details-form-container .button-group {
|
||||||
@ -204,7 +190,7 @@ th {
|
|||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem; /* Add margin to separate from buttons */
|
||||||
}
|
}
|
||||||
|
|
||||||
.audit-title {
|
.audit-title {
|
||||||
@ -226,95 +212,3 @@ th {
|
|||||||
color: #212529;
|
color: #212529;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-selector-button-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row !important;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-selector-button-group .form-select {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal Styles */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
padding: 2rem !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important;
|
|
||||||
width: 90% !important;
|
|
||||||
max-width: 600px !important;
|
|
||||||
position: relative !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-modal-thumbnail {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 300px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-modal-description {
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #555;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-details-button {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-mode-buttons {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Added for modal info alignment */
|
|
||||||
.modal-info-grid p {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin: 0.6rem 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.modal-info-grid p strong {
|
|
||||||
flex: 0 0 130px; /* fixed width for labels */
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.modal-info-grid p span {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -81,15 +81,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-modal-content {
|
.theme-modal-content {
|
||||||
background-color: #ffffff !important;
|
background-color: #ffffff;
|
||||||
padding: 30px !important;
|
padding: 30px;
|
||||||
border-radius: 16px !important;
|
border-radius: 16px;
|
||||||
width: 90% !important;
|
width: 90%;
|
||||||
max-width: 600px !important;
|
max-width: 600px;
|
||||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2) !important;
|
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
|
||||||
display: flex !important;
|
display: flex;
|
||||||
flex-direction: column !important;
|
flex-direction: column;
|
||||||
gap: 20px !important;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-thumbnail {
|
.modal-thumbnail {
|
||||||
@ -163,18 +163,3 @@
|
|||||||
.modal-button.close:hover {
|
.modal-button.close:hover {
|
||||||
background-color: #5a6268;
|
background-color: #5a6268;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Added for modal info alignment */
|
|
||||||
.modal-info-grid p {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin: 0.6rem 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.modal-info-grid p strong {
|
|
||||||
flex: 0 0 130px; /* fixed width for labels */
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.modal-info-grid p span {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -49,24 +49,10 @@
|
|||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-subdetails-v2 {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0px;
|
|
||||||
gap: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-store-name-v2 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #505a67;
|
|
||||||
margin: 0 0 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-details-v2 {
|
.summary-details-v2 {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-theme-name-v2 {
|
.summary-theme-name-v2 {
|
||||||
@ -79,15 +65,15 @@
|
|||||||
.summary-datetime-v2 {
|
.summary-datetime-v2 {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #505a67;
|
color: #505a67;
|
||||||
margin-bottom: 5px;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Status Badge --- */
|
/* --- Status Badge --- */
|
||||||
.card-status-badge {
|
.card-status-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 30px;
|
top: 15px;
|
||||||
right: 10px;
|
right: 15px;
|
||||||
padding: 3px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@ -191,16 +177,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content-v2 {
|
.modal-content-v2 {
|
||||||
background: #ffffff !important;
|
background: #ffffff;
|
||||||
padding: 30px !important;
|
padding: 30px;
|
||||||
border-radius: 16px !important;
|
border-radius: 16px;
|
||||||
width: 90% !important;
|
width: 90%;
|
||||||
max-width: 500px !important;
|
max-width: 500px;
|
||||||
position: relative !important;
|
position: relative;
|
||||||
box-shadow: 0 5px 15px rgba(0,0,0,0.3) !important;
|
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
||||||
animation: slide-up 0.3s ease-out !important;
|
animation: slide-up 0.3s ease-out;
|
||||||
max-height: 90vh !important; /* Prevent modal from being too tall */
|
max-height: 90vh; /* Prevent modal from being too tall */
|
||||||
overflow-y: auto !important; /* Allow scrolling for long content */
|
overflow-y: auto; /* Allow scrolling for long content */
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
@ -254,6 +240,13 @@
|
|||||||
color: #505a67;
|
color: #505a67;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-section-v2 p strong {
|
||||||
|
color: #333d4b;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 100px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
.cancellation-section-v2 {
|
.cancellation-section-v2 {
|
||||||
background-color: #fcf2f2;
|
background-color: #fcf2f2;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
@ -353,18 +346,3 @@
|
|||||||
border-color: #007bff;
|
border-color: #007bff;
|
||||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Added for modal info alignment */
|
|
||||||
.modal-info-grid p {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin: 0.6rem 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.modal-info-grid p strong {
|
|
||||||
flex: 0 0 130px; /* fixed width for labels */
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.modal-info-grid p span {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,43 +1,43 @@
|
|||||||
/* General Container */
|
/* General Container */
|
||||||
.reservation-v21-container {
|
.reservation-v21-container {
|
||||||
width: 100%;
|
padding: 40px;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 2rem auto;
|
margin: 40px auto;
|
||||||
padding: 2rem;
|
background-color: #ffffff;
|
||||||
font-family: 'Pretendard', sans-serif;
|
border-radius: 16px;
|
||||||
background-color: #fff;
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
|
||||||
border-radius: 12px;
|
font-family: 'Toss Product Sans', sans-serif;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
color: #333D4B;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
text-align: center;
|
font-size: 28px;
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 40px;
|
||||||
color: #212529;
|
color: #191F28;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Step Section */
|
/* Step Sections */
|
||||||
.step-section {
|
.step-section {
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 40px;
|
||||||
padding: 1.5rem;
|
padding: 24px;
|
||||||
border: 1px solid #f1f3f5;
|
border: 1px solid #E5E8EB;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
background-color: #f8f9fa;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-section.disabled {
|
.step-section.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
background-color: #F9FAFB;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-section h3 {
|
.step-section h3 {
|
||||||
font-size: 1.5rem;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-top: 0;
|
margin-bottom: 20px;
|
||||||
margin-bottom: 1.5rem;
|
color: #191F28;
|
||||||
color: #343a40;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Date Carousel */
|
/* Date Carousel */
|
||||||
@ -45,241 +45,274 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 1rem;
|
gap: 10px;
|
||||||
}
|
margin: 20px 0;
|
||||||
|
|
||||||
.carousel-arrow {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 2rem;
|
|
||||||
color: #868e96;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-options-container {
|
.date-options-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: hidden;
|
||||||
-ms-overflow-style: none;
|
flex-grow: 1;
|
||||||
scrollbar-width: none;
|
justify-content: space-between;
|
||||||
|
margin: 0px 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-options-container::-webkit-scrollbar {
|
.carousel-arrow, .today-button {
|
||||||
display: none;
|
background-color: #F2F4F6;
|
||||||
}
|
border: 1px solid #E5E8EB;
|
||||||
|
|
||||||
.date-option {
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 60px;
|
width: 36px;
|
||||||
height: 60px;
|
height: 36px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4E5968;
|
||||||
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: background-color 0.3s, color 0.3s;
|
justify-content: center;
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
transition: background-color 0.2s;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-button {
|
.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;
|
border-radius: 8px;
|
||||||
background-color: #fff;
|
font-size: 14px;
|
||||||
font-size: 1rem;
|
font-weight: 600;
|
||||||
|
width: auto;
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow:hover, .today-button:hover {
|
||||||
|
background-color: #E5E8EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s;
|
padding: 8px;
|
||||||
-webkit-appearance: none;
|
border-radius: 8px;
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23868e96%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E');
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right .7em top 50%;
|
|
||||||
background-size: .65em auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.region-store-selectors select:disabled {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
cursor: not-allowed;
|
|
||||||
color: #adb5bd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.region-store-selectors select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #0064FF;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Schedule List --- */
|
|
||||||
.schedule-list {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-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;
|
align-items: center;
|
||||||
padding-bottom: 1rem;
|
justify-content: center;
|
||||||
margin-bottom: 1rem;
|
border: 1px solid transparent;
|
||||||
border-bottom: 1px solid #f1f3f5;
|
transition: all 0.3s ease;
|
||||||
|
width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-header h4 {
|
.date-option:hover {
|
||||||
margin: 0;
|
background-color: #f0f0f0;
|
||||||
font-size: 1.25rem;
|
}
|
||||||
|
|
||||||
|
.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;
|
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 {
|
.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%;
|
width: 100%;
|
||||||
max-width: 400px;
|
padding: 8px;
|
||||||
padding: 1rem;
|
font-size: 14px;
|
||||||
font-size: 1.2rem;
|
font-weight: 600;
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
background-color: #0064FF;
|
|
||||||
border: none;
|
border: none;
|
||||||
|
background-color: #F2F4F6;
|
||||||
|
color: #4E5968;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.next-step-button:hover:not(:disabled) {
|
.theme-detail-button:hover {
|
||||||
background-color: #0053d1;
|
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 {
|
.next-step-button:disabled {
|
||||||
background-color: #a0a0a0;
|
background-color: #B0B8C1;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.next-step-button:hover:not(:disabled) {
|
||||||
|
background-color: #1B64DA;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Modal Styles --- */
|
/* Modal Styles */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -295,158 +328,170 @@
|
|||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background-color: #ffffff !important;
|
background-color: #ffffff !important;
|
||||||
padding: 2rem !important;
|
padding: 32px !important;
|
||||||
border-radius: 12px !important;
|
border-radius: 16px !important;
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important;
|
|
||||||
width: 90% !important;
|
width: 90% !important;
|
||||||
max-width: 500px !important;
|
max-width: 500px !important;
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
max-height: 90vh !important;
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1) !important;
|
||||||
overflow-y: auto !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close-button {
|
.modal-close-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 1rem;
|
top: 16px;
|
||||||
right: 1rem;
|
right: 16px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1.5rem;
|
font-size: 24px;
|
||||||
color: #868e96;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
color: #8A94A2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-theme-thumbnail {
|
.modal-theme-thumbnail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content h2 {
|
.modal-content h2 {
|
||||||
margin-top: 0;
|
font-size: 24px;
|
||||||
margin-bottom: 2rem;
|
font-weight: 700;
|
||||||
text-align: center;
|
margin-bottom: 24px;
|
||||||
|
color: #191F28;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-section {
|
.modal-section {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 20px;
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
border-bottom: 1px solid #f1f3f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-section:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-section h3 {
|
.modal-section h3 {
|
||||||
margin-top: 0;
|
font-size: 18px;
|
||||||
margin-bottom: 1rem;
|
font-weight: 600;
|
||||||
font-size: 1.1rem;
|
margin-bottom: 12px;
|
||||||
color: #495057;
|
border-bottom: 1px solid #E5E8EB;
|
||||||
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-section p {
|
.modal-section p {
|
||||||
margin: 0.5rem 0;
|
font-size: 16px;
|
||||||
color: #495057;
|
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section p strong {
|
||||||
|
color: #333D4B;
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 1rem;
|
gap: 12px;
|
||||||
margin-top: 2rem;
|
margin-top: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions .cancel-button,
|
.modal-actions button {
|
||||||
.modal-actions .confirm-button {
|
padding: 12px 24px;
|
||||||
padding: 0.75rem 1.5rem;
|
font-size: 16px;
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions .cancel-button {
|
.modal-actions .cancel-button {
|
||||||
background-color: #f1f3f5;
|
background-color: #E5E8EB;
|
||||||
color: #495057;
|
color: #4E5968;
|
||||||
|
}
|
||||||
|
.modal-actions .cancel-button:hover {
|
||||||
|
background-color: #D1D6DB;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions .confirm-button {
|
.modal-actions .confirm-button {
|
||||||
background-color: #0064FF;
|
background-color: #3182F6;
|
||||||
color: #fff;
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.modal-actions .confirm-button:hover {
|
||||||
|
background-color: #1B64DA;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Form Styles for ReservationFormPage --- */
|
/* Styles for ReservationFormPage */
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.5rem;
|
font-weight: bold;
|
||||||
font-weight: 600;
|
margin-bottom: 8px;
|
||||||
color: #495057;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="tel"],
|
||||||
|
.form-group input[type="number"],
|
||||||
|
.form-group textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 12px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ccc;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
font-size: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Success Page */
|
.form-group input:focus, .form-group textarea:focus {
|
||||||
.success-icon {
|
outline: none;
|
||||||
font-size: 4rem;
|
border-color: #3182F6;
|
||||||
color: #0064FF;
|
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-page-actions {
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 2.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-page-actions .action-button {
|
.participant-control input {
|
||||||
padding: 0.8rem 1.6rem;
|
text-align: center;
|
||||||
border-radius: 8px;
|
border-left: none;
|
||||||
text-decoration: none;
|
border-right: none;
|
||||||
font-size: 1rem;
|
width: 60px;
|
||||||
font-weight: 600;
|
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;
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-page-actions .action-button.secondary {
|
.participant-control button:hover:not(:disabled) {
|
||||||
background-color: #f1f3f5;
|
background-color: #e0e0e0;
|
||||||
color: #495057;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-page-actions .action-button:not(.secondary) {
|
.participant-control button:disabled {
|
||||||
background-color: #0064FF;
|
background-color: #e9ecef;
|
||||||
color: #fff;
|
cursor: not-allowed;
|
||||||
|
color: #aaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Added for modal info alignment */
|
.participant-control button:first-of-type {
|
||||||
.modal-info-grid p {
|
border-radius: 8px 0 0 8px;
|
||||||
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 */
|
.participant-control button:last-of-type {
|
||||||
font-weight: 600;
|
border-radius: 0 8px 8px 0;
|
||||||
}
|
|
||||||
.modal-info-grid p span {
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,12 +69,3 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-select-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.region-select-group select {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
|
import {fetchMostReservedThemeIds} from '@_api/reservation/reservationAPI';
|
||||||
import '@_css/home-page-v2.css';
|
import '@_css/home-page-v2.css';
|
||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {useNavigate} from 'react-router-dom';
|
import {useNavigate} from 'react-router-dom';
|
||||||
import {fetchMostReservedThemes} from '@_api/theme/themeAPI';
|
import {findThemesByIds} from '@_api/theme/themeAPI';
|
||||||
import {DifficultyKoreanMap, mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||||
|
|
||||||
const HomePage: React.FC = () => {
|
const HomePage: React.FC = () => {
|
||||||
const [ranking, setRanking] = useState<ThemeInfoResponse[]>([]);
|
const [ranking, setRanking] = useState<ThemeInfoResponse[]>([]);
|
||||||
@ -12,8 +13,19 @@ const HomePage: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const themeFetchCount = 10;
|
const themeIds = await fetchMostReservedThemeIds().then(res => {
|
||||||
const response = await fetchMostReservedThemes(themeFetchCount);
|
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));
|
setRanking(response.themes.map(mapThemeResponse));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching ranking:', err);
|
console.error('Error fetching ranking:', err);
|
||||||
@ -59,12 +71,11 @@ const HomePage: React.FC = () => {
|
|||||||
<div className="modal-theme-info">
|
<div className="modal-theme-info">
|
||||||
<h2>{selectedTheme.name}</h2>
|
<h2>{selectedTheme.name}</h2>
|
||||||
<p>{selectedTheme.description}</p>
|
<p>{selectedTheme.description}</p>
|
||||||
<div className="theme-details modal-info-grid">
|
<div className="theme-details">
|
||||||
<p><strong>난이도:</strong><span>{DifficultyKoreanMap[selectedTheme.difficulty]}</span></p>
|
<p><strong>난이도:</strong> {selectedTheme.difficulty}</p>
|
||||||
<p><strong>이용 가능 인원:</strong><span>{selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</span></p>
|
<p><strong>가격:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
||||||
<p><strong>1인당 요금:</strong><span>{selectedTheme.price.toLocaleString()}원</span></p>
|
<p><strong>예상 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
||||||
<p><strong>예상 시간:</strong><span>{selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</span></p>
|
<p><strong>이용 가능 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
||||||
<p><strong>이용 가능 시간:</strong><span>{selectedTheme.availableMinutes}분</span></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-buttons">
|
<div className="modal-buttons">
|
||||||
|
|||||||
@ -15,11 +15,11 @@ const LoginPage: React.FC = () => {
|
|||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
await login({ account: email, password: password });
|
const principalType = from.startsWith('/admin') ? 'ADMIN' : 'USER';
|
||||||
|
await login({ account: email, password: password, principalType: principalType });
|
||||||
|
|
||||||
alert('로그인에 성공했어요!');
|
alert('로그인에 성공했어요!');
|
||||||
const redirectTo = from.startsWith('/admin') ? '/' : from;
|
navigate(from, { replace: true });
|
||||||
navigate(redirectTo, { replace: true });
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.message || '로그인에 실패했어요. 이메일과 비밀번호를 확인해주세요.';
|
const message = error.response?.data?.message || '로그인에 실패했어요. 이메일과 비밀번호를 확인해주세요.';
|
||||||
alert(message);
|
alert(message);
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
import { cancelPayment } from '@_api/payment/paymentAPI';
|
import {cancelPayment} from '@_api/payment/paymentAPI';
|
||||||
import type { PaymentRetrieveResponse } from '@_api/payment/PaymentTypes';
|
import type {PaymentRetrieveResponse} from '@_api/payment/PaymentTypes';
|
||||||
import { cancelReservation, fetchDetailById, fetchAllOverviewByUser } from '@_api/reservation/reservationAPI';
|
import {cancelReservation, fetchDetailById, fetchSummaryByMember} from '@_api/reservation/reservationAPI';
|
||||||
import {
|
import {
|
||||||
ReservationStatus,
|
|
||||||
type ReservationDetail,
|
type ReservationDetail,
|
||||||
type ReservationOverviewResponse
|
ReservationStatus,
|
||||||
|
type ReservationSummaryRetrieveResponse
|
||||||
} from '@_api/reservation/reservationTypes';
|
} from '@_api/reservation/reservationTypes';
|
||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
import '@_css/my-reservation-v2.css';
|
import '@_css/my-reservation-v2.css';
|
||||||
import { formatDate, formatDisplayDateTime, formatTime } from '@_util/DateTimeFormatter';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
const getReservationStatus = (reservation: ReservationOverviewResponse): { className: string, text: string } => {
|
const getReservationStatus = (reservation: ReservationSummaryRetrieveResponse): { className: string, text: string } => {
|
||||||
const now = new Date();
|
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) {
|
switch (reservation.status) {
|
||||||
case ReservationStatus.CANCELED:
|
case ReservationStatus.CANCELED:
|
||||||
@ -29,6 +28,75 @@ const getReservationStatus = (reservation: ReservationOverviewResponse): { class
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDisplayDateTime = (dateTime: any): string => {
|
||||||
|
let date: Date;
|
||||||
|
|
||||||
|
if (typeof dateTime === 'string') {
|
||||||
|
// ISO 문자열 형식 처리 (LocalDateTime, OffsetDateTime 모두 포함)
|
||||||
|
date = new Date(dateTime);
|
||||||
|
} else if (typeof dateTime === 'number') {
|
||||||
|
// Unix 타임스탬프(초) 형식 처리
|
||||||
|
date = new Date(dateTime * 1000);
|
||||||
|
} else if (Array.isArray(dateTime) && dateTime.length >= 6) {
|
||||||
|
// 배열 형식 처리: [year, month, day, hour, minute, second, nanosecond?]
|
||||||
|
const year = dateTime[0];
|
||||||
|
const month = dateTime[1] - 1; // JS Date의 월은 0부터 시작
|
||||||
|
const day = dateTime[2];
|
||||||
|
const hour = dateTime[3];
|
||||||
|
const minute = dateTime[4];
|
||||||
|
const second = dateTime[5];
|
||||||
|
const millisecond = dateTime.length > 6 ? Math.floor(dateTime[6] / 1000000) : 0;
|
||||||
|
date = new Date(year, month, day, hour, minute, second, millisecond);
|
||||||
|
} else {
|
||||||
|
return '유효하지 않은 날짜 형식';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return '유효하지 않은 날짜';
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
hour12: true,
|
||||||
|
second: 'numeric'
|
||||||
|
};
|
||||||
|
return new Intl.DateTimeFormat('ko-KR', options).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCardDateTime = (dateStr: string, timeStr: string): string => {
|
||||||
|
const date = new Date(`${dateStr}T${timeStr}`);
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const reservationYear = date.getFullYear();
|
||||||
|
|
||||||
|
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
const dayOfWeek = days[date.getDay()];
|
||||||
|
const month = date.getMonth() + 1;
|
||||||
|
const day = date.getDate();
|
||||||
|
let hours = date.getHours();
|
||||||
|
const minutes = date.getMinutes();
|
||||||
|
const ampm = hours >= 12 ? '오후' : '오전';
|
||||||
|
hours = hours % 12;
|
||||||
|
hours = hours ? hours : 12;
|
||||||
|
|
||||||
|
let datePart = '';
|
||||||
|
if (currentYear === reservationYear) {
|
||||||
|
datePart = `${month}월 ${day}일(${dayOfWeek})`;
|
||||||
|
} else {
|
||||||
|
datePart = `${reservationYear}년 ${month}월 ${day}일(${dayOfWeek})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timePart = `${ampm} ${hours}시`;
|
||||||
|
if (minutes !== 0) {
|
||||||
|
timePart += ` ${minutes}분`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${datePart} ${timePart}`;
|
||||||
|
};
|
||||||
|
|
||||||
// --- Cancellation View Component ---
|
// --- Cancellation View Component ---
|
||||||
const CancellationView: React.FC<{
|
const CancellationView: React.FC<{
|
||||||
reservation: ReservationDetail;
|
reservation: ReservationDetail;
|
||||||
@ -49,10 +117,10 @@ const CancellationView: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<div className="cancellation-view-v2">
|
<div className="cancellation-view-v2">
|
||||||
<h3>취소 정보 확인</h3>
|
<h3>취소 정보 확인</h3>
|
||||||
<div className="cancellation-summary-v2 modal-info-grid">
|
<div className="cancellation-summary-v2">
|
||||||
<p><strong>테마:</strong><span>{reservation.overview.themeName}</span></p>
|
<p><strong>테마:</strong> {reservation.themeName}</p>
|
||||||
<p><strong>신청 일시:</strong><span>{formatDisplayDateTime(reservation.applicationDateTime)}</span></p>
|
<p><strong>신청 일시:</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
|
||||||
{reservation.payment && <p><strong>결제 금액:</strong><span>{reservation.payment.totalAmount.toLocaleString()}원</span></p>}
|
{reservation.payment && <p><strong>결제 금액:</strong> {reservation.payment.totalAmount.toLocaleString()}원</p>}
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={reason}
|
value={reason}
|
||||||
@ -89,33 +157,33 @@ const ReservationDetailView: React.FC<{
|
|||||||
<>
|
<>
|
||||||
{payment.totalAmount !== detail.amount && (
|
{payment.totalAmount !== detail.amount && (
|
||||||
<>
|
<>
|
||||||
<p><strong>승인 금액:</strong><span>{detail.amount.toLocaleString()}원</span></p>
|
<p><strong>(카드)승인 금액:</strong> {detail.amount.toLocaleString()}원</p>
|
||||||
{detail.easypayDiscountAmount && (
|
{detail.easypayDiscountAmount && (
|
||||||
<p><strong>할인 금액:</strong><span>{detail.easypayDiscountAmount.toLocaleString()}원</span></p>
|
<p><strong>(간편결제)할인 금액:</strong> {detail.easypayDiscountAmount.toLocaleString()}원</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{detail.easypayProviderName && (
|
{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> {detail.issuerCode}({detail.ownerType}) / {detail.cardType}</p>
|
||||||
<p><strong>카드 번호:</strong><span>{detail.cardNumber}</span></p>
|
<p><strong>카드 번호:</strong> {detail.cardNumber}</p>
|
||||||
<p><strong>할부:</strong><span>{detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</span></p>
|
<p><strong>할부 방식:</strong> {detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</p>
|
||||||
<p><strong>승인 번호:</strong><span>{detail.approvalNumber}</span></p>
|
<p><strong>승인 번호:</strong> {detail.approvalNumber}</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case 'BANK_TRANSFER':
|
case 'BANK_TRANSFER':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p><strong>은행:</strong><span>{detail.bankName}</span></p>
|
<p><strong>은행:</strong> {detail.bankName}</p>
|
||||||
<p><strong>정산 상태:</strong><span>{detail.settlementStatus}</span></p>
|
<p><strong>정산 상태:</strong> {detail.settlementStatus}</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case 'EASYPAY_PREPAID':
|
case 'EASYPAY_PREPAID':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p><strong>결제 금액:</strong><span>{detail.amount.toLocaleString()}원</span></p>
|
<p><strong>결제 금액:</strong> {detail.amount.toLocaleString()}원</p>
|
||||||
{detail.discountAmount > 0 && <p><strong>포인트 사용:</strong><span>{detail.discountAmount.toLocaleString()}원</span></p>}
|
{detail.discountAmount > 0 && <p><strong>포인트 사용:</strong> {detail.discountAmount.toLocaleString()}원</p>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@ -125,14 +193,13 @@ const ReservationDetailView: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="modal-section-v2 modal-info-grid">
|
<div className="modal-section-v2">
|
||||||
<h3>예약 정보</h3>
|
<h3>예약 정보</h3>
|
||||||
<p><strong>매장:</strong><span>{reservation.overview.storeName}</span></p>
|
<p><strong>예약 테마:</strong> {reservation.themeName}</p>
|
||||||
<p><strong>테마:</strong><span>{reservation.overview.themeName}</span></p>
|
<p><strong>이용 예정일:</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p>
|
||||||
<p><strong>이용일시:</strong><span>{formatDate(reservation.overview.date)} {formatTime(reservation.overview.startFrom)} ~ {formatTime(reservation.overview.endAt)}</span></p>
|
<p><strong>예약자 이름:</strong> {reservation.user.name}</p>
|
||||||
<p><strong>예약자 성함:</strong><span>{reservation.reserver.name}</span></p>
|
<p><strong>예약자 이메일:</strong> {reservation.user.phone}</p>
|
||||||
<p><strong>예약자 연락처:</strong><span>{reservation.reserver.contact}</span></p>
|
<p><strong>예약 신청 일시:</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
|
||||||
<p><strong>예약 신청 일시:</strong><span>{formatDisplayDateTime(reservation.applicationDateTime)}</span></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!reservation.payment ? (
|
{!reservation.payment ? (
|
||||||
@ -142,14 +209,14 @@ const ReservationDetailView: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="modal-section-v2 modal-info-grid">
|
<div className="modal-section-v2">
|
||||||
<h3>결제 정보</h3>
|
<h3>결제 정보</h3>
|
||||||
<p><strong>주문 ID:</strong><span>{reservation.payment.orderId}</span></p>
|
<p><strong>주문 ID:</strong> {reservation.payment.orderId}</p>
|
||||||
<p><strong>총 결제액:</strong><span>{reservation.payment.totalAmount.toLocaleString()}원</span></p>
|
<p><strong>총 결제액:</strong> {reservation.payment.totalAmount.toLocaleString()}원</p>
|
||||||
<p><strong>결제 수단:</strong><span>{reservation.payment.method}</span></p>
|
<p><strong>결제 수단:</strong> {reservation.payment.method}</p>
|
||||||
{reservation.payment.approvedAt && <p><strong>결제 승인 일시:</strong><span>{formatDisplayDateTime(reservation.payment.approvedAt)}</span></p>}
|
{reservation.payment.approvedAt && <p><strong>결제 승인 일시:</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-section-v2 modal-info-grid">
|
<div className="modal-section-v2">
|
||||||
<h3>결제 상세 정보</h3>
|
<h3>결제 상세 정보</h3>
|
||||||
{renderPaymentSubDetails(reservation.payment)}
|
{renderPaymentSubDetails(reservation.payment)}
|
||||||
</div>
|
</div>
|
||||||
@ -157,12 +224,12 @@ const ReservationDetailView: React.FC<{
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{reservation.payment && reservation.payment.cancel && (
|
{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>
|
<h3>취소 정보</h3>
|
||||||
<p><strong>취소 요청 일시:</strong><span>{formatDisplayDateTime(reservation.payment.cancel.cancellationRequestedAt)}</span></p>
|
<p><strong>취소 요청 일시:</strong> {formatDisplayDateTime(reservation.payment.cancel.cancellationRequestedAt)}</p>
|
||||||
<p><strong>환불 완료 일시:</strong><span>{formatDisplayDateTime(reservation.payment.cancel.cancellationApprovedAt)}</span></p>
|
<p><strong>환불 완료 일시:</strong> {formatDisplayDateTime(reservation.payment.cancel.cancellationApprovedAt)}</p>
|
||||||
<p><strong>취소 사유:</strong><span>{reservation.payment.cancel.cancelReason}</span></p>
|
<p><strong>취소 사유:</strong> {reservation.payment.cancel.cancelReason}</p>
|
||||||
<p><strong>취소 요청인:</strong><span>{reservation.payment.cancel.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</span></p>
|
<p><strong>취소 요청인:</strong> {reservation.payment.cancel.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{reservation.payment && reservation.payment.status !== 'CANCELED' && (
|
{reservation.payment && reservation.payment.status !== 'CANCELED' && (
|
||||||
@ -176,7 +243,7 @@ const ReservationDetailView: React.FC<{
|
|||||||
|
|
||||||
// --- Main Page Component ---
|
// --- Main Page Component ---
|
||||||
const MyReservationPage: React.FC = () => {
|
const MyReservationPage: React.FC = () => {
|
||||||
const [reservations, setReservations] = useState<ReservationOverviewResponse[]>([]);
|
const [reservations, setReservations] = useState<ReservationSummaryRetrieveResponse[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -191,7 +258,7 @@ const MyReservationPage: React.FC = () => {
|
|||||||
const loadReservations = async () => {
|
const loadReservations = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const data = await fetchAllOverviewByUser();
|
const data = await fetchSummaryByMember();
|
||||||
setReservations(data.reservations);
|
setReservations(data.reservations);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -205,15 +272,17 @@ const MyReservationPage: React.FC = () => {
|
|||||||
loadReservations();
|
loadReservations();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleShowDetail = async (overview: ReservationOverviewResponse) => {
|
const handleShowDetail = async (id: string, themeName: string, date: string, time: string) => {
|
||||||
try {
|
try {
|
||||||
setIsDetailLoading(true);
|
setIsDetailLoading(true);
|
||||||
setDetailError(null);
|
setDetailError(null);
|
||||||
setModalView('detail');
|
setModalView('detail');
|
||||||
const detailData = await fetchDetailById(overview.id);
|
const detailData = await fetchDetailById(id);
|
||||||
setSelectedReservation({
|
setSelectedReservation({
|
||||||
overview: overview,
|
id: detailData.id,
|
||||||
reserver: detailData.reserver,
|
themeName: themeName,
|
||||||
|
date: date,
|
||||||
|
startAt: time,
|
||||||
user: detailData.user,
|
user: detailData.user,
|
||||||
applicationDateTime: detailData.applicationDateTime,
|
applicationDateTime: detailData.applicationDateTime,
|
||||||
payment: detailData.payment
|
payment: detailData.payment
|
||||||
@ -241,8 +310,8 @@ const MyReservationPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setIsCancelling(true);
|
setIsCancelling(true);
|
||||||
setDetailError(null);
|
setDetailError(null);
|
||||||
await cancelPayment({ reservationId: selectedReservation.overview.id, cancelReason: reason });
|
await cancelReservation(selectedReservation.id, reason);
|
||||||
await cancelReservation(selectedReservation.overview.id, reason);
|
cancelPayment({ reservationId: selectedReservation.id, cancelReason: reason });
|
||||||
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
|
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
|
||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
await loadReservations(); // Refresh the list
|
await loadReservations(); // Refresh the list
|
||||||
@ -256,7 +325,7 @@ const MyReservationPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-reservation-container-v2">
|
<div className="my-reservation-container-v2">
|
||||||
<h1>내 예약</h1>
|
<h1>내 예약 V2</h1>
|
||||||
|
|
||||||
{isLoading && <p>목록 로딩 중...</p>}
|
{isLoading && <p>목록 로딩 중...</p>}
|
||||||
{error && <p className="error-message-v2">{error}</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 key={res.id} className={`reservation-summary-card-v2 ${status.className}`}>
|
||||||
<div className="card-status-badge">{status.text}</div>
|
<div className="card-status-badge">{status.text}</div>
|
||||||
<div className="summary-details-v2">
|
<div className="summary-details-v2">
|
||||||
<div><h3 className="summary-theme-name-v2">{res.themeName}</h3></div>
|
<h3 className="summary-theme-name-v2">{res.themeName}</h3>
|
||||||
<div className="summary-subdetails-v2">
|
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p>
|
||||||
<p className="summary-store-name-v2">{res.storeName}</p>
|
|
||||||
<p className="summary-datetime-v2">{formatDate(res.date)} {formatTime(res.startFrom)} ~ {formatTime(res.endAt)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleShowDetail(res)}
|
onClick={() => handleShowDetail(res.id, res.themeName, res.date, res.startAt)}
|
||||||
disabled={isDetailLoading}
|
disabled={isDetailLoading}
|
||||||
className="detail-button-v2"
|
className="detail-button-v2"
|
||||||
>
|
>
|
||||||
{isDetailLoading && selectedReservation?.overview.id === res.id ? '로딩중...' : '상세보기'}
|
{isDetailLoading && selectedReservation?.id === res.id ? '로딩중...' : '상세보기'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,20 +1,19 @@
|
|||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
import {isLoginRequiredError} from '@_api/apiClient';
|
||||||
import { createPendingReservation } from '@_api/reservation/reservationAPI';
|
import {createPendingReservation} from '@_api/reservation/reservationAPI';
|
||||||
import type { ReservationData } from '@_api/reservation/reservationTypes';
|
import {fetchContact} from '@_api/user/userAPI';
|
||||||
import { fetchContact } from '@_api/user/userAPI';
|
|
||||||
import '@_css/reservation-v2-1.css';
|
import '@_css/reservation-v2-1.css';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
const ReservationFormPage: React.FC = () => {
|
const ReservationFormPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const reservationData = location.state as ReservationData;
|
const { scheduleId, theme, date, time } = location.state || {};
|
||||||
|
|
||||||
const [reserverName, setReserverName] = useState('');
|
const [reserverName, setReserverName] = useState('');
|
||||||
const [reserverContact, setReserverContact] = 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 [requirement, setRequirement] = useState('');
|
||||||
const [isLoadingUserInfo, setIsLoadingUserInfo] = useState(true);
|
const [isLoadingUserInfo, setIsLoadingUserInfo] = useState(true);
|
||||||
|
|
||||||
@ -51,29 +50,30 @@ const ReservationFormPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
createPendingReservation({
|
const reservationData = {
|
||||||
scheduleId: reservationData.scheduleId,
|
scheduleId,
|
||||||
reserverName,
|
reserverName,
|
||||||
reserverContact,
|
reserverContact,
|
||||||
participantCount,
|
participantCount,
|
||||||
requirement,
|
requirement,
|
||||||
}).then(res => {
|
};
|
||||||
|
|
||||||
|
createPendingReservation(reservationData)
|
||||||
|
.then(res => {
|
||||||
navigate('/reservation/payment', {
|
navigate('/reservation/payment', {
|
||||||
state: {
|
state: {
|
||||||
reservationId: res.id,
|
reservationId: res.id,
|
||||||
storeName: reservationData.store.name,
|
themeName: theme.name,
|
||||||
themeName: reservationData.theme.name,
|
date: date,
|
||||||
date: reservationData.date,
|
startAt: time,
|
||||||
time: formatTime(reservationData.startFrom) + ' ~ ' + formatTime(reservationData.endAt),
|
price: theme.price * participantCount,
|
||||||
themePrice: reservationData.theme.price,
|
|
||||||
totalPrice: reservationData.theme.price * participantCount,
|
|
||||||
participantCount: participantCount,
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}).catch(handleError);
|
})
|
||||||
|
.catch(handleError);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!reservationData) {
|
if (!scheduleId || !theme) {
|
||||||
return (
|
return (
|
||||||
<div className="reservation-v21-container">
|
<div className="reservation-v21-container">
|
||||||
<h2 className="page-title">잘못된 접근</h2>
|
<h2 className="page-title">잘못된 접근</h2>
|
||||||
@ -89,10 +89,9 @@ const ReservationFormPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="step-section">
|
<div className="step-section">
|
||||||
<h3>예약 내용 확인</h3>
|
<h3>예약 내용 확인</h3>
|
||||||
<p><strong>매장:</strong> {reservationData.store.name}</p>
|
<p><strong>테마:</strong> {theme.name}</p>
|
||||||
<p><strong>테마:</strong> {reservationData.theme.name}</p>
|
<p><strong>날짜:</strong> {formatDate(date)}</p>
|
||||||
<p><strong>날짜:</strong> {formatDate(reservationData.date)}</p>
|
<p><strong>시간:</strong> {formatTime(time)}</p>
|
||||||
<p><strong>시간:</strong> {formatTime(reservationData.startFrom)} ~ {formatTime(reservationData.endAt)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="step-section">
|
<div className="step-section">
|
||||||
@ -125,9 +124,9 @@ const ReservationFormPage: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={participantCount}
|
value={participantCount}
|
||||||
onChange={e => setParticipantCount(Math.max(reservationData.theme.minParticipants, Math.min(reservationData.theme.maxParticipants, Number(e.target.value))))}
|
onChange={e => setParticipantCount(Math.max(theme.minParticipants, Math.min(theme.maxParticipants, Number(e.target.value))))}
|
||||||
min={reservationData.theme.minParticipants}
|
min={theme.minParticipants}
|
||||||
max={reservationData.theme.maxParticipants}
|
max={theme.maxParticipants}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,44 +1,30 @@
|
|||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
import {isLoginRequiredError} from '@_api/apiClient';
|
||||||
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
|
import {findAvailableThemesByDate, findSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
|
||||||
import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
|
import {type ScheduleRetrieveResponse, ScheduleStatus} from '@_api/schedule/scheduleTypes';
|
||||||
import { type ReservationData } from '@_api/reservation/reservationTypes';
|
import {findThemesByIds} from '@_api/theme/themeAPI';
|
||||||
import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
|
import {mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||||
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
|
|
||||||
import { getStores } from '@_api/store/storeAPI';
|
|
||||||
import { type SimpleStoreResponse } from '@_api/store/storeTypes';
|
|
||||||
import { fetchThemeById } from '@_api/theme/themeAPI';
|
|
||||||
import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes';
|
|
||||||
import '@_css/reservation-v2-1.css';
|
import '@_css/reservation-v2-1.css';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
import { formatDate } from 'src/util/DateTimeFormatter';
|
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
|
|
||||||
const ReservationStep1Page: React.FC = () => {
|
const ReservationStep1Page: React.FC = () => {
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
const [viewDate, setViewDate] = useState<Date>(new Date());
|
const [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel
|
||||||
|
const [themes, setThemes] = useState<ThemeInfoResponse[]>([]);
|
||||||
const [sidoList, setSidoList] = useState<SidoResponse[]>([]);
|
const [selectedTheme, setSelectedTheme] = useState<ThemeInfoResponse | null>(null);
|
||||||
const [sigunguList, setSigunguList] = useState<SigunguResponse[]>([]);
|
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
|
||||||
const [storeList, setStoreList] = useState<SimpleStoreResponse[]>([]);
|
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleRetrieveResponse | null>(null);
|
||||||
|
|
||||||
const [selectedSido, setSelectedSido] = useState('');
|
|
||||||
const [selectedSigungu, setSelectedSigungu] = useState('');
|
|
||||||
const [selectedStore, setSelectedStore] = useState<SimpleStoreResponse | null>(null);
|
|
||||||
|
|
||||||
const [schedulesByTheme, setSchedulesByTheme] = useState<Record<string, ScheduleWithThemeResponse[]>>({});
|
|
||||||
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleWithThemeResponse | null>(null);
|
|
||||||
|
|
||||||
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||||||
const [modalThemeDetails, setModalThemeDetails] = useState<ThemeInfoResponse | null>(null);
|
|
||||||
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const handleError = (err: any) => {
|
const handleError = (err: any) => {
|
||||||
if (isLoginRequiredError(err)) {
|
if (isLoginRequiredError(err)) {
|
||||||
alert('로그인이 필요해요.');
|
alert('로그인이 필요해요.');
|
||||||
navigate('/login', {state: {from: location}});
|
navigate('/login', { state: { from: location } });
|
||||||
} else {
|
} else {
|
||||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||||
alert(message);
|
alert(message);
|
||||||
@ -47,48 +33,89 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSidoList().then(res => setSidoList(res.sidoList)).catch(handleError);
|
if (selectedDate) {
|
||||||
}, []);
|
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
|
||||||
|
findAvailableThemesByDate(dateStr)
|
||||||
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)
|
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const grouped = res.schedules.reduce((acc, schedule) => {
|
console.log('Available themes response:', res);
|
||||||
const key = schedule.theme.name;
|
const themeIds: string[] = res.themeIds;
|
||||||
if (!acc[key]) acc[key] = [];
|
console.log('Available theme IDs:', themeIds);
|
||||||
acc[key].push(schedule);
|
if (themeIds.length > 0) {
|
||||||
return acc;
|
return findThemesByIds({ themeIds });
|
||||||
}, {} as Record<string, ScheduleWithThemeResponse[]>);
|
|
||||||
setSchedulesByTheme(grouped);
|
|
||||||
})
|
|
||||||
.catch(handleError);
|
|
||||||
} else {
|
} else {
|
||||||
setSchedulesByTheme({});
|
return Promise.resolve({ themes: [] });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(themeResponse => {
|
||||||
|
setThemes(themeResponse.themes.map(mapThemeResponse));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (isLoginRequiredError(err)) {
|
||||||
|
setThemes([]);
|
||||||
|
} else {
|
||||||
|
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||||
|
alert(message);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setSelectedTheme(null);
|
||||||
|
setSchedules([]);
|
||||||
|
setSelectedSchedule(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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);
|
setSelectedSchedule(null);
|
||||||
}, [selectedDate, selectedStore]);
|
});
|
||||||
|
}
|
||||||
|
}, [selectedDate, selectedTheme]);
|
||||||
|
|
||||||
|
const handleNextStep = () => {
|
||||||
|
if (!selectedDate || !selectedTheme || !selectedSchedule) {
|
||||||
|
alert('날짜, 테마, 시간을 모두 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedSchedule.status !== ScheduleStatus.AVAILABLE) {
|
||||||
|
alert('예약할 수 없는 시간입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsConfirmModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmReservation = () => {
|
||||||
|
if (!selectedSchedule) return;
|
||||||
|
|
||||||
|
holdSchedule(selectedSchedule.id)
|
||||||
|
.then(() => {
|
||||||
|
navigate('/reservation/form', {
|
||||||
|
state: {
|
||||||
|
scheduleId: selectedSchedule.id,
|
||||||
|
theme: selectedTheme,
|
||||||
|
date: selectedDate.toLocaleDateString('en-CA'),
|
||||||
|
time: selectedSchedule.time,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(handleError)
|
||||||
|
.finally(() => setIsConfirmModalOpen(false));
|
||||||
|
};
|
||||||
|
|
||||||
const handleDateSelect = (date: Date) => {
|
const handleDateSelect = (date: Date) => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@ -98,53 +125,7 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextStep = () => {
|
|
||||||
if (!selectedSchedule) {
|
|
||||||
alert('예약할 시간을 선택해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setIsConfirmModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmReservation = () => {
|
|
||||||
if (!selectedSchedule) return;
|
|
||||||
|
|
||||||
holdSchedule(selectedSchedule.schedule.id)
|
|
||||||
.then(() => {
|
|
||||||
fetchThemeById(selectedSchedule.theme.id).then(res => {
|
|
||||||
const reservationData: ReservationData = {
|
|
||||||
scheduleId: selectedSchedule.schedule.id,
|
|
||||||
store: {
|
|
||||||
id: selectedStore!.id,
|
|
||||||
name: selectedStore!.name,
|
|
||||||
},
|
|
||||||
theme: {
|
|
||||||
id: res.id,
|
|
||||||
name: res.name,
|
|
||||||
price: res.price,
|
|
||||||
minParticipants: res.minParticipants,
|
|
||||||
maxParticipants: res.maxParticipants,
|
|
||||||
},
|
|
||||||
date: selectedDate.toLocaleDateString('en-CA'),
|
|
||||||
startFrom: selectedSchedule.schedule.startFrom,
|
|
||||||
endAt: selectedSchedule.schedule.endAt,
|
|
||||||
};
|
|
||||||
navigate('/reservation/form', {state: reservationData});
|
|
||||||
}).catch(handleError);
|
|
||||||
})
|
|
||||||
.catch(handleError);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openThemeModal = (themeId: string) => {
|
|
||||||
fetchThemeById(themeId)
|
|
||||||
.then(themeDetails => {
|
|
||||||
setModalThemeDetails(themeDetails);
|
|
||||||
setIsThemeModalOpen(true);
|
|
||||||
})
|
|
||||||
.catch(handleError);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderDateCarousel = () => {
|
const renderDateCarousel = () => {
|
||||||
const dates = [];
|
const dates = [];
|
||||||
@ -203,6 +184,11 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openThemeModal = (theme: ThemeInfoResponse) => {
|
||||||
|
setSelectedTheme(theme);
|
||||||
|
setIsThemeModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusText = (status: ScheduleStatus) => {
|
const getStatusText = (status: ScheduleStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case ScheduleStatus.AVAILABLE:
|
case ScheduleStatus.AVAILABLE:
|
||||||
@ -214,6 +200,8 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== ScheduleStatus.AVAILABLE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reservation-v21-container">
|
<div className="reservation-v21-container">
|
||||||
<h2 className="page-title">예약하기</h2>
|
<h2 className="page-title">예약하기</h2>
|
||||||
@ -224,97 +212,82 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
|
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
|
||||||
<h3>2. 매장 선택</h3>
|
<h3>2. 테마 선택</h3>
|
||||||
<div className="region-store-selectors">
|
<div className="theme-list">
|
||||||
<select value={selectedSido} onChange={e => setSelectedSido(e.target.value)}>
|
{themes.map(theme => (
|
||||||
<option value="">시/도</option>
|
|
||||||
{sidoList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<select value={selectedSigungu} onChange={e => setSelectedSigungu(e.target.value)}
|
|
||||||
disabled={!selectedSido}>
|
|
||||||
<option value="">시/군/구 (전체)</option>
|
|
||||||
{sigunguList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<select value={selectedStore?.id || ''}
|
|
||||||
onChange={e => setSelectedStore(storeList.find(s => s.id === e.target.value) || null)}
|
|
||||||
disabled={storeList.length === 0}>
|
|
||||||
<option value="">매장 선택</option>
|
|
||||||
{storeList.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`step-section ${!selectedStore ? 'disabled' : ''}`}>
|
|
||||||
<h3>3. 시간 선택</h3>
|
|
||||||
<div className="schedule-list">
|
|
||||||
{Object.keys(schedulesByTheme).length > 0 ? (
|
|
||||||
Object.entries(schedulesByTheme).map(([themeName, scheduleAndTheme]) => (
|
|
||||||
<div key={themeName} className="theme-schedule-group">
|
|
||||||
<div className="theme-header">
|
|
||||||
<h4>{themeName}</h4>
|
|
||||||
<button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
|
|
||||||
className="theme-detail-button">상세보기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="time-slots">
|
|
||||||
{scheduleAndTheme.map(schedule => (
|
|
||||||
<div
|
<div
|
||||||
key={schedule.schedule.id}
|
key={theme.id}
|
||||||
className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
className={`theme-card ${selectedTheme?.id === theme.id ? 'active' : ''}`}
|
||||||
onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
onClick={() => setSelectedTheme(theme)}
|
||||||
>
|
>
|
||||||
{`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
|
<div className="theme-info">
|
||||||
<span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
) : (
|
<div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}>
|
||||||
<div className="no-times">선택한 조건으로 예약 가능한 시간이 없습니다.</div>
|
<h3>3. 시간 선택</h3>
|
||||||
)}
|
<div className="time-slots">
|
||||||
|
{schedules.length > 0 ? schedules.map(schedule => (
|
||||||
|
<div
|
||||||
|
key={schedule.id}
|
||||||
|
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||||
|
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||||
|
>
|
||||||
|
{schedule.time}
|
||||||
|
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
||||||
|
</div>
|
||||||
|
)) : <div className="no-times">선택 가능한 시간이 없습니다.</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="next-step-button-container">
|
<div className="next-step-button-container">
|
||||||
<button className="next-step-button" disabled={!selectedSchedule} onClick={handleNextStep}>
|
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}>
|
||||||
예약하기
|
예약하기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isThemeModalOpen && modalThemeDetails && (
|
{isThemeModalOpen && selectedTheme && (
|
||||||
<div className="modal-overlay" onClick={() => setIsThemeModalOpen(false)}>
|
<div className="modal-overlay" onClick={() => setIsThemeModalOpen(false)}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<button className="modal-close-button" onClick={() => setIsThemeModalOpen(false)}>×</button>
|
<button className="modal-close-button" onClick={() => setIsThemeModalOpen(false)}>×</button>
|
||||||
<img src={modalThemeDetails.thumbnailUrl} alt={modalThemeDetails.name}
|
<img src={selectedTheme.thumbnailUrl} alt={selectedTheme.name} className="modal-theme-thumbnail" />
|
||||||
className="modal-theme-thumbnail"/>
|
<h2>{selectedTheme.name}</h2>
|
||||||
<h2>{modalThemeDetails.name}</h2>
|
<div className="modal-section">
|
||||||
<div className="modal-section modal-info-grid">
|
|
||||||
<h3>테마 정보</h3>
|
<h3>테마 정보</h3>
|
||||||
<p><strong>난이도:</strong><span>{DifficultyKoreanMap[modalThemeDetails.difficulty]}</span></p>
|
<p><strong>난이도:</strong> {selectedTheme.difficulty}</p>
|
||||||
<p><strong>이용 가능 인원:</strong><span>{modalThemeDetails.minParticipants} ~ {modalThemeDetails.maxParticipants}명</span></p>
|
<p><strong>참여 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
||||||
<p><strong>1인당 요금:</strong><span>{modalThemeDetails.price.toLocaleString()}원</span></p>
|
<p><strong>소요 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
||||||
<p><strong>예상 시간:</strong><span>{modalThemeDetails.expectedMinutesFrom} ~ {modalThemeDetails.expectedMinutesTo}분</span></p>
|
<p><strong>1인당 요금:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
||||||
<p><strong>이용 가능 시간:</strong><span>{modalThemeDetails.availableMinutes}분</span></p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-section">
|
<div className="modal-section">
|
||||||
<h3>소개</h3>
|
<h3>소개</h3>
|
||||||
<p>{modalThemeDetails.description}</p>
|
<p>{selectedTheme.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isConfirmModalOpen && selectedSchedule && (
|
{isConfirmModalOpen && (
|
||||||
<div className="modal-overlay" onClick={() => setIsConfirmModalOpen(false)}>
|
<div className="modal-overlay" onClick={() => setIsConfirmModalOpen(false)}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<button className="modal-close-button" onClick={() => setIsConfirmModalOpen(false)}>×</button>
|
<button className="modal-close-button" onClick={() => setIsConfirmModalOpen(false)}>×</button>
|
||||||
<h2>예약 정보를 확인해주세요</h2>
|
<h2>예약 정보를 확인해주세요</h2>
|
||||||
<div className="modal-section modal-info-grid">
|
<div className="modal-section">
|
||||||
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
<p><strong>날짜:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
|
||||||
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
<p><strong>테마:</strong> {selectedTheme!!.name}</p>
|
||||||
<p><strong>테마:</strong><span>{selectedSchedule.theme.name}</span></p>
|
<p><strong>시간:</strong> {formatTime(selectedSchedule!!.time)}</p>
|
||||||
<p><strong>시간:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { confirm } from '@_api/order/orderAPI';
|
import {isLoginRequiredError} from '@_api/apiClient';
|
||||||
import type { OrderErrorResponse } from '@_api/order/orderTypes';
|
import {confirmPayment} from '@_api/payment/paymentAPI';
|
||||||
import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
|
import {type PaymentConfirmRequest, PaymentType} from '@_api/payment/PaymentTypes';
|
||||||
import { confirmReservation } from '@_api/reservation/reservationAPI';
|
import {confirmReservation} from '@_api/reservation/reservationAPI';
|
||||||
import '@_css/reservation-v2-1.css';
|
import '@_css/reservation-v2-1.css';
|
||||||
import type { AxiosError } from 'axios';
|
import React, {useEffect, useRef} from 'react';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
|
||||||
import { formatDate } from 'src/util/DateTimeFormatter';
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -20,7 +19,18 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
const paymentWidgetRef = useRef<any>(null);
|
const paymentWidgetRef = useRef<any>(null);
|
||||||
const paymentMethodsRef = useRef<any>(null);
|
const paymentMethodsRef = useRef<any>(null);
|
||||||
|
|
||||||
const { reservationId, 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(() => {
|
useEffect(() => {
|
||||||
if (!reservationId) {
|
if (!reservationId) {
|
||||||
@ -41,12 +51,12 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
|
|
||||||
const paymentMethods = paymentWidget.renderPaymentMethods(
|
const paymentMethods = paymentWidget.renderPaymentMethods(
|
||||||
"#payment-method",
|
"#payment-method",
|
||||||
{ value: totalPrice, currency: "KRW" },
|
{ value: price },
|
||||||
{ variantKey: "DEFAULT" }
|
{ variantKey: "DEFAULT" }
|
||||||
);
|
);
|
||||||
paymentMethodsRef.current = paymentMethods;
|
paymentMethodsRef.current = paymentMethods;
|
||||||
};
|
};
|
||||||
}, [reservationId, totalPrice, navigate]);
|
}, [reservationId, price, navigate]);
|
||||||
|
|
||||||
const handlePayment = () => {
|
const handlePayment = () => {
|
||||||
if (!paymentWidgetRef.current || !reservationId) {
|
if (!paymentWidgetRef.current || !reservationId) {
|
||||||
@ -57,75 +67,36 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
const generateRandomString = () =>
|
const generateRandomString = () =>
|
||||||
crypto.randomUUID().replace(/-/g, '');
|
crypto.randomUUID().replace(/-/g, '');
|
||||||
|
|
||||||
|
|
||||||
paymentWidgetRef.current.requestPayment({
|
paymentWidgetRef.current.requestPayment({
|
||||||
orderId: generateRandomString(),
|
orderId: generateRandomString(),
|
||||||
orderName: `${themeName} 예약 결제`,
|
orderName: `${themeName} 예약 결제`,
|
||||||
amount: totalPrice,
|
amount: price,
|
||||||
}).then((data: any) => {
|
}).then((data: any) => {
|
||||||
const paymentData: PaymentConfirmRequest = {
|
const paymentData: PaymentConfirmRequest = {
|
||||||
paymentKey: data.paymentKey,
|
paymentKey: data.paymentKey,
|
||||||
orderId: data.orderId,
|
orderId: data.orderId,
|
||||||
amount: 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(() => {
|
.then(() => {
|
||||||
alert('결제가 완료되었어요!');
|
alert('결제가 완료되었어요!');
|
||||||
navigate('/reservation/success', {
|
navigate('/reservation/success', {
|
||||||
state: {
|
state: {
|
||||||
storeName: storeName,
|
themeName,
|
||||||
themeName: themeName,
|
date,
|
||||||
date: date,
|
startAt,
|
||||||
time: time,
|
|
||||||
participantCount: participantCount,
|
|
||||||
totalPrice: totalPrice,
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(handleError);
|
||||||
const error = err as AxiosError<OrderErrorResponse>;
|
|
||||||
const errorCode = error.response?.data?.code;
|
|
||||||
const errorMessage = error.response?.data?.message;
|
|
||||||
|
|
||||||
if (errorCode === 'B000') {
|
|
||||||
alert(`예약을 완료할 수 없어요.(${errorMessage})`);
|
|
||||||
navigate('/reservation');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trial = error.response?.data?.trial || 0;
|
|
||||||
if (trial < 2) {
|
|
||||||
alert(errorMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
alert(errorMessage);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const agreeToOnsitePayment = window.confirm('재시도 횟수를 초과했어요. 현장결제를 하시겠어요?');
|
|
||||||
|
|
||||||
if (agreeToOnsitePayment) {
|
|
||||||
confirmReservation(reservationId)
|
|
||||||
.then(() => {
|
|
||||||
navigate('/reservation/success', {
|
|
||||||
state: {
|
|
||||||
storeName,
|
|
||||||
themeName,
|
|
||||||
date,
|
|
||||||
time,
|
|
||||||
participantCount,
|
|
||||||
totalPrice,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
alert('다음에 다시 시도해주세요. 메인 페이지로 이동할게요.');
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
}).catch((error: any) => {
|
}).catch((error: any) => {
|
||||||
console.error("Payment request error:", error);
|
console.error("Payment request error:", error);
|
||||||
alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
|
alert("결제 요청 중 오류가 발생했습니다.");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -138,13 +109,10 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
<h2 className="page-title">결제하기</h2>
|
<h2 className="page-title">결제하기</h2>
|
||||||
<div className="step-section">
|
<div className="step-section">
|
||||||
<h3>결제 정보 확인</h3>
|
<h3>결제 정보 확인</h3>
|
||||||
<p><strong>날짜:</strong> {formatDate(date)}</p>
|
|
||||||
<p><strong>시간:</strong> {time}</p>
|
|
||||||
<p><strong>테마:</strong> {themeName}</p>
|
<p><strong>테마:</strong> {themeName}</p>
|
||||||
<p><strong>매장:</strong> {storeName}</p>
|
<p><strong>날짜:</strong> {formatDate(date)}</p>
|
||||||
<p><strong>인원:</strong> {participantCount}명</p>
|
<p><strong>시간:</strong> {formatTime(startAt)}</p>
|
||||||
<p><strong>1인당 금액:</strong> {themePrice.toLocaleString()}원</p>
|
<p><strong>금액:</strong> {price.toLocaleString()}원</p>
|
||||||
<p><strong>총 금액:</strong> {totalPrice.toLocaleString()}원</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="step-section">
|
<div className="step-section">
|
||||||
<h3>결제 수단</h3>
|
<h3>결제 수단</h3>
|
||||||
@ -153,7 +121,7 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="next-step-button-container">
|
<div className="next-step-button-container">
|
||||||
<button onClick={handlePayment} className="next-step-button">
|
<button onClick={handlePayment} className="next-step-button">
|
||||||
{totalPrice.toLocaleString()}원 결제하기
|
{price.toLocaleString()}원 결제하기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 React from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import {Link, useLocation} from 'react-router-dom';
|
||||||
import { formatDate } from 'src/util/DateTimeFormatter';
|
import {formatDate, formatTime} from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
const ReservationSuccessPage: React.FC = () => {
|
const ReservationSuccessPage: React.FC = () => {
|
||||||
const location = useLocation();
|
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 (
|
return (
|
||||||
<div className="reservation-v21-container">
|
<div className="reservation-v21-container">
|
||||||
@ -15,12 +20,9 @@ const ReservationSuccessPage: React.FC = () => {
|
|||||||
<h2 className="page-title">예약이 확정되었습니다!</h2>
|
<h2 className="page-title">예약이 확정되었습니다!</h2>
|
||||||
<div className="step-section">
|
<div className="step-section">
|
||||||
<h3>최종 예약 정보</h3>
|
<h3>최종 예약 정보</h3>
|
||||||
<p><strong>매장:</strong> {storeName}</p>
|
|
||||||
<p><strong>테마:</strong> {themeName}</p>
|
<p><strong>테마:</strong> {themeName}</p>
|
||||||
<p><strong>날짜:</strong> {formattedDate}</p>
|
<p><strong>날짜:</strong> {formattedDate}</p>
|
||||||
<p><strong>시간:</strong> {time}</p>
|
<p><strong>시간:</strong> {formattedTime}</p>
|
||||||
<p><strong>인원:</strong> {participantCount}명</p>
|
|
||||||
<p><strong>결제 금액:</strong> {totalPrice?.toLocaleString()}원</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="success-page-actions">
|
<div className="success-page-actions">
|
||||||
<Link to="/my-reservation" className="action-button">
|
<Link to="/my-reservation" className="action-button">
|
||||||
|
|||||||
@ -1,17 +1,8 @@
|
|||||||
import {
|
import {signup} from '@_api/user/userAPI';
|
||||||
fetchRegionCode,
|
import type {UserCreateRequest, UserCreateResponse} from '@_api/user/userTypes';
|
||||||
fetchSidoList,
|
|
||||||
fetchSigunguList,
|
|
||||||
} from '@_api/region/regionAPI';
|
|
||||||
import type {
|
|
||||||
SidoResponse,
|
|
||||||
SigunguResponse,
|
|
||||||
} from '@_api/region/regionTypes';
|
|
||||||
import { signup } from '@_api/user/userAPI';
|
|
||||||
import type { UserCreateRequest, UserCreateResponse } from '@_api/user/userTypes';
|
|
||||||
import '@_css/signup-page-v2.css';
|
import '@_css/signup-page-v2.css';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import {useNavigate} from 'react-router-dom';
|
||||||
|
|
||||||
const MIN_PASSWORD_LENGTH = 8;
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
@ -23,43 +14,8 @@ const SignupPage: React.FC = () => {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||||
|
|
||||||
const [sidoList, setSidoList] = useState<SidoResponse[]>([]);
|
|
||||||
const [sigunguList, setSigunguList] = useState<SigunguResponse[]>([]);
|
|
||||||
const [selectedSidoCode, setSelectedSidoCode] = useState('');
|
|
||||||
const [selectedSigunguCode, setSelectedSigunguCode] = useState('');
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSido = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetchSidoList();
|
|
||||||
setSidoList(response.sidoList);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('시/도 목록을 불러오는 데 실패했습니다.', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchSido();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedSidoCode) {
|
|
||||||
const fetchSigungu = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetchSigunguList(selectedSidoCode);
|
|
||||||
setSigunguList(response.sigunguList);
|
|
||||||
setSelectedSigunguCode('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('시/군/구 목록을 불러오는 데 실패했습니다.', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchSigungu();
|
|
||||||
} else {
|
|
||||||
setSigunguList([]);
|
|
||||||
setSelectedSigunguCode('');
|
|
||||||
}
|
|
||||||
}, [selectedSidoCode]);
|
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
@ -80,12 +36,6 @@ const SignupPage: React.FC = () => {
|
|||||||
newErrors.phone = '올바른 휴대폰 번호 형식이 아닙니다. (예: 01012345678)';
|
newErrors.phone = '올바른 휴대폰 번호 형식이 아닙니다. (예: 01012345678)';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedSidoCode || selectedSigunguCode) {
|
|
||||||
if (!selectedSidoCode || !selectedSigunguCode) {
|
|
||||||
newErrors.region = '모든 지역 정보를 선택해주세요.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newErrors;
|
return newErrors;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -94,7 +44,7 @@ const SignupPage: React.FC = () => {
|
|||||||
if (hasSubmitted) {
|
if (hasSubmitted) {
|
||||||
setErrors(validate());
|
setErrors(validate());
|
||||||
}
|
}
|
||||||
}, [email, password, name, phone, hasSubmitted, selectedSidoCode, selectedSigunguCode]);
|
}, [email, password, name, phone, hasSubmitted]);
|
||||||
|
|
||||||
const handleSignup = async (e: React.FormEvent) => {
|
const handleSignup = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -105,22 +55,7 @@ const SignupPage: React.FC = () => {
|
|||||||
|
|
||||||
if (Object.keys(newErrors).length > 0) return;
|
if (Object.keys(newErrors).length > 0) return;
|
||||||
|
|
||||||
let regionCode: string | null = null;
|
const request: UserCreateRequest = { email, password, name, phone, regionCode: null };
|
||||||
if (selectedSidoCode && selectedSigunguCode) {
|
|
||||||
try {
|
|
||||||
const response = await fetchRegionCode(
|
|
||||||
selectedSidoCode,
|
|
||||||
selectedSigunguCode,
|
|
||||||
);
|
|
||||||
regionCode = response.code;
|
|
||||||
} catch (error) {
|
|
||||||
alert('지역 코드를 가져오는 데 실패했습니다.');
|
|
||||||
console.error(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const request: UserCreateRequest = { email, password, name, phone, regionCode };
|
|
||||||
try {
|
try {
|
||||||
const response: UserCreateResponse = await signup(request);
|
const response: UserCreateResponse = await signup(request);
|
||||||
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
|
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
|
||||||
@ -198,40 +133,6 @@ const SignupPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">지역 (선택)</label>
|
|
||||||
<div className="region-select-group">
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={selectedSidoCode}
|
|
||||||
onChange={e => setSelectedSidoCode(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">시/도</option>
|
|
||||||
{sidoList.map(s => (
|
|
||||||
<option key={s.code} value={s.code}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={selectedSigunguCode}
|
|
||||||
onChange={e => setSelectedSigunguCode(e.target.value)}
|
|
||||||
disabled={!selectedSidoCode}
|
|
||||||
>
|
|
||||||
<option value="">시/군/구</option>
|
|
||||||
{sigunguList.map(s => (
|
|
||||||
<option key={s.code} value={s.code}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{hasSubmitted && errors.region && (
|
|
||||||
<p className="error-text">{errors.region}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import { useAdminAuth } from '@_context/AdminAuthContext';
|
import React, {type ReactNode} from 'react';
|
||||||
import React, { type ReactNode, useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import AdminNavbar from './AdminNavbar';
|
import AdminNavbar from './AdminNavbar';
|
||||||
|
|
||||||
interface AdminLayoutProps {
|
interface AdminLayoutProps {
|
||||||
@ -8,23 +6,6 @@ interface AdminLayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => {
|
const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => {
|
||||||
const { isAdmin, loading } = useAdminAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loading && !isAdmin) {
|
|
||||||
navigate('/admin/login');
|
|
||||||
}
|
|
||||||
}, [isAdmin, loading, navigate]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminNavbar />
|
<AdminNavbar />
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import { useAdminAuth } from '@_context/AdminAuthContext';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import {Link, useNavigate} from 'react-router-dom';
|
||||||
|
import {useAuth} from '@_context/AuthContext';
|
||||||
import '@_css/navbar.css';
|
import '@_css/navbar.css';
|
||||||
|
|
||||||
const AdminNavbar: React.FC = () => {
|
const AdminNavbar: React.FC = () => {
|
||||||
const { isAdmin, name, type, logout } = useAdminAuth();
|
const { loggedIn, userName, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleLogout = async (e: React.MouseEvent) => {
|
const handleLogout = async (e: React.MouseEvent) => {
|
||||||
@ -21,17 +21,16 @@ const AdminNavbar: React.FC = () => {
|
|||||||
<nav className="navbar-container">
|
<nav className="navbar-container">
|
||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<Link className="nav-link" to="/admin">홈</Link>
|
<Link className="nav-link" to="/admin">홈</Link>
|
||||||
{type === 'HQ' && <Link className="nav-link" to="/admin/theme">테마</Link>}
|
<Link className="nav-link" to="/admin/theme">테마</Link>
|
||||||
{type === 'HQ' && <Link className="nav-link" to="/admin/store">매장</Link>}
|
|
||||||
<Link className="nav-link" to="/admin/schedule">일정</Link>
|
<Link className="nav-link" to="/admin/schedule">일정</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="nav-actions">
|
<div className="nav-actions">
|
||||||
{!isAdmin ? (
|
{!loggedIn ? (
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/admin/login')}>Login</button>
|
<button className="btn btn-primary" onClick={() => navigate('/v2/login')}>Login</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="profile-info">
|
<div className="profile-info">
|
||||||
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
|
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
|
||||||
<span>{name || 'Profile'}</span>
|
<span>{userName || 'Profile'}</span>
|
||||||
<div className="dropdown-menu">
|
<div className="dropdown-menu">
|
||||||
<a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a>
|
<a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,20 +1,19 @@
|
|||||||
import {isLoginRequiredError} from '@_api/apiClient';
|
import {isLoginRequiredError} from '@_api/apiClient';
|
||||||
import type {AuditInfo} from '@_api/common/commonTypes';
|
|
||||||
import {
|
import {
|
||||||
createSchedule,
|
createSchedule,
|
||||||
deleteSchedule,
|
deleteSchedule,
|
||||||
fetchAdminSchedules,
|
findScheduleById,
|
||||||
fetchScheduleAudit,
|
findSchedules,
|
||||||
updateSchedule
|
updateSchedule
|
||||||
} from '@_api/schedule/scheduleAPI';
|
} from '@_api/schedule/scheduleAPI';
|
||||||
import {type AdminScheduleSummaryResponse, ScheduleStatus,} from '@_api/schedule/scheduleTypes';
|
import {
|
||||||
import {getStores} from '@_api/store/storeAPI';
|
type ScheduleDetailRetrieveResponse,
|
||||||
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
type ScheduleRetrieveResponse,
|
||||||
import {fetchActiveThemes} from '@_api/theme/themeAPI';
|
ScheduleStatus
|
||||||
import {DifficultyKoreanMap, type SimpleActiveThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
} from '@_api/schedule/scheduleTypes';
|
||||||
import {useAdminAuth} from '@_context/AdminAuthContext';
|
import {fetchAdminThemes} from '@_api/theme/themeAPI';
|
||||||
|
import type {AdminThemeSummaryRetrieveResponse} from '@_api/theme/themeTypes';
|
||||||
import '@_css/admin-schedule-page.css';
|
import '@_css/admin-schedule-page.css';
|
||||||
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
|
|
||||||
import React, {Fragment, useEffect, useState} from 'react';
|
import React, {Fragment, useEffect, useState} from 'react';
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
|
|
||||||
@ -33,41 +32,28 @@ const getScheduleStatusText = (status: ScheduleStatus): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type ScheduleDetail = AdminScheduleSummaryResponse & { audit?: AuditInfo };
|
|
||||||
type EditingSchedule = ScheduleDetail & { time: string };
|
|
||||||
|
|
||||||
const AdminSchedulePage: React.FC = () => {
|
const AdminSchedulePage: React.FC = () => {
|
||||||
const [schedules, setSchedules] = useState<AdminScheduleSummaryResponse[]>([]);
|
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
|
||||||
const [themes, setThemes] = useState<SimpleActiveThemeResponse[]>([]);
|
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]);
|
||||||
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
|
const [selectedThemeId, setSelectedThemeId] = useState<string>('');
|
||||||
const [selectedStoreId, setSelectedStoreId] = useState<string>('');
|
|
||||||
const [selectedTheme, setSelectedTheme] = useState<SimpleActiveThemeResponse | null>(null);
|
|
||||||
const [selectedDate, setSelectedDate] = useState<string>(new Date().toLocaleDateString('en-CA'));
|
const [selectedDate, setSelectedDate] = useState<string>(new Date().toLocaleDateString('en-CA'));
|
||||||
|
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [newScheduleTime, setNewScheduleTime] = useState('');
|
const [newScheduleTime, setNewScheduleTime] = useState('');
|
||||||
|
|
||||||
const [expandedScheduleId, setExpandedScheduleId] = useState<string | null>(null);
|
const [expandedScheduleId, setExpandedScheduleId] = useState<string | null>(null);
|
||||||
const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetail }>({});
|
const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetailRetrieveResponse }>({});
|
||||||
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
|
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null);
|
const [editingSchedule, setEditingSchedule] = useState<ScheduleDetailRetrieveResponse | null>(null);
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [selectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
|
|
||||||
const [isLoadingThemeDetails] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const {type: adminType, storeId: adminStoreId} = useAdminAuth();
|
|
||||||
|
|
||||||
const storeIdForFetch = adminType === 'HQ' ? selectedStoreId : adminStoreId;
|
|
||||||
const showThemeColumn = !selectedTheme?.id;
|
|
||||||
|
|
||||||
const handleError = (err: any) => {
|
const handleError = (err: any) => {
|
||||||
if (isLoginRequiredError(err)) {
|
if (isLoginRequiredError(err)) {
|
||||||
alert('로그인이 필요해요.');
|
alert('로그인이 필요해요.');
|
||||||
navigate('/admin/login', {state: {from: location}});
|
navigate('/login', { state: { from: location } });
|
||||||
} else {
|
} else {
|
||||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||||
alert(message);
|
alert(message);
|
||||||
@ -76,36 +62,19 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!adminType) return;
|
fetchAdminThemes()
|
||||||
|
.then(res => {
|
||||||
const fetchPrerequisites = async () => {
|
setThemes(res.themes);
|
||||||
try {
|
if (res.themes.length > 0) {
|
||||||
// Fetch themes
|
setSelectedThemeId(String(res.themes[0].id));
|
||||||
const themeRes = await fetchActiveThemes();
|
|
||||||
const themeData = themeRes.themes.map(t => ({id: String(t.id), name: t.name}));
|
|
||||||
const allThemesOption = {id: '', name: '전체'};
|
|
||||||
setThemes([allThemesOption, ...themeData]);
|
|
||||||
setSelectedTheme(allThemesOption);
|
|
||||||
|
|
||||||
// Fetch stores for HQ admin
|
|
||||||
if (adminType === 'HQ') {
|
|
||||||
const storeRes = (await getStores()).stores;
|
|
||||||
setStores(storeRes);
|
|
||||||
if (storeRes.length > 0) {
|
|
||||||
setSelectedStoreId(String(storeRes[0].id));
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
} catch (error) {
|
.catch(handleError);
|
||||||
handleError(error);
|
}, []);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchPrerequisites();
|
|
||||||
}, [adminType]);
|
|
||||||
|
|
||||||
const fetchSchedules = () => {
|
const fetchSchedules = () => {
|
||||||
if (storeIdForFetch) {
|
if (selectedDate && selectedThemeId) {
|
||||||
fetchAdminSchedules(storeIdForFetch, selectedDate, selectedTheme?.id === '' ? undefined : selectedTheme?.id)
|
findSchedules(selectedDate, selectedThemeId)
|
||||||
.then(res => setSchedules(res.schedules))
|
.then(res => setSchedules(res.schedules))
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
setSchedules([]);
|
setSchedules([]);
|
||||||
@ -113,14 +82,12 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
handleError(err);
|
handleError(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
setSchedules([]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSchedules();
|
fetchSchedules();
|
||||||
}, [selectedDate, selectedTheme, storeIdForFetch]);
|
}, [selectedDate, selectedThemeId]);
|
||||||
|
|
||||||
const handleAddSchedule = async () => {
|
const handleAddSchedule = async () => {
|
||||||
if (!newScheduleTime) {
|
if (!newScheduleTime) {
|
||||||
@ -131,18 +98,10 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
alert('시간 형식이 올바르지 않습니다. HH:MM 형식으로 입력해주세요.');
|
alert('시간 형식이 올바르지 않습니다. HH:MM 형식으로 입력해주세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (adminType !== 'STORE' || !adminStoreId) {
|
|
||||||
alert('매장 관리자만 일정을 추가할 수 있습니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!selectedDate || !selectedTheme?.id) {
|
|
||||||
alert('날짜와 특정 테마를 선택해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await createSchedule(adminStoreId, {
|
await createSchedule({
|
||||||
date: selectedDate,
|
date: selectedDate,
|
||||||
themeId: selectedTheme.id,
|
themeId: selectedThemeId,
|
||||||
time: newScheduleTime,
|
time: newScheduleTime,
|
||||||
});
|
});
|
||||||
fetchSchedules();
|
fetchSchedules();
|
||||||
@ -157,7 +116,7 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
if (window.confirm('정말 이 일정을 삭제하시겠습니까?')) {
|
if (window.confirm('정말 이 일정을 삭제하시겠습니까?')) {
|
||||||
try {
|
try {
|
||||||
await deleteSchedule(scheduleId);
|
await deleteSchedule(scheduleId);
|
||||||
fetchSchedules();
|
setSchedules(schedules.filter(s => s.id !== scheduleId));
|
||||||
setExpandedScheduleId(null); // Close the details view after deletion
|
setExpandedScheduleId(null); // Close the details view after deletion
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error);
|
handleError(error);
|
||||||
@ -167,22 +126,16 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
|
|
||||||
const handleToggleDetails = async (scheduleId: string) => {
|
const handleToggleDetails = async (scheduleId: string) => {
|
||||||
const isAlreadyExpanded = expandedScheduleId === scheduleId;
|
const isAlreadyExpanded = expandedScheduleId === scheduleId;
|
||||||
setIsEditing(false);
|
setIsEditing(false); // Reset editing state whenever toggling
|
||||||
if (isAlreadyExpanded) {
|
if (isAlreadyExpanded) {
|
||||||
setExpandedScheduleId(null);
|
setExpandedScheduleId(null);
|
||||||
} else {
|
} else {
|
||||||
setExpandedScheduleId(scheduleId);
|
setExpandedScheduleId(scheduleId);
|
||||||
const scheduleInList = schedules.find(s => s.id === scheduleId);
|
if (!detailedSchedules[scheduleId]) {
|
||||||
if (!scheduleInList) return;
|
|
||||||
|
|
||||||
if (!detailedSchedules[scheduleId]?.audit) {
|
|
||||||
setIsLoadingDetails(true);
|
setIsLoadingDetails(true);
|
||||||
try {
|
try {
|
||||||
const auditInfo = await fetchScheduleAudit(scheduleId);
|
const details = await findScheduleById(scheduleId);
|
||||||
setDetailedSchedules(prev => ({
|
setDetailedSchedules(prev => ({ ...prev, [scheduleId]: details }));
|
||||||
...prev,
|
|
||||||
[scheduleId]: {...scheduleInList, audit: auditInfo}
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error);
|
handleError(error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -194,15 +147,7 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
|
|
||||||
const handleEditClick = () => {
|
const handleEditClick = () => {
|
||||||
if (expandedScheduleId && detailedSchedules[expandedScheduleId]) {
|
if (expandedScheduleId && detailedSchedules[expandedScheduleId]) {
|
||||||
const scheduleToEdit = detailedSchedules[expandedScheduleId];
|
setEditingSchedule({ ...detailedSchedules[expandedScheduleId] });
|
||||||
setEditingSchedule({
|
|
||||||
...scheduleToEdit,
|
|
||||||
time: new Date(scheduleToEdit.startFrom).toLocaleTimeString('en-CA', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -213,9 +158,9 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
const {name, value} = e.target;
|
const { name, value } = e.target;
|
||||||
if (editingSchedule) {
|
if (editingSchedule) {
|
||||||
setEditingSchedule({...editingSchedule, [name]: value});
|
setEditingSchedule({ ...editingSchedule, [name]: value });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -227,39 +172,24 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
time: editingSchedule.time,
|
time: editingSchedule.time,
|
||||||
status: editingSchedule.status,
|
status: editingSchedule.status,
|
||||||
});
|
});
|
||||||
fetchSchedules();
|
// Refresh data
|
||||||
setExpandedScheduleId(null);
|
const details = await findScheduleById(editingSchedule.id);
|
||||||
setIsEditing(false);
|
setDetailedSchedules(prev => ({ ...prev, [editingSchedule.id]: details }));
|
||||||
setEditingSchedule(null);
|
setSchedules(schedules.map(s => s.id === editingSchedule.id ? { ...s, time: details.time, status: details.status } : s));
|
||||||
|
|
||||||
alert('일정이 성공적으로 업데이트되었습니다.');
|
alert('일정이 성공적으로 업데이트되었습니다.');
|
||||||
|
setIsEditing(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error);
|
handleError(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canModify = adminType === 'STORE';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-schedule-container">
|
<div className="admin-schedule-container">
|
||||||
<h2 className="page-title">일정 관리</h2>
|
<h2 className="page-title">일정 관리</h2>
|
||||||
|
|
||||||
<div className="schedule-controls">
|
<div className="schedule-controls">
|
||||||
{adminType === 'HQ' && (
|
<div className="form-group">
|
||||||
<div className="form-group store-selector-group">
|
|
||||||
<label className="form-label" htmlFor="store-filter">매장</label>
|
|
||||||
<select
|
|
||||||
id="store-filter"
|
|
||||||
className="form-select"
|
|
||||||
value={selectedStoreId}
|
|
||||||
onChange={e => setSelectedStoreId(e.target.value)}
|
|
||||||
>
|
|
||||||
{stores.map(store => (
|
|
||||||
<option key={store.id} value={store.id}>{store.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="form-group date-selector-group">
|
|
||||||
<label className="form-label" htmlFor="date-filter">날짜</label>
|
<label className="form-label" htmlFor="date-filter">날짜</label>
|
||||||
<input
|
<input
|
||||||
id="date-filter"
|
id="date-filter"
|
||||||
@ -269,17 +199,13 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
onChange={e => setSelectedDate(e.target.value)}
|
onChange={e => setSelectedDate(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group theme-selector-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="theme-filter">테마</label>
|
<label className="form-label" htmlFor="theme-filter">테마</label>
|
||||||
<div className='theme-selector-button-group'>
|
|
||||||
<select
|
<select
|
||||||
id="theme-filter"
|
id="theme-filter"
|
||||||
className="form-select"
|
className="form-select"
|
||||||
value={selectedTheme?.id || ''}
|
value={selectedThemeId}
|
||||||
onChange={e => {
|
onChange={e => setSelectedThemeId(e.target.value)}
|
||||||
const theme = themes.find(t => t.id === e.target.value);
|
|
||||||
setSelectedTheme(theme || null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{themes.map(theme => (
|
{themes.map(theme => (
|
||||||
<option key={theme.id} value={theme.id}>{theme.name}</option>
|
<option key={theme.id} value={theme.id}>{theme.name}</option>
|
||||||
@ -287,19 +213,15 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section-card">
|
<div className="section-card">
|
||||||
{canModify && (
|
|
||||||
<div className="table-header">
|
<div className="table-header">
|
||||||
<button className="btn btn-primary" onClick={() => setIsAdding(true)}>일정 추가</button>
|
<button className="btn btn-primary" onClick={() => setIsAdding(true)}>일정 추가</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="table-container">
|
<div className="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{showThemeColumn && <th>테마</th>}
|
|
||||||
<th>시간</th>
|
<th>시간</th>
|
||||||
<th>상태</th>
|
<th>상태</th>
|
||||||
<th>관리</th>
|
<th>관리</th>
|
||||||
@ -309,8 +231,7 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
{schedules.map(schedule => (
|
{schedules.map(schedule => (
|
||||||
<Fragment key={schedule.id}>
|
<Fragment key={schedule.id}>
|
||||||
<tr>
|
<tr>
|
||||||
{showThemeColumn && <td>{schedule.themeName}</td>}
|
<td>{schedule.time}</td>
|
||||||
<td>{schedule.startFrom}</td>
|
|
||||||
<td>{getScheduleStatusText(schedule.status)}</td>
|
<td>{getScheduleStatusText(schedule.status)}</td>
|
||||||
<td className="action-buttons">
|
<td className="action-buttons">
|
||||||
<button
|
<button
|
||||||
@ -323,76 +244,49 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
{expandedScheduleId === schedule.id && (
|
{expandedScheduleId === schedule.id && (
|
||||||
<tr className="schedule-details-row">
|
<tr className="schedule-details-row">
|
||||||
<td colSpan={showThemeColumn ? 4 : 3}>
|
<td colSpan={3}>
|
||||||
{isLoadingDetails ? (
|
{isLoadingDetails ? (
|
||||||
<p>로딩 중...</p>
|
<p>로딩 중...</p>
|
||||||
) : detailedSchedules[schedule.id] ? (
|
) : detailedSchedules[schedule.id] ? (
|
||||||
<div className="details-form-container">
|
<div className="details-form-container">
|
||||||
{detailedSchedules[schedule.id].audit ? (
|
|
||||||
<div className="audit-info">
|
<div className="audit-info">
|
||||||
<h4 className="audit-title">감사 정보</h4>
|
<h4 className="audit-title">감사 정보</h4>
|
||||||
<div className="audit-body">
|
<div className="audit-body">
|
||||||
<p>
|
<p><strong>생성일:</strong> {new Date(detailedSchedules[schedule.id].createdAt).toLocaleString()}</p>
|
||||||
<strong>생성일:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.createdAt)}
|
<p><strong>수정일:</strong> {new Date(detailedSchedules[schedule.id].updatedAt).toLocaleString()}</p>
|
||||||
</p>
|
<p><strong>생성자:</strong> {detailedSchedules[schedule.id].createdBy}</p>
|
||||||
<p>
|
<p><strong>수정자:</strong> {detailedSchedules[schedule.id].updatedBy}</p>
|
||||||
<strong>수정일:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.updatedAt)}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>생성자:</strong> {detailedSchedules[schedule.id].audit!.createdBy.name}({detailedSchedules[schedule.id].audit!.createdBy.id})
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>수정자:</strong> {detailedSchedules[schedule.id].audit!.updatedBy.name}({detailedSchedules[schedule.id].audit!.updatedBy.id})
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : <p>감사 정보를 불러오는 중...</p>}
|
|
||||||
|
|
||||||
{isEditing && editingSchedule?.id === schedule.id ? (
|
{isEditing && editingSchedule ? (
|
||||||
// --- EDIT MODE ---
|
// --- EDIT MODE ---
|
||||||
<div className="form-card">
|
<div className="form-card">
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">시간</label>
|
<label className="form-label">시간</label>
|
||||||
<input type="time" name="time"
|
<input type="time" name="time" className="form-input" value={editingSchedule.time} onChange={handleEditChange} />
|
||||||
className="form-input"
|
|
||||||
value={editingSchedule.time}
|
|
||||||
onChange={handleEditChange}/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">상태</label>
|
<label className="form-label">상태</label>
|
||||||
<select name="status" className="form-select"
|
<select name="status" className="form-select" value={editingSchedule.status} onChange={handleEditChange}>
|
||||||
value={editingSchedule.status}
|
{Object.values(ScheduleStatus).map(s => <option key={s} value={s}>{getScheduleStatusText(s)}</option>)}
|
||||||
onChange={handleEditChange}>
|
|
||||||
{Object.values(ScheduleStatus).map(s =>
|
|
||||||
<option key={s}
|
|
||||||
value={s}>{getScheduleStatusText(s)}</option>)}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-group">
|
<div className="button-group">
|
||||||
<button type="button" className="btn btn-secondary"
|
<button type="button" className="btn btn-secondary" onClick={handleCancelEdit}>취소</button>
|
||||||
onClick={handleCancelEdit}>취소
|
<button type="button" className="btn btn-primary" onClick={handleSave}>저장</button>
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-primary"
|
|
||||||
onClick={handleSave}>저장
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// --- VIEW MODE ---
|
// --- VIEW MODE ---
|
||||||
canModify && (
|
|
||||||
<div className="button-group view-mode-buttons">
|
<div className="button-group view-mode-buttons">
|
||||||
<button type="button" className="btn btn-danger"
|
<button type="button" className="btn btn-danger" onClick={() => handleDeleteSchedule(schedule.id)}>삭제</button>
|
||||||
onClick={() => handleDeleteSchedule(schedule.id)}>삭제
|
<button type="button" className="btn btn-primary" onClick={handleEditClick}>수정</button>
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-primary"
|
|
||||||
onClick={handleEditClick}>수정
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -403,9 +297,8 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{isAdding && canModify && (
|
{isAdding && (
|
||||||
<tr className="editing-row">
|
<tr className="editing-row">
|
||||||
{showThemeColumn && <td></td>}
|
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
@ -425,33 +318,6 @@ const AdminSchedulePage: React.FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isModalOpen && (
|
|
||||||
<div className="modal-overlay">
|
|
||||||
<div className="modal-content">
|
|
||||||
<button className="modal-close-btn" onClick={() => setIsModalOpen(false)}>×</button>
|
|
||||||
{isLoadingThemeDetails ? (
|
|
||||||
<p>로딩 중...</p>
|
|
||||||
) : selectedThemeDetails ? (
|
|
||||||
<div className="theme-details-modal">
|
|
||||||
<h3 className="modal-title">{selectedThemeDetails.name}</h3>
|
|
||||||
<img src={selectedThemeDetails.thumbnailUrl} alt={selectedThemeDetails.name}
|
|
||||||
className="theme-modal-thumbnail"/>
|
|
||||||
<p className="theme-modal-description">{selectedThemeDetails.description}</p>
|
|
||||||
<div className="modal-info-grid">
|
|
||||||
<p><strong>난이도</strong><span>{DifficultyKoreanMap[selectedThemeDetails.difficulty]}</span></p>
|
|
||||||
<p><strong>이용 가능 인원</strong><span>{selectedThemeDetails.minParticipants} ~ {selectedThemeDetails.maxParticipants}명</span></p>
|
|
||||||
<p><strong>1인당 요금</strong><span>{selectedThemeDetails.price.toLocaleString()}원</span></p>
|
|
||||||
<p><strong>예상 시간</strong><span>{selectedThemeDetails.expectedMinutesFrom} ~ {selectedThemeDetails.expectedMinutesTo}분</span></p>
|
|
||||||
<p><strong>이용 가능 시간</strong><span>{selectedThemeDetails.availableMinutes}분</span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p>테마 정보를 불러올 수 없습니다.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -1,30 +1,14 @@
|
|||||||
import {isLoginRequiredError} from '@_api/apiClient';
|
import {isLoginRequiredError} from '@_api/apiClient';
|
||||||
import {createTheme, deleteTheme, fetchAdminThemeDetail, updateTheme} from '@_api/theme/themeAPI';
|
import {createTheme, deleteTheme, fetchAdminThemeDetail, updateTheme} from '@_api/theme/themeAPI';
|
||||||
import {
|
import {
|
||||||
|
type AdminThemeDetailResponse,
|
||||||
Difficulty,
|
Difficulty,
|
||||||
DifficultyKoreanMap,
|
|
||||||
type ThemeCreateRequest,
|
type ThemeCreateRequest,
|
||||||
type ThemeUpdateRequest
|
type ThemeUpdateRequest
|
||||||
} from '@_api/theme/themeTypes';
|
} from '@_api/theme/themeTypes';
|
||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {useLocation, useNavigate, useParams} from 'react-router-dom';
|
import {useLocation, useNavigate, useParams} from 'react-router-dom';
|
||||||
import '@_css/admin-theme-edit-page.css';
|
import '@_css/admin-theme-edit-page.css';
|
||||||
import type {AuditInfo} from '@_api/common/commonTypes';
|
|
||||||
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
|
|
||||||
|
|
||||||
interface ThemeFormData {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
difficulty: Difficulty;
|
|
||||||
price: number;
|
|
||||||
minParticipants: number;
|
|
||||||
maxParticipants: number;
|
|
||||||
availableMinutes: number;
|
|
||||||
expectedMinutesFrom: number;
|
|
||||||
expectedMinutesTo: number;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AdminThemeEditPage: React.FC = () => {
|
const AdminThemeEditPage: React.FC = () => {
|
||||||
const { themeId } = useParams<{ themeId: string }>();
|
const { themeId } = useParams<{ themeId: string }>();
|
||||||
@ -33,16 +17,15 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
|
|
||||||
const isNew = themeId === 'new';
|
const isNew = themeId === 'new';
|
||||||
|
|
||||||
const [formData, setFormData] = useState<ThemeFormData | null>(null);
|
const [theme, setTheme] = useState<AdminThemeDetailResponse | ThemeCreateRequest | null>(null);
|
||||||
const [originalFormData, setOriginalFormData] = useState<ThemeFormData | null>(null);
|
const [originalTheme, setOriginalTheme] = useState<AdminThemeDetailResponse | ThemeCreateRequest | null>(null);
|
||||||
const [auditInfo, setAuditInfo] = useState<AuditInfo | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isEditing, setIsEditing] = useState(isNew);
|
const [isEditing, setIsEditing] = useState(isNew);
|
||||||
|
|
||||||
const handleError = (err: any) => {
|
const handleError = (err: any) => {
|
||||||
if (isLoginRequiredError(err)) {
|
if (isLoginRequiredError(err)) {
|
||||||
alert('로그인이 필요해요.');
|
alert('로그인이 필요해요.');
|
||||||
navigate('/admin/login', { state: { from: location } });
|
navigate('/login', { state: { from: location } });
|
||||||
} else {
|
} else {
|
||||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||||
alert(message);
|
alert(message);
|
||||||
@ -52,7 +35,7 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
const newTheme: ThemeFormData = {
|
const newTheme: ThemeCreateRequest = {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
thumbnailUrl: '',
|
thumbnailUrl: '',
|
||||||
@ -60,34 +43,38 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
price: 0,
|
price: 0,
|
||||||
minParticipants: 2,
|
minParticipants: 2,
|
||||||
maxParticipants: 4,
|
maxParticipants: 4,
|
||||||
availableMinutes: 80,
|
availableMinutes: 60,
|
||||||
expectedMinutesFrom: 50,
|
expectedMinutesFrom: 50,
|
||||||
expectedMinutesTo: 60,
|
expectedMinutesTo: 70,
|
||||||
isActive: true,
|
isOpen: true,
|
||||||
};
|
};
|
||||||
setFormData(newTheme);
|
setTheme(newTheme);
|
||||||
setOriginalFormData(newTheme);
|
setOriginalTheme(newTheme);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} else if (themeId) {
|
} else if (themeId) {
|
||||||
fetchAdminThemeDetail(themeId)
|
fetchAdminThemeDetail(themeId)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const { theme, isActive, audit } = data;
|
// Map AdminThemeDetailRetrieveResponse to ThemeV2
|
||||||
const themeData: ThemeFormData = {
|
const fetchedTheme: AdminThemeDetailResponse = {
|
||||||
name: theme.name,
|
id: data.id,
|
||||||
description: theme.description,
|
name: data.name,
|
||||||
thumbnailUrl: theme.thumbnailUrl,
|
description: data.description,
|
||||||
difficulty: theme.difficulty,
|
thumbnailUrl: data.thumbnailUrl,
|
||||||
price: theme.price,
|
difficulty: data.difficulty,
|
||||||
minParticipants: theme.minParticipants,
|
price: data.price,
|
||||||
maxParticipants: theme.maxParticipants,
|
minParticipants: data.minParticipants,
|
||||||
availableMinutes: theme.availableMinutes,
|
maxParticipants: data.maxParticipants,
|
||||||
expectedMinutesFrom: theme.expectedMinutesFrom,
|
availableMinutes: data.availableMinutes,
|
||||||
expectedMinutesTo: theme.expectedMinutesTo,
|
expectedMinutesFrom: data.expectedMinutesFrom,
|
||||||
isActive: isActive,
|
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);
|
setTheme(fetchedTheme);
|
||||||
setOriginalFormData(themeData);
|
setOriginalTheme(fetchedTheme);
|
||||||
setAuditInfo(audit);
|
|
||||||
})
|
})
|
||||||
.catch(handleError)
|
.catch(handleError)
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
@ -98,20 +85,20 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
const { name, value, type } = e.target;
|
const { name, value, type } = e.target;
|
||||||
let processedValue: string | number | boolean = value;
|
let processedValue: string | number | boolean = value;
|
||||||
|
|
||||||
if (name === 'isActive') {
|
if (name === 'isOpen') {
|
||||||
processedValue = value === 'true';
|
processedValue = value === 'true';
|
||||||
} else if (type === 'checkbox') {
|
} else if (type === 'checkbox') {
|
||||||
processedValue = (e.target as HTMLInputElement).checked;
|
processedValue = (e.target as HTMLInputElement).checked;
|
||||||
} else if (type === 'number') {
|
} else if (type === 'number') {
|
||||||
processedValue = value === '' ? 0 : Number(value);
|
processedValue = value === '' ? '' : Number(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(prev => prev ? { ...prev, [name]: processedValue } : null);
|
setTheme(prev => prev ? { ...prev, [name]: processedValue } : null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
setFormData(originalFormData);
|
setTheme(originalTheme);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} else {
|
} else {
|
||||||
navigate('/admin/theme');
|
navigate('/admin/theme');
|
||||||
@ -119,21 +106,22 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
console.log('handleSubmit called');
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData) return;
|
if (!theme) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
await createTheme(formData as ThemeCreateRequest);
|
await createTheme(theme as ThemeCreateRequest);
|
||||||
alert('테마가 성공적으로 생성되었습니다.');
|
alert('테마가 성공적으로 생성되었습니다.');
|
||||||
navigate(`/admin/theme`);
|
navigate(`/admin/theme`);
|
||||||
} else {
|
} else {
|
||||||
if (!themeId) {
|
if (!themeId) {
|
||||||
throw new Error('themeId is undefined');
|
throw new Error('themeId is undefined');
|
||||||
}
|
}
|
||||||
await updateTheme(themeId, formData as ThemeUpdateRequest);
|
await updateTheme(themeId, theme as ThemeUpdateRequest);
|
||||||
alert('테마가 성공적으로 업데이트되었습니다.');
|
alert('테마가 성공적으로 업데이트되었습니다.');
|
||||||
setOriginalFormData(formData);
|
setOriginalTheme(theme);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
navigate(`/admin/theme`);
|
navigate(`/admin/theme`);
|
||||||
}
|
}
|
||||||
@ -159,7 +147,7 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
return <div className="admin-theme-edit-container"><p>로딩 중...</p></div>;
|
return <div className="admin-theme-edit-container"><p>로딩 중...</p></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData) {
|
if (!theme) {
|
||||||
return <div className="admin-theme-edit-container"><p>테마 정보를 찾을 수 없습니다.</p></div>;
|
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-section">
|
||||||
<div className="form-group full-width">
|
<div className="form-group full-width">
|
||||||
<label className="form-label" htmlFor="name">테마 이름</label>
|
<label className="form-label" htmlFor="name">테마 이름</label>
|
||||||
<input id="name" name="name" type="text" className="form-input" value={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>
|
||||||
<div className="form-group full-width">
|
<div className="form-group full-width">
|
||||||
<label className="form-label" htmlFor="description">설명</label>
|
<label className="form-label" htmlFor="description">설명</label>
|
||||||
<textarea id="description" name="description" className="form-textarea" value={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>
|
||||||
<div className="form-group full-width">
|
<div className="form-group full-width">
|
||||||
<label className="form-label" htmlFor="thumbnailUrl">썸네일 URL</label>
|
<label className="form-label" htmlFor="thumbnailUrl">썸네일 URL</label>
|
||||||
<input id="thumbnailUrl" name="thumbnailUrl" type="text" className="form-input" value={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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -189,13 +177,13 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="difficulty">난이도</label>
|
<label className="form-label" htmlFor="difficulty">난이도</label>
|
||||||
<select id="difficulty" name="difficulty" className="form-select" value={formData.difficulty} onChange={handleChange} disabled={!isEditing}>
|
<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}>{DifficultyKoreanMap[d]}</option>)}
|
{Object.values(Difficulty).map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="isActive">공개 여부</label>
|
<label className="form-label" htmlFor="isOpen">공개 여부</label>
|
||||||
<select id="isActive" name="isActive" className="form-select" value={String(formData.isActive)} onChange={handleChange} disabled={!isEditing}>
|
<select id="isOpen" name="isOpen" className="form-select" value={String(theme.isOpen)} onChange={handleChange} disabled={!isEditing}>
|
||||||
<option value="true">공개</option>
|
<option value="true">공개</option>
|
||||||
<option value="false">비공개</option>
|
<option value="false">비공개</option>
|
||||||
</select>
|
</select>
|
||||||
@ -206,11 +194,11 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="price">1인당 요금 (원)</label>
|
<label className="form-label" htmlFor="price">1인당 요금 (원)</label>
|
||||||
<input id="price" name="price" type="number" className="form-input" value={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>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="availableMinutes">총 이용시간 (분)</label>
|
<label className="form-label" htmlFor="availableMinutes">총 이용시간 (분)</label>
|
||||||
<input id="availableMinutes" name="availableMinutes" type="number" className="form-input" value={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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -218,22 +206,22 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="expectedMinutesFrom">최소 예상 시간 (분)</label>
|
<label className="form-label" htmlFor="expectedMinutesFrom">최소 예상 시간 (분)</label>
|
||||||
<input id="expectedMinutesFrom" name="expectedMinutesFrom" type="number" className="form-input" value={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>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="expectedMinutesTo">최대 예상 시간 (분)</label>
|
<label className="form-label" htmlFor="expectedMinutesTo">최대 예상 시간 (분)</label>
|
||||||
<input id="expectedMinutesTo" name="expectedMinutesTo" type="number" className="form-input" value={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>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="minParticipants">최소 인원 (명)</label>
|
<label className="form-label" htmlFor="minParticipants">최소 인원 (명)</label>
|
||||||
<input id="minParticipants" name="minParticipants" type="number" className="form-input" value={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>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="maxParticipants">최대 인원 (명)</label>
|
<label className="form-label" htmlFor="maxParticipants">최대 인원 (명)</label>
|
||||||
<input id="maxParticipants" name="maxParticipants" type="number" className="form-input" value={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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -247,20 +235,20 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="main-actions">
|
<div className="main-actions">
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/admin/theme')}>목록</button>
|
<button type="button" className="btn btn-secondary" onClick={() => navigate('/admin/theme')}>목록</button>
|
||||||
<button type="button" className="btn btn-primary" onClick={(e) => { e.preventDefault(); setIsEditing(true); }}>수정</button>
|
<button type="button" className="btn btn-primary" onClick={(e) => { e.preventDefault(); console.log('setIsEditing(true) called'); setIsEditing(true); }}>수정</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{!isNew && auditInfo && (
|
{!isNew && 'id' in theme && (
|
||||||
<div className="audit-info">
|
<div className="audit-info">
|
||||||
<h4 className="audit-title">감사 정보</h4>
|
<h4 className="audit-title">감사 정보</h4>
|
||||||
<div className="audit-body">
|
<div className="audit-body">
|
||||||
<p><strong>생성일:</strong> {formatDisplayDateTime(auditInfo.createdAt)}</p>
|
<p><strong>생성일:</strong> {new Date(theme.createDate).toLocaleString()}</p>
|
||||||
<p><strong>수정일:</strong> {formatDisplayDateTime(auditInfo.updatedAt)}</p>
|
<p><strong>수정일:</strong> {new Date(theme.updatedDate).toLocaleString()}</p>
|
||||||
<p><strong>생성자:</strong> {auditInfo.createdBy.name}</p>
|
<p><strong>생성자:</strong> {theme.createdBy}</p>
|
||||||
<p><strong>수정자:</strong> {auditInfo.updatedBy.name}</p>
|
<p><strong>수정자:</strong> {theme.updatedBy}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {fetchAdminThemes} from '@_api/theme/themeAPI';
|
import {fetchAdminThemes} from '@_api/theme/themeAPI';
|
||||||
import {DifficultyKoreanMap, type AdminThemeSummaryResponse} from '@_api/theme/themeTypes';
|
import type {AdminThemeSummaryRetrieveResponse} from '@_api/theme/themeTypes';
|
||||||
import {isLoginRequiredError} from '@_api/apiClient';
|
import {isLoginRequiredError} from '@_api/apiClient';
|
||||||
import '@_css/admin-theme-page.css';
|
import '@_css/admin-theme-page.css';
|
||||||
|
|
||||||
const AdminThemePage: React.FC = () => {
|
const AdminThemePage: React.FC = () => {
|
||||||
const [themes, setThemes] = useState<AdminThemeSummaryResponse[]>([]);
|
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const handleError = (err: any) => {
|
const handleError = (err: any) => {
|
||||||
if (isLoginRequiredError(err)) {
|
if (isLoginRequiredError(err)) {
|
||||||
alert('로그인이 필요해요.');
|
alert('로그인이 필요해요.');
|
||||||
navigate('/admin/login', { state: { from: location } });
|
navigate('/login', { state: { from: location } });
|
||||||
} else {
|
} else {
|
||||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||||||
alert(message);
|
alert(message);
|
||||||
@ -63,9 +63,9 @@ const AdminThemePage: React.FC = () => {
|
|||||||
{themes.map(theme => (
|
{themes.map(theme => (
|
||||||
<tr key={theme.id}>
|
<tr key={theme.id}>
|
||||||
<td>{theme.name}</td>
|
<td>{theme.name}</td>
|
||||||
<td>{DifficultyKoreanMap[theme.difficulty]}</td>
|
<td>{theme.difficulty}</td>
|
||||||
<td>{theme.price.toLocaleString()}원</td>
|
<td>{theme.price.toLocaleString()}원</td>
|
||||||
<td>{theme.isActive ? '공개' : '비공개'}</td>
|
<td>{theme.isOpen ? '공개' : '비공개'}</td>
|
||||||
<td>
|
<td>
|
||||||
<button className="btn btn-secondary" onClick={() => handleManageClick(theme.id)}>관리</button>
|
<button className="btn btn-secondary" onClick={() => handleManageClick(theme.id)}>관리</button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -33,42 +33,3 @@ export const formatTime = (timeStr: string) => {
|
|||||||
|
|
||||||
return timePart;
|
return timePart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatDisplayDateTime = (dateTime: any): string => {
|
|
||||||
let date: Date;
|
|
||||||
|
|
||||||
if (typeof dateTime === 'string') {
|
|
||||||
// ISO 문자열 형식 처리 (LocalDateTime, OffsetDateTime 모두 포함)
|
|
||||||
date = new Date(dateTime);
|
|
||||||
} else if (typeof dateTime === 'number') {
|
|
||||||
// Unix 타임스탬프(초) 형식 처리
|
|
||||||
date = new Date(dateTime * 1000);
|
|
||||||
} else if (Array.isArray(dateTime) && dateTime.length >= 6) {
|
|
||||||
// 배열 형식 처리: [year, month, day, hour, minute, second, nanosecond?]
|
|
||||||
const year = dateTime[0];
|
|
||||||
const month = dateTime[1] - 1; // JS Date의 월은 0부터 시작
|
|
||||||
const day = dateTime[2];
|
|
||||||
const hour = dateTime[3];
|
|
||||||
const minute = dateTime[4];
|
|
||||||
const second = dateTime[5];
|
|
||||||
const millisecond = dateTime.length > 6 ? Math.floor(dateTime[6] / 1000000) : 0;
|
|
||||||
date = new Date(year, month, day, hour, minute, second, millisecond);
|
|
||||||
} else {
|
|
||||||
return '유효하지 않은 날짜 형식';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNaN(date.getTime())) {
|
|
||||||
return '유효하지 않은 날짜';
|
|
||||||
}
|
|
||||||
|
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: 'numeric',
|
|
||||||
hour12: true,
|
|
||||||
second: 'numeric'
|
|
||||||
};
|
|
||||||
return new Intl.DateTimeFormat('ko-KR', options).format(date);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
@ -33,7 +34,6 @@
|
|||||||
"@_hooks/*": ["src/hooks/*"],
|
"@_hooks/*": ["src/hooks/*"],
|
||||||
"@_pages/*": ["src/pages/*"],
|
"@_pages/*": ["src/pages/*"],
|
||||||
"@_types/*": ["/src/types/*"],
|
"@_types/*": ["/src/types/*"],
|
||||||
"@_util/*": ["src/util/*"]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package com.sangdol.roomescape.auth.business.domain
|
|
||||||
|
|
||||||
enum class PrincipalType {
|
|
||||||
USER, ADMIN
|
|
||||||
}
|
|
||||||
@ -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>>
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user