From acfe787d5f33245f258cb80c307f0360b8ef8fd6 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 4 Sep 2025 18:54:39 +0900 Subject: [PATCH 01/41] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=ED=9B=84=20=EC=98=88=EC=95=BD=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EB=84=98=EC=96=B4=EA=B0=88=20=EB=95=8C=20?= =?UTF-8?q?=ED=95=B4=EB=8B=B9=20=EC=9D=BC=EC=A0=95=EC=9D=98=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A5=BC=20=EB=B3=80=EA=B2=BD=ED=95=98=EB=8A=94=20API?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/business/ScheduleService.kt | 12 ++ .../roomescape/schedule/docs/ScheduleAPI.kt | 13 ++ .../schedule/exception/ScheduleErrorCode.kt | 1 + .../persistence/ScheduleEntity.kt | 6 +- .../schedule/web/ScheduleController.kt | 9 ++ .../roomescape/schedule/ScheduleApiTest.kt | 117 ++++++++++++++---- 6 files changed, 134 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt index dd989d70..3637d42c 100644 --- a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt +++ b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt @@ -83,6 +83,18 @@ class ScheduleService( } } + @Transactional + fun holdSchedule(id: Long) { + val schedule: ScheduleEntity = findOrThrow(id) + + if (schedule.status == ScheduleStatus.AVAILABLE) { + schedule.hold() + return + } + + throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE) + } + @Transactional fun updateSchedule(id: Long, request: ScheduleUpdateRequest) { log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" } diff --git a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt index 579c02a5..95e2cc2d 100644 --- a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt +++ b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt @@ -38,6 +38,19 @@ interface ScheduleAPI { @RequestParam("themeId") themeId: Long ): ResponseEntity> + @LoginRequired + @Operation(summary = "일정을 Hold 상태로 변경", tags = ["로그인이 필요한 API"]) + @ApiResponses( + ApiResponse( + responseCode = "200", + description = "일정을 Hold 상태로 변경하여 중복 예약 방지", + useReturnTypeSchema = true + ) + ) + fun holdSchedule( + @PathVariable("id") id: Long + ): ResponseEntity> + @Admin @Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true)) diff --git a/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt b/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt index 4436ac96..096fce94 100644 --- a/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt +++ b/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt @@ -12,4 +12,5 @@ enum class ScheduleErrorCode( SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."), PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."), SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."), + SCHEDULE_NOT_AVAILABLE(HttpStatus.CONFLICT, "S005", "예약이 완료되었거나 예약할 수 없는 일정이에요.") } diff --git a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt index 7da08a63..60cd394f 100644 --- a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt +++ b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt @@ -29,8 +29,12 @@ class ScheduleEntity( time?.let { this.time = it } status?.let { this.status = it } } + + fun hold() { + this.status = ScheduleStatus.HOLD + } } enum class ScheduleStatus { - AVAILABLE, PENDING, RESERVED, BLOCKED + AVAILABLE, HOLD, RESERVED, BLOCKED } diff --git a/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt b/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt index 6396fe40..e496c7db 100644 --- a/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt +++ b/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt @@ -50,6 +50,15 @@ class ScheduleController( return ResponseEntity.ok(CommonApiResponse(response)) } + @PatchMapping("/schedules/{id}/hold") + override fun holdSchedule( + @PathVariable("id") id: Long + ): ResponseEntity> { + scheduleService.holdSchedule(id) + + return ResponseEntity.ok(CommonApiResponse()) + } + @PatchMapping("/schedules/{id}") override fun updateSchedule( @PathVariable("id") id: Long, diff --git a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt index 036060f3..25ae6bc0 100644 --- a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt +++ b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt @@ -22,6 +22,7 @@ import roomescape.schedule.infrastructure.persistence.ScheduleStatus import roomescape.schedule.web.ScheduleCreateRequest import roomescape.schedule.web.ScheduleUpdateRequest import roomescape.util.* +import roomescape.util.ScheduleFixture.createRequest import java.time.LocalDate import java.time.LocalTime @@ -59,7 +60,7 @@ class ScheduleApiTest( runTest( token = token, using = { - body(ScheduleFixture.createRequest) + body(createRequest) }, on = { get("/schedules/1") @@ -71,13 +72,13 @@ class ScheduleApiTest( test("일정 수정: PATCH /schedules/{id}") { val createdSchedule: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest + createRequest ) runTest( token = token, using = { - body(ScheduleFixture.createRequest) + body(createRequest) }, on = { patch("/schedules/${createdSchedule.id}") @@ -89,7 +90,7 @@ class ScheduleApiTest( test("일정 삭제: DELETE /schedules/{id}") { val createdSchedule: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest + createRequest ) runTest( @@ -114,7 +115,7 @@ class ScheduleApiTest( val time = LocalTime.now() val createdSchedule: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = time ) @@ -139,13 +140,13 @@ class ScheduleApiTest( val createdSchedule: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = time ) ) createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date.plusDays(1L), time = time, themeId = createdSchedule.themeId @@ -175,7 +176,7 @@ class ScheduleApiTest( val date = LocalDate.now().plusDays(1) for (i in 1..10) { createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = LocalTime.now().plusMinutes(i.toLong()) ) @@ -199,14 +200,14 @@ class ScheduleApiTest( test("정상 응답") { val date = LocalDate.now().plusDays(1) val createdSchedule = createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = LocalTime.now() ) ) for (i in 1..10) { createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = LocalTime.now().plusMinutes(i.toLong()), themeId = createdSchedule.themeId @@ -233,7 +234,7 @@ class ScheduleApiTest( context("관리자 페이지에서 특정 일정의 감사 정보를 조회한다.") { test("정상 응답") { - val createdSchedule = createDummySchedule(ScheduleFixture.createRequest) + val createdSchedule = createDummySchedule(createRequest) runTest( token = loginUtil.loginAsAdmin(), @@ -302,7 +303,7 @@ class ScheduleApiTest( runTest( token = token, using = { - body(ScheduleFixture.createRequest.copy(themeId = themeId)) + body(createRequest.copy(themeId = themeId)) }, on = { post("/schedules") @@ -316,9 +317,9 @@ class ScheduleApiTest( val createdSchedule: ScheduleEntity = scheduleRepository.findByIdOrNull(createdScheduleId) ?: throw AssertionError("Unexpected Exception Occurred.") - createdSchedule.date shouldBe ScheduleFixture.createRequest.date - createdSchedule.time.hour shouldBe ScheduleFixture.createRequest.time.hour - createdSchedule.time.minute shouldBe ScheduleFixture.createRequest.time.minute + createdSchedule.date shouldBe createRequest.date + createdSchedule.time.hour shouldBe createRequest.time.hour + createdSchedule.time.minute shouldBe createRequest.time.minute createdSchedule.createdAt shouldNotBeNull {} createdSchedule.createdBy shouldNotBeNull {} createdSchedule.updatedAt shouldNotBeNull {} @@ -331,7 +332,7 @@ class ScheduleApiTest( val time = LocalTime.of(10, 0) val alreadyCreated: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = time ) ) @@ -340,7 +341,7 @@ class ScheduleApiTest( token = token, using = { body( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = time, themeId = alreadyCreated.themeId ) ) @@ -360,7 +361,7 @@ class ScheduleApiTest( token = token, using = { body( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = LocalDate.now(), time = LocalTime.now().minusMinutes(1) ) @@ -377,6 +378,76 @@ class ScheduleApiTest( } } + context("일정을 잠시 Hold 상태로 변경하여 중복 예약을 방지한다.") { + test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태이면 정상 응답") { + val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) + + runTest( + token = loginUtil.loginAsUser(), + on = { + patch("/schedules/${createdSchedule.id}/hold") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val updatedSchedule = scheduleRepository.findByIdOrNull(createdSchedule.id) + ?: throw AssertionError("Unexpected Exception Occurred.") + + updatedSchedule.status shouldBe ScheduleStatus.HOLD + } + } + + test("예약이 없으면 실패한다.") { + runTest( + token = loginUtil.loginAsUser(), + on = { + patch("/schedules/1/hold") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode)) + } + ) + } + + test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태가 아니면 실패한다.") { + val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) + + /* + * 테스트를 위해 수정 API를 호출하여 상태를 HOLD 상태로 변경 + * 생성 API에서는 일정 생성 시 AVAILABLE을 기본 상태로 지정하기 때문. + */ + runTest( + token = loginUtil.loginAsAdmin(), + using = { + body( + ScheduleUpdateRequest( + status = ScheduleStatus.HOLD + ) + ) + }, + on = { + patch("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ) + + runTest( + token = loginUtil.loginAsUser(), + on = { + patch("/schedules/${createdSchedule.id}/hold") + }, + expect = { + statusCode(HttpStatus.CONFLICT.value()) + body("code", equalTo(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE.errorCode)) + } + ) + } + } + context("일정을 수정한다.") { val updateRequest = ScheduleUpdateRequest( time = LocalTime.now().plusHours(1), @@ -385,7 +456,7 @@ class ScheduleApiTest( test("정상 수정 및 감사 정보 변경 확인") { val createdSchedule: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = LocalDate.now().plusDays(1), time = LocalTime.now().plusMinutes(1), ) @@ -416,7 +487,7 @@ class ScheduleApiTest( } test("입력값이 없으면 수정하지 않는다.") { - val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) + val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) runTest( token = loginUtil.loginAsAdmin(), @@ -455,7 +526,7 @@ class ScheduleApiTest( test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { val createdSchedule: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest.copy(date = LocalDate.now(), time = LocalTime.now().plusMinutes(1)) + createRequest.copy(date = LocalDate.now(), time = LocalTime.now().plusMinutes(1)) ) runTest( @@ -480,7 +551,7 @@ class ScheduleApiTest( context("일정을 삭제한다.") { test("정상 삭제") { - val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) + val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) runTest( token = loginUtil.loginAsAdmin(), @@ -496,7 +567,7 @@ class ScheduleApiTest( } test("예약 중이거나 예약이 완료된 일정이면 실패한다.") { - val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) + val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) /* * 테스트를 위해 수정 API를 호출하여 상태를 예약 중 상태로 변경 -- 2.47.2 From d1d81b89b8b64547038e0925747d81dee8aec14e Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 4 Sep 2025 18:55:24 +0900 Subject: [PATCH 02/41] =?UTF-8?q?refactor:=20BaseEntity=EB=A5=BC=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=20PK=20/=20Audit=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/common/entity/BaseEntityV2.kt | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt b/src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt index f0f94700..fe07538e 100644 --- a/src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt +++ b/src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt @@ -1,11 +1,6 @@ package roomescape.common.entity -import jakarta.persistence.Column -import jakarta.persistence.EntityListeners -import jakarta.persistence.Id -import jakarta.persistence.MappedSuperclass -import jakarta.persistence.PostLoad -import jakarta.persistence.PrePersist +import jakarta.persistence.* import org.springframework.data.annotation.CreatedBy import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.LastModifiedBy @@ -13,17 +8,13 @@ import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.domain.Persistable import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.LocalDateTime +import kotlin.jvm.Transient @MappedSuperclass @EntityListeners(AuditingEntityListener::class) abstract class AuditingBaseEntity( - @Id - @Column(name = "id") - private val _id: Long, - - @Transient - private var isNewEntity: Boolean = true -) : Persistable { + id: Long, +) : BaseEntityV2(id) { @Column(updatable = false) @CreatedDate lateinit var createdAt: LocalDateTime @@ -43,6 +34,17 @@ abstract class AuditingBaseEntity( @LastModifiedBy var updatedBy: Long = 0L protected set +} + +@MappedSuperclass +abstract class BaseEntityV2( + @Id + @Column(name = "id") + private val _id: Long, + + @Transient + private var isNewEntity: Boolean = true +) : Persistable { @PostLoad @PrePersist -- 2.47.2 From 04d1510bd136e331f2650d5023eb38032e056e65 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 7 Sep 2025 18:31:31 +0900 Subject: [PATCH 03/41] =?UTF-8?q?feat:=20Schedule=EC=9D=84=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=ED=95=9C=20=EC=98=88=EC=95=BD=20=EB=B0=8F=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 2 + frontend/src/api/member/memberTypes.ts | 6 + frontend/src/api/payment/PaymentTypes.ts | 73 +++++++++++ frontend/src/api/payment/paymentAPI.ts | 10 ++ .../src/api/reservation/reservationAPI.ts | 5 +- .../src/api/reservation/reservationAPIV2.ts | 23 ++++ .../src/api/reservation/reservationTypes.ts | 82 +----------- .../src/api/reservation/reservationTypesV2.ts | 58 +++++++++ frontend/src/api/schedule/scheduleAPI.ts | 4 + frontend/src/api/schedule/scheduleTypes.ts | 2 +- frontend/src/css/reservation-v2-1.css | 79 +++++++++++- .../src/pages/admin/AdminSchedulePage.tsx | 2 +- .../src/pages/admin/AdminThemeEditPage.tsx | 2 +- frontend/src/pages/admin/ThemePage.tsx | 4 +- frontend/src/pages/v2/MyReservationPageV2.tsx | 65 +++++----- frontend/src/pages/v2/ReservationFormPage.tsx | 121 ++++++++++++++++++ .../src/pages/v2/ReservationStep1PageV21.tsx | 58 +++++---- .../src/pages/v2/ReservationStep2Page.tsx | 10 +- .../src/pages/v2/ReservationStep2PageV21.tsx | 63 +++++---- .../pages/v2/ReservationSuccessPageV21.tsx | 15 +-- 20 files changed, 497 insertions(+), 187 deletions(-) create mode 100644 frontend/src/api/payment/PaymentTypes.ts create mode 100644 frontend/src/api/payment/paymentAPI.ts create mode 100644 frontend/src/api/reservation/reservationTypesV2.ts create mode 100644 frontend/src/pages/v2/ReservationFormPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e85af96a..b95ec603 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import ReservationSuccessPageV21 from './pages/v2/ReservationSuccessPageV21'; import HomePageV2 from './pages/v2/HomePageV2'; import LoginPageV2 from './pages/v2/LoginPageV2'; import SignupPageV2 from './pages/v2/SignupPageV2'; +import ReservationFormPage from './pages/v2/ReservationFormPage'; import AdminThemeEditPage from './pages/admin/AdminThemeEditPage'; import AdminSchedulePage from './pages/admin/AdminSchedulePage'; @@ -72,6 +73,7 @@ function App() { {/* V2.1 Reservation Flow */} } /> + } /> } /> } /> diff --git a/frontend/src/api/member/memberTypes.ts b/frontend/src/api/member/memberTypes.ts index 32d4ab17..6dc36555 100644 --- a/frontend/src/api/member/memberTypes.ts +++ b/frontend/src/api/member/memberTypes.ts @@ -17,3 +17,9 @@ export interface SignupResponse { id: string; name: string; } + +export interface MemberSummaryRetrieveResponse { + id: string; + name: string; + email: string; +} diff --git a/frontend/src/api/payment/PaymentTypes.ts b/frontend/src/api/payment/PaymentTypes.ts new file mode 100644 index 00000000..93b015df --- /dev/null +++ b/frontend/src/api/payment/PaymentTypes.ts @@ -0,0 +1,73 @@ +export interface PaymentConfirmRequest { + paymentKey: string; + orderId: string; + amount: number; + paymentType: PaymentType; +} + +export interface PaymentCancelRequest { + reservationId: string, + cancelReason: String +} + +// V2 types +export const PaymentType = { + NORMAL: 'NORMAL', + BILLING: 'BILLING', + BRANDPAY: 'BRANDPAY' +} as const; + +export type PaymentType = + | typeof PaymentType.NORMAL + | typeof PaymentType.BILLING + | typeof PaymentType.BRANDPAY; + +export interface PaymentCreateResponseV2 { + paymentId: string; + detailId: string; +} + +export interface PaymentRetrieveResponse { + orderId: string; + totalAmount: number; + method: string; + status: 'DONE' | 'CANCELED'; + requestedAt: string; + approvedAt: string; + detail: CardPaymentDetail | BankTransferPaymentDetail | EasyPayPrepaidPaymentDetail; + cancellation?: CanceledPaymentDetailResponse; +} + +export interface CardPaymentDetail { + type: 'CARD'; + issuerCode: string; + cardType: 'CREDIT' | 'CHECK' | 'GIFT'; + ownerType: 'PERSONAL' | 'CORPORATE'; + cardNumber: string; + amount: number; + approvalNumber: string; + installmentPlanMonths: number; + isInterestFree: boolean; + easypayProviderName?: string; + easypayDiscountAmount?: number; +} + +export interface BankTransferPaymentDetail { + type: 'BANK_TRANSFER'; + bankName: string; + settlementStatus: string; +} + +export interface EasyPayPrepaidPaymentDetail { + type: 'EASYPAY_PREPAID'; + providerName: string; + amount: number; + discountAmount: number; +} + +export interface CanceledPaymentDetailResponse { + cancellationRequestedAt: string; // ISO 8601 format + cancellationApprovedAt: string; // ISO 8601 format + cancelReason: string; + canceledBy: string; +} diff --git a/frontend/src/api/payment/paymentAPI.ts b/frontend/src/api/payment/paymentAPI.ts new file mode 100644 index 00000000..c5481ec8 --- /dev/null +++ b/frontend/src/api/payment/paymentAPI.ts @@ -0,0 +1,10 @@ +import apiClient from "@_api/apiClient"; +import type { PaymentCancelRequest, PaymentConfirmRequest, PaymentCreateResponseV2 } from "./PaymentTypes"; + +export const confirmPayment = async (reservationId: string, request: PaymentConfirmRequest): Promise => { + return await apiClient.post(`/payments?reservationId=${reservationId}`, request); +}; + +export const cancelPayment = async (request: PaymentCancelRequest): Promise => { + return await apiClient.post(`/payments/cancel`, request); +}; diff --git a/frontend/src/api/reservation/reservationAPI.ts b/frontend/src/api/reservation/reservationAPI.ts index 01a39c4a..ca370e74 100644 --- a/frontend/src/api/reservation/reservationAPI.ts +++ b/frontend/src/api/reservation/reservationAPI.ts @@ -85,10 +85,7 @@ export const confirmReservationPayment = async (id: string, data: ReservationPay return await apiClient.post(`/v2/reservations/${id}/pay`, data, true); }; -// POST /v2/reservations/{id}/cancel -export const cancelReservationV2 = async (id: string, cancelReason: string): Promise => { - return await apiClient.post(`/v2/reservations/${id}/cancel`, { cancelReason }, true); -}; + // GET /v2/reservations export const fetchMyReservationsV2 = async (): Promise => { diff --git a/frontend/src/api/reservation/reservationAPIV2.ts b/frontend/src/api/reservation/reservationAPIV2.ts index e69de29b..65f9d54b 100644 --- a/frontend/src/api/reservation/reservationAPIV2.ts +++ b/frontend/src/api/reservation/reservationAPIV2.ts @@ -0,0 +1,23 @@ +import apiClient from '../apiClient'; +import type { PendingReservationCreateRequest, PendingReservationCreateResponse, ReservationDetailRetrieveResponse, ReservationSummaryRetrieveListResponse } from './reservationTypesV2'; + +export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise => { + return await apiClient.post('/reservations/pending', request); +}; + +export const confirmReservation = async (reservationId: string): Promise => { + await apiClient.patch(`/reservations/${reservationId}/confirm`, {}); +}; + + +export const cancelReservation = async (id: string, cancelReason: string): Promise => { + return await apiClient.post(`/v3/reservations/${id}/cancel`, { cancelReason }, true); +}; + +export const fetchSummaryByMember = async (): Promise => { + return await apiClient.get('/v2/reservations/summary'); +} + +export const fetchDetailById = async (reservationId: string): Promise => { + return await apiClient.get(`/v2/reservations/${reservationId}/detail`); +} diff --git a/frontend/src/api/reservation/reservationTypes.ts b/frontend/src/api/reservation/reservationTypes.ts index 5501ed8c..29d20a57 100644 --- a/frontend/src/api/reservation/reservationTypes.ts +++ b/frontend/src/api/reservation/reservationTypes.ts @@ -1,4 +1,5 @@ -import type { MemberRetrieveResponse } from '@_api/member/memberTypes'; +import type { MemberRetrieveResponse, MemberSummaryRetrieveResponse } from '@_api/member/memberTypes'; +import type { PaymentRetrieveResponse, PaymentType } from '@_api/payment/PaymentTypes'; import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes'; import type { TimeRetrieveResponse } from '@_api/time/timeTypes'; @@ -77,18 +78,6 @@ export interface ReservationSearchQuery { dateTo?: string; } -// V2 types -export const PaymentType = { - NORMAL: 'NORMAL', - BILLING: 'BILLING', - BRANDPAY: 'BRANDPAY' -} as const; - -export type PaymentType = - | typeof PaymentType.NORMAL - | typeof PaymentType.BILLING - | typeof PaymentType.BRANDPAY; - export const PaymentStatus = { IN_PROGRESS: '결제 진행 중', DONE: '결제 완료', @@ -123,7 +112,7 @@ export interface ReservationPaymentRequest { paymentKey: string; orderId: string; amount: number; - paymentType: PaymentType + paymentType: PaymentType; } export interface ReservationPaymentResponse { @@ -133,75 +122,14 @@ export interface ReservationPaymentResponse { paymentStatus: PaymentStatus; } -export interface ReservationSummaryV2 { - id: string; - themeName: string; - date: string; - startAt: string; - status: string; // 'CONFIRMED', 'CANCELED_BY_USER', etc. -} -export interface ReservationSummaryListV2 { - reservations: ReservationSummaryV2[]; -} export interface ReservationDetailV2 { id: string; - user: UserDetailV2; + user: MemberSummaryRetrieveResponse; themeName: string; date: string; startAt: string; applicationDateTime: string; - payment: PaymentV2; - cancellation: CancellationV2 | null; -} - -export interface UserDetailV2 { - id: string; - name: string; - email: string; -} - -export interface PaymentV2 { - orderId: string; - totalAmount: number; - method: string; - status: 'DONE' | 'CANCELED'; - requestedAt: string; - approvedAt: string; - detail: CardPaymentDetailV2 | BankTransferPaymentDetailV2 | EasyPayPrepaidPaymentDetailV2; -} - -export interface CardPaymentDetailV2 { - type: 'CARD'; - issuerCode: string; - cardType: 'CREDIT' | 'CHECK' | 'GIFT'; - ownerType: 'PERSONAL' | 'CORPORATE'; - cardNumber: string; - amount: number; - approvalNumber: string; - installmentPlanMonths: number; - isInterestFree: boolean; - easypayProviderName?: string; - easypayDiscountAmount?: number; -} - -export interface BankTransferPaymentDetailV2 { - type: 'BANK_TRANSFER'; - bankName: string; - settlementStatus: string; -} - -export interface EasyPayPrepaidPaymentDetailV2 { - type: 'EASYPAY_PREPAID'; - providerName: string; - amount: number; - discountAmount: number; -} - -export interface CancellationV2 { - cancellationRequestedAt: string; // ISO 8601 format - cancellationApprovedAt: string; // ISO 8601 format - cancelReason: string; - canceledBy: string; + payment: PaymentRetrieveResponse; } diff --git a/frontend/src/api/reservation/reservationTypesV2.ts b/frontend/src/api/reservation/reservationTypesV2.ts new file mode 100644 index 00000000..3dc595fa --- /dev/null +++ b/frontend/src/api/reservation/reservationTypesV2.ts @@ -0,0 +1,58 @@ +import type { MemberSummaryRetrieveResponse } from "@_api/member/memberTypes"; +import type { PaymentRetrieveResponse } from "@_api/payment/PaymentTypes"; + +export const ReservationStatusV2 = { + PENDING: 'PENDING', + CONFIRMED: 'CONFIRMED', + CANCELED: 'CANCELED', + FAILED: 'FAILED', + EXPIRED: 'EXPIRED' +} as const; + +export type ReservationStatusV2 = + | typeof ReservationStatusV2.PENDING + | typeof ReservationStatusV2.CONFIRMED + | typeof ReservationStatusV2.CANCELED + | typeof ReservationStatusV2.FAILED + | typeof ReservationStatusV2.EXPIRED; + +export interface PendingReservationCreateRequest { + scheduleId: string, + reserverName: string, + reserverContact: string, + participantCount: number, + requirement: string +} + +export interface PendingReservationCreateResponse { + id: string +} + +export interface ReservationSummaryRetrieveResponse { + id: string; + themeName: string; + date: string; + startAt: string; + status: ReservationStatusV2; +} + +export interface ReservationSummaryRetrieveListResponse { + reservations: ReservationSummaryRetrieveResponse[]; +} + +export interface ReservationDetailRetrieveResponse { + id: string; + member: MemberSummaryRetrieveResponse; + applicationDateTime: string; + payment: PaymentRetrieveResponse; +} + +export interface ReservationDetail { + id: string; + themeName: string; + date: string; + startAt: string; + member: MemberSummaryRetrieveResponse; + applicationDateTime: string; + payment: PaymentRetrieveResponse; +} diff --git a/frontend/src/api/schedule/scheduleAPI.ts b/frontend/src/api/schedule/scheduleAPI.ts index 403a5204..f828fa2b 100644 --- a/frontend/src/api/schedule/scheduleAPI.ts +++ b/frontend/src/api/schedule/scheduleAPI.ts @@ -30,3 +30,7 @@ export const updateSchedule = async (id: string, request: ScheduleUpdateRequest) export const deleteSchedule = async (id: string): Promise => { await apiClient.del(`/schedules/${id}`); }; + +export const holdSchedule = async (id: string): Promise => { + await apiClient.patch(`/schedules/${id}/hold`, {}); +}; diff --git a/frontend/src/api/schedule/scheduleTypes.ts b/frontend/src/api/schedule/scheduleTypes.ts index 73550b46..6230cf01 100644 --- a/frontend/src/api/schedule/scheduleTypes.ts +++ b/frontend/src/api/schedule/scheduleTypes.ts @@ -1,6 +1,6 @@ export enum ScheduleStatus { AVAILABLE = 'AVAILABLE', - PENDING = 'PENDING', + HOLD = 'HOLD', RESERVED = 'RESERVED', BLOCKED = 'BLOCKED', } diff --git a/frontend/src/css/reservation-v2-1.css b/frontend/src/css/reservation-v2-1.css index 6e56c6ba..76820556 100644 --- a/frontend/src/css/reservation-v2-1.css +++ b/frontend/src/css/reservation-v2-1.css @@ -417,4 +417,81 @@ } .modal-actions .confirm-button:hover { background-color: #1B64DA; -} \ No newline at end of file +} + +/* Styles for ReservationFormPage */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + font-weight: bold; + margin-bottom: 8px; + color: #333; +} + +.form-group input[type="text"], +.form-group input[type="tel"], +.form-group input[type="number"], +.form-group textarea { + width: 100%; + padding: 12px; + border: 1px solid #ccc; + border-radius: 8px; + font-size: 16px; + box-sizing: border-box; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-group input:focus, .form-group textarea:focus { + outline: none; + border-color: #3182F6; + box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2); +} + +.form-group textarea { + resize: vertical; + min-height: 100px; +} + +.participant-control { + display: flex; + align-items: center; +} + +.participant-control input { + text-align: center; + border-left: none; + border-right: none; + width: 60px; + border-radius: 0; +} + +.participant-control button { + width: 44px; + height: 44px; + border: 1px solid #ccc; + background-color: #f0f0f0; + font-size: 20px; + cursor: pointer; + transition: background-color 0.2s; +} + +.participant-control button:hover:not(:disabled) { + background-color: #e0e0e0; +} + +.participant-control button:disabled { + background-color: #e9ecef; + cursor: not-allowed; + color: #aaa; +} + +.participant-control button:first-of-type { + border-radius: 8px 0 0 8px; +} + +.participant-control button:last-of-type { + border-radius: 0 8px 8px 0; +} diff --git a/frontend/src/pages/admin/AdminSchedulePage.tsx b/frontend/src/pages/admin/AdminSchedulePage.tsx index 28657fe6..7f8c5793 100644 --- a/frontend/src/pages/admin/AdminSchedulePage.tsx +++ b/frontend/src/pages/admin/AdminSchedulePage.tsx @@ -11,7 +11,7 @@ const getScheduleStatusText = (status: ScheduleStatus): string => { switch (status) { case ScheduleStatus.AVAILABLE: return '예약 가능'; - case ScheduleStatus.PENDING: + case ScheduleStatus.HOLD: return '예약 진행 중'; case ScheduleStatus.RESERVED: return '예약 완료'; diff --git a/frontend/src/pages/admin/AdminThemeEditPage.tsx b/frontend/src/pages/admin/AdminThemeEditPage.tsx index 88a8fc51..0121d5dd 100644 --- a/frontend/src/pages/admin/AdminThemeEditPage.tsx +++ b/frontend/src/pages/admin/AdminThemeEditPage.tsx @@ -193,7 +193,7 @@ const AdminThemeEditPage: React.FC = () => {
- +
diff --git a/frontend/src/pages/admin/ThemePage.tsx b/frontend/src/pages/admin/ThemePage.tsx index f2daaba4..68eec8b0 100644 --- a/frontend/src/pages/admin/ThemePage.tsx +++ b/frontend/src/pages/admin/ThemePage.tsx @@ -37,7 +37,7 @@ const AdminThemePage: React.FC = () => { navigate('/admin/theme/edit/new'); }; - const handleManageClick = (themeId: number) => { + const handleManageClick = (themeId: string) => { navigate(`/admin/theme/edit/${themeId}`); }; @@ -54,7 +54,7 @@ const AdminThemePage: React.FC = () => { 이름 난이도 - 가격 + 1인당 요금 공개여부 diff --git a/frontend/src/pages/v2/MyReservationPageV2.tsx b/frontend/src/pages/v2/MyReservationPageV2.tsx index ccbcb99e..a229b953 100644 --- a/frontend/src/pages/v2/MyReservationPageV2.tsx +++ b/frontend/src/pages/v2/MyReservationPageV2.tsx @@ -1,10 +1,8 @@ +import { cancelPayment } from '@_api/payment/paymentAPI'; +import type { PaymentRetrieveResponse } from '@_api/payment/PaymentTypes'; +import { cancelReservation, fetchDetailById, fetchSummaryByMember } from '@_api/reservation/reservationAPIV2'; +import type { ReservationDetail, ReservationSummaryRetrieveResponse } from '@_api/reservation/reservationTypesV2'; import React, { useEffect, useState } from 'react'; -import { - cancelReservationV2, - fetchMyReservationsV2, - fetchReservationDetailV2 -} from '../../api/reservation/reservationAPI'; -import type { PaymentV2, ReservationDetailV2, ReservationSummaryV2 } from '../../api/reservation/reservationTypes'; import '../../css/my-reservation-v2.css'; const formatDisplayDateTime = (dateTime: any): string => { @@ -78,7 +76,7 @@ const formatCardDateTime = (dateStr: string, timeStr: string): string => { // --- Cancellation View Component --- const CancellationView: React.FC<{ - reservation: ReservationDetailV2; + reservation: ReservationDetail; onCancelSubmit: (reason: string) => void; onBack: () => void; isCancelling: boolean; @@ -119,13 +117,12 @@ const CancellationView: React.FC<{ }; -// --- Reservation Detail View Component --- const ReservationDetailView: React.FC<{ - reservation: ReservationDetailV2; + reservation: ReservationDetail; onGoToCancel: () => void; }> = ({ reservation, onGoToCancel }) => { - const renderPaymentDetails = (payment: PaymentV2) => { + const renderPaymentDetails = (payment: PaymentRetrieveResponse) => { const { detail } = payment; switch (detail.type) { @@ -178,8 +175,8 @@ const ReservationDetailView: React.FC<{

예약 정보

예약 테마: {reservation.themeName}

이용 예정일: {formatCardDateTime(reservation.date, reservation.startAt)}

-

예약자 이름: {reservation.user.name}

-

예약자 이메일: {reservation.user.email}

+

예약자 이름: {reservation.member.name}

+

예약자 이메일: {reservation.member.email}

예약 신청 일시: {formatDisplayDateTime(reservation.applicationDateTime)}

@@ -188,13 +185,13 @@ const ReservationDetailView: React.FC<{ {renderPaymentDetails(reservation.payment)}

결제 승인 일시: {formatDisplayDateTime(reservation.payment.approvedAt)}

- {reservation.cancellation && ( + {reservation.payment.cancellation && (

취소 정보

-

취소 요청 일시: {formatDisplayDateTime(reservation.cancellation.cancellationRequestedAt)}

-

환불 완료 일시: {formatDisplayDateTime(reservation.cancellation.cancellationApprovedAt)}

-

취소 사유: {reservation.cancellation.cancelReason}

-

취소 요청자: {reservation.cancellation.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}

+

취소 요청 일시: {formatDisplayDateTime(reservation.payment.cancellation.cancellationRequestedAt)}

+

환불 완료 일시: {formatDisplayDateTime(reservation.payment.cancellation.cancellationApprovedAt)}

+

취소 사유: {reservation.payment.cancellation.cancelReason}

+

취소 요청자: {reservation.payment.cancellation.canceledBy == reservation.member.id ? '회원 본인' : '관리자'}

)} {reservation.payment.status !== 'CANCELED' && ( @@ -208,11 +205,11 @@ const ReservationDetailView: React.FC<{ // --- Main Page Component --- const MyReservationPageV2: React.FC = () => { - const [reservations, setReservations] = useState([]); + const [reservations, setReservations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [selectedReservation, setSelectedReservation] = useState(null); + const [selectedReservation, setSelectedReservation] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [isDetailLoading, setIsDetailLoading] = useState(false); const [detailError, setDetailError] = useState(null); @@ -223,7 +220,7 @@ const MyReservationPageV2: React.FC = () => { const loadReservations = async () => { try { setIsLoading(true); - const data = await fetchMyReservationsV2(); + const data = await fetchSummaryByMember(); setReservations(data.reservations); setError(null); } catch (err) { @@ -237,14 +234,21 @@ const MyReservationPageV2: React.FC = () => { loadReservations(); }, []); - const handleShowDetail = async (id: string) => { + const handleShowDetail = async (id: string, themeName: string, date: string, time: string) => { try { setIsDetailLoading(true); setDetailError(null); setModalView('detail'); - const detailData = await fetchReservationDetailV2(id); - console.log('상세 정보:', detailData); - setSelectedReservation(detailData); + const detailData = await fetchDetailById(id); + setSelectedReservation({ + id: detailData.id, + themeName: themeName, + date: date, + startAt: time, + member: detailData.member, + applicationDateTime: detailData.applicationDateTime, + payment: detailData.payment + }); setIsModalOpen(true); } catch (err) { setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.'); @@ -268,16 +272,18 @@ const MyReservationPageV2: React.FC = () => { try { setIsCancelling(true); setDetailError(null); - await cancelReservationV2(selectedReservation.id, reason); + await cancelReservation(selectedReservation.id, reason); + cancelPayment({ reservationId: selectedReservation.id, cancelReason: reason }); alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.'); handleCloseModal(); - loadReservations(); // Refresh the list + await loadReservations(); // Refresh the list } catch (err) { setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.'); } finally { - setIsCancelling(false); + setIsCancelling(true); } }; + console.log("reservations=", reservations); return (
@@ -289,14 +295,13 @@ const MyReservationPageV2: React.FC = () => { {!isLoading && !error && (
{reservations.map((res) => ( - console.log(res), -
+

{res.themeName}

{formatCardDateTime(res.date, res.startAt)}

+
+ ); + } + + return ( +
+

예약 정보 입력

+ +
+

예약 내용 확인

+

테마: {theme.name}

+

날짜: {formatDate(date)}

+

시간: {formatTime(time)}

+
+ +
+

예약자 정보

+
+ + setReserverName(e.target.value)} /> +
+
+ + setReserverContact(e.target.value)} placeholder="'-' 없이 입력"/> +
+
+ +
+ setParticipantCount(Math.max(theme.minParticipants, Math.min(theme.maxParticipants, Number(e.target.value))))} + min={theme.minParticipants} + max={theme.maxParticipants} + /> +
+
+
+ +